tenant

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package tenant holds the layer-free tenancy value types shared across all GoCell layers: the TenantID sealed newtype (the multi-tenant isolation boundary identifier) and the RowScope authorization obligation enum.

It lives under pkg/ (stdlib + google/uuid only) precisely because every layer — kernel/, runtime/, cells/, adapters/ — must be able to reference these types: TenantID becomes a mandatory typed positional parameter on tenant-scoped repo methods (PR-2; "漏传" = compile error), and RowScope becomes a typed obligation consumed by list/get repo methods (PR-4).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func WithScope

func WithScope(ctx context.Context, t TenantID) context.Context

WithScope returns a context carrying t as the transaction RLS tenant scope.

WRITE-SIDE TRUST BOUNDARY: the value placed here is what RunInTx interpolates into the per-transaction RLS GUC (`set_config('app.tenant_id', …, true)`), so WithScope is the tenant-isolation write boundary for any path that does not already carry an authenticated-principal ctxkeys.TenantID. Its production callers are pinned by archtest TENANT-TXSCOPE-WRITE-CALLER-01. Business cells MUST NOT call it for ordinary post-auth work — there the tenant flows from the authenticated principal (the ctxkeys.TenantID fallback). The sanctioned callers are pre-auth derivation sites and the internal control-plane read path.

t is stored as-is; RunInTx defensively Validate()s it before use and fails closed on any non-canonical value, so a malformed scope can never reach the GUC.

Types

type RowSQLPredicate

type RowSQLPredicate struct {
	// Apply reports whether the owner predicate should be appended at all.
	Apply bool
	// Prefix is the WHERE fragment up to (but excluding) the placeholder, e.g.
	// " AND actor_id = "; the builder appends the "$N" placeholder after it.
	Prefix string
	// Arg is the bound subject value for the placeholder (always a bound
	// parameter, never interpolated).
	Arg any
}

RowSQLPredicate is the parameterized owner-predicate fragment produced by RowVisibility.SQLPredicate. It is shaped to compose with a parameterized query builder that assigns the placeholder number, e.g. pgquery.Builder.AppendIf(p.Apply, p.Prefix, p.Arg). The zero value (Apply false) means "no owner predicate".

type RowScope

type RowScope uint8

RowScope is the authorization obligation that decides the row-visibility predicate a tenant-scoped list/get repo method must apply. It is the GoCell typed projection of the XACML PDP→PEP obligation model (a bounded set of scopes, not an OPA-style partial-evaluation AST residue): the policy layer emits a RowScope, the repo layer (PEP) enforces it.

RowScope lives in pkg/tenant only because it must be referenceable from every layer with no dependency cycle; it is NOT a tenancy primitive. Should the value set ever grow past these four, the migration target is pkg/authz/.

The zero value is intentionally invalid (iota+1, mirroring kernel/saga.Status and kernel/outbox.Disposition) so an unset obligation can never be mistaken for a permissive scope.

This is a TYPE-ONLY foundation in PR-1; repo methods begin taking RowScope as a mandatory positional parameter in PR-4.

const (
	// RowScopeSelf restricts visibility to rows owned by the subject
	// (owner_id = $subject); the non-privileged user default.
	RowScopeSelf RowScope = iota + 1
	// RowScopeDevice restricts visibility to rows belonging to the device
	// subject (device_id = $subject); the principalKind=device default.
	RowScopeDevice
	// RowScopeTenant grants visibility to all rows within the caller's tenant;
	// the admin default.
	RowScopeTenant
	// RowScopeAll grants cross-tenant visibility; reserved for the explicit,
	// audited platform super-admin path only.
	RowScopeAll
)

func (RowScope) String

func (rs RowScope) String() string

String returns the wire/log spelling of the scope, or "invalid" for the zero value and any out-of-range value.

func (RowScope) Valid

func (rs RowScope) Valid() bool

Valid reports whether rs is one of the four defined scopes.

func (RowScope) Validate

