stdcrpcenttenancyfx

package
v0.0.230 Latest Latest
Warning

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

Go to latest
Published: May 13, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package stdcrpcenttenancyfx maps a per-RPC `db_role` proto annotation to a per-transaction Postgres role plus a tenancy GUC, via the stdent [BeginHook] extension point. The role decision is made at the wire boundary by a Connect interceptor — NOT by inspecting JWT claim shape or context markers at transaction-begin time. Composition roots supply the per-tenant role names through `STDCRPCENTTENANCY_*_DATABASE_ROLE` env vars (env prefix derived from the module name `stdcrpcenttenancy`).

The hook is contributed to the fx graph as an stdent.BeginHookFunc, which [stdenttxfx.New] picks up via its optional `TxBeginSQL` parameter and installs on every Transactor it creates. There is no per-package opt-in: any package using the rw / ro transactors automatically runs through the role switch + GUC injection on transaction begin.

Three switch positions, driven by the DatabaseRole stamped on ctx by the stdcrpcenttenancyfx interceptor (or, for trusted internal callers without an inbound RPC, by WithDatabaseRole gated behind a project-wide forbidigo lint rule):

  1. DatabaseRoleSysuser → `SET LOCAL ROLE <SystemDatabaseRole>` (BYPASSRLS). Selected by the proto annotation `DB_ROLE_SYSUSER` for RPCs that legitimately cross tenant boundaries (admin, dev/test reset, system bootstrap).
  2. DatabaseRoleAnonymous → `SET LOCAL ROLE <AnonymousDatabaseRole>`. Selected by `DB_ROLE_ANONYMOUS`. Cannot read or write tenanted tables, regardless of GUC value.
  3. DatabaseRoleWebuser → `SELECT set_config('<TenantIDGUC>', tenant, true); SET LOCAL ROLE <WebUserDatabaseRole>`. Selected by `DB_ROLE_WEBUSER`. The GUC value is read from the required TenantIDResolver (empty-string sentinel when no tenant is in scope for the request — e.g. an M2M token without an org claim). The package is identity-agnostic — binding the resolver to a JWT claim, an API-key lookup, or anything else is the composition root's job, not stdcrpcenttenancyfx's.

`SET LOCAL` is transaction-scoped and reverts at COMMIT/ROLLBACK, so the role switch and the GUC are scoped to exactly the transaction the hook fires on; the underlying connection (typically a privilege-less `_authenticator` LOGIN role) retains its original identity for any subsequent transaction.

The "DatabaseRole" name is deliberate: this package's "role" is the Postgres role posture a transaction runs in, and a consumer's codebase usually also carries an unrelated user-role concept (e.g. from JWT claims). Calling this just `Role` would conflate the two at the import site.

Index

Constants

This section is empty.

Variables

View Source
var ErrMissingDatabaseRole = errors.New(
	"stdcrpcenttenancyfx: refusing to open a transaction with no database role on ctx. " +
		"Inside an RPC handler this means the stdcrpcenttenancyfx interceptor was " +
		"not wired into the handler chain (or the proto method is missing " +
		"its `db_role` annotation). " +
		"Outside an RPC (Temporal activities, system bootstrap, test seed " +
		"helpers), call stdcrpcenttenancyfx.WithDatabaseRole(ctx, " +
		"stdcrpcenttenancyfx.DatabaseRoleSysuser) before calling " +
		"stdcrpcenttenancyfx.Transact / Transact0",
)

ErrMissingDatabaseRole is returned by Transact / Transact0 (and the BeginHook as a defense-in-depth backstop) when ctx carries no database role. In production this is a programmer error: every RPC handler runs under the stdcrpcenttenancyfx interceptor which stamps a role from the proto annotation, and trusted internal callers must explicitly stamp a role via WithDatabaseRole.

