Skip to content
Merged
9 changes: 9 additions & 0 deletions AUTHZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ when the setting is missing. So:
| `app.bypass = '1'` | All rows visible (workers + platform admin). |
| Neither set | **Zero rows** — fail-closed. |

For the length of the scope-setting compatibility window (store migration 66)
the predicate reads
`COALESCE(NULLIF(current_setting('codefly.current_tenant_id', true), ''), current_setting('app.current_org_id', true))`:
a pod that binds either generation of the name is admitted to its own tenant,
and "neither set" is still zero rows, because an empty live binding does not
fall through to a value — both arms empty is a false predicate.
[module/services/store/migrations/README.md](./module/services/store/migrations/README.md)
carries the window and the release that closes it.

**What this catches:**
- A SQL injection that bypassed `WHERE org_id = $1` parsing.
- A bug that uses the wrong orgID variable (`getWebhook(id, attackerOrg)` reading another tenant's row).
Expand Down
3 changes: 2 additions & 1 deletion CLAIM_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ must not be inferred from a row here. See
| The daily audit archive is "sealed" | AUTHZ.md § The daily archive, `pkg/business/audit_archive.go` | the object is written create-if-absent and its SHA-256 digest is receipted in the audit log, so a replacement is **detectable**; the object is **not immutable** — that needs an object-lock/retention policy on the bucket, which belongs to the infrastructure that provisions it and does not exist yet. Read "sealed" as tamper-evident, never as WORM | **configured**, with the immutability half **planned** elsewhere | accounts |
| Scope-node role grants and installation identity ship | PLATFORM_REFERENCE.md §1.2 | `98_layered_access`, `112_installations`, `pkg/business/installations.go` | implemented | accounts |
| RLS table→migration adoption table | AUTHZ.md § RLS coverage | names `audit_export_configs`, dropped by store migration 102 | historical (labelled) | #541 |
| The tenant and user are bound and read under the platform Postgres library's own setting names, `codefly.current_tenant_id` / `codefly.current_user_id` | MODULE.md, AUTHZ.md, module/DATABASE_AUTHORITY.md, module/JOBS.md, module/services/accounts/AUTHZ.md | store migration `65_library_scope_settings`; `pkg/infra.TenantScopeSetting` / `UserScopeSetting`; `TestEveryScopedPolicyReadsTheLibrarySettings` fails on any policy or function still reading `app.current_*`; `rls-migration-gate.mjs` accepts only these two | implemented | #1293 |
| The tenant and user are bound and read under the platform Postgres library's own setting names, `codefly.current_tenant_id` / `codefly.current_user_id` | MODULE.md, AUTHZ.md, module/DATABASE_AUTHORITY.md, module/JOBS.md, module/services/accounts/AUTHZ.md | store migration `65_library_scope_settings`; `pkg/infra.TenantScopeSetting` / `UserScopeSetting`, which `WithScopeSettings` also hands the library's scoped pools; `TestEveryScopedPolicyReadsTheLibrarySettings` fails on any policy or function reading `app.current_*` outside the compatibility read; `rls-migration-gate.mjs` accepts only these two as the live name | implemented | #1293 |
| The rename is deployable: for one release every predicate reads the library's name with the host's retired one behind it, the host binds both generations, and a catalog that reads only the retired names is refused rather than served empty | module/services/store/migrations/README.md § The compatibility window, module/services/accounts/AGENTS.md, module/DATABASE_AUTHORITY.md, module/module.package.codefly.yaml breaking-changes | store migration `66_scope_settings_transition` = `module/tools/rls-scope-settings-transition.sql` (`TestMigration66IsTheShippedScopeSettingsRecipe`); `TestMigration66OpensTheScopeSettingCompatibilityWindow` admits a transaction binding either generation on a real cluster and refuses an unbound one; `requireCatalogReadsScopeSettings` with `TestScopeSettingRefusalReadsTheCatalogsVersion` and `TestCatalogScopeSettingCheckReadsTheLiveCatalog` | implemented, **window open** — the migration that closes it is `66_..._transition.down.sql`, uncut | #1293 |
| ~~`app.current_org_id` / `app.current_user_id` as the host's RLS settings~~ | RLS_PLAN.md, APPROVALS_DESIGN.md, module/docs/adr/0003, docs/authorization/2-proposals/0001, docs/authorization/3-implementation/spikes/ltree-record-shares.md | renamed by store migration 65; these five keep the old names because each records a design or a decision as it was written (and each already names the long-gone `app.bypass`) | historical (not rewritten) | #1293 |

## Gateway hot path
Expand Down
10 changes: 10 additions & 0 deletions RLS_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ in [module/DATABASE_AUTHORITY.md](./module/DATABASE_AUTHORITY.md). Relations
named here have since been renamed or dropped (`audit_export_configs` went in
store migration 102), and the foundation has changed shape — the
application-settable bypass is gone, replaced by the `app_control_plane` role.
The two settings every policy below reads are also renamed: a tenant-scoped
policy reads `codefly.current_tenant_id` and a user-scoped one
`codefly.current_user_id` (store migration 65), with the
`app.current_org_id` / `app.current_user_id` spellings kept in this file as the
record of what was written. A consumer writing a policy today uses the two
library names and nothing else —
[module/services/store/migrations/README.md](./module/services/store/migrations/README.md)
is the current convention, including the compatibility window a rename runs
through and `module/tools/rls-scope-settings-transition.sql`, which rewrites
policies already installed on the retired names.

> Defense-in-depth for tenant isolation at the database row level.
> Layered on top of:
Expand Down
25 changes: 21 additions & 4 deletions module/DATABASE_AUTHORITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ constructor. That primitive owns the separate reader/writer pools, startup pings
failure cleanup and repeated-close safety, and it also owns the two
transaction-local settings a scope is bound to: its defaults,
`codefly.current_tenant_id` and `codefly.current_user_id`, are what the policies
read, so accounts passes no `WithScopeSettings` override and supplies only its
verified identity adapter. Its current request adapter issues Readers only;
read, and accounts passes them back through `WithScopeSettings` from the same
two constants its own hand-written SQL names, so the library's unexported
default cannot drift away from the policies without a refusal. It supplies its
verified identity adapter and nothing else. Its current request adapter issues Readers only;
authentication alone does not grant a Writer.

External-identity deployments with separate logins configure
Expand Down Expand Up @@ -88,6 +90,20 @@ transaction-locally, the half it does not carry as the empty string, so a
session-level binding is never visible inside one: an org transaction cannot
read a previous user's `sessions` or `mfa_devices`.

For the length of the scope-setting compatibility window it binds four: the
host's retired `app.current_org_id` and `app.current_user_id` carry the same two
values, and store migration 66's predicates read the library's name with the
retired one behind it. That is what makes a catalog and an image deployable
independently — a rename applied in one step is zero rows on every scoped
SELECT for whichever side lands first, silently. The retired half is bound to
the same value as the live one precisely so clearing one half clears both.
Startup refuses a database whose policies read only the retired names
(`requireCatalogReadsScopeSettings`): the two pools `service-postgres` owns bind
the library's names alone, so no binding here could make such a catalog serve,
and a started process would report healthy and answer nothing through them.
`module/services/store/migrations/README.md` carries the window and how it
closes.

Every new request connection, the first one at startup included, is judged
before the pool hands it out. It is refused if its login can reach any role
other than `app_tenant` — through any chain of memberships, whatever their
Expand Down Expand Up @@ -184,8 +200,9 @@ importers to `pkg/infra` and `storetx` and refuses an exported `pkg/infra`
function that takes a `pgx.Tx` and returns a context — a binder handed on.
Code holding the raw transaction could still rebind it in SQL, under a binding
that claims the old scope; `TestNoTenantRebindingOutsideInfra` fails on a
string literal containing `set_config(`, `SET ROLE`, `RESET ROLE` or
`app.current_` in any non-test file outside `pkg/infra`. Both are checks on
string literal containing `set_config(`, `SET ROLE`, `RESET ROLE` or either
generation of the scope setting names (`app.current_`, `codefly.current_`) in
any non-test file outside `pkg/infra`. Both are checks on
the source, not capabilities.

The store pins published Postgres agent 0.0.140, which provisions its declared
Expand Down
4 changes: 4 additions & 0 deletions module/module.package.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,14 @@ migrations:
- id: composition-v2
from: ">=0.0.32 <0.1.0"
breaking: true
- id: scope-settings-transition
from: ">=0.1.0 <0.2.0"
breaking: true
breaking-changes:
- Consumers resolve the immutable package into a disposable projection instead of editing Starter-owned files.
- "Audit event types are namespaced under `saas.*` (`auth.login` -> `saas.auth.login`). The wire value of `audit_events.event_type`, the `X-Webhook-Event` header, and webhook routing keys all change; migration 116 rewrites stored history and existing subscriptions in place, so a live subscriber keeps firing, but a consumer that re-creates a subscription from a hardcoded pre-namespace name, or emits one through `ModuleEmitAuditEvent`, is now rejected. No alias layer."
- "The telemetry service and its dependency contract are removed. SDK Init exports traces when OTEL_EXPORTER_OTLP_ENDPOINT is delivered; no endpoint means no tracing, including native local runs. Exporter initialization failures are logged without stopping the service. Accounts and auth-gateway push metrics to the same endpoint and no longer serve /metrics. Telemetry uses standard process variables, not the removed observability configuration group. Deliver standard OTEL variables to every service through the environment telemetry declaration using a compatible CLI. Existing consumers of the telemetry service must migrate to the HTTPS operations intake."
- "The two transaction-local settings an RLS policy reads are now the platform Postgres library's own, `codefly.current_tenant_id` and `codefly.current_user_id`, not the host's `app.current_org_id` and `app.current_user_id` (store migration 65). A consumer whose own migrations created tenant policies against the retired names must rewrite them: nothing binds those names as the only read any more, so each such table reads as EMPTY for every caller and every INSERT fails its WITH CHECK, with no error raised anywhere. Run `tools/rls-scope-settings-transition.sql` against the database — it is catalog-driven, idempotent, and reaches a consumer's own policies and routine bodies as well as this module's — or rewrite the predicates onto the two library names. Store migration 66 is that same file applied to this module's ledger: for the length of the compatibility window every predicate reads the library's name with the retired one behind it, and the host binds both generations, so the catalog and the fleet can move independently. A later release closes the window by deleting the retired arm; a policy still reading a retired name alone at that point goes silently empty, which accounts refuses to start against and `tools/rls-migration-gate.mjs` refuses to build."
reserved-namespaces:
- "@codefly/saas-"
- "@codefly-dev/saas-"
Expand Down
31 changes: 27 additions & 4 deletions module/services/accounts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,38 @@ is read by the capability checks and does not create synthetic directory rows.
**The two scope settings are named once.** A transaction's tenant and user are
bound to `codefly.current_tenant_id` and `codefly.current_user_id` — the
defaults of the platform's Postgres library, which installs them for any
transaction opened through the scoped factory. Nothing here passes
`WithScopeSettings`, and hand-written SQL names them from
transaction opened through the scoped factory. Both the scoped factory and the
hand-written SQL in `pkg/infra` take the two names from
`pkg/infra.TenantScopeSetting` / `UserScopeSetting` rather than spelling the
literal again. Only `pkg/infra` may bind them at all
(`TestNoTenantRebindingOutsidePkgInfra`), and a policy or function body left on
literal again, the factory through `WithScopeSettings`: the library's default is
unexported, so passing it is what ties the pools to the same constant the
policies and the request binding read. Only `pkg/infra` may bind them at all
(`TestNoTenantRebindingOutsideInfra`), and a policy or function body left on
some other setting does not fail loudly: `current_setting(…, true)` returns
NULL, the predicate is false, and the relation reads as empty for every caller
(`TestEveryScopedPolicyReadsTheLibrarySettings` is what catches that).

**A scope-setting rename is a two-release move, and this is the window.** A
one-step rename is undeployable: the catalog and the image ship separately, and
whichever loses the race reads an unset setting, which is zero rows on every
scoped SELECT and a WITH CHECK failure on every INSERT, with nothing raised. So
store migration 66 leaves every predicate reading the library's name with the
host's retired `app.current_org_id` / `app.current_user_id` behind it
(`COALESCE(NULLIF(<library>, ''), <retired>)`), `bindRequestScopeSQL` binds all
four names, and one image is correct against a catalog from before the rename,
after it, or inside the window. The retired names are
`pkg/infra.LegacyTenantScopeSetting` / `LegacyUserScopeSetting`, and nothing but
that binding and the migration may name them.

What the window does not cover is the two pools `service-postgres` owns: those
bind the library's two names and take no more, so against a catalog that reads
only the retired ones every read through them is silently empty and no binding
here can fix it. `requireCatalogReadsScopeSettings` refuses to start against
such a database rather than serving it — it asks the catalog what the policies
judge by, not the ledger what version it claims. The window closes in a later
release, whose migration is `66_scope_settings_transition.down.sql`: it deletes
the retired arm, and the two legacy constants and this section go with it.

## Forwarded Work Contexts on the organization directory

`DirectoryService` on the Connect endpoint accepts a person-owned Work Context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ func read(ctx context.Context, store Store) {

// TestNoTenantRebindingOutsideInfra fails on a string literal, in a non-test Go
// file outside pkg/infra, that binds or reads a transaction's request scope or
// role: set_config(, SET [LOCAL|SESSION] ROLE, RESET ROLE or codefly.current_,
// matched without regard to case or spacing. pkg/business, pkg/auth/pg and
// role: set_config(, SET [LOCAL|SESSION] ROLE, RESET ROLE or either generation
// of the scope setting names (app.current_, codefly.current_), matched without
// regard to case or spacing. pkg/business, pkg/auth/pg and
// pkg/billing/pg hold the raw transaction through storetx.Tx, and one such
// statement on it would rebind the tenant — or leave app_tenant — under a
// binding that still claims the org, user and role pkg/infra set, which is what
Expand Down Expand Up @@ -150,6 +151,7 @@ func escalate(ctx context.Context, tx pgx.Tx) {
_, _ = tx.Exec(ctx, "reset role")
_, _ = tx.Exec(ctx, "SELECT pg_catalog.set_config ('role', 'app_control_plane', true)")
_, _ = tx.Exec(ctx, "SELECT current_setting('codefly.current_user_id', true)")
_, _ = tx.Exec(ctx, "SELECT set_config('app.current_org_id', $1, true)")
_, _ = tx.Exec(ctx, "SET LOCAL " + "ROLE " + role)
_, _ = tx.Exec(ctx, "UPDATE organization_members SET a = 1; SET role = 'app_control_plane'")
}
Expand All @@ -169,13 +171,15 @@ func harmless(ctx context.Context, tx pgx.Tx) {
}
require.ElementsMatch(t, []string{
"(package level): set_config(",
"(package level): codefly.current_",
"(package level): app|codefly.current_",
"escalate: SET ROLE",
"escalate: SET ROLE",
"escalate: SET ROLE",
"escalate: RESET ROLE",
"escalate: set_config(",
"escalate: codefly.current_",
"escalate: app|codefly.current_",
"escalate: set_config(",
"escalate: app|codefly.current_",
"escalate: SET ROLE",
"escalate: SET ROLE",
}, got)
Expand All @@ -190,7 +194,12 @@ var tenantRebinding = []struct {
{"set_config(", regexp.MustCompile(`(?i)\bset_config\s*\(`)},
{"SET ROLE", regexp.MustCompile(`(?i)\bSET\s+(?:LOCAL\s+|SESSION\s+)?ROLE\b`)},
{"RESET ROLE", regexp.MustCompile(`(?i)\bRESET\s+ROLE\b`)},
{"codefly.current_", regexp.MustCompile(`(?i)\bcodefly\s*\.\s*current_`)},
// Both generations of the two names. The retired spelling has to be refused
// as well as the live one: it is readable again for the length of migration
// 66's compatibility window, so a statement outside pkg/infra that binds or
// reads it rebinds a transaction's scope exactly as the live name would —
// and a scan that matched only the live one would have reported nothing.
{"app|codefly.current_", regexp.MustCompile(`(?i)\b(?:app|codefly)\s*\.\s*current_`)},
}

var updateStatement = regexp.MustCompile(`(?i)\bUPDATE\b`)
Expand Down
25 changes: 25 additions & 0 deletions module/services/accounts/code/pkg/infra/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,28 @@ func ProjectedTokenForTest(lifetime time.Duration) string {
encode := base64.RawURLEncoding.EncodeToString
return encode([]byte(`{"alg":"RS256"}`)) + "." + encode(claims) + "." + encode([]byte("signature"))
}

// ScopeSettingPolicy is one policy as the startup catalog check reads it.
type ScopeSettingPolicy = scopeSettingPolicy

// ScopeSettingRefusal is the decision requireCatalogReadsScopeSettings makes
// over what it read, so the suite can state a catalog — notably the one from
// before migration 65, every policy reading the retired names alone — without
// standing a second database to hold it.
func ScopeSettingRefusal(judged []ScopeSettingPolicy) error { return scopeSettingRefusal(judged) }

// RequireCatalogReadsScopeSettings is the refusal startup applies, query and
// decision together, against whatever connection the caller hands it.
func RequireCatalogReadsScopeSettings(ctx context.Context, q interface {
Query(context.Context, string, ...any) (pgx.Rows, error)
}) error {
return requireCatalogReadsScopeSettings(ctx, q)
}

// ReturnsToTenantRole is the request pool's AfterRelease judgement: it runs the
// reset a released connection runs and returns the verdict the pool acts on —
// true to put the connection back, false to destroy it. The suite drives it on
// a connection it poisoned itself, so "the connection is reused, cleaned" is a
// decision this returns rather than a backend reappearing in a shared pool
// within some number of seconds.
func ReturnsToTenantRole(conn *pgx.Conn) bool { return returnsToTenantRole(conn) }
Loading
Loading