Multi-Tenancy
Context-based tenant isolation across all subsystems.
Vault is tenant-scoped by design. Every operation -- secrets, flags, config, overrides, rotation, and audit -- uses context values to identify the caller. The scope package provides the helpers for injecting and extracting these values.
The scope package
Import: github.com/xraph/vault/scope
The scope package defines four context keys and helper functions for reading and writing them. All keys are of type scope.ContextKey (a string alias).
Context keys
type ContextKey string
const (
KeyAppID ContextKey = "vault.app_id"
KeyTenantID ContextKey = "vault.tenant_id"
KeyUserID ContextKey = "vault.user_id"
KeyIP ContextKey = "vault.ip"
)| Key | Constant | Purpose |
|---|---|---|
vault.app_id | scope.KeyAppID | Application identifier -- scopes all entities to a logical application. |
vault.tenant_id | scope.KeyTenantID | Tenant identifier -- isolates data between tenants in a multi-tenant deployment. |
vault.user_id | scope.KeyUserID | User identifier -- used by flag rules (WhenUser) and audit logging. |
vault.ip | scope.KeyIP | Client IP address -- recorded in audit log entries. |
Setting scope values
Each key has a dedicated With* helper that returns a new context with the value set:
import "github.com/xraph/vault/scope"
ctx := context.Background()
ctx = scope.WithAppID(ctx, "myapp")
ctx = scope.WithTenantID(ctx, "tenant-1")
ctx = scope.WithUserID(ctx, "user-42")
ctx = scope.WithIP(ctx, "10.0.0.1")Or set all four values at once with WithScope:
ctx = scope.WithScope(ctx, "myapp", "tenant-1", "user-42", "10.0.0.1")The signature is:
func WithScope(ctx context.Context, appID, tenantID, userID, ip string) context.ContextReading scope values
FromContext extracts all four scope values from a context. Missing values are returned as empty strings.
appID, tenantID, userID, ip := scope.FromContext(ctx)The signature is:
func FromContext(ctx context.Context) (appID, tenantID, userID, ip string)Cross-package consistency
The context keys used by scope, flag, and override are intentionally identical. This means a single scope.WithTenantID call propagates the tenant ID to every subsystem automatically.
| Package | Constant | Value |
|---|---|---|
scope | scope.KeyTenantID | "vault.tenant_id" |
flag | flag.ContextKeyTenantID | "vault.tenant_id" |
override | override.ContextKeyTenantID | "vault.tenant_id" |
scope | scope.KeyUserID | "vault.user_id" |
flag | flag.ContextKeyUserID | "vault.user_id" |
Because these values match, you set scope once and all subsystems read from the same context keys:
// Set once.
ctx = scope.WithTenantID(ctx, "tenant-1")
ctx = scope.WithUserID(ctx, "user-42")
// flag.Engine reads vault.tenant_id and vault.user_id for rule evaluation.
val, _ := engine.Evaluate(ctx, "new-dashboard", "myapp")
// override.Resolver reads vault.tenant_id for per-tenant override lookup.
val, _ = resolver.Resolve(ctx, "rate-limit", "myapp")
// audit.Logger reads all four values for the audit entry.
logger.LogAccess(ctx, "api-key", "secret.accessed", "secret")How each subsystem uses scope
Secret service
The secret.Service uses appID as a parameter (not from context) to scope secrets. The context is passed through to the store and to OnAccess/OnMutate callbacks, which can extract scope values for audit logging.
// appID is explicit in the API.
sec, err := secretSvc.Get(ctx, "api-key", "myapp")Flag engine
The flag.Engine reads vault.tenant_id and vault.user_id from context to evaluate targeting rules.
- Tenant override check -- if
tenantIDis non-empty, checks for a direct tenant override first. - Rule evaluation --
WhenTenantrules match againsttenantID,WhenUserrules match againstuserID, andRolloutrules hashtenantID:flagKeyfor deterministic bucketing.
ctx = scope.WithTenantID(ctx, "tenant-1")
ctx = scope.WithUserID(ctx, "user-42")
// Engine extracts tenant and user from context.
enabled := flagSvc.Bool(ctx, "new-dashboard", false)Override resolver
The override.Resolver reads vault.tenant_id from context. If a tenant ID is present, it checks for a per-tenant override before falling back to the app-level config.
ctx = scope.WithTenantID(ctx, "tenant-1")
// Resolver checks for tenant-1's override of "rate-limit" first.
val, _ := resolver.Resolve(ctx, "rate-limit", "myapp")Resolution order:
- If
tenantIDis in context, look upoverride.Store.GetOverride(key, appID, tenantID). - If found, return the override value.
- If not found (or no tenant in context), fall back to
config.Store.GetConfig(key, appID).
Audit logger
The audit.Logger calls scope.FromContext(ctx) to extract all four values and writes them into the audit entry:
// Internally, the logger does:
appID, tenantID, userID, ip := scope.FromContext(ctx)
entry := &audit.Entry{
AppID: appID,
TenantID: tenantID,
UserID: userID,
IP: ip,
// ...
}This ensures every audit entry has full provenance without the caller needing to pass these values explicitly.
Typical HTTP middleware setup
In a web application, set scope values in an HTTP middleware so all downstream handlers have a scoped context:
func scopeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract from auth token, headers, or session.
ctx = scope.WithAppID(ctx, "myapp")
ctx = scope.WithTenantID(ctx, extractTenantID(r))
ctx = scope.WithUserID(ctx, extractUserID(r))
ctx = scope.WithIP(ctx, r.RemoteAddr)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Mono-tenant deployments
For single-tenant deployments, use a constant tenant ID:
ctx = scope.WithTenantID(ctx, "default")All Vault features work identically -- the tenant ID simply never varies. You can later migrate to multi-tenant by changing this value per request.
Background jobs
For background jobs (rotation, cleanup, cron), inject scope explicitly from stored identifiers rather than relying on an HTTP request context:
func rotateSecrets(mgr *rotation.Manager, appID string) {
ctx := context.Background()
ctx = scope.WithAppID(ctx, appID)
ctx = scope.WithTenantID(ctx, "system")
ctx = scope.WithUserID(ctx, "rotation-manager")
mgr.RotateNow(ctx, "database-password", appID)
}