View Source
var ErrUnmanagedTransaction = errors.New(
	"stdcrpcenttenancyfx: refusing to open a transaction that did not go through " +
		"stdcrpcenttenancyfx.Transact / stdcrpcenttenancyfx.Transact0. " +
		"Calling stdent.Transact* directly with the rw/ro transactor bypasses the " +
		"package's audit chokepoint (where future cross-cutting auth concerns will " +
		"live). Fix: replace `stdent.Transact0(ctx, h.rw, fn)` with " +
		"`stdcrpcenttenancyfx.Transact0(ctx, h.rw, fn)` (same signature). " +
		"If this code path is trusted internal code that legitimately needs to " +
		"cross tenant boundaries (Temporal activities, system bootstrap, test seed " +
		"helpers), stamp the ctx with stdcrpcenttenancyfx.WithDatabaseRole(ctx, " +
		"stdcrpcenttenancyfx.DatabaseRoleSysuser) before calling Transact / Transact0 — " +
		"consumers typically gate that import-site form behind a forbidigo lint, " +
		"NOT a direct stdent.Transact* call",
)

ErrUnmanagedTransaction is returned by Authorize.BeginHook when a transaction is opened against one of the package-managed transactors WITHOUT going through Transact / Transact0 first. Exposed as a sentinel so callers (and tests) can `errors.Is` it rather than match on message text.

In practice the developer who triggers this sees the error wrapped twice (`failed to setup tx, rolled back: setup hook: <this>`); the stdcrpcenttenancyfx-prefixed message below is the leaf and is intentionally long because there is no second chance to direct the developer at the right fix.

Functions

func ProtoExtensionDatabaseRole

func ProtoExtensionDatabaseRole(ext protoreflect.ExtensionType) fx.Option

ProtoExtensionDatabaseRole returns an fx.Option that provides a DatabaseRoleResolver backed by the given protobuf method-option extension type. Mirrors stdcrpcauthfx.ProtoExtensionScope so wiring stays uniform: a composition root passes `stdcrpcenttenancyfx.ProtoExtensionDatabaseRole(rpcv1.E_DbRole)` next to `stdcrpcauthfx.ProtoExtensionScope(rpcv1.E_RequiredPermission)`.

func Provide

func Provide() fx.Option

Provide wires the package as an fx module. Env vars are read with the prefix `STDCRPCENTTENANCY_` (derived from the module name).

Callers must also supply a DatabaseRoleResolver in the same fx graph — typically via ProtoExtensionDatabaseRole passing the app's `db_role` extension type (e.g. `rpcv1.E_DbRole`).

func Transact

func Transact[T stdent.Tx, I any, O any, IP interface{ *I }, OP interface{ *O }](
	ctx context.Context,
	txr *stdent.Transactor[T],
	inp IP,
	fn func(ctx context.Context, tx T, inp IP) (OP, error),
) (OP, error)

Transact is the supported way for handler / activity code to open an ent transaction. It funnels through stdent.Transact1, which fires this package's Authorize.BeginHook before the first query — so every transaction observably runs as the per-tenant Postgres role driven by ctx (anonymous / webuser / sysuser) and carries the `access.tenant_id` GUC when authenticated.

The role decision is made at the wire boundary by the stdcrpcenttenancyfx Connect interceptor, which reads the procedure's proto annotation and stamps a DatabaseRole on ctx via WithDatabaseRole. Trusted internal callers without an inbound RPC (Temporal activities, system bootstrap, test seed helpers) call WithDatabaseRole directly with DatabaseRoleSysuser (gated by a project-wide forbidigo lint rule that requires a justification comment). Either way, by the time Transact runs, ctx MUST already carry a role — it does not infer one from claim shape. A ctx with no role is rejected with ErrMissingDatabaseRole before any query runs.

Splitting handlers into an outer method (calls Transact) and an inner method (receives T) is what lets this package own the proof that the role switch happened: the package's three-posture integration test exercises the same Transact helper production calls and observes `current_user` / `current_setting` inside the resulting tx. No black-box wire introspection (e.g. echoing `current_user` over an RPC) is needed.

