tamper

package module
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Aug 31, 2026 License: MIT Imports: 10 Imported by: 0

README

Tamper

An embeddable, Go-native enterprise auth / authz / tamper-evident-audit framework. Tamper was extracted from Barista — a self-hosted PaaS that is its flagship and proving ground — mirroring the Espresso ← Barista relationship.

Tamper is a library, not a server: you compose its engines into your own single binary and mount its routes on your own Espresso router. Every engine is decoupled behind a port interface your app implements — Tamper never names a table, owns a cookie brand, or freezes your audit vocabulary.

Status: v0.4.0 — pooled multi-tenancy. One process, N tenants, deny by default. v0.4.0 is Phase 7's single deliberate breaking release: the tenant became a type (tenant.ID) and entered the base ports. Coming from v0.2.x? Step through v0.3.0 (drop-in, zero code changes) and then follow MIGRATION-v0.4.md — for a single-tenant deployment the whole upgrade is mechanical.

Install

go get github.com/suryakencana007/tamper@v0.4.0

What's shipped

Subpackage What it is
crypto JWT issue/verify, bcrypt passwords, refresh-token hashing, TOTP, and the KEK keyset + secretbox envelope that seals at-rest secrets
audit tamper-evident hash-chain logging (per-row canonical-version dispatch, boot-time verify, per-tenant export, GDPR-style commitment redaction) over an internal SQLite persistence layer
authz the Authorizer PDP (Check + reverse queries) over two interchangeable engines — the rank RBAC and the set-based PermissionSet — plus a converter that makes them decide identically
tenant the tenancy vocabulary: tenant.ID (zero value denies), Descriptor + Store/Resolver ports, home-realm domain discovery, per-tenant entitlements
identity credentials + session core (register / login / refresh / logout, TOTP enrollment, multi-IdP linking) behind one identity.Store port
oidc / saml OIDC relying-party + SAML service-provider substrates and store-backed provider managers with KEK-sealed secrets
scim SCIM 2.0 substrate — filter engine, RFC 7644 PATCH applier, group-cycle detection — over app-supplied ports
espresso the first-class transport adapter: mountable auth / OIDC / SAML / SCIM route surfaces + RequireAuth / RequireServiceAccount / RequireDecision / RequireFreshAuth / PinTenant / RequireTenant middleware

The framework roadmap, extraction playbook, and design rationale live in TAMPER-DESIGN.md.

Getting started

Two additive layers compose the whole surface. A complete, runnable version of the snippets below — driven end-to-end by a test — is in examples/quickstart:

go run ./examples/quickstart      # serves the auth API on :8080
1. Build the engines — tamper.New

New bundles the engine constructors into one validated Provider, rooted at the JWT service + KEK keyset. Misconfiguration fails here at boot, never as a per-request denial. Everything except JWT is optional; a nil field means "not configured".

provider, err := tamper.New(tamper.Config{
	JWT: crypto.JWTConfig{Secret: cfg.Secret, TTL: 15 * time.Minute, Issuer: "myapp"},

	// Optional: seal at-rest secrets (TOTP envelopes, OIDC/SAML client secrets).
	KEKs: []crypto.KEKEntry{{ID: 1, Key: cfg.KEKHex}},

	// Optional: a non-empty DBPath opens the SQLite hash-chain audit log
	// (empty => a no-op logger). provider.Audit is always non-nil.
	Audit: tamper.AuditConfig{DBPath: "audit.db"},

	// Optional: build the identity Core over your own persistent Store.
	Identity: &tamper.IdentityConfig{
		Store:   myStore, // implements identity.Store
		Options: []identity.Option{identity.WithRefreshTTL(30 * 24 * time.Hour)},
	},

	// Optional: your built policy-decision point (see tamper.RBAC / tamper.PermissionSet).
	Authz: pdp,
})
if err != nil {
	return err
}
defer provider.Close() // releases the audit DB handle
2. Aggregate the HTTP surface — tamper/espresso.Routes

Routes constructs the route surfaces + middleware from the Provider, auto-wiring the OIDC/SAML registry hooks and binding RequireAuth to the JWT service. It returns the surfaces for you to register — there is no Mount, because each surface spans both public and authenticated route blocks and your app owns its paths (see PHASE4D-BOUNDARY-DECISION.md §A10).

