tenancy

package
v2.0.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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).

View Source
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

func EnsureFrameworkMigration(jobCtx, parent context.Context) context.Context

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

func IsFrameworkMigration(ctx context.Context) bool

IsFrameworkMigration reports whether ctx carries the unexported migration elevation marker.

func IsSystemPrincipal added in v2.0.14

func IsSystemPrincipal(ctx context.Context) bool

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

func ValidatePartitionIDs(ids []string) error

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

func WithClaims(ctx context.Context, c *Claims) context.Context

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

func WithExtraPartitions(ctx context.Context, partitionIDs ...string) context.Context

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

func WithFrameworkMigration(ctx context.Context) context.Context

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

func WithSkipEnforcement(ctx context.Context) context.Context

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

func ClaimsFromContext(ctx context.Context) *Claims

ClaimsFromContext returns the bound Claims with graceful fallback:

  1. Explicit Claims bound via WithClaims (fastest path).
  2. Derived from security.AuthenticationClaims if present in ctx (job workers / services that haven't run the tenancy interceptor still get correct enforcement).
  3. 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

func (c *Claims) ExtendPartitions(partitionIDs ...string) *Claims

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

func (c *Claims) IsBindable() bool

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

func (c *Claims) IsEmpty() bool

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

func (c *Claims) Normalize() *Claims

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

func (c *Claims) Validate() error

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.

func (*ClaimsBinder) Bind added in v2.0.14

func (b *ClaimsBinder) Bind(ctx context.Context) (context.Context, error)

Bind derives tenancy claims from auth context and binds them. Returns ErrClaimsRequired when RequireClaims is set and no bindable claims result (and no SystemPrincipal).

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

func EnrolledModels(db *gorm.DB, models []any) ([]ModelInfo, error)

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

func EnrolledModelsWithOptions(db *gorm.DB, models []any, strict bool) ([]ModelInfo, error)

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.

Directories

Path Synopsis
Package postgres provides the Postgres concrete tenancy.Provider.
Package postgres provides the Postgres concrete tenancy.Provider.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL