Vault

Override System

Per-tenant configuration overrides with resolution caching.

The override package provides per-tenant configuration overrides with a resolver that integrates with the config.Service. Overrides allow individual tenants to have custom values for configuration entries, while all other tenants receive the app-level default.

Architecture

override.Resolver (implements config.ValueResolver)
  ├── override.Store  (per-tenant override persistence)
  ├── config.Store    (app-level config fallback)
  └── resolverCache   (optional TTL cache)

The Resolver sits between the config service and both stores. When a value is requested, it checks for a tenant override first, then falls back to the app-level config.

Installation

import "github.com/xraph/vault/override"

Override entity

An Override represents a per-tenant value for a specific config key.

type Override struct {
    vault.Entity
    ID       id.ID             `json:"id"`
    Key      string            `json:"key"`
    Value    any               `json:"value"`
    AppID    string            `json:"app_id"`
    TenantID string            `json:"tenant_id"`
    Metadata map[string]string `json:"metadata,omitempty"`
}
FieldTypeDescription
IDid.IDUnique override identifier (TypeID)
KeystringThe config key being overridden
ValueanyThe tenant-specific value
AppIDstringApplication scope identifier
TenantIDstringTenant this override applies to
Metadatamap[string]stringArbitrary key-value metadata
CreatedAttime.TimeFrom embedded vault.Entity
UpdatedAttime.TimeFrom embedded vault.Entity

ListOpts

type ListOpts struct {
    Limit  int
    Offset int
}

Store interface

The store persists per-tenant overrides. Vault ships with in-memory and PostgreSQL implementations.

type Store interface {
    GetOverride(ctx context.Context, key, appID, tenantID string) (*Override, error)
    SetOverride(ctx context.Context, o *Override) error
    DeleteOverride(ctx context.Context, key, appID, tenantID string) error
    ListOverridesByTenant(ctx context.Context, appID, tenantID string) ([]*Override, error)
    ListOverridesByKey(ctx context.Context, key, appID string) ([]*Override, error)
}
MethodDescription
GetOverrideRetrieves a single override for a key/app/tenant combination
SetOverrideCreates or updates an override
DeleteOverrideRemoves a tenant override
ListOverridesByTenantReturns all overrides for a specific tenant within an app
ListOverridesByKeyReturns all tenant overrides for a specific config key

Resolver

The Resolver implements the config.ValueResolver interface. It resolves config values by checking for a tenant override first, then falling back to the app-level config.

Creating a Resolver

resolver := override.NewResolver(configStore, overrideStore,
    override.WithLogger(logger),
    override.WithCacheTTL(1*time.Minute),
)

ResolverOption

OptionSignatureDescription
WithLoggerWithLogger(l *slog.Logger) ResolverOptionSets the logger for the resolver
WithCacheTTLWithCacheTTL(ttl time.Duration) ResolverOptionEnables result caching with the given TTL

Resolution order

When Resolve is called:

  1. Cache check -- If caching is enabled and a cached value exists (not expired), return it.
  2. Tenant override -- If the context contains a tenant ID (via vault.tenant_id context key), look up the tenant override in the override store. If found, cache and return it.
  3. App-level fallback -- Read the config entry from the config store. Cache and return it.
func (r *Resolver) Resolve(ctx context.Context, key, appID string) (any, error)

The tenant ID is extracted from the context using the vault.tenant_id key, which matches the key used by the scope package and the flag engine:

const ContextKeyTenantID contextKey = "vault.tenant_id"

Invalidate

Removes cached entries for a specific config key and app ID. Called automatically by config.Service.Set and config.Service.Delete.

func (r *Resolver) Invalidate(key, appID string)

InvalidateAll

Removes all cached entries. Useful when performing bulk updates.

func (r *Resolver) InvalidateAll()

Resolution cache

When WithCacheTTL is configured, the resolver caches resolution results in memory:

  • Cache keys are composite: key + appID + tenantID.
  • Each entry has an independent TTL expiration.
  • Cache is thread-safe (protected by sync.RWMutex).
  • Invalidate(key, appID) removes all tenant variants for that key/app combination.
  • InvalidateAll() flushes the entire cache.

Integration with config.Service

The resolver is designed to plug into the config service via the WithResolver option:

import (
    "github.com/xraph/vault/config"
    "github.com/xraph/vault/override"
)

resolver := override.NewResolver(configStore, overrideStore,
    override.WithCacheTTL(1*time.Minute),
)

svc := config.NewService(configStore,
    config.WithAppID("myapp"),
    config.WithResolver(resolver),
)

Once connected:

  • All type-safe accessors (String, Bool, Int, Float, Duration, JSON) route through the resolver.
  • config.Service.Set and config.Service.Delete automatically call Invalidate on the resolver.
  • Tenant context is automatically extracted and used for override lookups.

Full example

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/xraph/vault/config"
    "github.com/xraph/vault/override"
    "github.com/xraph/vault/scope"
)

func main() {
    ctx := context.Background()

    // Create the resolver.
    resolver := override.NewResolver(configStore, overrideStore,
        override.WithCacheTTL(1*time.Minute),
    )

    // Create the config service with resolver.
    svc := config.NewService(configStore,
        config.WithAppID("myapp"),
        config.WithResolver(resolver),
    )

    // Set an app-level config.
    _ = svc.Set(ctx, "max-users", 100, "myapp",
        config.WithDescription("Maximum users per workspace"),
    )

    // Set a tenant override.
    _ = overrideStore.SetOverride(ctx, &override.Override{
        Key:      "max-users",
        Value:    500,
        AppID:    "myapp",
        TenantID: "tenant-enterprise",
    })

    // Read without tenant context -- gets app default.
    maxUsers := svc.Int(ctx, "max-users", 50)
    fmt.Printf("Default max users: %d\n", maxUsers) // 100

    // Read with tenant context -- gets override.
    ctx = scope.WithTenantID(ctx, "tenant-enterprise")
    maxUsers = svc.Int(ctx, "max-users", 50)
    fmt.Printf("Enterprise max users: %d\n", maxUsers) // 500

    // Read with a different tenant -- gets app default.
    ctx = scope.WithTenantID(context.Background(), "tenant-starter")
    maxUsers = svc.Int(ctx, "max-users", 50)
    fmt.Printf("Starter max users: %d\n", maxUsers) // 100

    // List all overrides for a key.
    overrides, _ := overrideStore.ListOverridesByKey(ctx, "max-users", "myapp")
    for _, o := range overrides {
        fmt.Printf("  tenant=%s value=%v\n", o.TenantID, o.Value)
    }

    // List all overrides for a tenant.
    tenantOverrides, _ := overrideStore.ListOverridesByTenant(ctx, "myapp", "tenant-enterprise")
    for _, o := range tenantOverrides {
        fmt.Printf("  key=%s value=%v\n", o.Key, o.Value)
    }
}

On this page