Generic over T = stdent.Tx so the package stays free of any consumer's generated `*ent.Tx` type. Handlers instantiate it as `Transact[*ent.Tx, ReqT, RespT]`.

The inp / fn shape (taking a typed input + returning a typed output) mirrors common request/response transactional helpers.

Going around Transact (e.g. calling stdent.Transact1 directly with the same transactor) is detected at runtime: see [managedTxKey] and the check at the top of Authorize.BeginHook. Such a tx will be rolled back at begin time with an actionable error pointing the developer at this helper.

func Transact0

func Transact0[T stdent.Tx](
	ctx context.Context,
	txr *stdent.Transactor[T],
	fn func(ctx context.Context, tx T) error,
) error

Transact0 is Transact for the common case where the inner function neither needs a typed input nor returns a typed output (probes, fire-and-forget mutations). Mirrors stdent.Transact0 but funnels through this package so the BeginHook is guaranteed to run AND so the runtime "managed tx" check (see Transact) accepts the begin.

func WithDatabaseRole

func WithDatabaseRole(ctx context.Context, role DatabaseRole) context.Context

WithDatabaseRole returns a copy of ctx with the given database role stamped on it. The role is read by Authorize.BeginHook when a transaction is opened against an stdcrpcenttenancyfx-managed transactor.

In production this function has exactly two legitimate callers:

  • the stdcrpcenttenancyfx Connect interceptor, which stamps the role declared by the procedure's `db_role` proto annotation;
  • trusted internal code paths that have no inbound RPC (Temporal activities, system bootstrap, test seed helpers), which stamp DatabaseRoleSysuser explicitly.

All other call sites are bypasses of the proto-driven role decision and are gated by a project-wide forbidigo lint rule that requires a `//nolint:forbidigo // <one-line justification>` comment at every call site. Code review should treat any new nolint as a privilege escalation that needs an explicit reason.

Types

type Authorize

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

Authorize is the per-request → per-tx role/GUC mapper. Held as a struct (rather than a free function) so future stateful dependencies (e.g. an org-membership lookup that scopes a sysuser tx to a single org) become an additive change rather than a signature change.

func (*Authorize) BeginHook

func (a *Authorize) BeginHook(
	ctx context.Context, sqlb *strings.Builder, _ entdialect.ExecQuerier,
) (*strings.Builder, error)

BeginHook is the stdent.BeginHookFunc this package contributes. It appends `SET LOCAL ROLE` plus (when the role is webuser) a `set_config` of the [Config.TenantIDGUC] to the transaction's setup SQL. The hook signature requires us to APPEND to sqlb and return the same builder; the stdent driver flushes the accumulated SQL inside the transaction.

Two runtime backstops fire here as defense-in-depth against developers calling stdent.Transact* directly (see [managedTxKey] and [databaseRoleKey] godoc for the rationale):

In both cases the stdent driver rolls the tx back before any query runs.

func (*Authorize) Interceptor

func (a *Authorize) Interceptor(
	resolver DatabaseRoleResolver, logs *zap.Logger,
) connect.UnaryInterceptorFunc

Interceptor returns the Connect unary interceptor that, for every inbound server-side call, reads the procedure's DatabaseRole from the DatabaseRoleResolver and stamps it on ctx via WithDatabaseRole. The interceptor is registered alongside the other rpc interceptors; its presence is what makes the BeginHook's ctx-driven role decision possible.

On the client side it is a no-op — the role decision is server-side only, and a misconfigured client interceptor would silently corrupt the BeginHook's view of the ctx.

On the server side, a missing or invalid annotation is surfaced as `connect.CodeInternal` rather than swallowed: the boot-time validation in New should already have failed for any such method, so reaching this branch indicates a registry / wiring inconsistency.

type Config