func (rs RowScope) Validate() error

Validate returns an error for the zero value or any out-of-range value.

type RowVisibility

type RowVisibility struct {
	// contains filtered or unexported fields
}

RowVisibility is the self-contained row-level authorization obligation that a tenant-scoped list/get repo (the PEP) enforces: a RowScope paired with the subject identity that "self"/"device" resolve to. It is the GoCell typed projection of the XACML <Obligation> (an ObligationId + its AttributeAssignments travel as ONE value from the decision point to the enforcement point) — and the structural analog of Oso data filtering's Filter value object, which is a returned value the data layer translates rather than a live query handle. That shape is mandatory here because pkg/tenant has no layer dependencies (stdlib + google/uuid only): RowVisibility can emit a SQL fragment string + arg, never hold a *sql.DB / ORM query.

Sealed construction

RowVisibility's fields are unexported and the only constructor is NewRowVisibility, which validates the obligation. A populated RowVisibility{…} literal is not expressible outside this package, so a caller cannot FORGE a RowScopeAll (cross-tenant) obligation by struct literal — mirroring the errcode.PublicDetail / outbox.Entry sealed-construction pattern. Combined with RowVisibility being a mandatory typed positional parameter on the repo interfaces (ROWSCOPE-REPO-PARAM-FUNNEL-01), "forget the obligation" and "forge the obligation" are both compile-time impossible.

Canonical form

The {scope, subject} pair has a canonical validity invariant enforced at construction (and re-checked by Validate): RowScopeSelf / RowScopeDevice REQUIRE a non-empty subject (the owner the row must belong to), while RowScopeTenant / RowScopeAll REQUIRE an EMPTY subject (they carry no owner identity). A tenant/all obligation with a non-empty subject is rejected fail-closed rather than silently ignored — so two semantically-equal obligations are byte-equal values and Subject() is always "" for tenant/all. This keeps the value object usable as a scope key (e.g. the auditquery cursor fingerprint) without a non-canonical subject leaking into the scope on one page and not another.

Owner dimension only

