Documentation
¶
Overview ¶
Package tenancy provides the storage-layer view of a principal's tenancy (Claims), the Provider abstraction for enforcement, and helpers to bind tenancy to a request context. The package is intentionally narrow: it depends only on security (for the default auth-claims derivation) and gorm.io/gorm.
Index ¶
- Variables
- func ClaimsMiddleware(next http.Handler, b *ClaimsBinder) http.Handler
- func EnsureFrameworkMigration(jobCtx, parent context.Context) context.Context
- func IsFrameworkMigration(ctx context.Context) bool
- func IsSystemPrincipal(ctx context.Context) bool
- func NewClaimsInterceptor() connect.Interceptor
- func NewClaimsInterceptorWithBinder(b *ClaimsBinder) connect.Interceptor
- func StreamClaimsInterceptor(b *ClaimsBinder) grpc.StreamServerInterceptor
- func UnaryClaimsInterceptor(b *ClaimsBinder) grpc.UnaryServerInterceptor
- func ValidatePartitionIDs(ids []string) error
- func WithClaims(ctx context.Context, c *Claims) context.Context
- func WithExtraPartitions(ctx context.Context, partitionIDs ...string) context.Context
- func WithFrameworkMigration(ctx context.Context) context.Context
- func WithSkipEnforcement(ctx context.Context) context.Context
- func WithSystemPrincipal(ctx context.Context, p SystemPrincipal) context.Context
- type AllowGlobalAware
- type Capabilities
- type Claims
- type ClaimsBinder
- type ClaimsFromAuthOption
- type ModeAware
- type ModelInfo
- type Provider
- type SecurityMode
- type SystemPrincipal
- type Tenanted
- type Unscoped
- type UnscopedMarker
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidTenantID is returned when a tenant id contains reserved // characters or is otherwise unusable for storage-layer binding. ErrInvalidTenantID = errors.New("tenancy: invalid tenant id") // ErrInvalidPartitionID is returned when a partition id contains the // reserved list separator ',' (used by the Postgres session GUC CSV // encoding) or is otherwise unusable. ErrInvalidPartitionID = errors.New("tenancy: invalid partition id") )
Sentinel errors for claims validation.
var ( // ErrClaimsRequired is returned in secure modes when no bindable // claims or SystemPrincipal is present on the context. ErrClaimsRequired = errors.New("tenancy: claims required") // ErrTenantIDRequired is returned when binding would be partition-only // (empty TenantID) or a scoped SystemPrincipal lacks TenantID. ErrTenantIDRequired = errors.New("tenancy: tenant id required for binding") // ErrSkipNotPermitted is returned when Claims.Skip is set without a // SystemPrincipal in secure modes. Bare WithSkipEnforcement is // FailOpen-only. ErrSkipNotPermitted = errors.New("tenancy: skip not permitted without system principal") // ErrAllowGlobalDenied is returned when SystemPrincipal.AllowGlobal is // set without the unexported framework migration marker and without // an allowlisted ServiceName on the provider. ErrAllowGlobalDenied = errors.New("tenancy: AllowGlobal not authorized for this service") // ErrInvalidCacheKeySegment is returned when a tenant/service segment // cannot be safely used as a cache key prefix. ErrInvalidCacheKeySegment = errors.New("tenancy: invalid cache key segment") // ErrCacheTenantRequired is returned when a tenant-aware cache is used // in a secure mode without a bindable tenant. ErrCacheTenantRequired = errors.New("tenancy: tenant required for cache key") )
Secure-mode acquire errors. Machines and interceptors should treat these as "not ready / not permitted to access storage" — map to FailedPrecondition on RPC paths (not PermissionDenied, which is reserved for ReBAC).
var ErrEnrollmentStrict = errors.New(
"tenancy: enrollment strict: model looks tenanted but does not implement Tenanted",
)
ErrEnrollmentStrict is returned when strict enrollment detects a model that looks tenanted but does not implement Tenanted and is not Unscoped.
Functions ¶
func ClaimsMiddleware ¶ added in v2.0.14
func ClaimsMiddleware(next http.Handler, b *ClaimsBinder) http.Handler
ClaimsMiddleware returns HTTP middleware that binds tenancy claims.
func EnsureFrameworkMigration ¶ added in v2.0.14
EnsureFrameworkMigration returns jobCtx with framework migration elevation if parent has it (or jobCtx already does). Used by migrator DB factories so nested acquires cannot drop elevation.
func IsFrameworkMigration ¶ added in v2.0.14
IsFrameworkMigration reports whether ctx carries the unexported migration elevation marker.
func IsSystemPrincipal ¶ added in v2.0.14
IsSystemPrincipal reports whether ctx carries a SystemPrincipal.
func NewClaimsInterceptor ¶
func NewClaimsInterceptor() connect.Interceptor
NewClaimsInterceptor returns a Connect interceptor that derives tenancy.Claims from auth claims and binds them to ctx. Zero-arg form uses a legacy binder (HonorInternalSkip=true, RequireClaims=false).
Register after the authentication interceptor so auth claims are available.
func NewClaimsInterceptorWithBinder ¶ added in v2.0.14
func NewClaimsInterceptorWithBinder(b *ClaimsBinder) connect.Interceptor
NewClaimsInterceptorWithBinder uses the supplied binder. Nil uses legacy defaults.
func StreamClaimsInterceptor ¶ added in v2.0.14
func StreamClaimsInterceptor(b *ClaimsBinder) grpc.StreamServerInterceptor
StreamClaimsInterceptor returns a gRPC stream interceptor that binds tenancy claims.
func UnaryClaimsInterceptor ¶ added in v2.0.14
func UnaryClaimsInterceptor(b *ClaimsBinder) grpc.UnaryServerInterceptor
UnaryClaimsInterceptor returns a gRPC unary interceptor that binds tenancy claims.
func ValidatePartitionIDs ¶ added in v2.0.14
ValidatePartitionIDs rejects partition IDs that would corrupt the CSV encoding used by storage providers (string_to_array on ','). Empty strings are ignored. Exported so providers can validate without reimplementing the rule.
func WithClaims ¶
WithClaims binds Claims to ctx. Nil claims leave ctx unchanged. Non-nil claims are normalized before binding so storage providers never see whitespace-padded IDs.
func WithExtraPartitions ¶
WithExtraPartitions reads the current Claims from ctx, extends them with the supplied partition IDs (preserving TenantID, AccessID, Skip), and binds the extended Claims to a child ctx. Returns ctx unchanged when no claims are present.
Use for service-on-behalf-of flows, cross-branch reporting, or any case where a principal legitimately needs visibility over additional partitions without changing tenant.
func WithFrameworkMigration ¶ added in v2.0.14
WithFrameworkMigration elevates ctx for Frame-owned migrate paths.
For Frame internals only (datastore/pool.Migrate, migration package). Application code cannot set the unexported marker from outside this module without the type; forging SystemPrincipal{Reason:"migration", AllowGlobal:true} alone does not authorize match-all.
Godoc: not part of the application tenancy API.
func WithSkipEnforcement ¶
WithSkipEnforcement returns a context that bypasses tenancy enforcement for any database query made through it. Use for migration scripts, admin tools, or system-level operations that legitimately need full-table access.
Internally this binds a Claims value with Skip=true. Providers do not bind session scope for Skip (same as missing claims): every row is visible. Prefer this over relying on "no claims" when you want the bypass to be explicit in call sites.
func WithSystemPrincipal ¶ added in v2.0.14
func WithSystemPrincipal(ctx context.Context, p SystemPrincipal) context.Context
WithSystemPrincipal binds a SystemPrincipal to ctx. The principal is normalized (trimmed IDs, deduped partitions).
Types ¶
type AllowGlobalAware ¶ added in v2.0.14
type AllowGlobalAware interface {
SetAllowGlobalServices(names ...string)
AllowGlobalServices() []string
}
AllowGlobalAware holds the service-name allowlist for AllowGlobal SystemPrincipal elevation. Implemented by tenancy/postgres.Provider.
type Capabilities ¶
type Capabilities struct {
// EnforcesAtStorage is true when the provider installs DB-side
// rules that block access without per-query gating (e.g., RLS,
// views). Used by the pool to skip any fallback scope it might
// otherwise have applied.
EnforcesAtStorage bool
}
Capabilities describes the runtime behaviour of a Provider.
type Claims ¶
type Claims struct {
// TenantID is the single tenant this principal belongs to.
TenantID string
// PartitionIDs are every partition this principal can access. One
// principal may legitimately span multiple partitions (e.g., an
// operator with access to several branches, an analyst aggregating
// across groups). Single-partition principals carry one element.
PartitionIDs []string
// AccessID is the membership grant used for this session (write
// attribution). Stamped onto rows at create; not used for RLS or Keto.
// Empty is allowed (service accounts, system jobs).
AccessID string
// Skip is true for internal/system callers that should bypass
// tenancy enforcement. Providers honour Skip by not binding session
// scope — the database-side policy's empty-match-all branch then
// keeps every row visible (same as missing claims).
Skip bool
}
Claims is the storage-layer view of a principal's tenancy. Treat as immutable: every transformation returns a new instance.
func ClaimsFromAuth ¶
func ClaimsFromAuth(ctx context.Context, auth *security.AuthenticationClaims, opts ...ClaimsFromAuthOption) *Claims
ClaimsFromAuth derives Claims from auth claims using the frame default mapping:
TenantID = auth.GetTenantID() PartitionIDs = auth.GetPartitionIDs() AccessID = auth.GetAccessID() Skip = (optional) auth.IsInternalSystem() || IsTenancyChecksOnClaimSkipped
The result is always normalized (trimmed, deduped partitions).
Default (no opts): HonorInternalSkip=true for back-compat. Secure Profile: ClaimsFromAuth(ctx, auth, WithHonorInternalSkip(false)).
func ClaimsFromContext ¶
ClaimsFromContext returns the bound Claims with graceful fallback:
- Explicit Claims bound via WithClaims (fastest path).
- Derived from security.AuthenticationClaims if present in ctx (job workers / services that haven't run the tenancy interceptor still get correct enforcement).
- nil — no tenancy in context; provider does not filter (and does not error). Use WithSkipEnforcement or omit claims for full-table access; bind claims when you want RLS filtering.
func ClaimsFromContextWithOptions ¶ added in v2.0.14
func ClaimsFromContextWithOptions(ctx context.Context, opts ...ClaimsFromAuthOption) *Claims
ClaimsFromContextWithOptions is like ClaimsFromContext but uses the supplied ClaimsFromAuth options when falling back to auth claims.
func (*Claims) ExtendPartitions ¶
ExtendPartitions returns a new Claims with the supplied partition IDs merged in. Preserves TenantID, AccessID, and Skip unchanged. Empty strings are ignored; duplicates are removed; existing order is kept and new IDs appended after. IDs are trimmed.
A nil receiver yields a fresh Claims carrying only the deduplicated non-empty partition IDs; TenantID, AccessID, and Skip default to zero values in that path.
func (*Claims) IsBindable ¶ added in v2.0.14
IsBindable reports whether claims can drive RLS session binding: non-nil, not Skip, non-empty, and with a non-empty TenantID after trim. Partition-only claims are not bindable under Secure Profile rules.
func (*Claims) IsEmpty ¶
IsEmpty reports whether the claims carry enforceable tenancy. Empty claims behave identically to "no claims attached" from a provider's perspective (no filtering, no error). Whitespace-only IDs are treated as empty (call Normalize first for full sanitisation).
func (*Claims) Normalize ¶ added in v2.0.14
Normalize returns a copy with trimmed IDs, empty partitions dropped, and partition IDs deduplicated (order preserved). A nil receiver yields nil. The receiver is never mutated.
func (*Claims) Validate ¶ added in v2.0.14
Validate reports whether the claims are safe to bind to a storage session. Empty claims and Skip claims always pass (providers do not bind session scope for them). Non-empty claims must have well-formed tenant and partition identifiers.
Rules:
- tenant id must not contain ','
- no partition id may contain ',' (CSV separator for session GUCs)
Call Normalize before Validate when inputs may carry surrounding whitespace.
type ClaimsBinder ¶ added in v2.0.14
type ClaimsBinder struct {
// HonorInternalSkip when true (default) maps roles=internal and
// SkipTenancyChecksOnClaims to Claims.Skip. Secure Profile sets false.
HonorInternalSkip bool
// RequireClaims when true fails Bind early if no bindable tenancy is
// present after auth (Secure Profile / Hybrid interceptors).
RequireClaims bool
}
ClaimsBinder derives and binds storage-layer tenancy.Claims for request paths. Construct once at service start from Secure Profile options — do not use package globals.
func NewClaimsBinder ¶ added in v2.0.14
func NewClaimsBinder(secure bool) *ClaimsBinder
NewClaimsBinder returns a binder with Secure Profile defaults when secure is true (HonorInternalSkip=false, RequireClaims=true). Legacy defaults: HonorInternalSkip=true, RequireClaims=false.
type ClaimsFromAuthOption ¶ added in v2.0.14
type ClaimsFromAuthOption func(*claimsFromAuthConfig)
ClaimsFromAuthOption configures ClaimsFromAuth. Options are applied per call — no package globals.
func WithHonorInternalSkip ¶ added in v2.0.14
func WithHonorInternalSkip(v bool) ClaimsFromAuthOption
WithHonorInternalSkip controls whether roles=internal and SkipTenancyChecksOnClaims map to Claims.Skip.
type ModeAware ¶ added in v2.0.14
type ModeAware interface {
SetSecurityMode(SecurityMode)
SecurityMode() SecurityMode
}
ModeAware is implemented by providers that honour SecurityMode. Custom WithTenancyProvider values that do not implement ModeAware ignore service mode configuration.
type ModelInfo ¶
type ModelInfo struct {
// Table is the SQL table name resolved through GORM's naming
// strategy.
Table string
// TenantColumn is the SQL column carrying the tenant identifier
// (conventionally "tenant_id").
TenantColumn string
// PartitionColumn is the SQL column carrying the partition
// identifier (conventionally "partition_id").
PartitionColumn string
}
ModelInfo describes one tenancy-enrolled model for Install. Built by the tenancy package via reflective enrollment; providers don't reimplement detection.
All fields are required. The conventional values are "tenant_id" / "partition_id"; the tenancy package's enrollment code populates them from those conventions, and providers MUST NOT assume defaults if a caller hand-builds a ModelInfo.
func EnrolledModels ¶
EnrolledModels filters the supplied migration models, returning ModelInfo for those that satisfy the Tenanted interface and do NOT satisfy Unscoped. Tenant and partition column names default to the conventional "tenant_id" / "partition_id"; future overrides can come from per-model tags but are not required today.
The supplied *gorm.DB is used only as a statement context for table name resolution — no queries are executed. GORM's statement parser honours any TableName() string method or gorm struct tags on the model, so custom table-name overrides are respected.
func EnrolledModelsWithOptions ¶ added in v2.0.14
EnrolledModelsWithOptions is EnrolledModels with optional strict detection. When strict is true, models that look tenanted (TenantID field, tenant_id gorm tag, or embedded BaseModel) but do not implement Tenanted and are not Unscoped cause an error.
type Provider ¶
type Provider interface {
// Name returns a short, stable identifier ("postgres-rls") used in
// logs and diagnostics.
Name() string
// Capabilities advertises what the provider does so the pool can
// decide whether a complementary fallback (e.g., GORM scope) is
// required.
Capabilities() Capabilities
// Install applies storage-side enforcement schema (RLS policies,
// views, etc.) for the supplied models. Called once per migration.
// Implementations MUST be idempotent — Frame re-runs migration on
// every boot.
Install(ctx context.Context, db *gorm.DB, models []ModelInfo) error
// WireAdapter registers dialect-level hooks. Called once when the
// pool is constructed, BEFORE any connection is opened. Providers
// that enforce per-acquire (Postgres-RLS) register here.
WireAdapter(adapter dialect.DialectAdapter) error
// WireGorm registers GORM-level callbacks on the supplied *gorm.DB.
// Called once per opened connection. Providers that enforce
// per-query (alternative dialects without per-acquire hooks)
// register here. Postgres-RLS implements as a no-op.
WireGorm(db *gorm.DB) error
}
Provider installs and enforces tenancy isolation at the storage layer. Implementations are database-specific; the bundled Postgres provider uses Row-Level Security policies, others might use views, query rewriting, or a different scheme entirely.
type SecurityMode ¶ added in v2.0.14
type SecurityMode int
SecurityMode controls how missing, empty, or skipped tenancy claims are handled at storage acquire time.
Stock default is ModeFailOpen (historical behaviour). Production services should enable ModeHybrid via the Secure Profile. See docs/superpowers/specs/2026-07-25-transparent-multi-tenant-isolation.md.
const ( // ModeFailOpen preserves historical behaviour: nil/empty/Skip → no // filter, no error. Default for compatibility. ModeFailOpen SecurityMode = iota // ModeFailClosed rejects connection acquire when claims are // missing/empty/invalid/partition-only, unless an authorized // SystemPrincipal authorizes match-all (AllowGlobal) or a scoped // SystemPrincipal / normal Claims bind applies. ModeFailClosed // ModeHybrid is the recommended production posture (Secure Profile). // Acquire rules match ModeFailClosed for match-all/Skip in v1. ModeHybrid )
func ParseSecurityMode ¶ added in v2.0.14
func ParseSecurityMode(s string) (SecurityMode, bool)
ParseSecurityMode parses FRAME_TENANCY_SECURITY_MODE style values. Empty or unknown values yield ModeFailOpen and false.
func (SecurityMode) IsSecure ¶ added in v2.0.14
func (m SecurityMode) IsSecure() bool
IsSecure reports whether the mode rejects unbound acquires.
func (SecurityMode) String ¶ added in v2.0.14
func (m SecurityMode) String() string
String returns a stable config/env token for the mode.
type SystemPrincipal ¶ added in v2.0.14
type SystemPrincipal struct {
ServiceName string
Reason string // logs/metrics ONLY — never used for authorization
TenantID string // if set and !AllowGlobal, beforeAcquire binds this scope
PartitionIDs []string
AllowGlobal bool // match-all; requires provider allowlist OR framework marker
}
SystemPrincipal elevates process-local work (jobs, admin exports) without relying on ambient roles=internal Skip. Prefer scoped TenantID over AllowGlobal. Reason is for logs/metrics only — never authorization.
For service-to-service "on behalf of tenant" RPCs, continue to use security.EnrichTenancyClaims with internal JWTs (binds RLS, no Skip under Secure Profile). SystemPrincipal is for process-local elevation.
func SystemPrincipalFromContext ¶ added in v2.0.14
func SystemPrincipalFromContext(ctx context.Context) (SystemPrincipal, bool)
SystemPrincipalFromContext returns the bound SystemPrincipal if any.
type Tenanted ¶
type Tenanted interface {
GetTenantID() string
GetPartitionID() string
GetAccessID() string
SetTenantID(string)
SetPartitionID(string)
SetAccessID(string)
}
Tenanted is the structural interface a model must satisfy to be enrolled in tenancy enforcement. data.BaseModel satisfies it; custom models that want enrollment can satisfy it explicitly.
The tenancy package never imports the data package — enrollment is purely structural, so downstream services can roll their own tenanted base type if needed.
type Unscoped ¶
type Unscoped interface {
TenancyUnscoped()
}
Unscoped opts a model out of tenancy enforcement. Implement this interface to skip RLS policy installation for the model's table. The canonical way to satisfy it is to embed UnscopedMarker:
type LookupTable struct {
ID string
tenancy.UnscopedMarker
}
type UnscopedMarker ¶
type UnscopedMarker struct{}
UnscopedMarker is an empty struct satisfying Unscoped. Embed it in a model to opt out of tenancy enforcement.
func (UnscopedMarker) TenancyUnscoped ¶
func (UnscopedMarker) TenancyUnscoped()
TenancyUnscoped implements Unscoped.