type Config struct {
	// AnonymousDatabaseRole is the Postgres role assumed when the request's
	// proto annotation resolves to [DatabaseRoleAnonymous]. Must NOT have
	// BYPASSRLS.
	AnonymousDatabaseRole string `env:"ANONYMOUS_DATABASE_ROLE,required"`
	// SystemDatabaseRole is the Postgres role assumed when the request's
	// proto annotation resolves to [DatabaseRoleSysuser] (or when ctx is
	// stamped with [DatabaseRoleSysuser] via [WithDatabaseRole]). Must
	// have BYPASSRLS — it is the only role permitted to read/write
	// across tenants and is reserved for trusted code paths.
	SystemDatabaseRole string `env:"SYSTEM_DATABASE_ROLE,required"`
	// WebUserDatabaseRole is the Postgres role assumed when the request's
	// proto annotation resolves to [DatabaseRoleWebuser]. Must NOT have
	// BYPASSRLS — RLS policies filter rows visible to it based on the
	// [Config.TenantIDGUC] value injected on transaction begin.
	WebUserDatabaseRole string `env:"WEBUSER_DATABASE_ROLE,required"`
	// TenantIDGUC is the Postgres custom GUC name written via `set_config`
	// on transaction begin to carry the caller's opaque tenant id. RLS
	// policies read it via `current_setting(TenantIDGUC, true)`. The
	// default `access.tenant_id` is intentionally generic — the package
	// is data-model-agnostic, so the GUC name does not assume any
	// particular tenant shape (organization, workspace, account, …).
	// Override only if a different name is needed for compatibility.
	TenantIDGUC string `env:"TENANT_ID_GUC" envDefault:"access.tenant_id"`
}

Config is the env-driven configuration for stdcrpcenttenancyfx.

type DatabaseRole

type DatabaseRole int

DatabaseRole is the Postgres role posture an RPC method runs in. Selected at the wire boundary by the stdcrpcenttenancyfx interceptor from the per-method proto annotation, and read at transaction-begin by Authorize.BeginHook.

The "Database" qualifier is load-bearing: a consumer's codebase usually also carries a user-role concept (e.g. from JWT claims); an unqualified "Role" type at the import site would conflate the two.

const (
	// DatabaseRoleUnspecified is the zero value. A ctx that carries this
	// value (or no role at all) is rejected by [Transact] / [Transact0].
	DatabaseRoleUnspecified DatabaseRole = iota
	// DatabaseRoleAnonymous selects the anonymous Postgres role (no
	// BYPASSRLS, no GUC).
	DatabaseRoleAnonymous
	// DatabaseRoleWebuser selects the per-tenant webuser Postgres role
	// and emits a `set_config` for the [Config.TenantIDGUC] populated
	// from the configured [TenantIDResolver].
	DatabaseRoleWebuser
	// DatabaseRoleSysuser selects the BYPASSRLS sysuser Postgres role
	// (no GUC).
	DatabaseRoleSysuser
)

func DatabaseRoleFromContext

func DatabaseRoleFromContext(ctx context.Context) (DatabaseRole, bool)

DatabaseRoleFromContext reads the database role previously stamped by WithDatabaseRole. Returns (DatabaseRoleUnspecified, false) when no role is set.

func (DatabaseRole) String

func (r DatabaseRole) String() string

String returns a human-readable name for the database role; used in error messages emitted by Transact / [BeginHook].

type DatabaseRoleResolver

type DatabaseRoleResolver interface {
	// RequiredDatabaseRole returns the role declared by the procedure's
	// proto annotation, or an error if the annotation is missing or
	// invalid.
	RequiredDatabaseRole(procedure string) (DatabaseRole, error)
	// AllProcedures returns every RPC procedure the resolver knows
	// about (e.g. by walking the proto registry). Used at boot to
	// validate the annotation is present on every method.
	AllProcedures() ([]string, error)
}