RowVisibility enforces the OWNER dimension of row visibility:

  • RowScopeSelf / RowScopeDevice → owner column = subject (the row's owner).
  • RowScopeTenant / RowScopeAll → no owner predicate (every owner is visible).

The TENANT boundary is a SEPARATE, per-table layer that this obligation does NOT touch (PG RLS via app.tenant_id for accesscore tables; AuditFilters.TenantID for audit_entries, which carries no RLS). RowScopeAll's cross-tenant bypass of that boundary is therefore a tenant-boundary concern, wired at the super-admin path in PR-5 (身份→RowScope 收窄 + RowScope=all 强制审计).

SQLPredicate and Allows below are PURE OWNER-DIMENSION TRANSLATORS: for the owner dimension RowScopeAll genuinely means "no owner predicate" (== tenant), so they translate it as such and PR-5 reuses them once the audited path lands. Whether RowScopeAll is PERMITTED AT ALL today is an orthogonal enforcement-layer decision: in PR-4 no caller constructs it, and enforcement layers fail-close any RowScopeAll that reaches them rather than silently degrade it to tenant scope (e.g. the audit ledger store returns ledger.RowScopeAllUnsupportedError).

func NewRowVisibility

func NewRowVisibility(scope RowScope, subject string) (RowVisibility, error)

NewRowVisibility constructs a validated RowVisibility obligation. It is the sole constructor (the fields are unexported). scope must be a defined RowScope; RowScopeSelf and RowScopeDevice require a non-empty subject (the owner the row must belong to) — an empty subject under a self/device scope would silently widen visibility to every row. RowScopeTenant and RowScopeAll require an EMPTY subject (they carry no owner identity); a non-empty subject is a malformed obligation and is rejected. Both rules are the canonical-form invariant checked by Validate (see type doc).

func (RowVisibility) Allows

func (v RowVisibility) Allows(ownerValue string) bool

Allows reports whether a row whose owner column holds ownerValue is visible under this obligation's OWNER dimension. It is the in-memory mirror of SQLPredicate, used by mem-backed repos and as a post-fetch check:

  • RowScopeSelf / RowScopeDevice → ownerValue == subject.
  • RowScopeTenant / RowScopeAll → always true (no owner restriction).

As with SQLPredicate, the tenant boundary is enforced separately; Allows only covers the owner dimension.

func (RowVisibility) SQLPredicate

func (v RowVisibility) SQLPredicate(ownerColumn string) (RowSQLPredicate, error)

SQLPredicate translates the obligation's OWNER dimension into a parameterized SQL WHERE fragment for the given owner column:

  • RowScopeSelf / RowScopeDevice → Apply=true, Prefix=" AND <ownerColumn> = ", Arg=subject. The builder appends the "$N" placeholder after Prefix, so the subject is always a bound parameter (never interpolated).
  • RowScopeTenant / RowScopeAll → zero RowSQLPredicate (Apply=false, no owner predicate). The tenant boundary is enforced separately (see type doc).

It returns an error for an invalid obligation (zero value / self|device without subject) or an ownerColumn that is not a SQL identifier (defense in depth — the column is a repo-supplied constant, not user input).

func (RowVisibility) Scope

func (v RowVisibility) Scope() RowScope

Scope returns the RowScope obligation.

func (RowVisibility) Subject

func (v RowVisibility) Subject() string

Subject returns the subject identity that self/device scopes resolve to. It is always "" for tenant/all (the canonical form rejects a non-empty subject there; see type doc).

func (RowVisibility) Validate

func (v RowVisibility) Validate() error

Validate returns an error if the obligation is not a well-formed value: zero value / invalid scope, a self/device scope with an empty subject, or a tenant/all scope with a NON-empty subject (the canonical-form invariant — see type doc). Repo methods receive RowVisibility as a typed positional parameter; calling Validate is the runtime fail-closed backstop to the compile-time "漏传 = 编译失败" guarantee.

type TenantID

type TenantID string

TenantID identifies the tenant isolation boundary. Unlike idutil.SafeID — whose zero value is the legitimate "absent" representation — an empty TenantID is INVALID: a tenant-scoped query without a tenant cannot express a valid isolation predicate (Validate rejects it). The "no tenant" (single-tenant) case is represented at the ctx/wire layer by the absence of the key, never by an empty TenantID reaching a repo.

A non-empty TenantID MUST be a canonical (lowercase) UUID. GoCell issues tenant identifiers itself and stores them in a PostgreSQL TEXT column; the PR-3 RLS predicate compares that TEXT column to the app.tenant_id GUC as TEXT (an unset/empty GUC maps to NULL via NULLIF, yielding 0 rows — fail-closed), so the canonical-UUID type boundary here is what makes that TEXT-to-TEXT compare exact (both sides are the same canonical lowercase form).

Empty/malformed rejection is RUNTIME fail-fast (Validate / ParseTenantID / UnmarshalJSON), not type-system Hard — an empty string is a legal Go expression. The type-system Hard guarantee ("漏传 tenant 编译失败") comes from repo methods taking TenantID as a mandatory positional parameter (PR-2).

ref: pkg/idutil.SafeID — sealed-newtype + UnmarshalJSON fail-closed pattern.

func FromContext

func FromContext(ctx context.Context) (TenantID, error)

FromContext is the fail-closed read-side bridge between the ctx tenant key (written only at the trusted auth boundary — runtime/auth/middleware.go's injectPrincipalCtxKeys, or the consumer-side outbox restore, per CTXKEYS-PRINCIPAL-WRITE-CALLER-01) and the TenantID typed positional parameter that tenant-scoped repo methods require.

It is the single sanctioned source a tenant-scoped service callsite uses to obtain the TenantID it must pass to the repo layer. Post-auth handlers and event consumers (after RestoreToContext) call it once and thread the result into the repo method.

FromContext fails closed: a missing key OR an empty / non-canonical value is an error, never a silent zero value. A tenant-scoped query reaching the repo without a tenant is a bug (the auth bridge must have populated the key for any tenant-bearing principal), so refusing here keeps the "no tenant predicate" failure mode impossible to express by accident. ParseTenantID re-validates (and canonicalizes) defensively even though the auth bridge already did so.

CLASSIFICATION — the failure-path error is a typed *errcode.Error classified as KindPermissionDenied (HTTP 403 Forbidden), single-sourced here so every tenant-scoped handler maps a missing/invalid tenant to a 403 instead of a 500. The semantics are "authenticated principal lacks a tenant scope": the caller is authenticated (the auth boundary ran) but presents no usable tenant predicate, which is an authorization failure, not an internal fault. The returned value is still an `error`, so non-HTTP callers (accesscore services, event consumers) are unaffected; HTTP cell adapters inspect the code via errors.As and return the generated typed 4xx response struct. The ParseTenantID cause is carried (errcode.Wrap) for server-side logging / errors.Is chains but is stripped from the wire on the 4xx envelope.

Pre-authentication paths that have no ctx tenant (e.g. sessionlogin, which derives the tenant from the request body's TenantID, and sessionrefresh, which reads users by the trusted global UUID primary key) do NOT use FromContext; they obtain the TenantID from their own request-scoped source or use the by-PK tenant-deriving read carve-out (UserRepository.GetByID).

func ParseTenantID

func ParseTenantID(s string) (TenantID, error)

ParseTenantID validates s and returns a canonical (lowercase) TenantID. Empty input is an error (TenantID has no absent semantic). Non-UUID input is an error.

SECURITY — untrusted-input boundary: ParseTenantID rejects the reserved nil-UUID ("00000000-0000-0000-0000-000000000000"). The nil-UUID is an invalid tenant identifier; allowing an external caller to supply it via a JWT claim or X-Tenant-ID header must be prevented at the parse boundary (F2 security fix).

func ScopeFromContext

func ScopeFromContext(ctx context.Context) (TenantID, bool)

ScopeFromContext returns the transaction tenant scope and whether one is present. The read side is unrestricted (mirrors the ctxkeys XxxIDFrom getters); only the write side (WithScope) is funneled.

func (TenantID) MarshalJSON

func (t TenantID) MarshalJSON() ([]byte, error)

MarshalJSON emits t as a JSON string. Implements json.Marshaler.

func (TenantID) String

func (t TenantID) String() string

String returns the underlying string.

func (*TenantID) UnmarshalJSON

func (t *TenantID) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes data into t, failing closed if the JSON value is not a string OR if the decoded string is empty / not a valid UUID. On success the value is canonicalized to lowercase UUID form. Implements json.Unmarshaler.

Routes through ParseTenantID (the untrusted-input constructor) so that the reserved nil-UUID is rejected at this boundary too — a JSON payload supplying "00000000-0000-0000-0000-000000000000" is an invalid tenant identifier (F2 security fix).

func (TenantID) Validate

func (t TenantID) Validate() error

Validate returns nil only for a non-empty, non-reserved TenantID that is ALREADY in canonical form: a 36-char dashed, lowercase UUID. The empty value is rejected (a tenant boundary cannot be absent at the point a TenantID is required), the reserved nil-UUID ("00000000-0000-0000-0000-000000000000") is rejected, and any non-canonical form — including a valid-but-uppercase UUID — is rejected.

Validate does NOT normalize the receiver: it asserts the value is canonical rather than coercing it. This makes Validate a true post-construction invariant check (e.g. on a TenantID repo parameter) — a value that passes is safe to use verbatim against the PostgreSQL TEXT column / RLS predicate. To obtain the canonical lowercase form from a raw (possibly uppercase) string, use ParseTenantID, which normalizes.

Jump to

Keyboard shortcuts

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