surfaces, err := tamperespresso.Routes(provider, tamperespresso.RouteConfig{
	Auth: tamperespresso.AuthRoutesConfig{
		MountPrefix: "/api/auth",
		Cookies:     tamperespresso.CookieConfig{Name: "myapp_refresh"},
		ProjectUser: projectUser, // renders YOUR user DTO into the response
	},
	// Identity is REQUIRED and app-supplied: identity.Core does not satisfy the
	// IdentityService port on its own (it has no Me lookup and no session-token
	// TOTP ceremony — that token is app policy), so you wrap it. The quickstart
	// shows a ~90-line coreIdentity adapter.
	Identity: myIdentityService,
})
if err != nil {
	return err
}
3. Register the surfaces on your Espresso router
auth := surfaces.Auth
readCookie := auth.ReadRefreshCookie()

r := espresso.Portafilter()
r.Post("/api/auth/register", espresso.Doppio(auth.Register))          // public
r.Post("/api/auth/login", espresso.Doppio(auth.Login))               // public
r.Get("/api/auth/me", surfaces.RequireAuth(espresso.HandlerCtx(auth.Me)))
r.Post("/api/auth/refresh", readCookie(espresso.HandlerCtx(auth.Refresh)))
r.Post("/api/auth/logout", readCookie(espresso.HandlerCtx(auth.Logout)))

// Serve with graceful shutdown; OnShutdown closes the Provider.
r.OnShutdown(func(context.Context) error { return provider.Close() })
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return r.BrewContext(ctx, espresso.WithAddr(":8080"))

surfaces also carries Federation (OIDC), SAML, and SCIM route surfaces (non-nil only when you configure them and the matching engine is present on the Provider), plus the RequireServiceAccount middleware for the SCIM surface.

OIDC single sign-on — integrating with Keycloak

examples/federation is a runnable, end-to-end OIDC SSO example. It stands up an embedded fake IdP so it runs with zero external dependencies, and its test drives the full authorization-code flow (start → IdP → callback → exchange), JIT-provisioning the federated user via the identity core and minting a session:

go run  ./examples/federation      # boots the SSO server on :8080
go test ./examples/federation/...  # drives + verifies the whole flow

The tamper wiring is identical for a real IdP — only the issuer URL and client credentials change. To point it at a real Keycloak instead of the fake IdP:

  1. In Keycloak, create a realm + a confidential OpenID Connect client, and add a valid redirect URI of <app-base>/api/auth/oidc/callback/<provider-id>.

  2. Seed the provider with your realm's values (the example hardcodes the fake IdP's; swap them):

    provider.OIDC.Create(ctx, oidc.ProviderDefinition{
        ID:           "keycloak",
        IssuerURL:    "https://keycloak.example.com/realms/myrealm", // the realm's issuer
        ClientID:     "myapp",
        ClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"),           // sealed by the KeySet at rest
        DisplayName:  "Keycloak",
        Enabled:      true,
        Scopes:       []string{"openid", "profile", "email"},
        GroupsClaim:  "groups", // optional — map Keycloak group membership
    })
    