DatabaseRoleResolver resolves the DatabaseRole required by a ConnectRPC procedure. Mirrors stdcrpcauthfx.ScopeResolver's shape so the stdcrpcenttenancyfx interceptor stays decoupled from the proto extension type.

type Params

type Params struct {
	fx.In
	Config       Config
	Logs         *zap.Logger
	RoleResolver DatabaseRoleResolver `optional:"true"`
	// TenantID resolves the opaque tenant id stamped into the
	// access.tenant_id GUC for the webuser path. Required: every
	// binary that wires [Provide] must also wire a resolver, even
	// binaries (e.g. the worker) whose webuser path is unreachable
	// in practice — it is one fx.Provide line at the composition
	// root and prevents a future code change from silently turning
	// the webuser path into a no-op.
	TenantID TenantIDResolver
}

Params is the fx input for New. DatabaseRoleResolver is optional so the worker binary (which wires Provide for the BeginHook only and has no RPC surface to validate) can skip wiring a resolver. When absent, New skips boot-time validation and returns a no-op [Interceptor]; the BeginHook still fires for every transaction opened against an stdcrpcenttenancyfx-managed transactor — workers must stamp roles via WithDatabaseRole (with a //nolint:forbidigo justification) on activity boundaries instead.

type Result

type Result struct {
	fx.Out
	Authorize   *Authorize
	BeginHook   stdent.BeginHookFunc
	Interceptor connect.UnaryInterceptorFunc `name:"stdcrpcenttenancyfx"`
}

Result is the fx output for New. The BeginHook is exported as stdent.BeginHookFunc so [stdenttxfx.New] picks it up via its optional `TxBeginSQL` parameter — no manual wiring needed in the composition root beyond calling Provide. The Interceptor is exposed so the rpc package can install it next to its other interceptors.

func New

func New(params Params) (Result, error)

New constructs an Authorize for the fx graph and, when a DatabaseRoleResolver is wired, validates that every RPC procedure it knows about declares a non-zero `db_role` annotation. The validation runs synchronously inside the constructor so a missing annotation fails app start instead of surfacing the first time the offending RPC is called.

type TenantIDResolver

type TenantIDResolver interface {
	TenantIDFromContext(ctx context.Context) string
}

TenantIDResolver returns the opaque tenant id associated with a request ctx, or "" when no tenant is in scope (anonymous request, M2M token without an org claim). The empty string is the load-bearing sentinel emitted into the access.tenant_id GUC for the webuser path — see Authorize.BeginHook.

stdcrpcenttenancyfx is identity-agnostic: it does not know whether the tenant id originates from a JWT claim, an API-key lookup, or a header. Production wires the resolver at the composition root (e.g. binding it to a JWT TenantID claim populated by stdcrpcauthfx). It is a required dependency: every binary that wires Provide must also wire a resolver, so the webuser path's tenant id is never silently absent.

Implementations must be pure ctx readers — no I/O, no allocation hot paths — because the resolver is invoked on every ent transaction the webuser path opens.

type TenantIDResolverFunc

type TenantIDResolverFunc func(ctx context.Context) string

TenantIDResolverFunc adapts a plain function into a TenantIDResolver. Saves callers the boilerplate of declaring a one-method type just to bind the interface in their fx graph — the same convenience http.HandlerFunc / connect.UnaryFunc give for their respective interfaces.

func (TenantIDResolverFunc) TenantIDFromContext

func (f TenantIDResolverFunc) TenantIDFromContext(ctx context.Context) string

TenantIDFromContext implements TenantIDResolver.

Directories

Path Synopsis
Package stdcrpcenttenancytemporalfx propagates the stdcrpcenttenancyfx.DatabaseRole stamped on an RPC ctx across the Temporal client → workflow → activity boundary.
Package stdcrpcenttenancytemporalfx propagates the stdcrpcenttenancyfx.DatabaseRole stamped on an RPC ctx across the Temporal client → workflow → activity boundary.

Jump to

Keyboard shortcuts

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