Vault

Multi-Tenant Patterns

Patterns for tenant isolation, data scoping, and crypto-shredding.

Vault is designed for multi-tenant applications from the ground up. Every entity (secrets, flags, config, overrides, audit entries) is scoped by appID, and many are further scoped by tenantID. This guide covers the key patterns for tenant isolation.

Scope propagation through HTTP middleware

The scope package provides context-injection helpers. In an HTTP application, set the scope in middleware so that all downstream Vault operations are automatically scoped to the correct tenant:

package middleware

import (
    "net/http"
    "strings"

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

// TenantScope extracts tenant information from the request and
// injects it into the context for all downstream Vault operations.
func TenantScope(appID string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := r.Context()

            // Always set the application ID.
            ctx = scope.WithAppID(ctx, appID)

            // Extract tenant ID from header (or JWT, subdomain, API key, etc.).
            tenantID := r.Header.Get("X-Tenant-ID")
            if tenantID == "" {
                // Fallback: extract from JWT claims, subdomain, or path.
                tenantID = extractTenantFromJWT(r)
            }
            if tenantID != "" {
                ctx = scope.WithTenantID(ctx, tenantID)
            }

            // Extract user identity.
            if userID := r.Header.Get("X-User-ID"); userID != "" {
                ctx = scope.WithUserID(ctx, userID)
            }

            // Capture IP for audit.
            ip := r.Header.Get("X-Forwarded-For")
            if ip == "" {
                ip = strings.Split(r.RemoteAddr, ":")[0]
            }
            ctx = scope.WithIP(ctx, ip)

            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Extracting scope values

Anywhere in your application, you can read back the full scope:

appID, tenantID, userID, ip := scope.FromContext(ctx)

The scope package context keys are the same constants used by flag.ContextKeyTenantID and override.ContextKeyTenantID, so values injected via scope.WithTenantID are automatically visible to the flag engine and override resolver without any extra wiring.

Context keyscope constantflag constantoverride constant
"vault.tenant_id"scope.KeyTenantIDflag.ContextKeyTenantIDoverride.ContextKeyTenantID
"vault.user_id"scope.KeyUserIDflag.ContextKeyUserID--
"vault.app_id"scope.KeyAppID----
"vault.ip"scope.KeyIP----

Per-tenant feature flag overrides

The flag system supports two levels of tenant customization: targeting rules and direct overrides.

Targeting rules with WhenTenant

Targeting rules match specific tenant IDs and return a custom value when matched:

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

// Enable the feature for specific tenants.
rule := flag.WhenTenant("tenant-alpha", "tenant-beta").Return(true)
rule.Priority = 1
rule.FlagKey = "new_feature"
rule.AppID = "myapp"

err := store.SetFlagRules(ctx, "new_feature", "myapp", []*flag.Rule{rule})

Other rule types for tenant targeting:

// Tag-based targeting (match tenants with a specific tag).
tagRule := flag.WhenTenantTag("plan", "enterprise").Return(true)

// Percentage rollout (deterministic hash of tenantID + flagKey).
rolloutRule := flag.Rollout(25).Return(true) // 25% of tenants

// Scheduled activation.
scheduleRule := flag.Schedule(startTime, endTime).Return(true)

Direct tenant overrides

For immediate, unconditional overrides, use SetFlagTenantOverride:

// Force-enable a flag for a specific tenant regardless of rules.
err := store.SetFlagTenantOverride(ctx, "new_feature", "myapp", "tenant-vip", true)

Direct overrides take precedence over all targeting rules during evaluation. The evaluation order is:

  1. If the flag is disabled, return the default value.
  2. If a tenant override exists for the current tenant, return its value.
  3. Evaluate targeting rules by priority (lowest number first).
  4. If no rule matches, return the default value.

Listing and managing overrides

// List all tenant overrides for a flag.
overrides, err := store.ListFlagTenantOverrides(ctx, "new_feature", "myapp")
for _, o := range overrides {
    fmt.Printf("tenant=%s value=%v\n", o.TenantID, o.Value)
}

// Remove a tenant override.
err = store.DeleteFlagTenantOverride(ctx, "new_feature", "myapp", "tenant-vip")

Per-tenant config overrides

The override package provides a separate override layer for runtime configuration. This allows you to customize config values on a per-tenant basis without changing the base config.

Setting overrides

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

err := store.SetOverride(ctx, &override.Override{
    ID:       id.NewOverrideID(),
    Key:      "rate_limit",
    Value:    500,
    AppID:    "myapp",
    TenantID: "tenant-enterprise",
    Metadata: map[string]string{"reason": "enterprise plan"},
})

Resolving with the override.Resolver

The override.Resolver automatically checks for tenant overrides when resolving config values:

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

resolver := override.NewResolver(store, store,
    override.WithCacheTTL(30 * time.Second),
)

// Without tenant context -- returns base config value.
val, err := resolver.Resolve(ctx, "rate_limit", "myapp")
// val = 100 (the base config value)

// With tenant context -- returns the tenant override.
tenantCtx := scope.WithTenantID(ctx, "tenant-enterprise")
val, err = resolver.Resolve(tenantCtx, "rate_limit", "myapp")
// val = 500 (the tenant override)

Integrating with the config service

Wire the resolver into the config service for automatic override resolution in type-safe accessors:

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

cfgSvc := config.NewService(store,
    config.WithAppID("myapp"),
    config.WithResolver(resolver),
)

// Type-safe accessors now automatically resolve tenant overrides.
limit := cfgSvc.Int(tenantCtx, "rate_limit", 100)
// Returns 500 for tenant-enterprise, 100 for other tenants.

Override precedence

Tenant contextOverride existsValue returned
Not present--Base config value
PresentNoBase config value
PresentYesTenant override value

Listing overrides

// All overrides for a specific tenant.
overrides, _ := store.ListOverridesByTenant(ctx, "myapp", "tenant-enterprise")

// All tenant overrides for a specific config key.
overrides, _ := store.ListOverridesByKey(ctx, "rate_limit", "myapp")

Crypto-shredding via crypto.KeyStore

Crypto-shredding is a GDPR compliance technique where each tenant's data is encrypted with a unique key. Deleting the key renders all that tenant's data permanently unrecoverable without actually deleting the rows.

The KeyStore interface

package crypto

type KeyStore interface {
    // GetOrCreate retrieves the key for the given ID, creating one if needed.
    GetOrCreate(ctx context.Context, id string) ([]byte, error)

    // Get retrieves the key for the given ID.
    Get(ctx context.Context, id string) ([]byte, error)

    // Delete removes the key for the given ID (crypto-shredding).
    Delete(ctx context.Context, id string) error
}

Per-tenant encryption workflow

// During secret storage: get or create a per-tenant key.
tenantKey, err := keyStore.GetOrCreate(ctx, tenantID)
if err != nil {
    return err
}

// Create a per-tenant encryptor.
tenantEnc, err := crypto.NewEncryptor(tenantKey)
if err != nil {
    return err
}

// Encrypt with the tenant's key.
ciphertext, err := tenantEnc.Encrypt(plaintext)

Right-to-erasure (GDPR Article 17)

When a tenant exercises their right to erasure:

// Delete the tenant's encryption key.
// All secrets encrypted with this key become permanently unrecoverable.
err := keyStore.Delete(ctx, tenantID)
if err != nil {
    return fmt.Errorf("crypto-shred tenant %s: %w", tenantID, err)
}

The encrypted data remains in the database but is cryptographically inaccessible. This satisfies the right to erasure without requiring deletion of every individual row.

Implementing a KeyStore

A basic database-backed KeyStore implementation:

type DBKeyStore struct {
    db *sql.DB
}

func (ks *DBKeyStore) GetOrCreate(ctx context.Context, tenantID string) ([]byte, error) {
    // Try to retrieve existing key.
    key, err := ks.Get(ctx, tenantID)
    if err == nil {
        return key, nil
    }

    // Generate a new 32-byte key.
    newKey := make([]byte, 32)
    if _, err := rand.Read(newKey); err != nil {
        return nil, err
    }

    // Store it (use UPSERT to handle races).
    _, err = ks.db.ExecContext(ctx,
        `INSERT INTO tenant_keys (tenant_id, encryption_key)
         VALUES ($1, $2)
         ON CONFLICT (tenant_id) DO NOTHING`,
        tenantID, newKey,
    )
    if err != nil {
        return nil, err
    }

    // Re-read to handle the race condition.
    return ks.Get(ctx, tenantID)
}

func (ks *DBKeyStore) Get(ctx context.Context, tenantID string) ([]byte, error) {
    var key []byte
    err := ks.db.QueryRowContext(ctx,
        `SELECT encryption_key FROM tenant_keys WHERE tenant_id = $1`,
        tenantID,
    ).Scan(&key)
    return key, err
}

func (ks *DBKeyStore) Delete(ctx context.Context, tenantID string) error {
    _, err := ks.db.ExecContext(ctx,
        `DELETE FROM tenant_keys WHERE tenant_id = $1`,
        tenantID,
    )
    return err
}

HTTP middleware example

A complete middleware that combines scope injection with per-tenant key resolution:

func TenantVaultMiddleware(appID string, keyStore crypto.KeyStore) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := r.Context()

            tenantID := r.Header.Get("X-Tenant-ID")
            if tenantID == "" {
                http.Error(w, "X-Tenant-ID header required", http.StatusBadRequest)
                return
            }

            // Inject scope.
            ctx = scope.WithScope(ctx, appID, tenantID,
                r.Header.Get("X-User-ID"),
                r.RemoteAddr,
            )

            // Verify the tenant's key exists (ensures tenant is active).
            _, err := keyStore.Get(ctx, tenantID)
            if err != nil {
                http.Error(w, "tenant not found or shredded", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Pattern summary

PatternPackageKey functionPurpose
Scope propagationscopeWithTenantID, WithScopeInject tenant context for all Vault operations
Flag tenant overridesflagSetFlagTenantOverrideDirect per-tenant flag values
Flag targeting rulesflagWhenTenant, RolloutRule-based tenant targeting
Config overridesoverrideoverride.Resolver.ResolvePer-tenant config customization
Crypto-shreddingcryptoKeyStore.DeleteGDPR right-to-erasure compliance
Audit contextauditLogger.LogAccessAutomatic tenant/user/IP capture

On this page