That's the whole change — the routes, the OnFederatedExchange hook, and the state-cookie handling are unchanged. The engine runs OIDC discovery against IssuerURL, so the realm's /.well-known/openid-configuration must be reachable from the server. Barista (tamper's flagship) drives exactly this setup in production — see its scripts/provision-keycloak.ps1 and deploy/helm/barista/INSTALL.md for a concrete Keycloak-on-Kubernetes wiring.

SAML SSO follows the same shape via the SAML engine + SAML route bundle.

Multi-tenancy — one process, many tenants

Since v0.4.0 every tenant-touching call names its tenant as a tenant.ID — a type whose zero value is invalid, so "I am single-tenant" and "I forgot to pass the tenant" are different values and only the second one denies:

tenant.Single         // "this deployment has one tenant" — said on purpose
tenant.New("acme")    // a real tenant from untrusted input (claims, headers, paths)
tenant.FromStored(s)  // a value read back out of your own database ("" == Single)
tenant.ID{}           // forgot? -> ErrTenantRequired. Nothing leaks.

The underlying identifier stays an opaque, app-defined string — a UUID, a slug, a realm/sub-realm path are all fine; Tamper never parses it. A single-tenant deployment passes tenant.Single everywhere and pins it once on the router:

// Single-tenant: one line. Pooled: resolve from subdomain, path or header —
// this resolver is the only line that changes when you go pooled.
r.Use(tamperespresso.PinTenant(func(*http.Request) string { return "" }))

(PinTenant is for routes before login; RequireTenant additionally cross-checks the authenticated token's tid against the routed tenant, so it runs inside RequireAuth. Two names on purpose.)

identity.Store's lookups take the tenant directly — an email is unique per tenant rather than globally, bob@acme.com and bob@globex.com are separate people, and the first-user bootstrap signal fires once per tenant instead of once ever. A store that cannot scope by tenant fails to compile, which is strictly earlier than the boot-time error it replaced.

Implementing the store comes with a proof obligation. Tamper cannot enforce isolation — the query lives in your adapter — so it ships the instrument that checks it. Run the conformance harness against your own store:

func TestMyStoreIsolation(t *testing.T) {
    tenanttest.RunLeakSuite(t, func() identity.Store {
        return newMyStore(t) // fresh and empty on every call
    })
}

examples/multitenant is the runnable proving ground: two tenants over one store in one process, bob@acme.com and bob@globex.com as separate people, and a test asserting that a token minted for one tenant is refused on the other's route.

go run  ./examples/multitenant      # serves both tenants on :8080
go test ./examples/multitenant/...  # drives both tenants end to end

What your app supplies

Tamper composes; your app provides the leaves:

  • the Store implementations (identity / authz / oidc / saml / scim);
  • the Espresso router and every route path (Routes returns surfaces, not a handler);
  • the tenant resolver behind PinTenant/RequireTenant — subdomain, path segment or header; Tamper pins what you resolve and never guesses;
  • policy hooks — ProjectUser, OnFederatedExchange, SanitizeRedirect, the DecisionGate deny-writers;
  • audit emission at your port implementations (the transport threads the facts down; the port writes the row);
  • an IdentityService adapter over identity.Core (see the quickstart).

License

MIT © 2026 Nanang Suryadi.

Documentation

Overview

Package tamper is the root of the Tamper enterprise auth/authz framework — an embeddable, Go-native auth layer extracted from Barista (Barista is its flagship + proving ground, mirroring Espresso <- Barista).

Tamper is consumed import-not-copy: an application depends on the subpackages below directly (Barista wraps them in thin façades under internal/auth, internal/audit, internal/authz, internal/idp, and internal/scimstore). Every subpackage is store-decoupled behind a port interface the app implements; the framework never names a table.

Shipped subpackages:

  • crypto: portable auth primitives — JWT issue/verify, bcrypt password hashing, refresh-token hashing, TOTP enrollment/verify, and the KEK keyset + secretbox envelope that seals at-rest secrets (TOTP secrets, OIDC/SAML provider secrets). Lifted in Phase 0b.
  • audit: tamper-evident hash-chain logging — per-row canonical-version dispatch, chain-segment anchors, boot-time chain verification — plus the audit/sqlitestore SQLite persistence layer. Lifted in Phase 0c.
  • authz: the Authorizer PDP — Check + reverse queries — over two interchangeable engines: the downward-closed rank RBAC and the set-based PermissionSet (which subsumes ranks and expresses non-downward-closed roles), plus a converter that makes PermissionSet decide identically to RBAC by construction. Phase 1.
  • identity: the credentials + session core — Register/Login/Refresh/ Logout, refresh-session rotation + revocation, TOTP enrollment, and multi-IdP account linking — behind one identity.Store port, with a caller-supplied ACR and first-user bootstrap signal. Phase 2.
  • oidc: the OIDC relying-party substrate (discovery + JWKS rotation, PKCE, ID-token verification, group-claim normalization) and a store-backed provider Manager with a TTL-cached live registry and KEK-sealed client secrets. Phase 3.
  • saml: the SAML service-provider substrate (crewjam/saml wrapping — metadata fetch/parse, AuthnRequest building incl. step-up, assertion helpers, signed state cookie) and the mirror-image provider Manager with a KEK-sealed SP signing key. Phase 3.
  • scim: the SCIM 2.0 substrate — the filter engine (parse + AST->SQL over a caller-supplied column mapping), the RFC 7644 PATCH applier, and group-cycle detection over a port. Phase 3.
  • espresso: the first-class transport adapter for the Espresso HTTP framework — mountable auth/OIDC/SAML/SCIM route surfaces plus the RequireAuth / RequireAuthWS / RequireServiceAccount / RequireDecision / RequireFreshAuth middleware and the Auditor mutation middleware. The core stays transport-agnostic; other adapters are possible later. Phase 4.

The vision, niche, extraction playbook, and phase roadmap live in TAMPER-DESIGN.md next to this file. A top-level composition facade (tamper.New(Config) + tamper/espresso.Routes) and a runnable example are the current milestone — see PHASE6-STANDALONE-PACKAGING-SKETCH.md.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func PermissionSet

func PermissionSet(store authz.PermissionStore) (authz.Authorizer, error)

PermissionSet builds a set-based Authorizer over a PermissionStore, ready to drop into Config.Authz. The set engine decides by permission-key membership over the union of a subject's roles, so it expresses non-downward-closed roles the rank engine cannot; it subsumes RBAC (a downward-closed role is just a particular key set).

func RBAC

RBAC builds a rank-based Authorizer over a BindingStore, ready to drop into Config.Authz. It is a thin one-liner over authz.NewRBAC so a greenfield consumer does not have to reach into the authz subpackage for the common case; applications that build their own PDP (a converter, an overlay, a custom store) pass that directly to Config.Authz instead.

The rank engine expresses downward-closed roles (a higher rank subsumes every lower one). For roles that are NOT downward-closed — arbitrary permission subsets like {view, manage} without deploy — use PermissionSet.

Types

type AuditConfig

type AuditConfig struct {
	DBPath string
	// EmailLookup optionally enriches service-direct emissions (an event
	// carrying a user id but no email) at Log time. Its signature matches
	// audit.SQLiteLoggerOptions.EmailLookup exactly. Optional.
	EmailLookup func(ctx context.Context, userID string) (email string, ok bool)
}

AuditConfig configures the audit logger. An empty DBPath selects the NoopLogger; otherwise the SQLite hash-chain logger is opened at DBPath.

type Config

type Config struct {
	// JWT is required — New returns an error on an empty Secret (the
	// underlying crypto.NewJWTService panics on empty, so New guards first).
	JWT crypto.JWTConfig

	// KEKs + WriteKeyID build the envelope keyset that seals at-rest
	// secrets (identity TOTP envelopes, OIDC/SAML provider secrets). Empty
	// KEKs leaves Provider.KeySet nil, mirroring crypto.NewKeySet's
	// (nil, nil) contract — callers gate "sealing configured" on
	// Provider.KeySet != nil exactly as before.
	KEKs       []crypto.KEKEntry
	WriteKeyID uint8

	// Audit configures the tamper-evident log. A non-empty DBPath opens the
	// SQLite hash-chain logger; an empty DBPath yields a NoopLogger.
	// Provider.Audit is always non-nil.
	Audit AuditConfig

	// Authz is the application's built policy-decision point. Optional —
	// nil leaves Provider.Authz nil (the transport's RequireDecision gate is
	// then unusable). Greenfield consumers can build one with the RBAC or
	// PermissionSet helpers in this package.
	Authz authz.Authorizer

	// Identity, when non-nil, builds the credentials + session Core over the
	// supplied Store, with JWT and (when configured) the KeySet auto-threaded
	// in. Applications with a richer identity service of their own leave this
	// nil and pass that service to the transport layer instead.
	Identity *IdentityConfig

	// OIDC / SAML, when non-nil, build the respective federation provider
	// Manager over the supplied Store, sealing provider secrets with the
	// KeySet. Nil leaves the corresponding Provider field nil.
	OIDC *OIDCConfig
	SAML *SAMLConfig
}

Config is the single boot input for New. It bundles the engine configuration; the application still supplies the leaves — the Store implementations, the built Authz PDP, and (at the transport layer) the route policy and the Espresso router.

The zero value is NOT valid: JWT.Secret is required. Everything else is optional and nil-encodes "not configured" exactly as the subpackage constructors already do (no KEKs => no KeySet; no DBPath => Noop audit; nil Identity/OIDC/SAML => that engine is absent from the Provider).

type IdentityConfig

type IdentityConfig struct {
	Store   identity.Store
	Options []identity.Option
}

IdentityConfig configures the identity Core. Store is required (New returns an error when it is nil). Options are passed through to identity.New; the KeySet is auto-threaded ahead of them when configured, so an explicit identity.WithKeySet in Options still wins.

type OIDCConfig

type OIDCConfig struct {
	Store       oidc.ProviderStore
	RedirectURL func(id string) string
	TTL         time.Duration
}

OIDCConfig configures the OIDC provider Manager. Store is required. TTL sets the live-registry cache lifetime (0 = the Manager default). RedirectURL maps a provider id to its callback URL — the route shape is the application's, so this is a function, not a base string. Optional.

type Provider

type Provider struct {
	JWT      *crypto.JWTService // always non-nil
	KeySet   *crypto.KeySet     // nil when KEKs is empty
	Audit    audit.Logger       // always non-nil (NoopLogger fallback)
	Authz    authz.Authorizer   // nil unless Config.Authz is set
	Identity *identity.Core     // nil unless Config.Identity is set
	OIDC     *oidc.Manager      // nil unless Config.OIDC is set
	SAML     *saml.Manager      // nil unless Config.SAML is set
}

Provider is the constructed engine bag. Every field mirrors a subpackage constructor's output; a nil field means "not configured", encoded the same way the subpackages themselves do. The application reads these to wire its services and (via tamper/espresso.Routes) its HTTP surface.

The Provider owns the audit DB handle when audit is SQLite-backed — call Close on shutdown to release it.

func New

func New(cfg Config) (*Provider, error)

New builds a Provider from cfg. It validates inputs and constructs each configured engine as a DAG rooted at the JWT service + KeySet, so a misconfiguration fails here at boot rather than as a per-request denial.

Cheap validation runs before any resource is allocated; the audit DB is opened only after every input has passed, and is closed again if a later step fails.

func (*Provider) Close

func (p *Provider) Close() error

Close releases resources the Provider owns — today the audit DB handle (a no-op for the NoopLogger). Safe on a nil Provider. Call it on application shutdown.

type SAMLConfig

type SAMLConfig struct {
	Store             saml.ProviderStore
	SPMetadataURL     func(id, acsURL string) string
	TTL               time.Duration
	AllowIDPInitiated bool
	SkewTolerance     time.Duration
}

SAMLConfig configures the SAML provider Manager. Store is required. SPMetadataURL maps (provider id, ACS URL) to the SP-metadata URL — again an application route shape, so a function. The remaining fields are flow knobs passed straight through to the Manager.

Directories

Path Synopsis
Package audit defines the append-only event log primitives for the v0.6 compliance work (closes the v0.4/v0.5-deferred non-goal).
Package audit defines the append-only event log primitives for the v0.6 compliance work (closes the v0.4/v0.5-deferred non-goal).
internal/sqlitestore
Package sqlitestore holds the SQLite-backed storage for the audit log (closes the v0.4/v0.5-deferred non-goal).
Package sqlitestore holds the SQLite-backed storage for the audit log (closes the v0.4/v0.5-deferred non-goal).
Package authz defines Tamper's Authorizer PDP — the policy decision point every Tamper consumer codes against — plus a built-in RBAC engine (rbac.go) that evaluates scoped-role bindings supplied by a pluggable BindingStore.
Package authz defines Tamper's Authorizer PDP — the policy decision point every Tamper consumer codes against — plus a built-in RBAC engine (rbac.go) that evaluates scoped-role bindings supplied by a pluggable BindingStore.
KeySet extends the v0.5 single-KEK SecretBox with key-id versioning so an operator can rotate the KEK without re-issuing every webhook secret.
KeySet extends the v0.5 single-KEK SecretBox with key-id versioning so an operator can rotate the KEK without re-issuing every webhook secret.
Package espresso is the Tamper transport adapter for the Espresso HTTP framework (Phase 4).
Package espresso is the Tamper transport adapter for the Espresso HTTP framework (Phase 4).
examples
discord command
Command discord is a runnable, end-to-end example of "Sign in with Discord" — a provider with NO OpenID Connect layer — using tamper/oauth2social.
Command discord is a runnable, end-to-end example of "Sign in with Discord" — a provider with NO OpenID Connect layer — using tamper/oauth2social.
federation command
Command federation is a runnable, end-to-end example of OIDC single sign-on with the Tamper framework: build the engines with tamper.New (including an OIDC provider), aggregate the HTTP surface with tamper/espresso.Routes (including the Federation surface), and run the full authorization-code flow against an embedded fake IdP.
Command federation is a runnable, end-to-end example of OIDC single sign-on with the Tamper framework: build the engines with tamper.New (including an OIDC provider), aggregate the HTTP surface with tamper/espresso.Routes (including the Federation surface), and run the full authorization-code flow against an embedded fake IdP.
multitenant command
Command multitenant is the POOLED PROVING GROUND for Phase 7: one process, one store, two tenants, and a test that asserts a genuine cross-tenant denial.
Command multitenant is the POOLED PROVING GROUND for Phase 7: one process, one store, two tenants, and a test that asserts a genuine cross-tenant denial.
quickstart command
Command quickstart is a runnable, end-to-end example of embedding the Tamper framework: build the engines with tamper.New, aggregate the HTTP surface with tamper/espresso.Routes, register it on an Espresso router, and serve register / login / me / refresh / logout.
Command quickstart is a runnable, end-to-end example of embedding the Tamper framework: build the engines with tamper.New, aggregate the HTTP surface with tamper/espresso.Routes, register it on an Espresso router, and serve register / login / me / refresh / logout.
scim command
Command scim is a runnable, end-to-end example of exposing a SCIM 2.0 provisioning surface with the Tamper framework: build the engines with tamper.New, aggregate the surface with tamper/espresso.Routes (a SCIM bundle), implement the scim.UserStore + scim.GroupStore ports in-memory (store.go), and gate every route behind a service-account bearer token.
Command scim is a runnable, end-to-end example of exposing a SCIM 2.0 provisioning surface with the Tamper framework: build the engines with tamper.New, aggregate the surface with tamper/espresso.Routes (a SCIM bundle), implement the scim.UserStore + scim.GroupStore ports in-memory (store.go), and gate every route behind a service-account bearer token.
Package identity is Tamper's identity core — users, credentials, and refresh-session lifecycle (Phase 2a of the extraction roadmap in ../TAMPER-DESIGN.md; TOTP enrollment and multi-IdP identity linking join in later sub-phases).
Package identity is Tamper's identity core — users, credentials, and refresh-session lifecycle (Phase 2a of the extraction roadmap in ../TAMPER-DESIGN.md; TOTP enrollment and multi-IdP identity linking join in later sub-phases).
tenanttest
Package tenanttest is the cross-tenant leak conformance harness for identity.Store.
Package tenanttest is the cross-tenant leak conformance harness for identity.Store.
Package oauth2social federates identity from providers that speak plain OAuth 2.0 and have no OpenID Connect layer — Discord being the case that forced it into existence.
Package oauth2social federates identity from providers that speak plain OAuth 2.0 and have no OpenID Connect layer — Discord being the case that forced it into existence.
Package oidc implements an OpenID Connect Relying Party (RP) substrate for Tamper consumers.
Package oidc implements an OpenID Connect Relying Party (RP) substrate for Tamper consumers.
Package saml implements a SAML 2.0 Service Provider (SP) substrate for Tamper consumers.
Package saml implements a SAML 2.0 Service Provider (SP) substrate for Tamper consumers.
Package scim is the SCIM 2.0 protocol substrate for Tamper consumers — the transport-agnostic RFC 7643/7644 mechanics an app's HTTP layer composes:
Package scim is the SCIM 2.0 protocol substrate for Tamper consumers — the transport-agnostic RFC 7643/7644 mechanics an app's HTTP layer composes:
Package tenant is the tenancy vocabulary for pooled multi-tenant deployments — the neutral Descriptor record, the Store and Resolver persistence ports, the ErrNotFound / ErrSuspended sentinels, and the context helpers that propagate the active tenant across a request.
Package tenant is the tenancy vocabulary for pooled multi-tenant deployments — the neutral Descriptor record, the Store and Resolver persistence ports, the ErrNotFound / ErrSuspended sentinels, and the context helpers that propagate the active tenant across a request.

Jump to

Keyboard shortcuts

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