Multi-tenant SaaS backend with three-layer authorization (handler gates + RBAC
- Postgres RLS), a Next.js authenticated product, a separately deployable public marketing site, and per-service introspection endpoints.
A codefly module is a collection of services; each service owns its own proto. The accounts service of saas-starter exposes a self-describing catalog of its OWN RPCs / RBAC vocabulary / RLS-protected tables / scopes. A "module-level" view = aggregation across every service's catalog and is a separate concern (CLI / gateway / a downstream aggregator) — not encoded in any single proto.
- Functional contract (the what, with the
HOST-*user stories): the umbrella docs repository'smodules/saas-starter.md; this file stays the engineering reference (the how), and README.md links the two. - The one plan — every
HOST-*story, its status and its proving test: docs/PLAN.md. Superseded plans sit underdocs/historical/. - Composing this module into a downstream workspace: Composing this module into a workspace
- Runnable local product with real identity: LOCAL_DOGFOODING.md
- External-provider configuration groups: LOCAL_DOGFOODING.md § Configure providers through Codefly
- Runtime capability owners and provider boundaries: Runtime capability ownership
- SigNoz dashboard/alert provisioning qualification: module/SIGNOZ_PROVISIONING.md
- accounts service introspection (after
codefly run):GET /v1/.well-known/service-info - Connect-RPC:
saas.accounts.v1.IntrospectionService/GetServiceInfo - Code:
pkg/business/introspection.go - Test:
pkg/business/introspection_test.go - Source-of-truth protos:
module/services/accounts/proto/saas/accounts/v1/*.proto(each bounded context owns one file) - Normalized generator input:
module/services/accounts/generated/service-catalog.json - Catalog contract and workflow:
module/SERVICE_CATALOG.md - Generated frontend clients and vocabulary:
module/FRONTEND_CATALOG.md - Generated frontend plugin routes/navigation:
module/FRONTEND_PLUGINS.md - Generic event meters, quota semantics, and product integration:
module/USAGE_METERING.md - Evidence-bound trust claims and adopter responsibilities:
module/TRUST_CAPABILITIES.md - Which claim in the root architecture documents is backed by what, and which are kept only as history: CLAIM_INVENTORY.md
- Postgres roles, grant rules, and RLS authority:
module/DATABASE_AUTHORITY.md - Generated Codefly topology and NetworkPolicies:
module/DEPLOYMENT_TOPOLOGY.md - Marketing runtime, deployment, and extraction contract:
module/services/marketing/README.md - Typed public brand and site configuration:
module/public/site.config.json - Generated PDP input:
module/services/accounts/generated/authz-methods.json - Authorization catalog and enforcement boundary:
module/AUTHORIZATION_CATALOG.md - Platform-functionality reference (external audit mapped to shipped/partial/gap):
PLATFORM_REFERENCE.md - Generated gateway inventory:
module/services/accounts/generated/gateway-routes.json - Gateway route contract and rollout boundary:
module/GATEWAY_ROUTES.md - Solution trust model, registration authority, and compatibility:
module/SOLUTION_REGISTRATION.md - Access-token signing-key rotation runbook:
module/KEY_ROTATION.md - Module-to-host internal gRPC transport, and the origin/key rules a consumer
must follow:
module/INTERNAL_TRANSPORT.md - Generated REST inventory:
module/services/accounts/generated/rest-surface.json - REST/OpenAPI contract and extension boundary:
module/REST_SURFACE.md - Resource follow subscriptions over events and notifications:
module/FOLLOWS.md - The host as an OAuth 2.1 authorization server, its two client sources, and resource indicators: The host as an OAuth 2.1 authorization server
example.com / www.example.com app.example.com
↓ ↓
marketing service product frontend
├─ repository content ↓ same-origin API proxy
├─ public plan projection auth-gateway
└─ fixed auth handoff ↓
accounts service
├─ adapters/ — gRPC + Connect + REST gateway servers
├─ business/ — RBAC + RLS wrappers
└─ infra/ — Postgres + Redis + Vault
Product API traffic
↓ Connect-ES (TS, generated from buf)
Gateway (Envoy/KrakenD) — merges per-service REST endpoints
↓ Bearer JWT or X-API-Key, plus X-Scopes
auth-gateway — validates token, stamps x-user-id / x-org-id metadata
↓ gRPC
accounts service
├─ adapters/ — gRPC + Connect + REST gateway servers
├─ business/ — Service methods (RBAC + RLS wrappers go here)
└─ infra/ — Store impl (Postgres + Redis + Vault)
↓
Postgres pool with BeforeAcquire SET ROLE app_tenant
↓
RLS-enforced reads / writes
The marketing process has no authentication session, database, Vault, object store, admin client, or product feature dependency. Product and marketing have independent build, health, deployment, rollback, cache, and hostname policies.
This is the authoritative ownership decision for epic #98 and the PostHog, Unleash, and SigNoz provider implementations. Ownership names the only implementation allowed to serve a capability; it does not claim that every planned provider is released yet.
| Capability | Single owner | Allowed SDK and runtime surface | Explicitly excluded |
|---|---|---|---|
| Runtime feature flags | Unleash | The provider-neutral feature-flags@1 SDK may evaluate flags with consumer-scoped Edge/browser or server tokens. The Unleash admin API is module-only; public clients reach only Unleash Edge. |
Product analytics, replay, exception capture, and OTLP/APM |
| Product analytics | PostHog | The product-analytics@1 browser/server APIs may capture registered events and perform consent-gated identify, alias, organization grouping, and privacy suppression. An event may carry a variant already evaluated by Unleash for experiment analysis. |
Flag definition or evaluation, exception capture, and traces, metrics, or logs |
| Session replay | PostHog | A browser-only recorder may start after separate replay consent and redaction policy resolve to allow. It has no server SDK or implicit analytics-consent fallback. | Flags, errors, and APM signals |
| Error tracking | Sentry | Browser/server exception events, release/environment tags, source-map upload, and the error-issue workflow. Provisioning credentials remain build/provider-only. | Performance transactions, profiles, replay, logs, metrics, and feature flags |
| APM traces, metrics, and logs | SigNoz | Applications use OpenTelemetry SDKs and export traces and metrics over OTLP/gRPC to the HTTPS operations intake, whose address the environment's telemetry: declaration delivers as the standard OTEL_EXPORTER_OTLP_ENDPOINT variable (or delivers nothing, and then nothing is exported); logs go to stdout. SigNoz receives those signals as the OTLP backend. |
Product analytics, replay, feature flags, and the Sentry error-issue contract |
Provider manifests are allowlists. provider-posthog may project only
product-analytics@1; its browser initialization must disable flag remote
configuration and exception autocapture, and replay must remain stopped until
replay consent is granted. provider-unleash may project only
feature-flags@1. Sentry may project only error-tracking; the starter fixes
Sentry trace sampling at zero and installs no Sentry tracing integration.
provider-signoz, if API qualification succeeds, may manage exactly owned
dashboards and alerts but projects no application runtime configuration. In
particular, dashboard provisioning is not an application telemetry contract:
the application OTLP endpoint remains instrumentation configuration, delivered
as the standard OTEL_EXPORTER_OTLP_ENDPOINT variable through the
environment's optional telemetry: declaration.
The starter keeps the capability configurations independent. Selecting PostHog never selects flags, errors, or observability; selecting Sentry never selects tracing; selecting an OTLP exporter never selects error tracking. PostHog's current direct HTTP adapters expose capture, identity, grouping, and suppression only, so no bundled vendor SDK can silently enable an overlapping feature.
The feature_flags table, read-only ListFeatureFlags API, and
/admin/platform/feature-flags page are a legacy migration inventory, not a
runtime flag owner. No application runtime evaluates or mutates that table: the
former database evaluator, combined flag/entitlement checker, and mutation path
have been removed. The published v1 UpsertFeatureFlag RPC remains deprecated
for wire compatibility, but its handler always fails closed and the runtime
database roles have no write grants. The remaining surface is retired in this
order:
- Land the
feature-flags@1contract, Unleash service, and Unleash provider. - Export each legacy row through the read-only inventory API and map
enabled,rollout_percent, andtarget_org_idsto one explicitly owned Unleash project/environment and its strategies. Reject ambiguous mappings, import once, then verify equivalent evaluations against fixed organization fixtures. Do not add dual reads or dual writes. - Bind consumers to
feature-flags@1and remove the admin route/navigation, list RPC and messages, business/infra read methods, generated read surfaces, permissions, and tests in the same cutover. Keep the deprecated v1 mutation compatibility shim until the stable-major support policy permits removal. - After the imported project is verified and no legacy consumers remain, add
a forward migration that drops
feature_flagsand its grants/indexes. The historical migration that originally created the table remains immutable.
Entitlements and plan gates stay in Accounts and Postgres. They answer whether an organization bought or is allowed a product capability; Unleash answers which runtime behavior is rolled out. A flag may disable entitled behavior but must never grant an entitlement, raise a quota, or replace authorization. Each product path checks its entitlement independently from its flag evaluation.
Every admission here carries a signed Work Context, and work no person started has nobody to sign one from. This host does not answer that with an identity of its own. A release declares whose authority its occurrences borrow, and the host re-derives that authority from current facts on every single occurrence.
Two arms, and they are not alternatives of equal standing. The names below are
the installation document's — the module that fires triggers spells the same
facts differently in the read API it answers an operator with, and only the
document's spelling parses at install
(module/services/accounts/code/pkg/business/datasource_trigger.go):
| Declared identity | What it is | State |
|---|---|---|
source |
The grant reference, and the contract. It names a connected data source; the host resolves the live delegation from it on every mint, re-checking the source, the person's membership and role, and the binding. An identifier rather than a credential — which is what lets an occurrence's run outlive the capability that admitted it, and makes a revocation land on the next occurrence. | implemented, with the revocation limit below |
binding |
The installation-principal arm. It would name one of the release's bindings and mint from that binding's headless scopes under the module's own principal — an ordinary token admission bounded by the capability's own short lifetime. | planned. It cannot serve this trigger: the headless mint's lifetime is 60 seconds against this trigger's 10-minute declared deadline, the capability is owned by the module's own principal while the reconcile operation requires an organization member, and nothing in the host ever emitted this field. Using it needs a lifetime policy for occurrences that the mint does not have. |
A revocation is not permanent while the reference is the source id. The host
resolves the active delegation from the source on every mint, so revoking one
and then reconnecting the source records a new delegation that the same
reference resolves to — an installed trigger re-arms with no explicit
re-delegation. The durable shape is an opaque, delegation-level reference, and
this host already accepts one (MintSourceOperationContext's delegation_id,
and the gateway field behind it). What is missing is on the caller's side: the
module-authority client a firing module uses sends source_id only, and refuses
any answer whose source_id differs from what it sent, so a delegation id in
the declaration is rejected by that client rather than by this host. Named as
that client's gap in
WORK_CONTEXTS.md § Triggers.
A composition must declare the grant for whichever arm it uses
(source_delegation_scopes, headless_scopes) and the audience its executing
module admits under; with neither declared, both arms have nowhere to mint and
the host refuses rather than running the work as itself.
DatasourceService/DelegateSourceTriggers binds a reference to an
already-connected source without making an administrator reconnect it, which is
otherwise the only way one is recorded.
The schedule is declared here, and — for now — also fired here. Every
connected source has a reconcile interval: the safety net for a lost webhook,
and the only path to a new version of a source whose provider has no webhook
receiver at all. That interval is the source's configuration, and what it
produces is a declaration: a cron in UTC, the missed-run and overlap
policies, a deadline, the input every occurrence is admitted with, and the
identity above. One occurrence admits DatasourceService/ReconcileSource, the
same host operation a person's manual pass takes.
Nothing consumes that declaration yet. GetSourceTriggerDeclaration is a read,
no path here installs a release, and the module that fires triggers keys an
installed trigger by its release name while this host declares every source
under one name — so per-source triggers cannot be installed for more than one
source even once something tries. That is the firing module's gap to close
(several triggers per release, or a release named per source), stated in
WORK_CONTEXTS.md § Triggers
and not worked around here.
So this host still runs the one-minute sweep that reads the due set and enqueues
each source, as an explicitly labelled stopgap (issue #1112): deleting it on
the declaration alone left no scheduled reconcile running anywhere, which made a
lost webhook permanent and meant a pull provider never refreshed without "Sync
now". The deletion list is one block beside RunDatasourceReconcile in
business/datasource_deliveries.go, and a test keeps every one of its names
inside the files that list names, so deleting it stays one commit. While both
exist they must not both run: the two enqueue under different idempotency keys,
so a double fire is a second pass that serialises behind the first — survivable
for GitHub, a second full re-send for a pull provider. That is the cost, and it
is why this is a stopgap rather than a belt-and-braces.
"Sync now" is that admission, not a request beside it (issue #1094). The
published kit points the button at ReconcileSource under an effect identity
the kit mints, so a person's pass and a scheduled occurrence are one admission
of one shape and the receipts cannot tell them apart. A press that repeats the
pass still being reported reads the original receipt back through
LookupReconcileSource and surfaces that — one receipt and one job for two
presses, rather than two full re-sends of the source — and the press after that
is a new pass. syncSource, which reached the same sync engines carrying no
effect identity, is deleted from the kit rather than deprecated, and a
TypeScript AST walk over this repository fails on any remaining caller
(src/features/datasources/__tests__/reconcile-is-the-only-manual-pass.test.ts).
The reconcile-schedules panel lists a person's pass in the same table as the
declared schedule's last and next occurrence, with its effect identity beside
them; the passes it can list are the ones this browser admitted, because a
receipt is recovered by identity and never enumerated.
What is not proven: no live occurrence exists. Nothing in this repository composes a module that executes declared work, so the declaration is built and held to the contract's field names by test, and the walk that has not been run is a declared schedule coming due in a composed environment. The reference arm is qualified at the route an executing module calls; the installation-binding arm is planned and has never been emitted.
The depth is in
services/accounts/AGENTS.md
and WORK_CONTEXTS.md;
the playground is /admin/platform/unattended-work.
The host signs people in for its own public clients — an add-in, a CLI, a mobile app, an MCP client — and it is the only sign-in UI: a client never talks to an identity provider. Two spellings of the same authorization server exist, over one registry, one authorization-code table, one minter and one session kind.
| Surface | Path | Owner |
|---|---|---|
| RFC 8414 authorization-server metadata | GET /.well-known/oauth-authorization-server |
accounts (pkg/business/oauth_authorization_server.go), presented at the edge by the frontend proxy |
| Authorization endpoint | GET /oauth2/authorize |
frontend (src/app/(auth)/oauth2/authorize/route.ts) → the login page → the consent page |
| Token endpoint (RFC 6749 form-encoded) | POST /oauth2/token |
accounts (pkg/adapters/oauth_http.go) |
| The first registered client's own RPCs | /v1/auth/clients/validate, /v1/auth/clients/authorize, /v1/auth/token |
accounts (pkg/business/client_authorization.go) |
| RFC 9728 protected-resource metadata for a solution's MCP endpoint | GET /api/solutions/<id>/proxy/.well-known/oauth-protected-resource |
the solution's runtime, reached through the frontend's solution proxy (the gateway serves a solution's .well-known GET unauthenticated) |
| The MCP endpoint itself | POST /api/solutions/<id>/proxy/mcp |
the solution's runtime, same route |
The issuer is APP_BASE_URL. RFC 8414 §2 requires an authorization
server's issuer to be an https URL, and a client that discovers the metadata
then verifies iss against it — so the published issuer and the iss of
every minted token are one configured value, resolved once at startup, never
from the request's Host. A deployment with no APP_BASE_URL publishes no
metadata and keeps the pre-metadata literal issuer, so the two cannot disagree —
and the registered-client browser handoff keeps working there, because issuing a
code never depends on a published issuer. One that sets it mints the URL and
keeps accepting the literal until tokens carrying it expire, so the change
signs nobody out.
Registration credentials keep their own issuer. The module- and
solution-registration credentials are internal, with their own audiences and
their own verifiers in the gateway and the frontend. They are not OAuth and
publish nothing, so they stay on the fixed saas-starter issuer rather than
following APP_BASE_URL: moving them would have refused a solution's frontend
half while its gateway half registered, and an active registration needs both.
The resource is the path a client can reach, which is the solution proxy.
/api/solutions/<id>/proxy/* is the only public route to a solution's
backend: it forwards the caller's bearer to the gateway and already serves the
solution's .well-known anonymously. /solutions/<id>/* is the gateway's own
internal surface — on the public origin that prefix is a page of the frontend
that redirects to login — so the RFC 8707 resource is
<base>/api/solutions/<id>/proxy/mcp and nothing else. An audience naming the
internal path is one no client could ever present at the resource it describes.
A denied solution request carries the discovery challenge. The gateway is
where it is refused — it strips identity, runs ext_authz and answers itself,
never proxying — so a challenge the runtime would have sent cannot reach anyone
through it. Every protected /solutions/<id>/* answers 401 with
WWW-Authenticate: Bearer resource_metadata="<base>/api/solutions/<id>/proxy/.well-known/oauth-protected-resource",
and the proxy route passes that header through: it is the only public way
out, so a challenge it dropped was one nobody could read. The host serves no
second copy of that document — two for one resource are two places to disagree,
and a client follows whichever URL the challenge names.
Every /.well-known/ path is public, served or not, matched as a path
SEGMENT. A well-known URI is a reserved metadata namespace (RFC 8615) and every
discovery chain begins by fetching one with no credential, so the page
middleware's login redirect must never reach them — including the ones this host
does not answer, which must get the 404 that says so. A segment test rather than
a root prefix, because these documents are not all at the root: a solution's
protected-resource metadata sits beneath its own route, and /solutions/<id>/ is
a page of the frontend. A browser follows a 307 and looks fine; a non-browser
client follows it, parses a login page as JSON, and concludes that this host is
not an authorization server, which is a report pointing nowhere near the
middleware that caused it. The predicate is isPublic in frontend/src/proxy.ts
and it is tested directly for exactly that reason.
Consent is enforced by the host, not advertised to the page. A grant request
for a client whose authorization requires consent is refused unless it states
that the person approved. That does not defend against a hostile browser — one
holding the session can claim anything — but it is what stops a browser-side
defect from issuing credentials nobody approved: the host does not rely on the
page having read requires_consent correctly.
The wire names are the contract. Accounts serialises OAuth snake_case and
the frontend decodes it (features/auth/model/oauth-authorization.ts),
failing closed on anything it cannot read. A cast would type-check while
producing undefined for every field, and a unit test on either side cannot
establish the agreement because each side's test fixes the shape that side
expects — so the seam is held from both ends, each spelled against the other's
own serialisation (accounts/pkg/adapters/oauth_wire_contract_test.go and
frontend/.../model/__tests__/wire-contract.test.ts).
The OAuth surface is raw HTTP, not transcoded RPCs, because its shape is the contract: RFC 6749 §3.2 requires a form-encoded token request and §5.2 a specific JSON error object, neither of which grpc-gateway produces. A client written against a standard OAuth library could not use a transcoded RPC.
Two client sources, which cannot collide — a registry slug can never spell
https://:
- Operator-registered.
IDENTITY_REGISTERED_CLIENTS, a JSON array in theidentityconfiguration group. Unset registers none, which refuses the flow. - Client ID Metadata Documents. A
client_idthat is an https URL is fetched (bounded size and time, no redirect at all per CIMD draft-02 §5.1, and guarded at the dial against every non-public destination — the IANA special-purpose registries for both families, not just loopback and RFC 1918, with IPv6 bounded to the2000::/3global unicast allocation; the other guarded egress paths use the same classifier, and the paths it covers and the OIDC clients it does not are listed in CALLABLE_SOURCES.md), validated, and used as a public client for that one flow. Failures and invalid documents are not cached (§5.2), so a publisher who fixes one is not made to wait out a window; the abuse a negative cache would have bounded is bounded by the authentication rate-limit class on the routes that reach it. Nothing durable is written. Gated byIDENTITY_CLIENT_METADATA_DOCUMENTS, unset meaning refused. There is no dynamic client registration (RFC 7591) and noregistration_endpointin the published metadata.
Resource indicators (RFC 8707). An authorization request may name one
resource: a solution's MCP endpoint as a client can reach it, which on a
deployed cell is the host frontend's solution proxy —
https://<host>/api/solutions/<id>/proxy/mcp. The gateway's own
/solutions/<id>/* is an internal surface, and on the public origin that prefix
is a frontend page that redirects to login, so a resource spelled that way names
a document no client can fetch.
The resource is recorded on the authorization code, persisted on the session, and
carried as a second aud value beside the host's own — so every existing
verifier still accepts the token. A rotation reissues the binding from the locked
session row, never from the request.
A solution's MCP endpoint admits only a token issued for itself (the security posture's SP-SOL-07). A token carrying just the host audience is a credential for the host's own API, and the whole purpose of a resource indicator is that one audience is not the other: it is refused there, with a 401 and a challenge saying what to authorize for. A deployment that has not been told its own public address cannot state which resource its tool endpoint requires, so it refuses by name rather than admitting what arrived.
That line is drawn at the tool endpoint, not across every solution route. A solution's other routes are the product's own API surface, reached by a signed-in person whose session token names no resource; a resource-bound token is still confined there to the solution it names, so one solution's token never reads another's data.
The resource identifier is compared EXACTLY, by code-point equality, at both
issuance and admission. The resource URL is itself the audience value this host
mints; a solution's runtime publishes that same string as its resource; and RFC
9728 §3.3 has the client require the document's resource to equal the URL it
dialled. No step in that chain folds case or re-renders the URL, so a host that
accepted several spellings of one resource would have several resources — and a
variant it minted would be rejected by the very client it was issued for. Both
sides compare against auth.SolutionMCPResource, the single place the string is
composed. A deployment with no configured public address cannot state what the
expected identifier is, so it refuses to issue one and refuses to admit one.
A token's audience set has three readings, not two. Bound to nothing, bound
to one valid resource, or unreadable — and the third is refused on every path. It
cannot be carried as an empty resource, because empty means "bound to nothing",
which is the reading admitted most widely. Two resource audiences, or one that is
not shaped like a resource identifier (userinfo, a query, a fragment, a
non-loopback http origin), are refused. The verifier's shape rule is the
issuer's own auth.ParseResourceIndicator, so verification cannot drift looser
than issuance; the gateway spells the identical rule on its own side of the
module boundary because nothing crosses it.
A sign-in state that cannot be recorded as used is refused (handbook SP-IDENT-04). While the consumption store is unavailable, "has this state been used before?" has no answer, and admitting on no answer means the single-use property does not hold for the duration of the outage. The refusal carries its own error so an operator can tell a failing dependency from a replayed state; the caller maps both to one sentinel before answering.
RFC 8252 §7.3 is unconditional for loopback. A native client takes an ephemeral port from the operating system at the moment of the request, so it cannot have registered the port it will listen on, and any port is allowed at request time whether or not the registration named one. The port is the only component relaxed — scheme, host, path and query must match, and userinfo and a fragment are refused.
This changes what an existing registration means. The rule lives on
RegisteredClient.AllowsRedirect, which the operator-declared registry uses too,
so a client already declared with http://localhost:3000/auth/callback now
matches that path on any port. An operator who wrote the port expecting it to
pin should read it as no longer pinning: on loopback the port is not the thing
that identifies the recipient, which is why §7.3 requires this and why PKCE is
what protects the exchange.
A session cookie is not a credential at this perimeter. The gateway verifies a bearer — an access token or an API key — and reads no cookie anywhere; a cookie is how the frontend holds a session for its own pages. A caller presenting one is told that, rather than reading "authentication required" while holding what looks to them like a credential.
Consent. The host asks the person to approve a client by name when the client registered itself by publishing a document (nobody but the person can vouch for it) or when the request narrows the credential to a named resource. An operator-declared client asking for nothing in particular keeps the silent handoff it has today.
The consent screen always shows the origin the host verified — the one it
fetched the document from. A document's own client_uri is read by nothing: a
document at https://client.example.com/doc may claim any client_uri, and
displaying that claim would name a publisher the host never reached (CIMD
draft-02 §8.5). The client's client_name is shown as untrusted presentation
beside the verified origin, never instead of it.
A redemption is checked against the client's policy as it stands then, not only against the code: a metadata client withdraws a callback by removing it from its document, since there is no row to delete.
An MCP token is a session credential: the gateway stamps
x-credential-kind: session with a session id, so a solution's SDK mints the
viewer's Work Context from it exactly as from a browser session. Lifetime,
refresh rotation and revocation are the registered-client tokens'.
| Layer | Where | What it asserts |
|---|---|---|
| L1 Handler gates | adapters/auth.go, adapters/connect_auth_interceptor.go, adapters/rpcs.go |
"Should this caller be ALLOWED to invoke this RPC?" — requireAuth / requireOrgMember / requireOrgAdmin / requirePlatformAdmin / requireMFA / requireScope, plus rate-limiting. |
| L2 RBAC | business/service.go:CheckPermission (+ Postgres roles / role_permissions / role_assignments) |
"Does this caller have THIS capability for THIS resource?" — wildcard support (*:*, users:*, *:read), team inheritance. |
| L3 RLS | Postgres ROW LEVEL SECURITY + WithOrgTx / WithUserTx / WithControlPlane |
"Even if L1+L2 said yes, does the row physically belong to the scope this transaction selected?" — fail-closed via BeforeAcquire SET ROLE app_tenant. |
The three guarantees are complementary, not interchangeable: each answers a question the others do not ask, so none of them is a backstop for a bug in another.
- RLS constrains a statement to the scope its transaction selected. It does not decide whether the caller may select that scope, and it cannot tell a correctly-scoped write that should not have been allowed from one that should — a proposed team member who does not belong to the parent organization is a well-scoped row.
- L2 answers whether this caller holds this capability, having been told which organization the caller is acting in. It does not verify that the subject of the operation belongs to that organization.
- L1 answers whether this RPC admits this caller at all. It reads identity headers stamped by the gateway and does not see rows.
What each layer does catch is the failure to use the next one: an unwrapped
Store call returns zero rows rather than every tenant's, because the pooled
connection is downgraded to app_tenant before the query runs. See AUTHZ.md
for the deep dive.
Every public application relation carries exactly one scope, and the scope
decides its required database boundary. The inventory itself is not repeated
here: it lives once as the executable map relationsByScope
(services/accounts/code/pkg/infra/postgres_role_hardening_test.go), which a
live database is checked against, and once as the table in
module/DATABASE_AUTHORITY.md, which
TestDatabaseAuthorityScopeInventoryMatchesCode (module/tools) holds to it.
Read the inventory there; a third hand-maintained copy would only drift.
What is worth knowing here is the shape:
- tenant relations enter through
WithOrgTx(orgID). Policies are direct (org_idon the row), JOIN (team_members→teams.org_id), polymorphic (audit_events,roles— built-ins carry a NULLorg_idand are globally readable), or self-referential (organizations). - user relations enter through
WithUserTx(userID)and are RLS-forced in the same way tenant relations are. TheWHERE user_id = $1predicate is no longer the safety property — it was, before34_rls_user_scoped,35_rls_sessionsand their successors brought these relations under forced policies. - global catalogs carry no RLS; exact grants are their whole boundary.
- pre-auth, job, and worker relations are reachable only from the control-plane role or one named worker role, never from request traffic.
There is no application-settable RLS bypass. Cross-scope work runs under
WithControlPlane, which is a distinct database role
(app_control_plane) with BYPASSRLS and its own grants — not a flag a request
transaction can set.
| Resource | Actions | Built-in roles holding write |
|---|---|---|
* |
* |
admin |
users |
read, write | admin, editor (write); +viewer (read) |
teams |
read, write | admin, editor (write); +viewer (read) |
knowledge |
read, write | admin, editor (write); +viewer (read) |
billing |
read, write | admin (write); +editor (read) |
audit |
read | admin |
webhooks |
read, write | admin |
api_keys |
read, write | admin |
Built-ins: admin (wildcard), editor (read+write on domain), viewer (read-only). Custom roles can be created per org via CreateRole(orgId).
| Scope | Description |
|---|---|
*:* |
Root |
| `users:read | write` |
| `orgs:read | write` |
| `teams:read | write` |
| `roles:read | write` |
| `api_keys:read | write` |
audit:read |
Read audit events |
| `invitations:read | write` |
| `webhooks:read | write` |
| `billing:read | write` |
entitlements:read |
View overrides + usage |
Wildcard semantics: users:* matches all users:X; *:read matches read across resources.
A scope grants only the RPCs whose method policy declares it: accounts checks
the declared scopes on every RPC before any handler runs, an RPC that declares
none refuses API keys, and a key with no scopes has no authority. Only an
interactive session can create an API key: CreateAPIKey declares no scope and
refuses every key, whatever it carries. The rules and the transports they cover
are in module/services/accounts/TRUST_BOUNDARY.md.
The full machine-readable list is derived from protobuf descriptors and saas.policy.v1.method_policy; only editorial summaries remain in pkg/business/introspection.go:rpcDescriptions. The complete deterministic generator input is checked in at module/services/accounts/generated/service-catalog.json, and the redacted runtime view is served at GET /v1/.well-known/service-info. Highlights:
| RPC | Path | Authz |
|---|---|---|
GetServiceInfo |
GET /v1/.well-known/service-info |
public (privileged tiers redacted for anonymous callers) |
| RPC | Path | Authz | Audit |
|---|---|---|---|
CreateRole |
POST /v1/roles |
org_admin | ✓ |
ListRoles |
GET /v1/roles?org_id=… |
auth | |
DeleteRole |
DELETE /v1/roles/{id} |
platform_admin | ✓ |
AssignRole |
POST /v1/role-assignments |
org_admin | ✓ |
RevokeRole |
DELETE /v1/role-assignments |
org_admin | ✓ |
ListRoleAssignments |
GET /v1/role-assignments?org_id=…&subject_id=… |
org_member | |
CheckPermission |
POST /v1/permissions:check |
public (internal-only) |
CreateOrganization, GetOrganization, UpdateOrganization, ListMembers, AddMember, RemoveMember, LeaveOrganization, DeleteOrganization — all org-scoped, RLS-wrapped. Creation follows ORGANIZATION_CREATION; leaving and deleting (archive) are described in AUTHZ.md. The platform view is PlatformAdminService/ListAllOrganizations and GetOrganizationRoster.
SwitchOrganization is an authenticated, target-id-only token exchange. The
server locks the current device session using verified sub + sid, resolves
the target membership and roles from PostgreSQL, and returns a new access token
without rotating the refresh credential or changing device/lifetime state. The
frontend decodes the signed org claim into one global organization context;
tenant-scoped pages do not maintain independent organization filters.
CreateTeam, ListTeams, AddMember, RemoveMember, ListMembers — team_id-scoped operations resolve team→org via WithControlPlane first, then enter WithOrgTx for the actual write. A caller that already carries a verified org claim skips the resolve.
ListPublicPlans (GET /v1/public/plans) is a public, credential-free
projection of the authoritative server catalog. It exposes only presentation,
checkout eligibility, trial/tax terms, and entitlement limits, plus a
deterministic revision. The marketing service never copies pricing into
content and disables a plan CTA when the catalog says checkout is unavailable.
UserService, APIKeyService, AuditService, AuditExportService, InvitationService, WebhookService, BillingService, PlatformAdminService, UsageService, SSOAdminService, MFAService, ConsentService, NotificationService, OnboardingService, GDPRService, UserSettingsService, AuthService, IdentityService.
UsageService.ConsumeUsage is an internal-only protobuf API for trusted product
services. Each logical operation supplies an organization, canonical meter,
positive quantity, idempotency key, and optional event time/dimensions. The
service resolves the plan/override inside the same tenant transaction, locks the
monthly aggregate, and persists an immutable accepted or rejected receipt.
Retries return that receipt without incrementing again; reusing the key with a
different payload fails. UsageService.GetUsage is the authenticated tenant
read path. Periods are UTC calendar months and period_end is exclusive.
Seats and API-key counts remain computed cardinality gauges. They must not be written as usage events because their authoritative rows can be reconciled directly. Their admission checks are serialized with the authoritative write in one tenant transaction; pending invitations reserve seats, while expired invitations and expired or revoked API keys release capacity. New event meters become active by adding their canonical key to the product plan/override catalog; unknown keys resolve to a disabled limit.
The protobuf and storage contract is product-neutral. The internal gRPC
listener is multiplexed onto the private REST h2c listener and is not a module
export. Cross-module callers use the named accounts/authority endpoint
(P1-NET-007), which serves only the module surface; ConsumeUsage is not on
it yet because it authorizes on the perimeter credential alone. Exporting the
mixed listener or making ingestion public is not an acceptable integration
shortcut. See module/USAGE_METERING.md for the complete producer contract.
Every page lives under frontend/code/src/app/admin/:
| Route | Backend RPCs |
|---|---|
/admin/roles |
ListRoles, CreateRole, DeleteRole |
/admin/teams |
ListTeams, CreateTeam, AddTeamMember, RemoveTeamMember, ListTeamMembers |
/admin/organizations |
GetOrganization, UpdateOrganization, ListMembers, AddMember (also the inline role change), RemoveMember, LeaveOrganization, DeleteOrganization, AssignRole/RevokeRole/ListRoleAssignments via the per-member "Manage roles" dialog |
/admin/organizations/settings |
GetOrgSettings, UpdateOrgSettings |
/admin/invitations |
CreateInvitation, ListInvitations, RevokeInvitation, IssueInvitationLink (copy an accept link) |
/admin/api-keys |
CreateAPIKey, ListAPIKeys, RevokeAPIKey |
/admin/webhooks |
full webhook CRUD + RotateSecret, TestWebhook |
/admin/audit-log |
QueryAuditLog |
/admin/sso |
GetOrgSSO, StartSSOSetup, DisableSSO |
/admin/billing |
OpenBillingPortal, ListInvoices |
/admin/platform/admins |
platform role grant/revoke |
/admin/platform/organizations |
ListAllOrganizations, GetOrganizationRoster, CreateOrganization (with owner_user_id), UpdateOrganization, AddMember, RemoveMember, DeleteOrganization |
/admin/platform/unattended-work |
ListSources, GetSourceTriggerDeclaration, DelegateSourceTriggers, RevokeSourceDelegation, ReconcileSource — the playground for whose authority work no person started runs under |
Client-side gating uses <RoleGate> (src/components/auth/role-gate.tsx) — display-only; backend remains authoritative.
A downstream workspace does not fork or copy saas-starter. It composes the
module: Codefly resolves a pinned, content-addressed release into the consumer
under modules/<name>/, and the consumer only ever ADDS files beside the base —
never edits the base in place. All paths below are relative to that composed
module root (the modules/<name>/ directory).
codefly add module --agent saas-starter <name>That creates the consumer's module shell; the base itself is never copied by
hand. Never compose with rsync, a directory copy, or a hand-edited manifest —
Codefly resolves the pinned release so the result has reproducible provenance.
Which release, and who must have signed it, is the pin below.
The canonical development identity documents are module/fixtures/*.yaml.
Accounts' embedded YAML is a generated packaging copy. From module/tools,
GOWORK=off go run ./cmd/module-package run-generators --module .. executes the
manifest's embedded-fixtures step along with the other declared generators.
It copies the complete canonical bytes and removes only stale YAML copies in
Accounts' embedded directory; it does not assemble grants or select principals.
The Accounts fixture test requires matching filename sets and bytes. Existing
composition conformance checks retain the explicit scenario-principal subset
against the canonical identities. Edit canonical inputs and run the complete
generator chain; never curate embedded output.
A workspace resolves the published module package at run time. The reference names the source repository and a package version rather than a path:
modules:
- name: saas-starter
source: codefly-dev/module-saas-starter
version: "0.2.0"Resolution fails closed, so the workspace must also say who it trusts to have signed that package:
module-trust:
repositories:
codefly/saas-starter: https://github.com/codefly-dev/module-saas-starter
signers:
codefly/saas-starter:
https://github.com/codefly-dev/module-saas-starter/.github/workflows/ci.yml@refs/heads/main: <base64 Ed25519 public key>codefly/saas-starter is the package id from
module/module.package.codefly.yaml; the signer is the workflow identity the
release was signed under, scoped to that package id so its key vouches for no
other package. Every module-package/vX.Y.Z release publishes both
with the public key filled in — copy the block out of the release notes, or
download the release's module-trust.yaml asset, which is written during
signing from the key the signature was verified against and so always matches
that release.
Neither the notes nor that asset are themselves signed, so taking the key from a release is trust on first use, not proof of it. What pinning buys is everything after: once the key is in your workspace, every later release must be signed by it or resolution fails closed. Establish it once, deliberately, and review changes to it as you would any other credential in your repository.
Codefly then fetches the release, checks its detached signature and archive digest against this policy, and materializes it into a content-addressed cache. Nothing is written into the consumer's tree and no sibling checkout is needed — which is what makes this the CI-portable route.
Base files are upstream-owned: the package is the exact set of files the release ships, and the base only moves via a new tag. A consumer composes by ADDING files on the side — a product plugin package, extra services, integration tests — never by editing a base file in place; the next compose replaces an in-place edit with the base's copy. A change a base file genuinely needs goes upstream: land it in canonical saas-starter, cut a new tag, and pin it. The base gets stronger and every consumer benefits.
A consumer may compose only some of the module's services. The composed set is
the services: list in the consumer's module.codefly.yaml. check skips
manifest files that belong to a non-composed service and reports them as an
expected omission, not a missing base file — module-level files are always
enforced. So absent base files for a service you never composed are normal;
missing base files for a service you DID compose are a real failure.
Composing no Vault is the common case of this, because a cell whose keys
live in its cloud key-management service reads none:
KEY_SERVICE_BACKEND=kms with KEY_SERVICE_SIGNING_BACKEND=kms-wrapped opens
no Vault connection at all. Leave vault out of services: and the service
tree is not copied, accounts' vault service-dependency is pruned, no
environment schedules one, and — because leaving it out is how a composition
selects the cloud key service today — the vault configuration group is
withdrawn with the selection, so nothing is left answering for the address and
AppRole credential of a Vault that is not there. The group follows the backend
selection rather than the service's presence: a cell whose Vault is the cell's
own keeps the group though this module renders no Vault at all.
A composition that wants the Vault in its local environment but not on its cell
needs the finer grain, key-service: vault | kms per environment. That is
implemented in the module agent and not declarable yet: the environment
block's schema is the CLI's, parsed strictly with no extension seam. Neither
route reaches a composition that pins a published release of this module,
which is the shape a hosted cell actually deploys: both are declarations in the
consumer's own manifest, and a pinned consumer still composes the vault
service until it edits that manifest. See module/deployment/README.md,
"Composing no Vault", for both routes, the refusals, what the CLI still owes,
and what a pinned composition can and cannot do today.
For the frontend product-plugin overlay specifically — package layout, generated projections, and gates — see docs/frontend-plugin-installation.md.
A contributed page reaches its own module's backend only through this host's
plugin transport, at /api/plugins/{plugin}/{alias}/{relative-path}, under the
viewer's forwarded credential. It carries what that module's bindings declare,
in both shapes: safe path segments with a Google custom verb allowed on the
last one, its colon forwarded literal (records:recover,
interactions/{id}:respond), and Connect procedures of the services a
requirement names. A connect requirement declares both halves — routePrefix
is where its upstream mounts Connect (/ is the origin root, where the protocol
and this contract's own capability handshake already put a procedure), and
connectServices is the bound, because a procedure's path carries its own
service name and the caller therefore supplies it. A page is not restricted to
REST bindings, and it never chooses a prefix or a service: a route outside the
alias its requirement declared is refused. The policy is
docs/frontend-plugin-bff-contract.md;
the playground that drives every shape is /admin/platform/plugin-transport.
- Write the migration (
store/migrations/N_create_X.up.sql) withorg_id NOT NULL REFERENCES organizations(id) ON DELETE CASCADE. - In a follow-up migration, enable RLS:
ALTER TABLE X ENABLE ROW LEVEL SECURITY; ALTER TABLE X FORCE ROW LEVEL SECURITY; CREATE POLICY X_tenant ON X USING (org_id::text = current_setting('app.current_org_id', true)) WITH CHECK (org_id::text = current_setting('app.current_org_id', true));
- Add a
Storemethod inpkg/business/store.go. - Implement in
pkg/infra/postgres_X.go. Uses.getQueryExecutor(ctx)so context-scoped tx (fromWithOrgTx) is reused. - Wrap every Service-layer call site in
s.store.WithOrgTx(ctx, orgID, ...). Cross-tenant workers require a dedicated least-privilege worker role and pool; do not add an application-settable bypass branch to a new policy. - Add a cross-tenant blocking test in
pkg/business/rls_X_test.gomirroringrls_audit_export_test.go. - Add the table to
pkg/business/introspection.go:serviceRLSTablesso it shows up inGetServiceInfo.
- Add the message + RPC to its bounded-context file under
proto/saas/accounts/v1. Add a completeoption (saas.policy.v1.method_policy); missing orUNSPECIFIEDpolicy is denied and fails tests. Annotategoogle.api.httponly when REST exposure is intended. - From the accounts service directory, run
codefly generate proto --proto ./proto --output .. --template accounts/proto/buf.gen.yaml(Docker, Codefly CLI ≥ 0.1.160; NEVER runbuf generatedirectly). One run of the versioned proto companion regenerates Go, gRPC, Connect, gateway, the raw OpenAPI document and the modular TypeScript together, with plugins pinned by the image and agoimportspass. The older--output . --local --template buf.gen.local.yamlspelling, and the "companion first, then local" pair, no longer work from CLI 0.1.160:--localnow runs inside the companion as well, where that template'sgo run/npxplugins fail. See module/REST_SURFACE.md. - Import browser types from their bounded module, for example
@/gen/saas/accounts/v1/teams_pb; do not restore the former monolithic TypeScript barrel. - Implement:
pkg/business/<feature>.go(the Service method, with aWithOrgTx/WithUserTx/WithControlPlanewrap),pkg/infra/postgres_<feature>.go(raw SQL),pkg/adapters/rpcs.goor a new<feature>_rpcs.go(handler authz + Validate + Service call), and a Connect adapter. Keep any still-manual gRPC/REST implementation wiring current. - For a new service only, add its finite implementation source to
pkg/adapters/connect_bindings.yaml; never hand-register a Connect service or procedure. If it opts into REST, also classify the service asgeneratedorplugininpkg/adapters/rest_bindings.yaml. Existing services need no binding change when an RPC is added. - Add only the editorial summary to
pkg/business/introspection.go:rpcDescriptions, then rungo generate ./pkg/business,go generate ./pkg/adapters, andgo generate ./pkg/cataloggen. This refreshes the normalized catalog, authorization catalog/matrix, auth-gateway policy lookup, Connect registration, REST registration/allowlists, filtered OpenAPI, and target-neutral gateway route artifacts. HTTP, authz, scopes, resource bindings, MFA, audit, rate, and sensitivity must come from descriptors; do not introduce another policy map or service list. - Frontend: a
useXhook insrc/features/<feature>/service/{queries,mutations}.ts, called from a UI insrc/features/<feature>/ui/.
- Pick a
resource:actionpair. Add it topkg/business/introspection.go:servicePermissionswith description + which built-ins hold it. - Update
migrations/4_create_roles_permissions.up.sql(or a follow-up migration) to grant it to the relevant built-in role. - If the FE displays a permission matrix, add the pair to
frontend/code/src/lib/permissions.ts.
- Forgetting
WithOrgTxon a per-tenant table → fail-closed (zero rows). Loud test failures, not silent leaks. TheBeforeAcquire SET ROLE app_tenanthook makes this fail-closed by default. - Using
pgx.BeginTxFuncinside a Store method → opens a fresh pool tx that ignores the WithOrgTx context. Always uses.getQueryExecutor(ctx)instead. Seepostgres_org.go:CreateOrganizationandpostgres_permissions.go:CreateRolefor the context-tx-reuse pattern. - Test fakes that embed
business.Storebut don't overrideWithOrgTx/WithUserTx/WithControlPlane→ nil panic. Add pass-through implementations (seesso_admin_test.go). - Custom-role assignment without UI — Use the
<ManageMemberRolesDialog>shipped atsrc/features/roles/ui/manage-member-roles-dialog.tsx. Don't bypass via direct SQL. - Assigning a built-in role to a user in a NEW org —
RegisterUseronly auto-assigns to the resolver-bootstrapped personal org. Explicit orgs need an explicitAssignRolecall.
pkg/business/rls_*_test.go — 22 RLS cross-tenant blocking tests
pkg/business/module_test.go — capabilities introspection smoke
pkg/infra/tenant_tx_test.go — empty-orgID guard
Run from module/services/accounts:
codefly test service --target ./pkg/business --filter TestRLS.
Codefly owns the dependency graph and Docker lifecycle.
- api service version: see
pkg/business/introspection.go:ServiceVersion(0.2.0at time of writing). Module-level versioning is owned bymodule.codefly.yaml, not by any single service.
go test ./... (the default -p N parallel mode) occasionally fails with:
WithDependencies failed: sdk.SetEnvironment: failed to get dependencies network mappings:
rpc error: code = Unavailable desc = error reading from server: EOF
Cause: each test package (pkg/business, pkg/infra, pkg/auth/pg, pkg/billing/pg) calls sdk.WithDependencies which spawns the codefly CLI; under parallel mode several of those races on the codefly daemon's gRPC socket. The naming-scope mechanism gives each test its own DB/cache/vault, but daemon connection races still happen.
Workaround: run sequentially:
go test -p 1 -count=1 ./pkg/business ./pkg/infra ./pkg/adapters
Each package passes individually; the flake only appears when several processes start at once. Tracked upstream in codefly-dev/core — when the daemon serializes connection setup this issue goes away.
Editor diagnostics may flag github.com/oklog/run, go.opentelemetry.io/proto/otlp, etc. as "not in your go.mod file" on generated grpc-gateway files. Those deps ARE present (go build ./... succeeds — verify with cd module/services/api/code && go build ./...). The diagnostics are stale LSP cache from a previous proto-gen iteration; they clear after restarting gopls or running go mod tidy in the api/code dir. No real build issue.
- Bump on proto-breaking changes (renamed RPCs, removed fields).
- Compatible-additive (new RPC, new field, new built-in role): patch-bump.
An API source can be connected for declared operations independently of the
connector's sync conformance. accepts_new_sources still describes sync;
accepts_operations and operations_gap describe the call surface. New API
connections are operation-only, with no automatic sync or reconcile schedule.
Existing API sources retain sync. Calls and legacy sync spend the same
credential budget, reserving capacity for interactive calls. New sources that
share a credential share a deployment-keyed budget identifier; OAuth rotation
preserves that identifier. Older source records retain their source-scoped key.
OAuth supports the existing refresh-token grant and client_credentials.
Unspecified grant means refresh-token behavior for compatibility. With client
credentials, credential is the client secret and the envelope contains no
refresh token. The existing source row lock serializes token exchanges and
caches the token and expiry in that same envelope.
Authorization-code sources use the host's deployment-wide
datasource-oauth group. An administrator
creates an API source with the authorization-code grant and no credential, then
passes its source_id to BeginDatasourceAccountLink. The same person completes
the browser sign-in. PKCE uses S256; the state is consumed before token exchange
and cannot be replayed, including after a lost exchange response. A fresh sign-in
is needed after such a failure. Only the provider tokens and expiry are sealed; app credentials and endpoints
are read from the current deployment registration at refresh. This version
supports one api registration and refuses connector-linker overrides. The
token set is committed alongside
the account link and its audit event. The source is personal to that caller;
other members cannot read it or its declarations. Generic OAuth alone does not
attest a remote user identity: this link identifies a source authorization and
is never used to infer a provider account from an email or grant provider ACLs.
Administrators replace a source's complete declaration set through
DeclareSourceOperations; boundary readers use ListSourceOperations.
Declarations fix the method, path template, query fields, closed input-object
schema, output-object schema, effect, and output byte cap. Their canonical digest
covers every declaration field. Parameters fill percent-encoded path segments;
query fields become query parameters; remaining fields form a JSON body and are
refused for GET and DELETE. Headers are not input mappings. Schema references
that could fetch another document are refused. The declaration audit contains
operation names only, never schemas, input, output, or credentials.
InvokeSourceOperation and LookupInvokeSourceOperation expose the bounded call
and its durable receipt on the accounts Connect endpoint. Each wire JSON value
is a string containing exactly one object; the host validates it against the
admitted declaration. A repeated effect replays only after current source and
delegated authority checks. An uncertain external mutation is retained as
unknown and never sent again. Callable sources
describes the JSON limits, Runnable source slot, custody and recovery contract.