From 60a35549b24efe8f22697c3404a43362f540e2d1 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:02:12 -0500 Subject: [PATCH 1/2] refactor(accounts): bind and read the tenant and user under the library's setting names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host bound a request's organisation and user to names of its own, app.current_org_id and app.current_user_id, while the platform's Postgres library defaults a scoped transaction to codefly.current_tenant_id and codefly.current_user_id — which is what the other platform stores read. With the document store moving to the library's names in its own repository, the host was the last store on app.*. Store migration 65 renames 75 policies over 70 relations and 9 function bodies onto the library's two settings, and brings the six audit_events partitions back in step through migration 34's audit_events_secure_partition. It is generated from pg_policy and pg_proc under an empty search_path, which is how the baseline's own text is rendered and what makes the deparser schema-qualify; only the two names are substituted. pkg/infra passes no WithScopeSettings override any more — the library's defaults are what the policies read — and names the two settings once, in TenantScopeSetting and UserScopeSetting. Semantics are unchanged: transaction-local, fail-closed, forced, NOBYPASSRLS roles, app_tenant as the request default and app_control_plane on its own login. A missed policy would not have failed loudly. Nothing binds the old name any more, so current_setting(..., true) returns NULL, the predicate is false, and that relation reads as empty for every caller. TestMigration65RenamesEveryScopeSettingAndNothingElse therefore fingerprints every row-security decision in the catalog at version 64 — each relation's ENABLE and FORCE state, each policy's kind, verb, role list and both expressions, each routine body, and every owner and ACL — and requires the migrated catalog to equal it with the two names substituted and no other difference, which catches a moved shape and a missed policy alike. It then proves the renamed policies isolate, that binding the retired name admits nothing rather than everything, and that down 1 restores version 64 exactly. TestEveryScopedPolicyReadsTheLibrarySettings is the standing version of that check against the live database. Two guards had the old name baked in and would have gone blind rather than failed: rls-migration-gate.mjs now accepts only the two library settings, and control_plane_call_sites_test.go's rebinding scan — the one that stops any package outside pkg/infra binding a scope — matches codefly.current_ instead of app.current_. Co-Authored-By: Claude Opus 5 --- AUTHZ.md | 18 +- CLAIM_INVENTORY.md | 2 + MODULE.md | 4 +- module/DATABASE_AUTHORITY.md | 30 +- module/JOBS.md | 2 +- module/services/accounts/AGENTS.md | 12 + module/services/accounts/AUTHZ.md | 2 +- .../code/pkg/auth/pg/resolver_test.go | 2 +- .../code/pkg/auth/pg/session_store.go | 2 +- .../code/pkg/auth/pg/session_store_test.go | 2 +- .../pkg/business/client_authorization_test.go | 2 +- .../accounts/code/pkg/business/identity.go | 7 +- .../code/pkg/business/notifications.go | 4 +- .../code/pkg/business/rls_bench_test.go | 4 +- .../code/pkg/business/rls_check_test.go | 2 +- .../business/rls_datasource_sources_test.go | 2 +- .../pkg/business/rls_layered_access_test.go | 2 +- .../pkg/business/rls_organizations_test.go | 2 +- .../code/pkg/business/rls_user_scoped_test.go | 2 +- .../code/pkg/business/rls_webhooks_test.go | 2 +- .../accounts/code/pkg/business/service.go | 2 +- .../accounts/code/pkg/business/store.go | 6 +- .../accounts/code/pkg/business/webhooks.go | 4 +- .../pkg/infra/auditmetricstest/audit_test.go | 12 +- .../infra/control_plane_call_sites_test.go | 12 +- .../pkg/infra/delegated_record_access_test.go | 2 +- .../pkg/infra/installation_lifecycle_test.go | 2 +- .../code/pkg/infra/internal/txbind/txbind.go | 2 +- .../accounts/code/pkg/infra/postgres.go | 19 +- .../code/pkg/infra/postgres_approvals_test.go | 2 +- .../infra/postgres_audit_retention_test.go | 2 +- ...gres_control_plane_login_authority_test.go | 6 +- ...postgres_delegation_view_isolation_test.go | 2 +- .../pkg/infra/postgres_effect_receipts.go | 2 +- .../postgres_event_relay_webhook_test.go | 2 +- .../code/pkg/infra/postgres_events_test.go | 6 +- .../infra/postgres_github_app_setups_test.go | 2 +- .../pkg/infra/postgres_installations_test.go | 2 +- .../code/pkg/infra/postgres_layered_access.go | 2 +- .../postgres_membership_diagnostic_test.go | 2 +- .../accounts/code/pkg/infra/postgres_org.go | 13 +- .../pkg/infra/postgres_partition_rls_test.go | 4 +- .../code/pkg/infra/postgres_permissions.go | 2 +- .../pkg/infra/postgres_scope_settings_test.go | 126 ++ .../accounts/code/pkg/infra/postgres_team.go | 2 +- .../accounts/code/pkg/infra/postgres_users.go | 2 +- .../code/pkg/infra/postgres_users_rls_test.go | 2 +- .../code/pkg/infra/postgres_webhooks_test.go | 2 +- .../accounts/code/pkg/infra/scoped.go | 7 +- .../accounts/code/pkg/infra/tenant_tx.go | 31 +- .../code/pkg/infra/tenant_tx_pooling_test.go | 18 +- .../scopedpools/connection_policy_test.go | 4 +- .../scopedpools/delegation_view_owner_test.go | 6 +- .../scopedpools/reader_authority_test.go | 4 +- .../scopedpools/scoped_pools_test.go | 4 +- .../code/migration_65_scope_settings_test.go | 203 ++++ .../65_library_scope_settings.down.sql | 1048 ++++++++++++++++ .../65_library_scope_settings.up.sql | 1067 +++++++++++++++++ module/services/store/migrations/README.md | 17 + module/tools/rls-migration-gate.mjs | 15 +- module/tools/rls-migration-gate.test.mjs | 64 +- qualification/managed-database/run.py | 10 +- 62 files changed, 2676 insertions(+), 171 deletions(-) create mode 100644 module/services/accounts/code/pkg/infra/postgres_scope_settings_test.go create mode 100644 module/services/store/code/migration_65_scope_settings_test.go create mode 100644 module/services/store/migrations/65_library_scope_settings.down.sql create mode 100644 module/services/store/migrations/65_library_scope_settings.up.sql diff --git a/AUTHZ.md b/AUTHZ.md index e8de0de6b..dda15bb47 100644 --- a/AUTHZ.md +++ b/AUTHZ.md @@ -39,7 +39,7 @@ accountability are different questions: | Field | Answers | Used by | |---|---|---| -| `EffectiveSubject` | "whose authority does this request run under?" | tenant membership, scoped roles, resource authority, the user-scoped RLS context (`app.current_user_id`) | +| `EffectiveSubject` | "whose authority does this request run under?" | tenant membership, scoped roles, resource authority, the user-scoped RLS context (`codefly.current_user_id`) | | `RealActor` | "who is this request attributable to?" | audit (`impersonated_by`), and the platform-authority gates, which withhold platform authority whenever the two differ | For an ordinary session the two are the same id and nothing needs a special @@ -219,7 +219,7 @@ same matrix. **Where:** Postgres `ROW LEVEL SECURITY` + policies on every per-tenant table. The api wraps every per-tenant request in a transaction that -sets `app.current_org_id`. Workers that legitimately span tenants set +sets `codefly.current_tenant_id`. Workers that legitimately span tenants set `app.bypass = '1'` instead. **What it asserts:** even if the SQL hitting Postgres has no WHERE @@ -230,7 +230,7 @@ different tenant. -- Example policy (applies to every per-tenant table): CREATE POLICY tenant_isolation ON foo USING ( - org_id::text = current_setting('app.current_org_id', true) + org_id::text = current_setting('codefly.current_tenant_id', true) OR current_setting('app.bypass', true) = '1' ); ``` @@ -240,7 +240,7 @@ when the setting is missing. So: | Setting | Effect on a per-tenant query | |---|---| -| `app.current_org_id = ''` | Rows where `org_id = uuid` are visible. | +| `codefly.current_tenant_id = ''` | Rows where `org_id = uuid` are visible. | | `app.bypass = '1'` | All rows visible (workers + platform admin). | | Neither set | **Zero rows** — fail-closed. | @@ -556,7 +556,7 @@ organization nobody can administer, so it asks whether there are any. An organization nobody else is in is left empty, not unadministrable. A user-scoped transaction can resolve none of this on its own: -`organization_members` is scoped to `app.current_org_id`, which a deactivation +`organization_members` is scoped to `codefly.current_tenant_id`, which a deactivation does not set, and `users` is readable only for the caller's own row. Both fail *silently* — zero rows, no error — and "administers nothing" is exactly the answer that lets the deactivation through. So request traffic resolves it through @@ -780,7 +780,7 @@ the executable inventory by a test. The user-scoped half of this list is gone: `users`, `mfa_devices`, `notifications`, `sessions` and their siblings carry forced RLS policies today -and enter through `WithUserTx` (`app.current_user_id`), so `WHERE user_id = $1` +and enter through `WithUserTx` (`codefly.current_user_id`), so `WHERE user_id = $1` is no longer the safety property. `oauth_state` and `refresh_tokens` no longer exist at all. `role_permissions` gained a parent-visibility policy in `65_role_permissions_rls`. @@ -810,8 +810,8 @@ changing codefly's Postgres plugin: │ ▼ ▼ SET LOCAL ROLE app_control_plane SET LOCAL (a named role with BYPASSRLS and - app.current_org_id = X its own explicit grants — NOT - app.current_user_id = U the session superuser) + codefly.current_tenant_id = X its own explicit grants — NOT + codefly.current_user_id = U the session superuser) (still app_tenant — policy filters by scope) @@ -828,7 +828,7 @@ unwind on commit/rollback. `AfterRelease` does `RESET ROLE` as a safety net before the connection returns to the pool. The fail-closed property: a Store method called WITHOUT either -wrapper runs as `app_tenant` with no `app.current_org_id` set. +wrapper runs as `app_tenant` with no `codefly.current_tenant_id` set. RLS policies see neither match, return zero rows. A bug that forgot to wrap surfaces in tests as "expected 1 row, got 0" — loud, not silent. diff --git a/CLAIM_INVENTORY.md b/CLAIM_INVENTORY.md index e5867005e..9866c70b6 100644 --- a/CLAIM_INVENTORY.md +++ b/CLAIM_INVENTORY.md @@ -47,6 +47,8 @@ must not be inferred from a row here. See | `GetServiceInfo`'s RLS catalog (`serviceRLSTables`) reports the current protected set | `pkg/business/introspection.go` | **stale**: 25 hand-maintained entries against 38 tenant + 13 user relations, sourced "store migrations through 60" | known drift, filed | #556 | | 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 | +| ~~`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 diff --git a/MODULE.md b/MODULE.md index 79fe1c640..049735bcb 100644 --- a/MODULE.md +++ b/MODULE.md @@ -806,8 +806,8 @@ the playground that drives every shape is `/admin/platform/plugin-transport`. 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)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); ``` 3. Add a `Store` method in `pkg/business/store.go`. 4. Implement in `pkg/infra/postgres_X.go`. Use `s.getQueryExecutor(ctx)` so context-scoped tx (from `WithOrgTx`) is reused. diff --git a/module/DATABASE_AUTHORITY.md b/module/DATABASE_AUTHORITY.md index 8be4de507..3bfafa7b1 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -34,10 +34,12 @@ and the runner refuses one it did not install. The authenticated request boundary uses the published service-postgres `Open` constructor. That primitive owns the separate reader/writer pools, startup pings, -failure cleanup and repeated-close safety. Accounts supplies its verified identity -adapter and transaction-local `app.current_org_id` / `app.current_user_id` settings. -Its current request adapter issues Readers only; authentication alone does not -grant a Writer. +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; +authentication alone does not grant a Writer. External-identity deployments with separate logins configure `POSTGRES_TOKEN_FILES`: a JSON object mapping each exact database login name @@ -77,7 +79,7 @@ to: `SET ROLE` to any cross-tenant role is refused by PostgreSQL, and `SET ROLE NONE` leaves the bare login, holding none of the privileges judged below. A request connection runs `RESET ROLE`, `RESET ALL` and `DISCARD TEMP` as it returns to the pool — back to that session default role, with -`app.current_org_id` and `app.current_user_id` cleared and any temporary table +`codefly.current_tenant_id` and `codefly.current_user_id` cleared and any temporary table dropped — and is discarded unless it comes back as `app_tenant` with neither set, so a borrower cannot hand the bare login, or its own request scope, to the next one. Every request transaction (`WithOrgTx`, @@ -812,7 +814,7 @@ counting administrative rows regardless of identity status reports an organization healthy when nobody holding one can sign in to administer it — exactly the backlog this inventory exists to size. Migration 130's `organization_eligible_administrators()` is not reused: it is `SECURITY DEFINER` -scoped to `app.current_org_id`, so it answers for a single tenant, and this is a +scoped to `codefly.current_tenant_id`, so it answers for a single tenant, and this is a cross-tenant inventory. The three are **mutually exclusive by construction** — one `CASE` over one row @@ -845,7 +847,7 @@ current backlog rather than an append-only history. **The connection principal must be a member of `app_control_plane`**, and that is load-bearing rather than ceremonial. `organizations`, `organization_members` and `membership_integrity_findings` all force RLS — which binds the table owner -too — with policies scoped to `app.current_org_id` and no bypass clause. A +too — with policies scoped to `codefly.current_tenant_id` and no bypass clause. A principal that does not span organizations therefore reads zero rows through all three: it would record an empty backlog and report a healthy platform. The function refuses to run for such a principal instead, and the migration assumes @@ -910,12 +912,12 @@ removes the former queue lifecycle columns for upgraded databases; migration Migration `69_user_directory_operations` replaces the historical `users_select USING (true)` policy. Request traffic can read only the row whose -UUID matches `app.current_user_id`; pre-authentication, platform administration, +UUID matches `codefly.current_user_id`; pre-authentication, platform administration, and workers use their separately named roles. Tenant code does not receive co-member visibility over the full `users` row. The `organization_member_primary_email(user_id)` operation returns exactly one -field, only when that user belongs to `app.current_org_id`. It is a +field, only when that user belongs to `codefly.current_tenant_id`. It is a `SECURITY DEFINER` function owned by the NOLOGIN `app_control_plane` role, revokes public execution, and grants only `EXECUTE` to `app_tenant`. Executable tests pin its owner, security mode, ACL, cross-user isolation, and membership @@ -936,7 +938,7 @@ Eligibility therefore has to read `users`, which request traffic cannot do for a co-member (migration `69`). The remedy is the same one that section describes: a `SECURITY DEFINER` function owned by the NOLOGIN `app_control_plane` role, public execution revoked, `EXECUTE` granted only to `app_tenant`, and the body -scoped to `app.current_org_id` so it answers only for the caller's own +scoped to `codefly.current_tenant_id` so it answers only for the caller's own organization. It returns membership ids that caller can already enumerate, and no `users` column. @@ -979,19 +981,19 @@ on this platform. A deactivating transaction is scoped to an identity and to no organization, which is the one scope that can read neither input: `organization_members` is -scoped to `app.current_org_id` (migrations `29`/`68`) and `users` to the +scoped to `codefly.current_tenant_id` (migrations `29`/`68`) and `users` to the caller's own row (migration `69`). Both return zero rows rather than an error, and zero administered organizations reads as "this identity administers nothing" — the answer that admits the deactivation. Same shape as the two sections above: a `SECURITY DEFINER` function owned by the NOLOGIN `app_control_plane` role, public execution revoked, `EXECUTE` granted only to -`app_tenant`, and the body scoped to `app.current_user_id` so it answers only +`app_tenant`, and the body scoped to `codefly.current_user_id` so it answers only for the caller's own identity. It returns organization ids that caller is already a member of, plus a count over them, and no `users` column. The administrator count repeats migration `133`'s eligibility predicate rather than calling `organization_eligible_administrators`, which scopes itself to -`app.current_org_id` — a value a deactivation has no single one of. +`codefly.current_tenant_id` — a value a deactivation has no single one of. `PostgresStore.ListAdministeredOrganizations` branches on the transaction's scope: the caller's own identity goes through the function, a transaction that has assumed `app_control_plane` evaluates the predicate directly, and one that is @@ -1114,7 +1116,7 @@ start instead. `executable_artifact_approvals` (migration 19) is organization-scoped consent metadata. It stores exact exported identities and their approved contract policy, not executable content. FORCE RLS admits only `app_tenant` with matching -`app.current_org_id`; there is no control-plane read policy. Tenant callers may +`codefly.current_tenant_id`; there is no control-plane read policy. Tenant callers may select/insert and update only `revoked_at`/`revoked_by`; they cannot change the approved identity, approver, installation or policy. A trigger makes revocation terminal. The service uses `As(verified tenant and current user).Within` and explicit tenant/installation/module diff --git a/module/JOBS.md b/module/JOBS.md index 3a51b7aa7..bcfbc8469 100644 --- a/module/JOBS.md +++ b/module/JOBS.md @@ -123,7 +123,7 @@ are database constraints rather than worker conventions. - `app_tenant` has no direct rights on any job relation. Its only capability is `EXECUTE` on `enqueue_job_message`. That security-definer operation accepts outbox work only and independently verifies that tenant scope matches - `app.current_org_id` or subject scope matches `app.current_user_id`. Forced + `codefly.current_tenant_id` or subject scope matches `codefly.current_user_id`. Forced insert RLS remains defense in depth. Request traffic cannot append global or inbox work, inspect payloads, claim, finalize, or write history. - `app_job_worker` is `NOLOGIN`, `NOINHERIT`, and `BYPASSRLS`. It has only diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 07c6fbc96..8d139eaac 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -25,6 +25,18 @@ Context exchanges below; no secret does. The principal directory lists stored identities only. Declared module authority 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 +`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 +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). + ## Forwarded Work Contexts on the organization directory `DirectoryService` on the Connect endpoint accepts a person-owned Work Context diff --git a/module/services/accounts/AUTHZ.md b/module/services/accounts/AUTHZ.md index 293a01efe..c7fc16019 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -106,7 +106,7 @@ A platform `super_admin` (a `platform_admins` row with that role) reads every scope node — every collection and every record placed under one — of whichever organization the request acts in, with no scope grant or record share. Any organization, but one tenant at a time: every oracle still runs under that -tenant's `app.current_org_id`, so RLS and the queries' explicit `org_id` +tenant's `codefly.current_tenant_id`, so RLS and the queries' explicit `org_id` predicates confine the answer to it (behaviour B2), exactly as they do for a grant. It is a third branch beside grant and share in every layered-access read oracle, all in diff --git a/module/services/accounts/code/pkg/auth/pg/resolver_test.go b/module/services/accounts/code/pkg/auth/pg/resolver_test.go index c7ad20d68..4a23cd0cb 100644 --- a/module/services/accounts/code/pkg/auth/pg/resolver_test.go +++ b/module/services/accounts/code/pkg/auth/pg/resolver_test.go @@ -364,7 +364,7 @@ func TestResolver_Signup_CreatesOrg(t *testing.T) { require.Equal(t, "owner", id.OrgRole) // organizations is RLS-protected (Phase 2F). Reading via the - // raw pool runs as app_tenant with no app.current_org_id set + // raw pool runs as app_tenant with no codefly.current_tenant_id set // → zero rows. Wrap in WithControlPlane + use the tx from ctx for // the assertion read. var name, slug string diff --git a/module/services/accounts/code/pkg/auth/pg/session_store.go b/module/services/accounts/code/pkg/auth/pg/session_store.go index 9e018c757..f556027db 100644 --- a/module/services/accounts/code/pkg/auth/pg/session_store.go +++ b/module/services/accounts/code/pkg/auth/pg/session_store.go @@ -81,7 +81,7 @@ func txFromCtx(ctx context.Context) pgx.Tx { // the access-token lifetime; RotateRefresh re-resolves current authorization // before minting the next access token. // -// Wrapped in WithUserTx so the row's user_id matches app.current_user_id +// Wrapped in WithUserTx so the row's user_id matches codefly.current_user_id // — the WITH CHECK on sessions's RLS policy passes. func (s *SessionStore) Insert(ctx context.Context, rec *auth.SessionRecord) error { if s.configErr != nil { diff --git a/module/services/accounts/code/pkg/auth/pg/session_store_test.go b/module/services/accounts/code/pkg/auth/pg/session_store_test.go index ad5ba5bfc..f4104f894 100644 --- a/module/services/accounts/code/pkg/auth/pg/session_store_test.go +++ b/module/services/accounts/code/pkg/auth/pg/session_store_test.go @@ -1099,7 +1099,7 @@ func TestRLS_Sessions_CrossUserBlocked(t *testing.T) { })) // Un-wrapped: zero rows. A request connection starts as app_tenant, - // and with no app.current_user_id set the policies admit nothing + // and with no codefly.current_user_id set the policies admit nothing // (fail-closed). var noWrapCount int require.NoError(t, testPool.QueryRow(ctx, diff --git a/module/services/accounts/code/pkg/business/client_authorization_test.go b/module/services/accounts/code/pkg/business/client_authorization_test.go index cde98a0b0..d82d40c89 100644 --- a/module/services/accounts/code/pkg/business/client_authorization_test.go +++ b/module/services/accounts/code/pkg/business/client_authorization_test.go @@ -290,7 +290,7 @@ func auditLoginRow(t *testing.T, userID string) (resourceID string, orgID *strin } // audit_events' tenant policy is USING (org_id IS NOT NULL AND org_id = -// app.current_org_id), so a login row emitted without an organization is +// codefly.current_tenant_id), so a login row emitted without an organization is // readable by no tenant at all — an org admin reviewing their audit trail would // never see that this person signed in through a client. The row must also name // the session the exchange created, not the host session that authorized it. diff --git a/module/services/accounts/code/pkg/business/identity.go b/module/services/accounts/code/pkg/business/identity.go index 6cc0dfec1..431f2c2ee 100644 --- a/module/services/accounts/code/pkg/business/identity.go +++ b/module/services/accounts/code/pkg/business/identity.go @@ -4,7 +4,7 @@ import "context" // Identity is the principal a unit of data access acts as. A Store handle bound // to an Identity (see infra.PostgresStore.As) derives the RLS session context — -// app.current_user_id and/or app.current_org_id — from it on every call, so the +// codefly.current_user_id and/or codefly.current_tenant_id — from it on every call, so the // identity decision is made once (at the edge, from the authenticated caller) // rather than each call site choosing WithUserTx / WithOrgTx / WithControlPlane by hand. // @@ -25,8 +25,9 @@ func System() Identity { return Identity{system: true} } func (i Identity) IsSystem() bool { return i.system } // Scoped is a Store handle bound to an Identity (see Store.As). Within runs fn -// in one transaction carrying the identity's RLS context — app.current_user_id -// and/or app.current_org_id, or the explicit bypass for System(). Existing Store +// in one transaction carrying the identity's RLS context — +// codefly.current_user_id and/or codefly.current_tenant_id, or the explicit +// bypass for System(). Existing Store // methods called inside fn pick up the tx via the ctx, so identity is set once // at the entry. Promoted domain methods hang off Scoped as slices migrate; this // interface grows with them (it lives in business to avoid an infra import cycle; diff --git a/module/services/accounts/code/pkg/business/notifications.go b/module/services/accounts/code/pkg/business/notifications.go index 9f9d2bc40..05e1ba2a5 100644 --- a/module/services/accounts/code/pkg/business/notifications.go +++ b/module/services/accounts/code/pkg/business/notifications.go @@ -335,8 +335,8 @@ type resourceRef struct { // org-scoped call per (org, resource_type) rather than a point check per item. // // Visibility is resolved in its own pass rather than as a join with the inbox: -// `notifications` is gated by app.current_user_id and the scope tree by -// app.current_org_id, each helper sets only its own GUC and neither may nest, so +// `notifications` is gated by codefly.current_user_id and the scope tree by +// codefly.current_tenant_id, each helper sets only its own GUC and neither may nest, so // a single statement spanning both would fail closed on one side. // // A reference carrying no org cannot be resolved against a scope tree at all, so diff --git a/module/services/accounts/code/pkg/business/rls_bench_test.go b/module/services/accounts/code/pkg/business/rls_bench_test.go index 6e6e2efab..badc5396a 100644 --- a/module/services/accounts/code/pkg/business/rls_bench_test.go +++ b/module/services/accounts/code/pkg/business/rls_bench_test.go @@ -18,8 +18,8 @@ import ( // goroutine) at time of writing: // // BenchmarkRLS_NoOpQuery_NoWrap ~850µs/op bare pool QueryRow("SELECT 1") -// BenchmarkRLS_NoOpQuery_OrgTx ~1680µs/op + Begin + SET LOCAL app.current_org_id + Commit -// BenchmarkRLS_NoOpQuery_UserTx ~1510µs/op + Begin + SET LOCAL app.current_user_id + Commit +// BenchmarkRLS_NoOpQuery_OrgTx ~1680µs/op + Begin + SET LOCAL codefly.current_tenant_id + Commit +// BenchmarkRLS_NoOpQuery_UserTx ~1510µs/op + Begin + SET LOCAL codefly.current_user_id + Commit // BenchmarkRLS_NoOpQuery_ControlPlane ~700µs/op + Begin + SET LOCAL ROLE + Commit // (* ControlPlane also runs recordControlPlane which emits a wool.Debug; // with DEBUG-level logging the per-op number includes that.) diff --git a/module/services/accounts/code/pkg/business/rls_check_test.go b/module/services/accounts/code/pkg/business/rls_check_test.go index b498af93a..e400b6c93 100644 --- a/module/services/accounts/code/pkg/business/rls_check_test.go +++ b/module/services/accounts/code/pkg/business/rls_check_test.go @@ -15,7 +15,7 @@ import ( // Background: codefly's Postgres plugin connects the accounts service as a // SUPERUSER by default. Postgres superusers bypass RLS unconditionally, // even when the table has FORCE ROW LEVEL SECURITY. So a naive -// "set app.current_org_id and run the query" approach has no effect +// "set codefly.current_tenant_id and run the query" approach has no effect // — the policy never evaluates. // // WithOrgTx defends against this by SET LOCAL ROLE app_tenant inside diff --git a/module/services/accounts/code/pkg/business/rls_datasource_sources_test.go b/module/services/accounts/code/pkg/business/rls_datasource_sources_test.go index f3433d4ef..63b981ca0 100644 --- a/module/services/accounts/code/pkg/business/rls_datasource_sources_test.go +++ b/module/services/accounts/code/pkg/business/rls_datasource_sources_test.go @@ -45,7 +45,7 @@ func insertDatasourceSource(t *testing.T, ctx context.Context, orgID, repo strin // TestRLS_DatasourceSources_CrossTenantBlocked mirrors the direct-org_id RLS // tests: each org sees only its own datasource rows, a cross-tenant read is -// hidden even when the id is known, and an un-wrapped read (no app.current_org_id) +// hidden even when the id is known, and an un-wrapped read (no codefly.current_tenant_id) // returns nothing — RLS fail-closed. The unauthenticated webhook by-id lookup, // which runs through the control-plane role, still resolves the row. func TestRLS_DatasourceSources_CrossTenantBlocked(t *testing.T) { diff --git a/module/services/accounts/code/pkg/business/rls_layered_access_test.go b/module/services/accounts/code/pkg/business/rls_layered_access_test.go index c15ec158d..2acc10f0d 100644 --- a/module/services/accounts/code/pkg/business/rls_layered_access_test.go +++ b/module/services/accounts/code/pkg/business/rls_layered_access_test.go @@ -83,7 +83,7 @@ func TestRLS_LayeredAccess_CrossTenantAndFailClosed(t *testing.T) { require.NoError(t, err) require.Empty(t, crossShares.GetShares(), "RLS must hide orgA's share from orgB") - // Fail-closed: an un-wrapped connection (no app.current_org_id) sees nothing. + // Fail-closed: an un-wrapped connection (no codefly.current_tenant_id) sees nothing. require.False(t, access("", true), "un-wrapped CheckAccess must fail closed") noWrapShares, err := testStore.ListShares(context.Background(), orgA, "doc", "shared-a") require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/business/rls_organizations_test.go b/module/services/accounts/code/pkg/business/rls_organizations_test.go index e6d100565..1c7ba29ff 100644 --- a/module/services/accounts/code/pkg/business/rls_organizations_test.go +++ b/module/services/accounts/code/pkg/business/rls_organizations_test.go @@ -79,7 +79,7 @@ func TestRLS_Organizations_ListForUser_BypassesPolicy(t *testing.T) { // ListOrganizations must return both. Without bypass, the // self-referential policy would match at most one (whichever - // app.current_org_id pointed at). + // codefly.current_tenant_id pointed at). resp, err := testService.ListOrganizations(ctx, userA) require.NoError(t, err) require.GreaterOrEqual(t, len(resp.Organizations), 2, diff --git a/module/services/accounts/code/pkg/business/rls_user_scoped_test.go b/module/services/accounts/code/pkg/business/rls_user_scoped_test.go index f7a96010f..3f59acd09 100644 --- a/module/services/accounts/code/pkg/business/rls_user_scoped_test.go +++ b/module/services/accounts/code/pkg/business/rls_user_scoped_test.go @@ -4,7 +4,7 @@ package business_test // Phase 2G — user-scoped RLS (notifications, mfa_devices, // mfa_backup_codes). Symmetric to the org-scoped tests but the GUC -// is `app.current_user_id` and the helper is WithUserTx. +// is `codefly.current_user_id` and the helper is WithUserTx. import ( "context" diff --git a/module/services/accounts/code/pkg/business/rls_webhooks_test.go b/module/services/accounts/code/pkg/business/rls_webhooks_test.go index e189b77d6..956227f94 100644 --- a/module/services/accounts/code/pkg/business/rls_webhooks_test.go +++ b/module/services/accounts/code/pkg/business/rls_webhooks_test.go @@ -66,7 +66,7 @@ func TestRLS_WebhookSubscriptions_CrossTenantBlocked(t *testing.T) { return nil })) - // Un-wrapped: app_tenant role + no app.current_org_id → zero + // Un-wrapped: app_tenant role + no codefly.current_tenant_id → zero // rows. Fail-closed. noWrap, err := testStore.ListWebhookSubscriptions(context.Background(), orgA) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index cbf7602c7..df0502d77 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -987,7 +987,7 @@ func (s *Service) ResolveIdentity(ctx context.Context, req *gen.ResolveIdentityR // Bootstrap path: at this moment the tenant doesn't exist yet, so // there's no org context to set. WithControlPlane (which keeps the // connection's session_user role) is correct here — the role-switch -// in WithOrgTx would put us as app_tenant with no app.current_org_id, +// in WithOrgTx would put us as app_tenant with no codefly.current_tenant_id, // and the WITH CHECK on organization_members would reject the insert. // User authz is at the handler — only authenticated users can create // orgs; abuse is rate-limited. diff --git a/module/services/accounts/code/pkg/business/store.go b/module/services/accounts/code/pkg/business/store.go index f86c20b6d..ba92aee58 100644 --- a/module/services/accounts/code/pkg/business/store.go +++ b/module/services/accounts/code/pkg/business/store.go @@ -12,13 +12,13 @@ type Store interface { // Transactions RunInTransaction(ctx context.Context, fn func(ctx context.Context) error) error - // WithOrgTx wraps fn in a transaction that has app.current_org_id + // WithOrgTx wraps fn in a transaction that has codefly.current_tenant_id // set, so RLS policies on per-tenant tables filter to that org. // Empty orgID is rejected (loud, fail-closed). Wrap every // per-tenant Service path in this — see AUTHZ.md. WithOrgTx(ctx context.Context, orgID string, fn func(ctx context.Context) error) error - // WithUserTx wraps fn in a transaction that has app.current_user_id + // WithUserTx wraps fn in a transaction that has codefly.current_user_id // set, so RLS policies on user-scoped tables (notifications, // mfa_devices, sessions) filter to that user. Empty userID is // rejected (loud, fail-closed). Same no-nesting rule as WithOrgTx. @@ -253,7 +253,7 @@ type Store interface { SetDatasourceSourceGitHubInstallation(ctx context.Context, orgID, id, installationID string) error // GitHub App onboarding (issue #687). All org-scoped: every call runs inside - // WithOrgTx, and the tables' RLS policies key on app.current_org_id. + // WithOrgTx, and the tables' RLS policies key on codefly.current_tenant_id. // // The datasource directory (datasource_directory.go). Every method runs // inside the organization's transaction. diff --git a/module/services/accounts/code/pkg/business/webhooks.go b/module/services/accounts/code/pkg/business/webhooks.go index 6018b8392..60f99ea04 100644 --- a/module/services/accounts/code/pkg/business/webhooks.go +++ b/module/services/accounts/code/pkg/business/webhooks.go @@ -232,7 +232,7 @@ func (s *Service) CreateSubscription(ctx context.Context, actor AuditActor, orgI } // RLS: write goes through WithOrgTx so the policy WITH CHECK - // passes (org_id matches the current_org_id setting). + // passes (org_id matches the tenant-scope setting). if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { if err := s.store.CreateWebhookSubscription(ctx, sub); err != nil { return err @@ -298,7 +298,7 @@ func (s *Service) ListSubscriptions(ctx context.Context, orgID string) ([]*Webho // ListDeliveries returns recent deliveries for a webhook subscription. // // orgID is the caller's org from the auth context. Required: under -// RLS, ListWebhookDeliveries needs the tx's app.current_org_id set +// RLS, ListWebhookDeliveries needs the tx's codefly.current_tenant_id set // or it returns zero rows. The subscription_id alone isn't enough — // the policy on webhook_deliveries joins to webhook_subscriptions to // check the parent's org_id matches. diff --git a/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go b/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go index 9fd96692c..61a490eab 100644 --- a/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go +++ b/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go @@ -309,7 +309,7 @@ type transactionStore struct { } func (s *transactionStore) WithOrgTx(ctx context.Context, org string, fn func(context.Context) error) error { - _, err := s.tx.Exec(ctx, "SELECT set_config('app.current_org_id', $1, true)", org) + _, err := s.tx.Exec(ctx, "SELECT set_config('codefly.current_tenant_id', $1, true)", org) if err != nil { return err } @@ -566,16 +566,16 @@ func TestPostgresAuditPolicyAsNonOwner(t *testing.T) { _, err = tx.Exec(ctx, "CREATE ROLE "+ident+" NOLOGIN NOSUPERUSER NOBYPASSRLS; CREATE SCHEMA "+ident+"; SET LOCAL search_path TO "+ident+"; CREATE TABLE audit_events(org_id text, actor_id text, event_type text, payload jsonb); INSERT INTO audit_events VALUES ('org-a','reader','saas.document.read','{}'),('org-b','reader','saas.document.read','{}'),(NULL,'reader','saas.document.read','{}')") require.NoError(t, err) // The isolation the real table carries, stated as the ledger states it: a - // tenant policy scoped by app.current_org_id, with the user-scoped insert + // tenant policy scoped by codefly.current_tenant_id, with the user-scoped insert // path for rows that belong to no organization. _, err = tx.Exec(ctx, ` ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_events FORCE ROW LEVEL SECURITY; CREATE POLICY audit_events_tenant ON audit_events - USING (org_id IS NOT NULL AND org_id::text = current_setting('app.current_org_id', true)) + USING (org_id IS NOT NULL AND org_id::text = current_setting('codefly.current_tenant_id', true)) WITH CHECK ( - (org_id IS NOT NULL AND org_id::text = current_setting('app.current_org_id', true)) - OR (org_id IS NULL AND actor_id IS NOT NULL AND actor_id::text = current_setting('app.current_user_id', true)) + (org_id IS NOT NULL AND org_id::text = current_setting('codefly.current_tenant_id', true)) + OR (org_id IS NULL AND actor_id IS NOT NULL AND actor_id::text = current_setting('codefly.current_user_id', true)) )`) require.NoError(t, err) _, err = tx.Exec(ctx, "GRANT USAGE ON SCHEMA "+ident+" TO "+ident+"; GRANT SELECT ON audit_events TO "+ident+"; SET LOCAL ROLE "+ident) @@ -583,7 +583,7 @@ func TestPostgresAuditPolicyAsNonOwner(t *testing.T) { ctx = txbind.BindRequest(ctx, tx, "", "") store := &infra.PostgresStore{} for _, org := range []string{"org-a", "org-b", ""} { - _, err = tx.Exec(ctx, "SELECT set_config('app.current_org_id', $1, true)", org) + _, err = tx.Exec(ctx, "SELECT set_config('codefly.current_tenant_id', $1, true)", org) require.NoError(t, err) rows, err := store.AggregateAuditLog(ctx, business.AuditQuery{}, business.AuditAggregationSpec{}) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/infra/control_plane_call_sites_test.go b/module/services/accounts/code/pkg/infra/control_plane_call_sites_test.go index 6bf7854c0..6db3c0959 100644 --- a/module/services/accounts/code/pkg/infra/control_plane_call_sites_test.go +++ b/module/services/accounts/code/pkg/infra/control_plane_call_sites_test.go @@ -93,7 +93,7 @@ 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 app.current_, +// 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 // 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 @@ -140,7 +140,7 @@ func TestNoTenantRebindingOutsideInfra(t *testing.T) { func TestTenantRebindingScanSeesEveryForm(t *testing.T) { file, err := parser.ParseFile(token.NewFileSet(), "synthetic.go", `package business -const bindOrg = "SELECT set_config('app.current_org_id', $1, true)" +const bindOrg = "SELECT set_config('codefly.current_tenant_id', $1, true)" func escalate(ctx context.Context, tx pgx.Tx) { _, _ = tx.Exec(ctx, "SET ROLE app_control_plane") @@ -149,7 +149,7 @@ func escalate(ctx context.Context, tx pgx.Tx) { _, _ = tx.Exec(ctx, "SET SESSION ROLE app_control_plane") _, _ = tx.Exec(ctx, "reset role") _, _ = tx.Exec(ctx, "SELECT pg_catalog.set_config ('role', 'app_control_plane', true)") - _, _ = tx.Exec(ctx, "SELECT current_setting('app.current_user_id', true)") + _, _ = tx.Exec(ctx, "SELECT current_setting('codefly.current_user_id', true)") _, _ = tx.Exec(ctx, "SET LOCAL " + "ROLE " + role) _, _ = tx.Exec(ctx, "UPDATE organization_members SET a = 1; SET role = 'app_control_plane'") } @@ -169,13 +169,13 @@ func harmless(ctx context.Context, tx pgx.Tx) { } require.ElementsMatch(t, []string{ "(package level): set_config(", - "(package level): app.current_", + "(package level): codefly.current_", "escalate: SET ROLE", "escalate: SET ROLE", "escalate: SET ROLE", "escalate: RESET ROLE", "escalate: set_config(", - "escalate: app.current_", + "escalate: codefly.current_", "escalate: SET ROLE", "escalate: SET ROLE", }, got) @@ -190,7 +190,7 @@ 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`)}, - {"app.current_", regexp.MustCompile(`(?i)\bapp\s*\.\s*current_`)}, + {"codefly.current_", regexp.MustCompile(`(?i)\bcodefly\s*\.\s*current_`)}, } var updateStatement = regexp.MustCompile(`(?i)\bUPDATE\b`) diff --git a/module/services/accounts/code/pkg/infra/delegated_record_access_test.go b/module/services/accounts/code/pkg/infra/delegated_record_access_test.go index c880ca1ad..0d57d1579 100644 --- a/module/services/accounts/code/pkg/infra/delegated_record_access_test.go +++ b/module/services/accounts/code/pkg/infra/delegated_record_access_test.go @@ -53,7 +53,7 @@ func TestDelegatedRecordAccessUsesRealPlacementAndAllSubjects(t *testing.T) { // Both halves of the decision — the grant check's record CTE and the placement // disclosure — resolve a record on (resource_type, resource_id) with no tenant -// predicate, because scope_nodes is RLS-scoped to app.current_org_id. The unique +// predicate, because scope_nodes is RLS-scoped to codefly.current_tenant_id. The unique // index is per tenant, so the same resource id may be placed in two of them, and // a scope path is spelled the same in both; a leak between them would therefore // still resolve to a real grant rather than fail closed. diff --git a/module/services/accounts/code/pkg/infra/installation_lifecycle_test.go b/module/services/accounts/code/pkg/infra/installation_lifecycle_test.go index a3cdcdf1a..6f0b7a44a 100644 --- a/module/services/accounts/code/pkg/infra/installation_lifecycle_test.go +++ b/module/services/accounts/code/pkg/infra/installation_lifecycle_test.go @@ -112,7 +112,7 @@ func TestInstallationLifecycleRefusalRetryAndAcknowledgement(t *testing.T) { require.Equal(t, gen.InstallationStatus_INSTALLATION_STATUS_PENDING, retried.Status) require.Empty(t, retried.FailureReason) var role, org string - err = storetx.Tx(ctx).QueryRow(ctx, `SELECT current_user, coalesce(current_setting('app.current_org_id',true),'')`).Scan(&role, &org) + err = storetx.Tx(ctx).QueryRow(ctx, `SELECT current_user, coalesce(current_setting('codefly.current_tenant_id',true),'')`).Scan(&role, &org) require.Equal(t, "app_control_plane", role, "enqueue restores the enclosing transaction's role") require.Empty(t, org, "enqueue does not leave tenant state on the control plane") } diff --git a/module/services/accounts/code/pkg/infra/internal/txbind/txbind.go b/module/services/accounts/code/pkg/infra/internal/txbind/txbind.go index e1850f1b5..5ee06454d 100644 --- a/module/services/accounts/code/pkg/infra/internal/txbind/txbind.go +++ b/module/services/accounts/code/pkg/infra/internal/txbind/txbind.go @@ -57,7 +57,7 @@ type Binding struct { Tx pgx.Tx Pool Pool // OrgID and UserID are the request scope a request transaction bound for - // itself — app.current_org_id and app.current_user_id, set transaction- + // itself — codefly.current_tenant_id and codefly.current_user_id, set transaction- // locally — with "" for a half it cleared or never set. A control-plane or // worker transaction binds no request scope, so both are "". OrgID string diff --git a/module/services/accounts/code/pkg/infra/postgres.go b/module/services/accounts/code/pkg/infra/postgres.go index ff3064e58..f2ce31903 100644 --- a/module/services/accounts/code/pkg/infra/postgres.go +++ b/module/services/accounts/code/pkg/infra/postgres.go @@ -292,7 +292,8 @@ const requestReleaseTimeout = 5 * time.Second // sessionReset returns a released connection to its login's session defaults // and reads back what it returned to. RESET ROLE restores the per-role default // role, app_tenant for the request login. RESET ALL restores every other -// session setting — app.current_org_id and app.current_user_id included, which +// session setting — codefly.current_tenant_id and codefly.current_user_id +// included, which // RESET ROLE leaves in place — and does not itself reset the role, so both are // needed. Neither deallocates prepared statements, so the statements pgx caches // per connection stay valid on the reused connection, which DISCARD ALL would @@ -302,8 +303,8 @@ const requestReleaseTimeout = 5 * time.Second // Unlike DISCARD ALL it may run inside the implicit transaction a multi-statement // message opens. The four statements travel in one simple-protocol message. const sessionReset = `RESET ROLE; RESET ALL; DISCARD TEMP; SELECT current_user::text, - coalesce(current_setting('app.current_org_id', true), ''), - coalesce(current_setting('app.current_user_id', true), '')` + coalesce(current_setting('` + TenantScopeSetting + `', true), ''), + coalesce(current_setting('` + UserScopeSetting + `', true), '')` // resetSession runs sessionReset on a released connection. It returns the role // the session came back to, and false if the reset failed or left a request @@ -322,7 +323,8 @@ func resetSession(conn *pgx.Conn) (string, bool) { // returnsToTenantRole is the request pool's AfterRelease. A session-level change // a borrower leaves behind would reach the next borrower until the connection is // recycled: `SET ROLE NONE` hands it the bare login, and a session-level -// app.current_org_id or app.current_user_id hands it the previous caller's +// codefly.current_tenant_id or codefly.current_user_id hands it the previous +// caller's // scope, which every un-wrapped read runs under. The reset clears both; a // connection that does not come back as app_tenant with no binding is // discarded rather than reused. @@ -765,7 +767,10 @@ func openScopedBoundaryAs(ctx context.Context, readOnlyConnection, readWriteConn return nil, nil, errors.New("reader and writer require distinct private identity sockets") } options := []scopedpostgres.Option{ - scopedpostgres.WithScopeSettings("app.current_org_id", "app.current_user_id"), + // No WithScopeSettings: the library's own defaults are + // codefly.current_tenant_id and codefly.current_user_id, which is what + // the policies read (migration 65). An override here would be a second + // place to keep the two names in step. scopedpostgres.WithOperationTimeout(scopedBoundaryOperationTimeout), // service-postgres judges both scoped pools on every new physical // connection and again on every checkout: it refuses a session whose @@ -849,7 +854,7 @@ func configureConnection(connectionURL string, hook beforeConnectHook) (*pgxpool // The one credential must hold SET membership in both app_tenant and // app_control_plane — the migration principal does. A checkout hook selects // app_tenant on every connection, so an un-wrapped Store call returns zero rows -// from a tenant table when no app.current_org_id is set (fail-closed), WithOrgTx +// from a tenant table when no codefly.current_tenant_id is set (fail-closed), WithOrgTx // adds the transaction-local org, and WithControlPlane assumes app_control_plane // on the same pool for its transaction. AfterRelease resets the role and every // session setting before the connection returns to the pool. If app_tenant does @@ -1398,7 +1403,7 @@ func (s *PostgresStore) ClearAll(ctx context.Context) error { w := wool.Get(ctx).In("ClearAll") // Most tables here are RLS-protected (Phase 2B-2F). A bare DELETE - // under app_tenant with no app.current_org_id set returns ZERO + // under app_tenant with no codefly.current_tenant_id set returns ZERO // rows — that's fail-closed for production but sabotages test // cleanup. Wrap in WithControlPlane so the deletes actually fire across // every tenant. diff --git a/module/services/accounts/code/pkg/infra/postgres_approvals_test.go b/module/services/accounts/code/pkg/infra/postgres_approvals_test.go index 0b4df7fe7..241e9967a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_approvals_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_approvals_test.go @@ -52,7 +52,7 @@ func TestApprovals_RLS_CrossTenantBlocked(t *testing.T) { reqID := seedApprovalRequest(t, orgA, 1) // Acting as orgB, try to read orgA's row (even naming orgA as the filter): - // RLS keys on app.current_org_id = orgB, so the row is invisible → NotFound. + // RLS keys on codefly.current_tenant_id = orgB, so the row is invisible → NotFound. var miss *business.ApprovalRequest err := testStore.As(business.Identity{OrgID: orgB}).Within(testCtx, func(ctx context.Context) error { var e error diff --git a/module/services/accounts/code/pkg/infra/postgres_audit_retention_test.go b/module/services/accounts/code/pkg/infra/postgres_audit_retention_test.go index 59e5355fe..3aeda3efa 100644 --- a/module/services/accounts/code/pkg/infra/postgres_audit_retention_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_audit_retention_test.go @@ -707,7 +707,7 @@ func retentionOwnerRows(t *testing.T, ctx context.Context, conn *pgxpool.Conn, t tx, err := conn.Begin(ctx) require.NoError(t, err) defer func() { _ = tx.Rollback(ctx) }() - _, err = tx.Exec(ctx, `SELECT set_config('app.current_org_id', $1, true)`, org) + _, err = tx.Exec(ctx, `SELECT set_config('codefly.current_tenant_id', $1, true)`, org) require.NoError(t, err) var rows int64 require.NoError(t, tx.QueryRow(ctx, `SELECT count(*) FROM public.`+pgx.Identifier{table}.Sanitize()).Scan(&rows)) diff --git a/module/services/accounts/code/pkg/infra/postgres_control_plane_login_authority_test.go b/module/services/accounts/code/pkg/infra/postgres_control_plane_login_authority_test.go index dc14e34db..bad523459 100644 --- a/module/services/accounts/code/pkg/infra/postgres_control_plane_login_authority_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_control_plane_login_authority_test.go @@ -130,8 +130,8 @@ func TestWorkerPoolRefusesAControlPlaneLoginBeyondItsDeclaredRoles(t *testing.T) func sessionState(t *testing.T, conn *pgxpool.Conn) (role, org, user string) { t.Helper() require.NoError(t, conn.QueryRow(testCtx, `SELECT current_user::text, - coalesce(current_setting('app.current_org_id', true), ''), - coalesce(current_setting('app.current_user_id', true), '')`).Scan(&role, &org, &user)) + coalesce(current_setting('codefly.current_tenant_id', true), ''), + coalesce(current_setting('codefly.current_user_id', true), '')`).Scan(&role, &org, &user)) return role, org, user } @@ -142,7 +142,7 @@ func leaveSessionState(t *testing.T, conn *pgxpool.Conn, role string) { t.Helper() _, err := conn.Exec(testCtx, "SET ROLE "+role) require.NoError(t, err) - _, err = conn.Exec(testCtx, `SELECT set_config('app.current_org_id', $1, false), set_config('app.current_user_id', $2, false)`, + _, err = conn.Exec(testCtx, `SELECT set_config('codefly.current_tenant_id', $1, false), set_config('codefly.current_user_id', $2, false)`, uuid.NewString(), uuid.NewString()) require.NoError(t, err) } diff --git a/module/services/accounts/code/pkg/infra/postgres_delegation_view_isolation_test.go b/module/services/accounts/code/pkg/infra/postgres_delegation_view_isolation_test.go index 49715b1f6..74bfcdd72 100644 --- a/module/services/accounts/code/pkg/infra/postgres_delegation_view_isolation_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_delegation_view_isolation_test.go @@ -84,7 +84,7 @@ func connectAs(t *testing.T, connection string) *pgx.Conn { // orgID is empty. Session-level, on the probe's own connection. func bindScope(t *testing.T, conn *pgx.Conn, orgID string) { t.Helper() - _, err := conn.Exec(testCtx, `SELECT set_config('app.current_org_id', $1, false)`, orgID) + _, err := conn.Exec(testCtx, `SELECT set_config('codefly.current_tenant_id', $1, false)`, orgID) require.NoError(t, err) } diff --git a/module/services/accounts/code/pkg/infra/postgres_effect_receipts.go b/module/services/accounts/code/pkg/infra/postgres_effect_receipts.go index 3abb5f08c..aca1b2d4d 100644 --- a/module/services/accounts/code/pkg/infra/postgres_effect_receipts.go +++ b/module/services/accounts/code/pkg/infra/postgres_effect_receipts.go @@ -25,7 +25,7 @@ func (s *PostgresStore) NewEffectReceipts() (*receipts.PostgresStore, func(), er db := stdlib.OpenDBFromPool(s.pool) db.SetMaxOpenConns(int(s.pool.Config().MaxConns / 2)) store, err := receipts.NewPostgresStore(db, receipts.WithTenantScope(func(ctx context.Context, tx *sql.Tx, tenant string) error { - _, err := tx.ExecContext(ctx, `SELECT set_config('app.current_org_id', $1, true), set_config('app.current_user_id', '', true)`, tenant) + _, err := tx.ExecContext(ctx, `SELECT set_config('`+TenantScopeSetting+`', $1, true), set_config('`+UserScopeSetting+`', '', true)`, tenant) return err })) if err != nil { diff --git a/module/services/accounts/code/pkg/infra/postgres_event_relay_webhook_test.go b/module/services/accounts/code/pkg/infra/postgres_event_relay_webhook_test.go index a49d47ffa..650b7b55a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_event_relay_webhook_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_event_relay_webhook_test.go @@ -358,7 +358,7 @@ func TestSyncWebhookEventSubscriptionsRejectsMissingTenantScope(t *testing.T) { // control-plane login cannot step down into it. err := testStore.RunInTransaction(testCtx, func(ctx context.Context) error { tx := storetx.Tx(ctx) - if _, err := tx.Exec(ctx, "SELECT set_config('app.current_org_id', '', true)"); err != nil { + if _, err := tx.Exec(ctx, "SELECT set_config('codefly.current_tenant_id', '', true)"); err != nil { return err } return testStore.SyncWebhookEventSubscriptions(ctx, orgID, endpointID, nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_events_test.go b/module/services/accounts/code/pkg/infra/postgres_events_test.go index 3cdd46cc8..1619e7cd6 100644 --- a/module/services/accounts/code/pkg/infra/postgres_events_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_events_test.go @@ -158,7 +158,7 @@ func TestDomainEventsTenantIsolationUnderRLS(t *testing.T) { require.Equal(t, 2, total, "both tenants' events must be durably seeded") // Tenant A's request role sees only tenant A's event, even when tenant B's - // id is named explicitly: the policy filters on app.current_org_id, not on + // id is named explicitly: the policy filters on codefly.current_tenant_id, not on // the query predicate. require.NoError(t, testStore.WithOrgTx(testCtx, tenantA, func(ctx context.Context) error { tx := txFromCtx(t, ctx) @@ -172,12 +172,12 @@ func TestDomainEventsTenantIsolationUnderRLS(t *testing.T) { return nil })) - // A bare request connection carries no tenant context (app.current_org_id is + // A bare request connection carries no tenant context (codefly.current_tenant_id is // unset). The policy's USING clause is then undefined, and the fail-closed // contract at 114:77-79 must yield zero rows — never every row. var leaked int require.NoError(t, testStore.Pool().QueryRow(testCtx, `SELECT count(*) FROM public.domain_events WHERE id = ANY($1::uuid[])`, seeded, ).Scan(&leaked)) - require.Zero(t, leaked, "an unset app.current_org_id must fail closed, not expose every tenant's events") + require.Zero(t, leaked, "an unset codefly.current_tenant_id must fail closed, not expose every tenant's events") } diff --git a/module/services/accounts/code/pkg/infra/postgres_github_app_setups_test.go b/module/services/accounts/code/pkg/infra/postgres_github_app_setups_test.go index 0af6e4a89..bb8cc7962 100644 --- a/module/services/accounts/code/pkg/infra/postgres_github_app_setups_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_github_app_setups_test.go @@ -92,7 +92,7 @@ func installationHeldBy(t *testing.T, orgID, installationID string) bool { } // setupRowExists reads through the tenant transaction rather than the pool: a -// fresh pooled connection carries no app.current_org_id, so RLS would hide every +// fresh pooled connection carries no codefly.current_tenant_id, so RLS would hide every // row and the answer would be false for the wrong reason. func setupRowExists(t *testing.T, orgID, stateHash string) bool { t.Helper() diff --git a/module/services/accounts/code/pkg/infra/postgres_installations_test.go b/module/services/accounts/code/pkg/infra/postgres_installations_test.go index 97608aa7f..5a203c3d5 100644 --- a/module/services/accounts/code/pkg/infra/postgres_installations_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_installations_test.go @@ -139,7 +139,7 @@ func writeScope(kind, action, resourceID string) []business.WorkContextPermissio // getAgentPrincipal reads an agent under the org tenant floor; a bare pooled read // sees no principals row because the principals RLS policy filters on -// app.current_org_id. +// codefly.current_tenant_id. func getAgentPrincipal(t *testing.T, orgID, identifier string) *business.Principal { t.Helper() var agent *business.Principal diff --git a/module/services/accounts/code/pkg/infra/postgres_layered_access.go b/module/services/accounts/code/pkg/infra/postgres_layered_access.go index 082dbd80c..4246b3695 100644 --- a/module/services/accounts/code/pkg/infra/postgres_layered_access.go +++ b/module/services/accounts/code/pkg/infra/postgres_layered_access.go @@ -80,7 +80,7 @@ func layeredSubjectPredicate(kind gen.SubjectKind, alias string) (string, error) // scope therefore cannot authorize a resource that actually lives under a // different scope (RFC-0001 open-question 2). // -// Runs inside WithOrgTx: app.current_org_id is set, so RLS confines every table +// Runs inside WithOrgTx: codefly.current_tenant_id is set, so RLS confines every table // below to the caller's tenant. func (s *PostgresStore) CheckAccess(ctx context.Context, subjectID string, subjectKind gen.SubjectKind, resourceType, resourceID, action string) (bool, string, error) { w := wool.Get(ctx).In("CheckAccess") diff --git a/module/services/accounts/code/pkg/infra/postgres_membership_diagnostic_test.go b/module/services/accounts/code/pkg/infra/postgres_membership_diagnostic_test.go index c39fdd429..93e9a628a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_membership_diagnostic_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_membership_diagnostic_test.go @@ -314,7 +314,7 @@ func TestMembershipDiagnosticReportsAnInactiveOwnerWhileAnotherAdministratorRema // The migration ran the scan directly under the store owner-connection. That // principal is a plain table owner, and organizations / organization_members are // FORCE ROW LEVEL SECURITY — which binds the owner too — with policies scoped to -// app.current_org_id and no bypass clause. On managed Postgres, where the owner +// codefly.current_tenant_id and no bypass clause. On managed Postgres, where the owner // is neither superuser nor BYPASSRLS, the scan therefore saw zero organizations, // recorded nothing, and returned 0: the same answer a healthy platform gives. // diff --git a/module/services/accounts/code/pkg/infra/postgres_org.go b/module/services/accounts/code/pkg/infra/postgres_org.go index 8c1106911..e4f12170c 100644 --- a/module/services/accounts/code/pkg/infra/postgres_org.go +++ b/module/services/accounts/code/pkg/infra/postgres_org.go @@ -71,7 +71,7 @@ func (s *PostgresStore) CreateOrganization(ctx context.Context, org *gen.Organiz // so the org row WITHOUT the membership row is a leak). Reuse the // caller's tx if one is on context — that's where WithOrgTx / // WithControlPlane put it, and stacking a fresh BeginTxFunc would lose - // the SET LOCAL ROLE / app.current_org_id state. If no tx, open + // the SET LOCAL ROLE / codefly.current_tenant_id state. If no tx, open // our own. exec := func(ctx context.Context) error { executor := s.getQueryExecutor(ctx) @@ -207,7 +207,7 @@ func (s *PostgresStore) CountOrgAdministrators(ctx context.Context, orgID string var scopedOrg string if err := executor.QueryRow(ctx, - `SELECT coalesce(pg_catalog.current_setting('app.current_org_id', true), '')`, + `SELECT coalesce(pg_catalog.current_setting('`+TenantScopeSetting+`', true), '')`, ).Scan(&scopedOrg); err != nil { return 0, 0, w.Wrapf(err, "failed to read tenant scope") } @@ -263,7 +263,7 @@ func (s *PostgresStore) CountOrgAdministrators(ctx context.Context, orgID string // and same reasoning, as migration 136's guard on // record_membership_integrity_findings(). const identityScopeProbe = ` - SELECT coalesce(pg_catalog.current_setting('app.current_user_id', true), ''), + SELECT coalesce(pg_catalog.current_setting('` + UserScopeSetting + `', true), ''), current_user::text = $1` // ListAdministeredOrganizations returns every organization the identity is an @@ -272,7 +272,8 @@ const identityScopeProbe = ` // // Same two audiences as CountOrgAdministrators and the same reason to branch on // the transaction's scope rather than fall back. A deactivation is not scoped to -// one organization, so it reads organization_members with no app.current_org_id +// one organization, so it reads organization_members with no +// codefly.current_tenant_id // set and users outside the caller's own row — both of which return zero rows // rather than an error, and "administers nothing" is the answer that lets the // deactivation through. A request transaction therefore goes through the @@ -447,8 +448,8 @@ func (s *PostgresStore) GetOrgMembership(ctx context.Context, orgID string, user return load(ctx, executor, ` SELECT org_id, user_id, role, joined_at FROM organization_members - WHERE org_id = current_setting('app.current_org_id', true)::uuid - AND user_id = current_setting('app.current_user_id', true)::uuid`) + WHERE org_id = current_setting('`+TenantScopeSetting+`', true)::uuid + AND user_id = current_setting('`+UserScopeSetting+`', true)::uuid`) }) return membership, err } diff --git a/module/services/accounts/code/pkg/infra/postgres_partition_rls_test.go b/module/services/accounts/code/pkg/infra/postgres_partition_rls_test.go index e0ddb326f..34afd860c 100644 --- a/module/services/accounts/code/pkg/infra/postgres_partition_rls_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_partition_rls_test.go @@ -150,7 +150,7 @@ func TestEnsuringAuditPartitionsReSecuresPartitionsOutsideTheWindow(t *testing.T _, err = tx.Exec(ctx, ` ALTER POLICY audit_events_tenant ON public.audit_events USING (org_id IS NOT NULL - AND org_id::text = current_setting('app.current_org_id', true) + AND org_id::text = current_setting('codefly.current_tenant_id', true) AND resource IS NOT NULL)`) require.NoError(t, err) require.NotEqual(t, relationPolicies(t, ctx, tx, parent), relationPolicies(t, ctx, tx, probe), @@ -214,7 +214,7 @@ func TestReadOnlyLoginSeesOnlyTheBoundOrganizationInAnAuditPartition(t *testing. var orgs []string require.NoError(t, pgx.BeginFunc(testCtx, reader, func(tx pgx.Tx) error { if boundOrg != "" { - if _, err := tx.Exec(testCtx, `SELECT set_config('app.current_org_id', $1, true)`, boundOrg); err != nil { + if _, err := tx.Exec(testCtx, `SELECT set_config('codefly.current_tenant_id', $1, true)`, boundOrg); err != nil { return err } } diff --git a/module/services/accounts/code/pkg/infra/postgres_permissions.go b/module/services/accounts/code/pkg/infra/postgres_permissions.go index ff5cc550f..c887f3dfd 100644 --- a/module/services/accounts/code/pkg/infra/postgres_permissions.go +++ b/module/services/accounts/code/pkg/infra/postgres_permissions.go @@ -24,7 +24,7 @@ func (s *PostgresStore) CreateRole(ctx context.Context, role *gen.Role) error { // Reuse the caller's tx if one is on context — that's where // WithOrgTx / WithControlPlane put it, and stacking a fresh BeginTxFunc - // would lose the SET LOCAL app.current_org_id state and the role + // would lose the SET LOCAL codefly.current_tenant_id state and the role // INSERT would fail the WITH CHECK on the polymorphic policy // (Phase 2E migration). Same pattern as postgres_org.go's // CreateOrganization. diff --git a/module/services/accounts/code/pkg/infra/postgres_scope_settings_test.go b/module/services/accounts/code/pkg/infra/postgres_scope_settings_test.go new file mode 100644 index 000000000..15725aeab --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_scope_settings_test.go @@ -0,0 +1,126 @@ +//go:build !pure + +package infra_test + +import ( + "testing" + + codefly "github.com/codefly-dev/sdk-go" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "accounts/pkg/infra" +) + +// The database must read the caller's scope under exactly the two settings the +// platform's Postgres library installs, and under no others. A policy or a +// SECURITY DEFINER body left on a setting nothing binds any more does not fail: +// current_setting(..., true) returns NULL, the predicate is false, and the +// relation reads as empty — so the break is a silent loss of access on one +// table, or, with the comparison the other way round, a silent loss of +// isolation. Nothing else in the suite would see it: a test that sets the old +// name and reads the old policy passes on both halves of the mistake. +// +// This asks the catalog rather than the migration text, which is the only place +// that knows the whole set — 75 policies over 70 relations, 6 more on partitions +// of audit_events, and 9 function bodies, when migration 65 renamed them. It +// reads every object kind an expression can hide in, not just pg_policies, and +// the counts below refuse a vacuous pass: a query that matched nothing would +// otherwise read exactly like a clean database. +func TestEveryScopedPolicyReadsTheLibrarySettings(t *testing.T) { + require.Equal(t, "codefly.current_tenant_id", infra.TenantScopeSetting) + require.Equal(t, "codefly.current_user_id", infra.UserScopeSetting) + + dsn, err := codefly.For(testCtx).Service("store").Secret("postgres", "owner-connection") + require.NoError(t, err) + pool, err := pgxpool.New(testCtx, dsn) + require.NoError(t, err) + defer pool.Close() + conn, err := pool.Acquire(testCtx) + require.NoError(t, err) + defer conn.Release() + // An empty search_path is what makes the deparser schema-qualify, which is + // how the ledger's own text is rendered; it also keeps these probes honest + // about the names they read back. + _, err = conn.Exec(testCtx, `SELECT pg_catalog.set_config('search_path', '', false)`) + require.NoError(t, err) + + // Every place a scope setting can be read: a policy predicate, a routine + // body, a view definition, a check constraint, a column default, an index + // expression or predicate, and a rule. + const sources = ` +WITH source AS ( + SELECT 'policy ' || p.polrelid::regclass::text || '.' || p.polname AS what, + coalesce(pg_get_expr(p.polqual, p.polrelid), '') || ' ' || + coalesce(pg_get_expr(p.polwithcheck, p.polrelid), '') AS body + FROM pg_catalog.pg_policy p + UNION ALL + SELECT 'routine ' || p.oid::regprocedure::text, p.prosrc + FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + UNION ALL + SELECT 'view ' || c.oid::regclass::text, pg_get_viewdef(c.oid) + FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('v', 'm') + UNION ALL + SELECT 'constraint ' || con.conrelid::regclass::text || '.' || con.conname, pg_get_constraintdef(con.oid) + FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace + WHERE n.nspname = 'public' + UNION ALL + SELECT 'default ' || a.attrelid::regclass::text || '.' || a.attname, pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + JOIN pg_catalog.pg_attribute a ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum + UNION ALL + SELECT 'index ' || i.indexrelid::regclass::text, pg_get_indexdef(i.indexrelid) + FROM pg_catalog.pg_index i + UNION ALL + SELECT 'rule ' || r.ev_class::regclass::text || '.' || r.rulename, pg_get_ruledef(r.oid) + FROM pg_catalog.pg_rewrite r WHERE r.rulename <> '_RETURN' +) +SELECT what, body FROM source WHERE body ~ $1 ORDER BY what` + + var stale []string + rows, err := conn.Query(testCtx, sources, `\mapp\.current_(org|user)_id\M`) + require.NoError(t, err) + for rows.Next() { + var what, body string + require.NoError(t, rows.Scan(&what, &body)) + stale = append(stale, what) + } + require.NoError(t, rows.Err()) + require.Empty(t, stale, + "these objects still read the host's old scope settings, which nothing binds any more, so each one reads as empty") + + // And the rename did land, rather than the probe above finding nothing + // because the database is empty or the query is wrong. + count := func(pattern string) int { + var n int + require.NoError(t, conn.QueryRow(testCtx, `SELECT count(*) FROM (`+sources+`) c`, pattern).Scan(&n)) + return n + } + tenantReaders := count(`\m` + infra.TenantScopeSetting + `\M`) + userReaders := count(`\m` + infra.UserScopeSetting + `\M`) + require.Greater(t, tenantReaders, 70, "the tenant setting is read by far more than this; a low count means the probe is wrong") + require.Greater(t, userReaders, 25, "the user setting is read by far more than this; a low count means the probe is wrong") + + // Each partition of audit_events carries its own copy of the parent's + // policies, and a query that names the partition is judged by those alone. + var outOfStep []string + partitions, err := conn.Query(testCtx, ` +SELECT i.inhrelid::regclass::text + FROM pg_catalog.pg_inherits i + WHERE i.inhparent = 'public.audit_events'::regclass + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_policy p + WHERE p.polrelid = i.inhrelid + AND coalesce(pg_get_expr(p.polqual, p.polrelid), '') ~ ('\m' || $1 || '\M')) + ORDER BY 1`, infra.TenantScopeSetting) + require.NoError(t, err) + for partitions.Next() { + var name string + require.NoError(t, partitions.Scan(&name)) + outOfStep = append(outOfStep, name) + } + require.NoError(t, partitions.Err()) + require.Empty(t, outOfStep, "every partition of audit_events carries its own tenant policy, and these do not read the tenant setting") +} diff --git a/module/services/accounts/code/pkg/infra/postgres_team.go b/module/services/accounts/code/pkg/infra/postgres_team.go index ce353fe39..d70e96eee 100644 --- a/module/services/accounts/code/pkg/infra/postgres_team.go +++ b/module/services/accounts/code/pkg/infra/postgres_team.go @@ -202,7 +202,7 @@ func (s *PostgresStore) GetTeamMembership(ctx context.Context, orgID string, tea SELECT team_id, user_id, role, joined_at FROM team_members WHERE team_id = $1 - AND user_id = current_setting('app.current_user_id', true)::uuid`, teamID) + AND user_id = current_setting('`+UserScopeSetting+`', true)::uuid`, teamID) }) return membership, err } diff --git a/module/services/accounts/code/pkg/infra/postgres_users.go b/module/services/accounts/code/pkg/infra/postgres_users.go index 4288fa6cd..eaf06b609 100644 --- a/module/services/accounts/code/pkg/infra/postgres_users.go +++ b/module/services/accounts/code/pkg/infra/postgres_users.go @@ -100,7 +100,7 @@ func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*gen. // belongs to the transaction's current organization. The database function is // the intentionally narrow co-member directory surface: app_tenant cannot read // another user's full users row, and the function returns NULL when userID is -// not a member of app.current_org_id. +// not a member of codefly.current_tenant_id. func (s *PostgresStore) GetOrganizationMemberPrimaryEmail(ctx context.Context, userID string) (string, error) { w := wool.Get(ctx).In("GetOrganizationMemberPrimaryEmail") executor := s.getQueryExecutor(ctx) diff --git a/module/services/accounts/code/pkg/infra/postgres_users_rls_test.go b/module/services/accounts/code/pkg/infra/postgres_users_rls_test.go index 99a1fdcae..227c89e8a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_users_rls_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_users_rls_test.go @@ -104,7 +104,7 @@ func TestOrganizationMemberEmailFunctionHasPinnedAuthority(t *testing.T) { require.True(t, tenantCanExecute) require.False(t, publicCanExecute) require.Equal(t, - "((uuid)::text = current_setting('app.current_user_id'::text, true))", + "((uuid)::text = current_setting('codefly.current_user_id'::text, true))", policyExpression, ) return nil diff --git a/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go b/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go index fa4e41375..3d17bb824 100644 --- a/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go @@ -256,7 +256,7 @@ func seedOrg(t *testing.T, ownerID string) string { } // seedOrgMember adds a membership row via the domain method as a tenant-scoped -// identity — organization_members is RLS-gated (org_id = app.current_org_id), so +// identity — organization_members is RLS-gated (org_id = codefly.current_tenant_id), so // the identity's OrgID satisfies the WITH CHECK. No raw SQL, no bypass. func seedOrgMember(t *testing.T, orgID, userID string) { t.Helper() diff --git a/module/services/accounts/code/pkg/infra/scoped.go b/module/services/accounts/code/pkg/infra/scoped.go index 70ec1e703..a952e36b4 100644 --- a/module/services/accounts/code/pkg/infra/scoped.go +++ b/module/services/accounts/code/pkg/infra/scoped.go @@ -10,7 +10,8 @@ import ( // Scoped is a Store handle bound to a principal Identity. Every call runs inside // a single transaction pre-seeded with that identity's RLS context — -// app.current_user_id and/or app.current_org_id — or, for business.System(), the +// codefly.current_user_id and/or codefly.current_tenant_id — or, for +// business.System(), the // explicit app_control_plane role. This is how RLS context gets // established: once, from the identity, instead of each call site reaching for // WithUserTx / WithOrgTx / WithControlPlane. @@ -118,7 +119,7 @@ func (sc *Scoped) describe() string { // ListPrincipals lists the principals in this identity's org, RLS enforced. // kind "" returns all kinds — humans surface via the organization_members join, -// which is RLS-gated on app.current_org_id (supplied by the identity). +// which is RLS-gated on codefly.current_tenant_id (supplied by the identity). func (sc *Scoped) ListPrincipals(ctx context.Context, kind string, pageSize int32, pageToken string) ([]*business.Principal, string, error) { var out []*business.Principal var next string @@ -131,7 +132,7 @@ func (sc *Scoped) ListPrincipals(ctx context.Context, kind string, pageSize int3 } // AddOrgMember adds a membership row to this identity's org. organization_members -// is RLS-gated (org_id = app.current_org_id); the identity's OrgID satisfies the +// is RLS-gated (org_id = codefly.current_tenant_id); the identity's OrgID satisfies the // WITH CHECK, so no bypass is needed for a tenant-scoped caller. func (sc *Scoped) AddOrgMember(ctx context.Context, userID, role string) error { return sc.Within(ctx, func(ctx context.Context) error { diff --git a/module/services/accounts/code/pkg/infra/tenant_tx.go b/module/services/accounts/code/pkg/infra/tenant_tx.go index f1b7cd4f2..b749da8a1 100644 --- a/module/services/accounts/code/pkg/infra/tenant_tx.go +++ b/module/services/accounts/code/pkg/infra/tenant_tx.go @@ -20,6 +20,20 @@ const ( controlPlaneDatabaseRole = "app_control_plane" ) +// The two transaction-local settings that carry a request's scope into the +// database. They are the defaults of the platform's Postgres library +// (codefly-dev/service-postgres, libs/go/store.go), which is what installs them +// for a transaction opened through the scoped factory, and what every other +// store on this platform reads back — so the host declares no names of its own +// and the SQL this package writes by hand names them from here. Every policy, +// and every SECURITY DEFINER operation that judges a caller's scope, reads +// exactly these two; migration 65 renamed the host's own app.current_org_id and +// app.current_user_id onto them. +const ( + TenantScopeSetting = "codefly.current_tenant_id" + UserScopeSetting = "codefly.current_user_id" +) + // bindRequestScopeSQL binds both halves of a request scope for the current // transaction only. Every request transaction binds both, the empty string for // the half it does not carry, so a session-level binding left on the connection @@ -28,7 +42,7 @@ const ( // policies compare the settings as text and the SECURITY DEFINER operations // map an empty setting to NULL through NULLIF, so an empty binding matches // nothing, as an unset one does. -const bindRequestScopeSQL = "SELECT set_config('app.current_org_id', $1, true), set_config('app.current_user_id', $2, true)" +const bindRequestScopeSQL = "SELECT set_config('" + TenantScopeSetting + "', $1, true), set_config('" + UserScopeSetting + "', $2, true)" // bindRequestScope binds orgID and userID, either possibly empty, for tx. func bindRequestScope(ctx context.Context, tx pgx.Tx, orgID, userID string) error { @@ -135,12 +149,12 @@ func itoa(n int) string { } // WithOrgTx runs `fn` inside a transaction that has -// `app.current_org_id` set to `orgID` for its lifetime. This is the +// `codefly.current_tenant_id` set to `orgID` for its lifetime. This is the // connection-level handshake the eventual Postgres RLS policies key // off — every per-tenant table will get a policy of the form: // // CREATE POLICY foo_tenant ON foo -// USING (org_id::text = current_setting('app.current_org_id', true)); +// USING (org_id::text = current_setting('codefly.current_tenant_id', true)); // // The `true` second arg to current_setting() means "missing setting // returns empty string instead of erroring", so a query path that @@ -208,8 +222,9 @@ func (s *PostgresStore) WithOrgTx(ctx context.Context, orgID string, fn func(ctx // The connection is already running as app_tenant: the request login // starts there and can reach nothing wider (a tooling store selects it on - // checkout). We just need to set app.current_org_id for the policy, and - // clear app.current_user_id so no user binding reaches this transaction. + // checkout). We just need to set codefly.current_tenant_id for the policy, + // and clear codefly.current_user_id so no user binding reaches this + // transaction. // // set_config is parameterized via pgx; the value is properly // quoted and not a SQL-injection vector even if orgID came from @@ -284,7 +299,7 @@ const errEmptyOrgID errString = "WithOrgTx: orgID is required" var _ pgx.Tx = (pgx.Tx)(nil) // WithUserTx is the user-scoped twin of WithOrgTx: runs `fn` inside -// a transaction that has `app.current_user_id` set, so RLS policies +// a transaction that has `codefly.current_user_id` set, so RLS policies // on user-scoped tables (notifications, mfa_devices, sessions) filter // to that user. Empty userID is rejected (loud, fail-closed). Same // no-nesting rule as WithOrgTx — see the comment on that function. @@ -292,7 +307,7 @@ var _ pgx.Tx = (pgx.Tx)(nil) // User-scoped tables aren't tenant-scoped (no org_id), but the same // fail-closed property applies: an un-wrapped Store call against a // user-RLS-protected table returns zero rows by default because the -// connection runs as `app_tenant` with no `app.current_user_id` set. Cross-user readers (platform +// connection runs as `app_tenant` with no `codefly.current_user_id` set. Cross-user readers (platform // admin / refresh-token-hash lookup during login) use WithControlPlane. func (s *PostgresStore) WithUserTx(ctx context.Context, userID string, fn func(ctx context.Context) error) error { if userID == "" { @@ -303,7 +318,7 @@ func (s *PostgresStore) WithUserTx(ctx context.Context, userID string, fn func(c return err } defer tx.Rollback(ctx) //nolint:errcheck // rollback after commit is a no-op - // Clear app.current_org_id so no tenant binding reaches this transaction. + // Clear codefly.current_tenant_id so no tenant binding reaches this transaction. if err := bindRequestScope(ctx, tx, "", userID); err != nil { return err } diff --git a/module/services/accounts/code/pkg/infra/tenant_tx_pooling_test.go b/module/services/accounts/code/pkg/infra/tenant_tx_pooling_test.go index ba46cb0b1..eda1df0b7 100644 --- a/module/services/accounts/code/pkg/infra/tenant_tx_pooling_test.go +++ b/module/services/accounts/code/pkg/infra/tenant_tx_pooling_test.go @@ -18,7 +18,7 @@ import ( ) // TestWithOrgTx_OrgIDIsTransactionLocal_NoPoolLeak pins the single -// highest-consequence multi-tenant RLS footgun: app.current_org_id MUST +// highest-consequence multi-tenant RLS footgun: codefly.current_tenant_id MUST // be transaction-scoped (set via set_config(..., is_local => true) / // SET LOCAL), never session-scoped. A session-scoped GUC survives on the // physical connection after the transaction ends, so the next checkout — @@ -47,7 +47,7 @@ func TestWithOrgTx_OrgIDIsTransactionLocal_NoPoolLeak(t *testing.T) { // Inside the transaction the GUC is set — the RLS policies read it. require.NoError(t, single.WithOrgTx(testCtx, orgA, func(ctx context.Context) error { require.Equal(t, orgA, currentOrgID(t, txFromCtx(t, ctx)), - "app.current_org_id must be visible to the policy inside WithOrgTx") + "codefly.current_tenant_id must be visible to the policy inside WithOrgTx") return nil })) @@ -55,7 +55,7 @@ func TestWithOrgTx_OrgIDIsTransactionLocal_NoPoolLeak(t *testing.T) { // pool. The next borrower on that exact connection MUST see no tenant // context — a bare (un-wrapped) query is fail-closed. require.Empty(t, currentOrgID(t, single.Pool()), - "app.current_org_id leaked past its transaction onto the pooled connection") + "codefly.current_tenant_id leaked past its transaction onto the pooled connection") // A second tenant reuses the same connection. The in-tx read only // confirms the mechanism re-engages for orgB — a leaked session GUC @@ -69,7 +69,7 @@ func TestWithOrgTx_OrgIDIsTransactionLocal_NoPoolLeak(t *testing.T) { return nil })) require.Empty(t, currentOrgID(t, single.Pool()), - "orgB's app.current_org_id leaked past its transaction onto the pooled connection") + "orgB's codefly.current_tenant_id leaked past its transaction onto the pooled connection") } // singleConnStore opens a dedicated store over the request login whose pool @@ -106,7 +106,7 @@ func currentOrgID(t *testing.T, q orgIDQuerier) string { t.Helper() var got string require.NoError(t, q.QueryRow(testCtx, - "SELECT coalesce(current_setting('app.current_org_id', true), '')").Scan(&got)) + "SELECT coalesce(current_setting('codefly.current_tenant_id', true), '')").Scan(&got)) return got } @@ -121,7 +121,7 @@ func currentUserID(t *testing.T, q orgIDQuerier) string { t.Helper() var got string require.NoError(t, q.QueryRow(testCtx, - "SELECT coalesce(current_setting('app.current_user_id', true), '')").Scan(&got)) + "SELECT coalesce(current_setting('codefly.current_user_id', true), '')").Scan(&got)) return got } @@ -132,7 +132,7 @@ func currentUserID(t *testing.T, q orgIDQuerier) string { func poisonSession(t *testing.T, store *infra.PostgresStore, orgID, userID string) { t.Helper() _, err := store.Pool().Exec(testCtx, - "SELECT set_config('app.current_org_id', $1, false), set_config('app.current_user_id', $2, false)", orgID, userID) + "SELECT set_config('codefly.current_tenant_id', $1, false), set_config('codefly.current_user_id', $2, false)", orgID, userID) require.NoError(t, err) require.Equal(t, orgID, currentOrgID(t, store.Pool()), "the session binding really did survive its statement") require.Equal(t, userID, currentUserID(t, store.Pool())) @@ -195,7 +195,7 @@ func TestRequestTransactionsClearTheBindingTheyDoNotCarry(t *testing.T) { // A request connection comes back to the pool carrying no request binding, // whatever its borrower left at session level. RESET ROLE alone would return -// the role and keep app.current_org_id and app.current_user_id, so the next +// the role and keep codefly.current_tenant_id and codefly.current_user_id, so the next // borrower's un-wrapped reads — and any transaction that binds only half the // scope — would run as the previous caller. func TestRequestConnectionCarriesNoBindingAfterRelease(t *testing.T) { @@ -207,7 +207,7 @@ func TestRequestConnectionCarriesNoBindingAfterRelease(t *testing.T) { require.NoError(t, err) pid := conn.Conn().PgConn().PID() _, err = conn.Exec(testCtx, - "SELECT set_config('app.current_org_id', $1, false), set_config('app.current_user_id', $2, false)", staleOrg, staleUser) + "SELECT set_config('codefly.current_tenant_id', $1, false), set_config('codefly.current_user_id', $2, false)", staleOrg, staleUser) require.NoError(t, err) conn.Release() diff --git a/module/services/accounts/code/qualification/scopedpools/connection_policy_test.go b/module/services/accounts/code/qualification/scopedpools/connection_policy_test.go index ce37bc7d6..c84b6b104 100644 --- a/module/services/accounts/code/qualification/scopedpools/connection_policy_test.go +++ b/module/services/accounts/code/qualification/scopedpools/connection_policy_test.go @@ -79,8 +79,8 @@ func newPolicyFixture(t *testing.T, ctx context.Context) policyFixture { ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY; ALTER TABLE organization_members FORCE ROW LEVEL SECURITY; CREATE POLICY cp_request_scope ON organization_members TO cp_reader, app_tenant - USING (org_id::text = current_setting('app.current_org_id', true) - AND user_id::text = current_setting('app.current_user_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true) + AND user_id::text = current_setting('codefly.current_user_id', true)); CREATE POLICY cp_control_scope ON organization_members TO app_control_plane USING (true); GRANT SELECT ON organization_members TO cp_reader, app_tenant, app_control_plane; INSERT INTO organization_members(org_id,user_id,role) VALUES diff --git a/module/services/accounts/code/qualification/scopedpools/delegation_view_owner_test.go b/module/services/accounts/code/qualification/scopedpools/delegation_view_owner_test.go index 02f5f8d16..df3c843df 100644 --- a/module/services/accounts/code/qualification/scopedpools/delegation_view_owner_test.go +++ b/module/services/accounts/code/qualification/scopedpools/delegation_view_owner_test.go @@ -126,7 +126,7 @@ func newDelegationViewFixture(t *testing.T, ctx context.Context) dvFixture { ALTER TABLE dv_rows ENABLE ROW LEVEL SECURITY; ALTER TABLE dv_rows FORCE ROW LEVEL SECURITY; CREATE POLICY dv_rows_tenant ON dv_rows - USING (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)); INSERT INTO dv_rows VALUES ('`+dvOrgA+`',1,'pattern',3,'a-secret'), ('`+dvOrgB+`',2,'pattern',7,'b-secret'); @@ -215,11 +215,11 @@ func TestDelegationViewOwnerAuthorityDecidesTheCrossing(t *testing.T) { // irrelevant and the tenant policy binds. _, err := fixture.admin.Exec(ctx, fmt.Sprintf(`ALTER VIEW %s SET (security_invoker = true)`, view.name)) require.NoError(t, err) - _, err = conn.Exec(ctx, `SELECT set_config('app.current_org_id', $1, false)`, dvOrgA) + _, err = conn.Exec(ctx, `SELECT set_config('codefly.current_tenant_id', $1, false)`, dvOrgA) require.NoError(t, err) require.Equal(t, []string{dvOrgA}, orgsRead(t, ctx, conn, view.name), "with security_invoker, %s reads only the tenant it bound", capability.name) - _, err = conn.Exec(ctx, `SELECT set_config('app.current_org_id', '', false)`) + _, err = conn.Exec(ctx, `SELECT set_config('codefly.current_tenant_id', '', false)`) require.NoError(t, err) require.Empty(t, orgsRead(t, ctx, conn, view.name), "with security_invoker and nothing bound, %s reads nothing", capability.name) diff --git a/module/services/accounts/code/qualification/scopedpools/reader_authority_test.go b/module/services/accounts/code/qualification/scopedpools/reader_authority_test.go index 31f3d1924..324242a02 100644 --- a/module/services/accounts/code/qualification/scopedpools/reader_authority_test.go +++ b/module/services/accounts/code/qualification/scopedpools/reader_authority_test.go @@ -100,7 +100,7 @@ func newReaderFixture(t *testing.T, ctx context.Context) readerFixture { ALTER TABLE reader_rows ENABLE ROW LEVEL SECURITY; ALTER TABLE reader_rows FORCE ROW LEVEL SECURITY; CREATE POLICY reader_scope ON reader_rows TO ra_reader, app_tenant - USING (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE POLICY reader_control ON reader_rows TO app_control_plane USING (true); GRANT SELECT ON reader_rows TO ra_reader, app_tenant, app_control_plane; INSERT INTO reader_rows(org_id,user_id,secret) VALUES @@ -169,7 +169,7 @@ func TestScopedReaderWithBypassRLSIsRefused(t *testing.T) { widened, err := pgx.Connect(ctx, rawReader.String()) require.NoError(t, err) defer widened.Close(context.Background()) //nolint:errcheck - _, err = widened.Exec(ctx, `SELECT set_config('app.current_org_id', $1, false)`, readerOrgA) + _, err = widened.Exec(ctx, `SELECT set_config('codefly.current_tenant_id', $1, false)`, readerOrgA) require.NoError(t, err) var visible int require.NoError(t, widened.QueryRow(ctx, `SELECT count(*) FROM reader_rows`).Scan(&visible)) diff --git a/module/services/accounts/code/qualification/scopedpools/scoped_pools_test.go b/module/services/accounts/code/qualification/scopedpools/scoped_pools_test.go index be92b8e20..1d8e9708f 100644 --- a/module/services/accounts/code/qualification/scopedpools/scoped_pools_test.go +++ b/module/services/accounts/code/qualification/scopedpools/scoped_pools_test.go @@ -52,8 +52,8 @@ func TestScopedPoolsIdentityRotationAndClosure(t *testing.T) { ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY; ALTER TABLE organization_members FORCE ROW LEVEL SECURITY; CREATE POLICY request_scope ON organization_members TO scoped_reader,app_tenant - USING (org_id::text=current_setting('app.current_org_id',true) - AND user_id::text=current_setting('app.current_user_id',true)); + USING (org_id::text=current_setting('codefly.current_tenant_id',true) + AND user_id::text=current_setting('codefly.current_user_id',true)); CREATE POLICY control_scope ON organization_members TO app_control_plane USING (true); GRANT SELECT ON organization_members TO scoped_reader,app_tenant,app_control_plane; INSERT INTO organization_members(org_id,user_id,role) VALUES diff --git a/module/services/store/code/migration_65_scope_settings_test.go b/module/services/store/code/migration_65_scope_settings_test.go new file mode 100644 index 000000000..7f904bb24 --- /dev/null +++ b/module/services/store/code/migration_65_scope_settings_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + "github.com/golang-migrate/migrate/v4" +) + +// The two settings the platform's Postgres library binds a scoped transaction +// to, which migration 65 renames every policy and function body onto. +const ( + libraryTenantSetting = "codefly.current_tenant_id" + libraryUserSetting = "codefly.current_user_id" + hostTenantSetting = "app.current_org_id" + hostUserSetting = "app.current_user_id" +) + +// Migration 65 is a rename and must be nothing else. Proving that by reading +// its SQL proves only that it says so; this stands a database at version 64, +// fingerprints every row-security decision in the catalog, applies 65, and +// requires the fingerprint to equal the old one with the two setting names +// substituted and no other difference. +// +// That one equality carries every claim at once. A policy whose USING or WITH +// CHECK shape, verb, PERMISSIVE/RESTRICTIVE kind or role list moved shows up as +// a differing line. A policy the migration MISSED shows up too, and this is the +// half a reading of the file cannot give: the catalog's copy still says +// app.current_org_id where the substituted fingerprint says +// codefly.current_tenant_id. A missed policy does not fail at runtime — nothing +// binds the old name any more, so current_setting returns NULL, the predicate +// is false and the relation simply reads as empty for every caller — so a +// suite that only exercises the renamed tables would stay green through it. +func TestMigration65RenamesEveryScopeSettingAndNothingElse(t *testing.T) { + db, databaseURL := throwawayPostgres(t) + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 64), databaseURL); err != nil { + t.Fatal(err) + } + + before := rowSecurityCatalog(t, db) + if !strings.Contains(before, hostTenantSetting) || !strings.Contains(before, hostUserSetting) { + t.Fatal("version 64 is expected to read the host's own settings; the fingerprint query found neither, so it proves nothing") + } + want := strings.NewReplacer( + hostTenantSetting, libraryTenantSetting, + hostUserSetting, libraryUserSetting, + ).Replace(before) + + // Two organisations, staged while the superuser still bypasses RLS. + orgA, _, _, _ := stageInstallationParents(t, db, "jane@example.com", "acme") + orgB, _, _, _ := stageInstallationParents(t, db, "john@example.com", "example-two") + + ledger := ledgerUpTo(t, 65) + if err := migrateStoreFrom("file://"+ledger, databaseURL); err != nil { + t.Fatal(err) + } + + after := rowSecurityCatalog(t, db) + if after != want { + t.Fatalf("migration 65 is not a pure rename:\n%s", firstDifference(want, after)) + } + if strings.Contains(after, hostTenantSetting) || strings.Contains(after, hostUserSetting) { + t.Fatal("a policy or function body still reads the host's old setting names, which nothing binds any more, so it reads as empty") + } + + // And the renamed policies isolate: the library's setting admits the bound + // organisation's row and no other, and an unbound transaction sees none. + // Reading one tenant's row proves admission; a count proves nothing about + // WHICH row was admitted. + if got := scopedOrganizationIDs(t, db, libraryTenantSetting, orgA); got != orgA { + t.Fatalf("a transaction bound to %s saw %q, want only %q", libraryTenantSetting, got, orgA) + } + if got := scopedOrganizationIDs(t, db, libraryTenantSetting, orgB); got != orgB { + t.Fatalf("a transaction bound to %s saw %q, want only %q", libraryTenantSetting, got, orgB) + } + // Fail-closed, and in the one way that matters: binding the name the host + // used to read admits nothing, rather than everything. + if got := scopedOrganizationIDs(t, db, hostTenantSetting, orgA); got != "" { + t.Fatalf("binding the retired %s admitted %q; the rename must leave it inert", hostTenantSetting, got) + } + + // The down half restores the catalog it found, exactly. + abs, err := filepath.Abs(ledger) + if err != nil { + t.Fatal(err) + } + runner, err := migrate.New("file://"+abs, databaseURL) + if err != nil { + t.Fatal(err) + } + defer runner.Close() + if err := runner.Steps(-1); err != nil { + t.Fatal(err) + } + if rolled := rowSecurityCatalog(t, db); rolled != before { + t.Fatalf("the down half did not restore version 64's catalog:\n%s", firstDifference(before, rolled)) + } + if err := runner.Steps(1); err != nil { + t.Fatal(err) + } + if replayed := rowSecurityCatalog(t, db); replayed != after { + t.Fatalf("replaying the up half did not reproduce it:\n%s", firstDifference(after, replayed)) + } +} + +// rowSecurityCatalog is every row-security decision in the database, as the +// catalog renders it: each relation's ENABLE and FORCE state, each policy with +// its kind, verb, role list and both expressions, and each routine body — every +// place a scope setting can be read. Expressions are rendered under an empty +// search_path, which is what makes the deparser schema-qualify, so the text is +// comparable to the ledger's own. +// +// It also carries each relation's and routine's owner and privileges, which the +// rename has no business changing. CREATE OR REPLACE FUNCTION is documented to +// keep both, and DROP POLICY / CREATE POLICY never touches a grant — but a +// migration that silently widened a grant while renaming a setting is exactly +// the failure a reviewer cannot see in a thousand generated lines, so these are +// compared rather than assumed. +func rowSecurityCatalog(t *testing.T, db *sql.DB) string { + t.Helper() + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(`SELECT pg_catalog.set_config('search_path', '', false)`); err != nil { + t.Fatal(err) + } + rows, err := tx.Query(` +SELECT line FROM ( + SELECT 'rls ' || c.oid::regclass::text || ' enabled=' || c.relrowsecurity::text || ' forced=' || c.relforcerowsecurity::text AS line + FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND (c.relrowsecurity OR c.relforcerowsecurity) + UNION ALL + SELECT 'policy ' || p.polrelid::regclass::text || ' ' || p.polname + || ' kind=' || CASE WHEN p.polpermissive THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END + || ' verb=' || p.polcmd::text + || ' roles=' || coalesce((SELECT string_agg(CASE WHEN g.oid = 0 THEN 'PUBLIC' ELSE r.rolname::text END, ',' ORDER BY g.ord) + FROM unnest(p.polroles) WITH ORDINALITY AS g(oid, ord) + LEFT JOIN pg_catalog.pg_roles r ON r.oid = g.oid), '') + || ' using=' || coalesce(pg_get_expr(p.polqual, p.polrelid), '') + || ' check=' || coalesce(pg_get_expr(p.polwithcheck, p.polrelid), '') + FROM pg_catalog.pg_policy p + UNION ALL + SELECT 'routine ' || p.oid::regprocedure::text + || ' owner=' || pg_get_userbyid(p.proowner) + || ' acl=' || coalesce(p.proacl::text, '') + || ' definer=' || p.prosecdef::text + || ' ' || p.prosrc + FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + UNION ALL + SELECT 'grants ' || c.oid::regclass::text + || ' owner=' || pg_get_userbyid(c.relowner) + || ' acl=' || coalesce(c.relacl::text, '') + FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') +) catalog ORDER BY line`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatal(err) + } + out = append(out, line) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(out) == 0 { + t.Fatal("the row-security catalog came back empty; the fingerprint query is wrong") + } + return strings.Join(out, "\n") +} + +// scopedOrganizationIDs is every organization id a request transaction sees +// with `setting` bound to `value`: as app_tenant, which holds no BYPASSRLS, in +// a transaction of its own so the binding cannot outlive it. +func scopedOrganizationIDs(t *testing.T, db *sql.DB, setting, value string) string { + t.Helper() + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(`SET LOCAL ROLE app_tenant`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`SELECT pg_catalog.set_config($1, $2, true)`, setting, value); err != nil { + t.Fatal(err) + } + var seen string + if err := tx.QueryRow(`SELECT coalesce(string_agg(id::text, ',' ORDER BY id::text), '') FROM public.organizations`).Scan(&seen); err != nil { + t.Fatal(err) + } + return seen +} diff --git a/module/services/store/migrations/65_library_scope_settings.down.sql b/module/services/store/migrations/65_library_scope_settings.down.sql new file mode 100644 index 000000000..e6aaa1eb2 --- /dev/null +++ b/module/services/store/migrations/65_library_scope_settings.down.sql @@ -0,0 +1,1048 @@ +-- The caller's organisation and user go back to this host's own setting names, +-- app.current_org_id and app.current_user_id, as the ledger read them up to +-- migration 64. Nothing else changes: this is the exact inverse of the up +-- half, generated the same way from the same catalog. + +-- The policies. + +DROP POLICY actor_chain_journal_tenant ON public.actor_chain_journal; +CREATE POLICY actor_chain_journal_tenant ON public.actor_chain_journal + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY actor_chain_revocations_tenant ON public.actor_chain_revocations; +CREATE POLICY actor_chain_revocations_tenant ON public.actor_chain_revocations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY api_keys_tenant ON public.api_keys; +CREATE POLICY api_keys_tenant ON public.api_keys + FOR ALL + TO PUBLIC + USING (((organization_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((organization_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY approval_decisions_tenant ON public.approval_decisions; +CREATE POLICY approval_decisions_tenant ON public.approval_decisions + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY approval_requests_tenant ON public.approval_requests; +CREATE POLICY approval_requests_tenant ON public.approval_requests + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY audit_event_idempotency_tenant ON public.audit_event_idempotency; +CREATE POLICY audit_event_idempotency_tenant ON public.audit_event_idempotency + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY audit_events_tenant ON public.audit_events; +CREATE POLICY audit_events_tenant ON public.audit_events + FOR ALL + TO PUBLIC + USING (((org_id IS NOT NULL) AND ((org_id)::text = current_setting('app.current_org_id'::text, true)))) + WITH CHECK ((((org_id IS NOT NULL) AND ((org_id)::text = current_setting('app.current_org_id'::text, true))) OR ((org_id IS NULL) AND (actor_id IS NOT NULL) AND ((actor_id)::text = current_setting('app.current_user_id'::text, true))))); + +DROP POLICY client_authorization_codes_user ON public.client_authorization_codes; +CREATE POLICY client_authorization_codes_user ON public.client_authorization_codes + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY codefly_effect_receipts_tenant ON public.codefly_effect_receipts; +CREATE POLICY codefly_effect_receipts_tenant ON public.codefly_effect_receipts + FOR ALL + TO app_tenant + USING ((tenant = NULLIF(current_setting('app.current_org_id'::text, true), ''::text))) + WITH CHECK ((tenant = NULLIF(current_setting('app.current_org_id'::text, true), ''::text))); + +DROP POLICY connector_credentials_tenant ON public.connector_credentials; +CREATE POLICY connector_credentials_tenant ON public.connector_credentials + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY dashboards_tenant ON public.dashboards; +CREATE POLICY dashboards_tenant ON public.dashboards + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY datasource_account_links_tenant ON public.datasource_account_links; +CREATE POLICY datasource_account_links_tenant ON public.datasource_account_links + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_domains_tenant ON public.datasource_domains; +CREATE POLICY datasource_domains_tenant ON public.datasource_domains + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_group_bindings_tenant ON public.datasource_group_bindings; +CREATE POLICY datasource_group_bindings_tenant ON public.datasource_group_bindings + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_oauth_consumed_states_tenant ON public.datasource_oauth_consumed_states; +CREATE POLICY datasource_oauth_consumed_states_tenant ON public.datasource_oauth_consumed_states + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_object_manifests_tenant ON public.datasource_object_manifests; +CREATE POLICY datasource_object_manifests_tenant ON public.datasource_object_manifests + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_operation_attempts_tenant ON public.datasource_operation_attempts; +CREATE POLICY datasource_operation_attempts_tenant ON public.datasource_operation_attempts + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_source_operations_tenant ON public.datasource_source_operations; +CREATE POLICY datasource_source_operations_tenant ON public.datasource_source_operations + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_sources_tenant ON public.datasource_sources; +CREATE POLICY datasource_sources_tenant ON public.datasource_sources + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY delegation_grants_tenant ON public.delegation_grants; +CREATE POLICY delegation_grants_tenant ON public.delegation_grants + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY domain_events_tenant ON public.domain_events; +CREATE POLICY domain_events_tenant ON public.domain_events + FOR SELECT + TO PUBLIC + USING (((tenant_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY entitlement_overrides_tenant ON public.entitlement_overrides; +CREATE POLICY entitlement_overrides_tenant ON public.entitlement_overrides + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY executable_artifact_approvals_tenant ON public.executable_artifact_approvals; +CREATE POLICY executable_artifact_approvals_tenant ON public.executable_artifact_approvals + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY gdpr_requests_user ON public.gdpr_requests; +CREATE POLICY gdpr_requests_user ON public.gdpr_requests + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY github_app_installations_tenant ON public.github_app_installations; +CREATE POLICY github_app_installations_tenant ON public.github_app_installations + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY github_app_setups_tenant ON public.github_app_setups; +CREATE POLICY github_app_setups_tenant ON public.github_app_setups + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY installations_tenant ON public.installations; +CREATE POLICY installations_tenant ON public.installations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY invitations_tenant ON public.invitations; +CREATE POLICY invitations_tenant ON public.invitations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY job_messages_request_enqueue ON public.job_messages; +CREATE POLICY job_messages_request_enqueue ON public.job_messages + FOR INSERT + TO app_tenant + WITH CHECK (((direction = 'outbox'::text) AND (replay_of IS NULL) AND (((scope_kind = 'tenant'::text) AND (organization_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) OR ((scope_kind = 'subject'::text) AND (subject_id = (NULLIF(current_setting('app.current_user_id'::text, true), ''::text))::uuid))))); + +DROP POLICY membership_integrity_findings_tenant ON public.membership_integrity_findings; +CREATE POLICY membership_integrity_findings_tenant ON public.membership_integrity_findings + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY mfa_backup_codes_user ON public.mfa_backup_codes; +CREATE POLICY mfa_backup_codes_user ON public.mfa_backup_codes + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY mfa_devices_user ON public.mfa_devices; +CREATE POLICY mfa_devices_user ON public.mfa_devices + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY mfa_login_transactions_user ON public.mfa_login_transactions; +CREATE POLICY mfa_login_transactions_user ON public.mfa_login_transactions + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY notifications_user ON public.notifications; +CREATE POLICY notifications_user ON public.notifications + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY onboarding_progress_user ON public.onboarding_progress; +CREATE POLICY onboarding_progress_user ON public.onboarding_progress + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY org_generic_settings_tenant ON public.org_generic_settings; +CREATE POLICY org_generic_settings_tenant ON public.org_generic_settings + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY org_identity_providers_tenant ON public.org_identity_providers; +CREATE POLICY org_identity_providers_tenant ON public.org_identity_providers + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY org_key_bindings_tenant ON public.org_key_bindings; +CREATE POLICY org_key_bindings_tenant ON public.org_key_bindings + FOR SELECT + TO app_tenant + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY org_settings_tenant ON public.org_settings; +CREATE POLICY org_settings_tenant ON public.org_settings + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY organization_activations_tenant ON public.organization_activations; +CREATE POLICY organization_activations_tenant ON public.organization_activations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY organization_authorization_revisions_tenant_read ON public.organization_authorization_revisions; +CREATE POLICY organization_authorization_revisions_tenant_read ON public.organization_authorization_revisions + FOR SELECT + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY organization_members_tenant ON public.organization_members; +CREATE POLICY organization_members_tenant ON public.organization_members + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY organizations_self ON public.organizations; +CREATE POLICY organizations_self ON public.organizations + FOR ALL + TO PUBLIC + USING (((id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY principal_authorization_revisions_tenant_read ON public.principal_authorization_revisions; +CREATE POLICY principal_authorization_revisions_tenant_read ON public.principal_authorization_revisions + FOR SELECT + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY principals_access ON public.principals; +CREATE POLICY principals_access ON public.principals + FOR ALL + TO PUBLIC + USING ((((org_id)::text = current_setting('app.current_org_id'::text, true)) OR ((id)::text = current_setting('app.current_user_id'::text, true)) OR (EXISTS ( SELECT 1 + FROM public.organization_members om + WHERE ((om.user_id = principals.id) AND ((om.org_id)::text = current_setting('app.current_org_id'::text, true))))))) + WITH CHECK ((((org_id)::text = current_setting('app.current_org_id'::text, true)) OR ((id)::text = current_setting('app.current_user_id'::text, true)))); + +DROP POLICY record_shares_tenant ON public.record_shares; +CREATE POLICY record_shares_tenant ON public.record_shares + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY resource_follows_user ON public.resource_follows; +CREATE POLICY resource_follows_user ON public.resource_follows + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY role_assignments_polymorphic ON public.role_assignments; +CREATE POLICY role_assignments_polymorphic ON public.role_assignments + FOR ALL + TO PUBLIC + USING (((org_id IS NULL) OR ((org_id)::text = current_setting('app.current_org_id'::text, true)))) + WITH CHECK (((org_id IS NOT NULL) AND ((org_id)::text = current_setting('app.current_org_id'::text, true)))); + +DROP POLICY role_permissions_delete ON public.role_permissions; +CREATE POLICY role_permissions_delete ON public.role_permissions + FOR DELETE + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.roles + WHERE ((roles.id = role_permissions.role_id) AND (roles.org_id IS NOT NULL) AND ((roles.org_id)::text = current_setting('app.current_org_id'::text, true)))))); + +DROP POLICY role_permissions_insert ON public.role_permissions; +CREATE POLICY role_permissions_insert ON public.role_permissions + FOR INSERT + TO PUBLIC + WITH CHECK ((EXISTS ( SELECT 1 + FROM public.roles + WHERE ((roles.id = role_permissions.role_id) AND (roles.org_id IS NOT NULL) AND ((roles.org_id)::text = current_setting('app.current_org_id'::text, true)))))); + +DROP POLICY role_permissions_select ON public.role_permissions; +CREATE POLICY role_permissions_select ON public.role_permissions + FOR SELECT + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.roles + WHERE ((roles.id = role_permissions.role_id) AND ((roles.org_id IS NULL) OR ((roles.org_id)::text = current_setting('app.current_org_id'::text, true))))))); + +DROP POLICY roles_polymorphic ON public.roles; +CREATE POLICY roles_polymorphic ON public.roles + FOR ALL + TO PUBLIC + USING (((org_id IS NULL) OR ((org_id)::text = current_setting('app.current_org_id'::text, true)))) + WITH CHECK (((org_id IS NOT NULL) AND ((org_id)::text = current_setting('app.current_org_id'::text, true)))); + +DROP POLICY scope_grants_tenant ON public.scope_grants; +CREATE POLICY scope_grants_tenant ON public.scope_grants + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY scope_nodes_tenant ON public.scope_nodes; +CREATE POLICY scope_nodes_tenant ON public.scope_nodes + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY sessions_user ON public.sessions; +CREATE POLICY sessions_user ON public.sessions + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY source_delegations_tenant ON public.source_delegations; +CREATE POLICY source_delegations_tenant ON public.source_delegations + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY source_read_revisions_tenant ON public.source_read_revisions; +CREATE POLICY source_read_revisions_tenant ON public.source_read_revisions + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('app.current_org_id'::text, true), ''::text))::uuid)); + +DROP POLICY subscriptions_tenant ON public.subscriptions; +CREATE POLICY subscriptions_tenant ON public.subscriptions + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY team_members_tenant ON public.team_members; +CREATE POLICY team_members_tenant ON public.team_members + FOR ALL + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.teams t + WHERE ((t.id = team_members.team_id) AND ((t.org_id)::text = current_setting('app.current_org_id'::text, true)))))) + WITH CHECK ((EXISTS ( SELECT 1 + FROM public.teams t + WHERE ((t.id = team_members.team_id) AND ((t.org_id)::text = current_setting('app.current_org_id'::text, true)))))); + +DROP POLICY team_membership_quarantine_tenant ON public.team_membership_quarantine; +CREATE POLICY team_membership_quarantine_tenant ON public.team_membership_quarantine + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY teams_tenant ON public.teams; +CREATE POLICY teams_tenant ON public.teams + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY usage_events_tenant ON public.usage_events; +CREATE POLICY usage_events_tenant ON public.usage_events + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY usage_totals_tenant ON public.usage_totals; +CREATE POLICY usage_totals_tenant ON public.usage_totals + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY user_consent_events_user ON public.user_consent_events; +CREATE POLICY user_consent_events_user ON public.user_consent_events + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY user_consent_preferences_user ON public.user_consent_preferences; +CREATE POLICY user_consent_preferences_user ON public.user_consent_preferences + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY user_identities_user ON public.user_identities; +CREATE POLICY user_identities_user ON public.user_identities + FOR ALL + TO PUBLIC + USING (((user_uuid)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_uuid)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY users_delete ON public.users; +CREATE POLICY users_delete ON public.users + FOR DELETE + TO PUBLIC + USING (((uuid)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY users_insert ON public.users; +CREATE POLICY users_insert ON public.users + FOR INSERT + TO PUBLIC + WITH CHECK (((uuid)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY users_select ON public.users; +CREATE POLICY users_select ON public.users + FOR SELECT + TO PUBLIC + USING (((uuid)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY users_update ON public.users; +CREATE POLICY users_update ON public.users + FOR UPDATE + TO PUBLIC + USING (((uuid)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((uuid)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY webauthn_ceremonies_user ON public.webauthn_ceremonies; +CREATE POLICY webauthn_ceremonies_user ON public.webauthn_ceremonies + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY webauthn_credentials_user ON public.webauthn_credentials; +CREATE POLICY webauthn_credentials_user ON public.webauthn_credentials + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('app.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('app.current_user_id'::text, true))); + +DROP POLICY webhook_deliveries_tenant ON public.webhook_deliveries; +CREATE POLICY webhook_deliveries_tenant ON public.webhook_deliveries + FOR ALL + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.webhook_subscriptions ws + WHERE ((ws.id = webhook_deliveries.subscription_id) AND ((ws.org_id)::text = current_setting('app.current_org_id'::text, true)))))) + WITH CHECK ((EXISTS ( SELECT 1 + FROM public.webhook_subscriptions ws + WHERE ((ws.id = webhook_deliveries.subscription_id) AND ((ws.org_id)::text = current_setting('app.current_org_id'::text, true)))))); + +DROP POLICY webhook_subscriptions_tenant ON public.webhook_subscriptions; +CREATE POLICY webhook_subscriptions_tenant ON public.webhook_subscriptions + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +DROP POLICY work_context_replay_tenant ON public.work_context_replay; +CREATE POLICY work_context_replay_tenant ON public.work_context_replay + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); + +-- The function bodies that read the settings directly. + +CREATE OR REPLACE FUNCTION public.enqueue_job_message(p_direction text, p_scope_kind text, p_organization_id uuid, p_subject_id uuid, p_queue text, p_topic text, p_source text, p_idempotency_key text, p_ordering_key text, p_schema_version integer, p_payload bytea, p_content_type text, p_attributes jsonb, p_priority smallint, p_max_attempts integer, p_available_at timestamp with time zone, p_request_fingerprint bytea) + RETURNS TABLE(job_id uuid, stored_fingerprint bytea, inserted boolean) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ +DECLARE + caller_role TEXT; + inserted_id UUID; +BEGIN + caller_role := COALESCE( + NULLIF(current_setting('role', true), 'none'), + session_user::text + ); + + IF caller_role = 'app_tenant' THEN + IF p_direction <> 'outbox' THEN + RAISE EXCEPTION 'request traffic may enqueue outbox work only' + USING ERRCODE = 'insufficient_privilege'; + END IF; + IF ( + ( + p_scope_kind = 'tenant' + AND p_organization_id = NULLIF( + current_setting('app.current_org_id', true), '' + )::uuid + AND p_subject_id IS NULL + ) + OR ( + p_scope_kind = 'subject' + AND p_subject_id = NULLIF( + current_setting('app.current_user_id', true), '' + )::uuid + AND p_organization_id IS NULL + ) + ) IS NOT TRUE THEN + RAISE EXCEPTION 'job scope does not match the signed request scope' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role = 'app_control_plane' THEN + IF p_direction <> 'outbox' + OR p_scope_kind <> 'global' + OR p_organization_id IS NOT NULL + OR p_subject_id IS NOT NULL THEN + RAISE EXCEPTION 'control-plane traffic may enqueue global outbox work only' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role <> 'app_job_worker' THEN + RAISE EXCEPTION 'role % cannot enqueue jobs', caller_role + USING ERRCODE = 'insufficient_privilege'; + END IF; + + INSERT INTO public.job_messages ( + direction, + scope_kind, + organization_id, + subject_id, + queue, + topic, + source, + idempotency_key, + request_fingerprint, + ordering_key, + schema_version, + payload, + content_type, + attributes, + priority, + max_attempts, + available_at + ) VALUES ( + p_direction, + p_scope_kind, + p_organization_id, + p_subject_id, + p_queue, + p_topic, + p_source, + p_idempotency_key, + p_request_fingerprint, + p_ordering_key, + p_schema_version, + p_payload, + p_content_type, + p_attributes, + p_priority, + p_max_attempts, + COALESCE(p_available_at, CURRENT_TIMESTAMP) + ) + ON CONFLICT DO NOTHING + RETURNING id INTO inserted_id; + + IF inserted_id IS NOT NULL THEN + RETURN QUERY SELECT inserted_id, p_request_fingerprint, TRUE; + RETURN; + END IF; + + RETURN QUERY + SELECT message.id, message.request_fingerprint, FALSE + FROM public.job_messages AS message + WHERE message.direction = p_direction + AND message.scope_kind = p_scope_kind + AND message.organization_id IS NOT DISTINCT FROM p_organization_id + AND message.subject_id IS NOT DISTINCT FROM p_subject_id + AND message.queue = p_queue + AND message.source = p_source + AND message.idempotency_key = p_idempotency_key; + + IF NOT FOUND THEN + RAISE EXCEPTION 'job idempotency conflict could not be resolved' + USING ERRCODE = 'serialization_failure'; + END IF; +END +$function$; + +CREATE OR REPLACE FUNCTION public.identity_administered_organizations(p_user_id uuid) + RETURNS TABLE(administered_org_id uuid, eligible_administrators integer, other_active_members integer) + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ + SELECT held.org_id, + ( + SELECT count(*)::integer + FROM public.organization_members AS peer + JOIN public.users AS peer_holder + ON peer_holder.uuid = peer.user_id + WHERE peer.org_id = held.org_id + AND peer.role IN ('owner', 'admin') + AND peer_holder.status = 'active' + ), + ( + SELECT count(*)::integer + FROM public.organization_members AS peer + JOIN public.users AS peer_holder + ON peer_holder.uuid = peer.user_id + WHERE peer.org_id = held.org_id + AND peer.user_id <> p_user_id + AND peer_holder.status = 'active' + ) + FROM public.organization_members AS held + JOIN public.users AS holder + ON holder.uuid = held.user_id + WHERE held.user_id = p_user_id + AND p_user_id::text = pg_catalog.current_setting('app.current_user_id', true) + AND held.role IN ('owner', 'admin') + AND holder.status = 'active' + ORDER BY held.org_id +$function$; + +CREATE OR REPLACE FUNCTION public.lock_module_job_authority(stamp jsonb) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +BEGIN + PERFORM 1 FROM public.installations i + WHERE i.id::text = stamp->>'codefly.installation_id' + AND i.org_id::text = stamp->>'codefly.organization_id' + AND i.registry_principal_id::text = stamp->>'codefly.principal_id' + AND i.registry_binding_id = stamp->>'codefly.binding_id' + AND i.revision::text = stamp->>'codefly.installation_revision' + AND i.status = 'active' AND i.scope->>'capabilities' = 'true' + AND (current_setting('role', true) = 'app_job_worker' OR ( + current_setting('role', true) = 'app_tenant' AND ( + i.org_id::text = NULLIF(current_setting('app.current_org_id', true), '') OR + EXISTS (SELECT 1 FROM public.organization_members m + WHERE m.org_id = i.org_id + AND m.user_id::text = NULLIF(current_setting('app.current_user_id', true), ''))))) + FOR SHARE; + IF NOT FOUND THEN RAISE EXCEPTION 'revoked_authority' USING ERRCODE = 'check_violation'; END IF; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.module_job_authority_current(stamp jsonb) + RETURNS boolean + LANGUAGE sql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ + SELECT EXISTS ( + SELECT 1 FROM public.installations i + WHERE i.id::text = stamp->>'codefly.installation_id' + AND i.org_id::text = stamp->>'codefly.organization_id' + AND i.registry_principal_id::text = stamp->>'codefly.principal_id' + AND i.registry_binding_id = stamp->>'codefly.binding_id' + AND i.revision::text = stamp->>'codefly.installation_revision' + AND i.status = 'active' AND i.scope->>'capabilities' = 'true' + AND (current_setting('role', true) = 'app_job_worker' OR ( + current_setting('role', true) = 'app_tenant' AND ( + i.org_id::text = NULLIF(current_setting('app.current_org_id', true), '') OR + EXISTS (SELECT 1 FROM public.organization_members m + WHERE m.org_id = i.org_id + AND m.user_id::text = NULLIF(current_setting('app.current_user_id', true), ''))))) + ); +$function$; + +CREATE OR REPLACE FUNCTION public.organization_eligible_administrators(p_org_id uuid) + RETURNS SETOF uuid + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ + SELECT member.user_id + FROM public.organization_members AS member + JOIN public.users AS u + ON u.uuid = member.user_id + WHERE member.org_id = p_org_id + AND member.org_id::text = pg_catalog.current_setting('app.current_org_id', true) + AND member.role IN ('owner', 'admin') + AND u.status = 'active' +$function$; + +CREATE OR REPLACE FUNCTION public.organization_member_primary_email(p_user_id uuid) + RETURNS text + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ + SELECT u.primary_email + FROM public.users AS u + JOIN public.organization_members AS member + ON member.user_id = u.uuid + WHERE u.uuid = p_user_id + AND member.org_id::text = pg_catalog.current_setting('app.current_org_id', true) + AND u.status <> 'deleted' + LIMIT 1 +$function$; + +CREATE OR REPLACE FUNCTION public.publish_domain_event(p_id uuid, p_type text, p_source text, p_subject text, p_event_time timestamp with time zone, p_specversion text, p_datacontenttype text, p_dataschema text, p_data bytea, p_tenant_id uuid, p_boundary_id text, p_partition_key text, p_correlation_id text, p_causation_id text, p_actor_principal_id text, p_owner_principal_id text, p_traceparent text, p_schema_version integer, p_request_fingerprint bytea) + RETURNS TABLE(event_id uuid, stored_fingerprint bytea, inserted boolean) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ +DECLARE + caller_role TEXT; + inserted_id UUID; +BEGIN + caller_role := COALESCE( + NULLIF(current_setting('role', true), 'none'), + session_user::text + ); + + IF caller_role = 'app_tenant' THEN + -- IS DISTINCT FROM, not <>: a transaction that never set app.current_org_id + -- (a user-scoped one, which runs as app_tenant and sets only + -- app.current_user_id) leaves the setting NULL, and `p_tenant_id <> NULL` + -- is NULL, not TRUE — so a plain comparison lets that transaction publish + -- for any tenant it names. The guard has to fail closed on an absent scope. + IF p_tenant_id IS NULL + OR p_tenant_id IS DISTINCT FROM NULLIF(current_setting('app.current_org_id', true), '')::uuid THEN + RAISE EXCEPTION 'event tenant does not match the signed request scope' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role = 'app_control_plane' THEN + -- The control plane publishes on behalf of a tenant. A platform-admin + -- mutation writes its audit record with RLS bypassed, and that record's + -- external domain event is what an outbound webhook subscription is + -- fanned out from, so refusing a tenant here would silently stop + -- delivering every privileged action to the endpoints subscribed to it. + -- The role already writes any tenant's audit rows; publishing the + -- matching event is the same authority, and the relay applies the tenant + -- gate again when it resolves subscriptions. + NULL; + ELSIF caller_role <> 'app_job_worker' THEN + RAISE EXCEPTION 'role % cannot publish domain events', caller_role + USING ERRCODE = 'insufficient_privilege'; + END IF; + + -- Serialize concurrent publishes within one partition so that the identity + -- seq assigned below is handed out in commit order, not merely in insert + -- order. The lock is transaction-scoped: it is held until the producer's + -- transaction commits or rolls back, which is exactly the window in which a + -- competing producer could otherwise take a higher seq and commit first. + -- Ordering is a per-partition guarantee, so the lock is per-partition; a + -- platform event with no partition key is unordered by construction + -- (eventOrdering returns no ordering key for it) and takes no lock, so it + -- never serializes against anything. + IF COALESCE(p_partition_key, '') <> '' THEN + PERFORM pg_advisory_xact_lock(hashtextextended('events:' || p_partition_key, 0)); + END IF; + + INSERT INTO public.domain_events ( + id, type, source, subject, event_time, specversion, datacontenttype, + dataschema, data, tenant_id, boundary_id, partition_key, correlation_id, + causation_id, actor_principal_id, owner_principal_id, traceparent, + schema_version, request_fingerprint + ) VALUES ( + p_id, p_type, p_source, COALESCE(p_subject, ''), p_event_time, + COALESCE(p_specversion, ''), COALESCE(p_datacontenttype, ''), + COALESCE(p_dataschema, ''), COALESCE(p_data, ''::bytea), p_tenant_id, + COALESCE(p_boundary_id, ''), COALESCE(p_partition_key, ''), + COALESCE(p_correlation_id, ''), COALESCE(p_causation_id, ''), + COALESCE(p_actor_principal_id, ''), COALESCE(p_owner_principal_id, ''), + COALESCE(p_traceparent, ''), COALESCE(p_schema_version, 1), + p_request_fingerprint + ) + ON CONFLICT (id) DO NOTHING + RETURNING id INTO inserted_id; + + IF inserted_id IS NOT NULL THEN + RETURN QUERY SELECT inserted_id, p_request_fingerprint, TRUE; + RETURN; + END IF; + + RETURN QUERY + SELECT existing.id, existing.request_fingerprint, FALSE + FROM public.domain_events AS existing + WHERE existing.id = p_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'domain event idempotency conflict could not be resolved' + USING ERRCODE = 'serialization_failure'; + END IF; +END +$function$; + +CREATE OR REPLACE FUNCTION public.record_membership_integrity_findings() + RETURNS integer + LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'public' +AS $function$ +DECLARE + outstanding integer; +BEGIN + -- An inventory has to see every organization, and organizations, + -- organization_members and users (administrator eligibility depends on + -- identity status) are all FORCE ROW LEVEL SECURITY — which subjects the + -- table owner to their policies as well. Those policies scope to + -- app.current_org_id and carry no bypass clause (a session setting must + -- never grant RLS authority; the role-hardening suite asserts no live + -- policy reads one). A caller that does not span organizations therefore + -- reads zero rows, records nothing, and returns 0 — a result identical to a + -- healthy platform and one nobody would think to question. Refuse instead. + IF current_user <> 'app_control_plane' THEN + RAISE EXCEPTION + 'record_membership_integrity_findings() must run as a role that spans every organization; % does not, and would record an empty backlog', + current_user + USING HINT = 'assume the control plane first: SET ROLE app_control_plane'; + END IF; + + WITH org_administration AS ( + -- One pass over the membership graph, so every finding below is decided + -- from the same per-organization facts rather than from repeated + -- correlated subqueries that could drift apart. + -- + -- "Eligible" is migration 133's definition, deliberately reproduced + -- rather than approximated: role IN ('owner','admin') AND the identity + -- is active. findIdentity admits only active identities and DeleteUser + -- is a soft delete that leaves the membership row standing, so counting + -- every administrative row regardless of identity status reports an + -- organization healthy when nobody can actually sign in and administer + -- it. That organization is precisely the backlog this inventory exists + -- to size. Migration 133's organization_eligible_administrators() is + -- not reused here: it is SECURITY DEFINER scoped to app.current_org_id, + -- so it answers for one tenant, and this is a cross-tenant inventory. + SELECT + o.id AS org_id, + o.owner_id AS owner_id, + ( + SELECT om.role FROM organization_members om + WHERE om.org_id = o.id AND om.user_id = o.owner_id + ) AS owner_membership_role, + ( + SELECT u.status::text FROM users u WHERE u.uuid = o.owner_id + ) AS owner_status, + count(*) FILTER ( + WHERE member.role IN ('owner', 'admin') + ) AS administrative_members, + count(*) FILTER ( + WHERE member.role IN ('owner', 'admin') AND holder.status = 'active' + ) AS eligible_administrators, + count(*) FILTER ( + WHERE member.role IN ('owner', 'admin') AND holder.status = 'active' + AND member.user_id = o.owner_id + ) AS owner_is_eligible + FROM organizations o + LEFT JOIN organization_members member ON member.org_id = o.id + LEFT JOIN users holder ON holder.uuid = member.user_id + GROUP BY o.id, o.owner_id + ), + current_findings AS ( + -- The findings are mutually exclusive by construction: one CASE over + -- one row per organization can only ever yield one of them. The + -- previous shape kept three disjoint WHERE clauses in step by hand, + -- which is the kind of agreement that silently stops holding. An + -- organization is therefore never counted twice, so the row count this + -- function returns is an organization count -- the number an operator + -- sizes the repair from. + SELECT + org_id, + CASE + -- No administrative membership exists at all. Repairing this + -- means choosing a user and granting them authority. + WHEN administrative_members = 0 + THEN 'organization_without_administrator' + -- Administrative memberships exist, but every holder is + -- deleted, suspended or otherwise not active. Materially + -- cheaper to repair -- reactivating one identity may be + -- enough -- and a different operator decision, which is why it + -- is reported apart from the case above rather than folded in. + WHEN eligible_administrators = 0 + THEN 'organization_without_an_eligible_administrator' + -- Somebody eligible can administer this organization, but it is + -- not the owner of record: either the owner holds no + -- administrative membership, or holds one whose identity is not + -- active. owner_status and membership_role tell those apart. + ELSE 'owner_of_record_is_not_an_administrator' + END AS finding, + jsonb_build_object( + 'owner_id', owner_id, + 'membership_role', owner_membership_role, + 'owner_status', owner_status, + 'administrative_members', administrative_members, + 'eligible_administrators', eligible_administrators + ) AS detail + FROM org_administration + WHERE administrative_members = 0 + OR eligible_administrators = 0 + OR owner_is_eligible = 0 + ), + resolved AS ( + DELETE FROM membership_integrity_findings f + WHERE NOT EXISTS ( + SELECT 1 FROM current_findings c + WHERE c.org_id = f.org_id AND c.finding = f.finding + ) + ) + INSERT INTO membership_integrity_findings (org_id, finding, detail) + SELECT org_id, finding, detail FROM current_findings + ON CONFLICT (org_id, finding) DO UPDATE SET detail = EXCLUDED.detail; + + SELECT count(*) INTO outstanding FROM membership_integrity_findings; + RETURN outstanding; +END +$function$; + +CREATE OR REPLACE FUNCTION public.sync_webhook_event_subscriptions(p_org_id uuid, p_webhook_subscription_id uuid, p_type_patterns text[]) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ +DECLARE + caller_role TEXT; + owner_org UUID; +BEGIN + caller_role := COALESCE( + NULLIF(current_setting('role', true), 'none'), + session_user::text + ); + + IF caller_role = 'app_tenant' THEN + IF p_org_id IS NULL + OR p_org_id IS DISTINCT FROM NULLIF(current_setting('app.current_org_id', true), '')::uuid THEN + RAISE EXCEPTION 'webhook subscription org does not match the signed request scope' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role NOT IN ('app_control_plane', 'app_job_worker') THEN + RAISE EXCEPTION 'role % cannot manage webhook event subscriptions', caller_role + USING ERRCODE = 'insufficient_privilege'; + END IF; + + -- The endpoint must belong to the org the subscriptions are being bound to, + -- so a caller holding one org's scope cannot attach a subscription to another + -- org's endpoint by naming its id. + SELECT org_id INTO owner_org + FROM public.webhook_subscriptions + WHERE id = p_webhook_subscription_id; + IF owner_org IS NULL OR owner_org <> p_org_id THEN + RAISE EXCEPTION 'webhook subscription does not belong to this organization' + USING ERRCODE = 'insufficient_privilege'; + END IF; + + DELETE FROM public.event_subscriptions + WHERE webhook_subscription_id = p_webhook_subscription_id + AND type_pattern <> ALL(COALESCE(p_type_patterns, ARRAY[]::text[])); + + INSERT INTO public.event_subscriptions + (subscriber_principal_id, type_pattern, queue, delivery, org_id, webhook_subscription_id) + SELECT NULL::uuid, pattern, 'webhooks', 'webhook', p_org_id, p_webhook_subscription_id + FROM unnest(COALESCE(p_type_patterns, ARRAY[]::text[])) AS pattern + ON CONFLICT DO NOTHING; +END +$function$; + +-- Every partition of public.audit_events carries its own copy of the parent's +-- policies: PostgreSQL applies a partitioned table's policies only to a query +-- that names the parent, so a partition read by name is judged by its own. +-- Their names are not known to this file -- migration 34's +-- audit_events_secure_partition drops a partition's policies and clones the +-- parent's, which is the ledger's own mechanism for exactly this, and the one +-- the runtime partition creator already calls. Nothing else here knows how to +-- rewrite a policy whose name depends on the month. +DO $$ +DECLARE + part regclass; +BEGIN + FOR part IN + SELECT i.inhrelid::regclass + FROM pg_catalog.pg_inherits i + WHERE i.inhparent = 'public.audit_events'::regclass + ORDER BY i.inhrelid::regclass::text + LOOP + PERFORM public.audit_events_secure_partition(part); + END LOOP; +END; +$$; diff --git a/module/services/store/migrations/65_library_scope_settings.up.sql b/module/services/store/migrations/65_library_scope_settings.up.sql new file mode 100644 index 000000000..676f5f49d --- /dev/null +++ b/module/services/store/migrations/65_library_scope_settings.up.sql @@ -0,0 +1,1067 @@ +-- The caller's organisation and user are bound, and read back, under the +-- names the platform's Postgres library installs: codefly.current_tenant_id +-- and codefly.current_user_id, not this host's own app.current_org_id and +-- app.current_user_id. +-- +-- The library (codefly-dev/service-postgres, libs/go/store.go) defaults its +-- scoped transaction to those two settings, and that is what the other stores +-- on this platform read. The host carried names of its own, so an operator +-- reading two of these databases' policies read two conventions. This is that +-- rename and nothing else: every policy keeps its USING and WITH CHECK shape, +-- its PERMISSIVE/RESTRICTIVE kind, its verb and its role list; every table +-- keeps ENABLE and FORCE ROW LEVEL SECURITY; the roles stay NOBYPASSRLS; the +-- binding stays transaction-local and fail-closed, so an unset setting still +-- reads as no rows rather than all of them. A table with no policy -- the +-- platform-global ones, whose boundary is the grant -- is untouched. +-- +-- Generated from pg_policy and pg_proc rather than written by hand, because +-- the catalog is the only place that knows the whole set. At version 64 it +-- was 75 policies over 70 relations, 6 more on partitions of +-- public.audit_events, and 9 function bodies. Each expression below is the +-- catalog's own rendering under an empty search_path -- which is how the +-- baseline's text was produced, and what makes it schema-qualified -- with +-- the two setting names substituted and nothing else touched. + +-- The policies. + +DROP POLICY actor_chain_journal_tenant ON public.actor_chain_journal; +CREATE POLICY actor_chain_journal_tenant ON public.actor_chain_journal + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY actor_chain_revocations_tenant ON public.actor_chain_revocations; +CREATE POLICY actor_chain_revocations_tenant ON public.actor_chain_revocations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY api_keys_tenant ON public.api_keys; +CREATE POLICY api_keys_tenant ON public.api_keys + FOR ALL + TO PUBLIC + USING (((organization_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((organization_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY approval_decisions_tenant ON public.approval_decisions; +CREATE POLICY approval_decisions_tenant ON public.approval_decisions + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY approval_requests_tenant ON public.approval_requests; +CREATE POLICY approval_requests_tenant ON public.approval_requests + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY audit_event_idempotency_tenant ON public.audit_event_idempotency; +CREATE POLICY audit_event_idempotency_tenant ON public.audit_event_idempotency + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY audit_events_tenant ON public.audit_events; +CREATE POLICY audit_events_tenant ON public.audit_events + FOR ALL + TO PUBLIC + USING (((org_id IS NOT NULL) AND ((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))) + WITH CHECK ((((org_id IS NOT NULL) AND ((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) OR ((org_id IS NULL) AND (actor_id IS NOT NULL) AND ((actor_id)::text = current_setting('codefly.current_user_id'::text, true))))); + +DROP POLICY client_authorization_codes_user ON public.client_authorization_codes; +CREATE POLICY client_authorization_codes_user ON public.client_authorization_codes + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY codefly_effect_receipts_tenant ON public.codefly_effect_receipts; +CREATE POLICY codefly_effect_receipts_tenant ON public.codefly_effect_receipts + FOR ALL + TO app_tenant + USING ((tenant = NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))) + WITH CHECK ((tenant = NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))); + +DROP POLICY connector_credentials_tenant ON public.connector_credentials; +CREATE POLICY connector_credentials_tenant ON public.connector_credentials + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY dashboards_tenant ON public.dashboards; +CREATE POLICY dashboards_tenant ON public.dashboards + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY datasource_account_links_tenant ON public.datasource_account_links; +CREATE POLICY datasource_account_links_tenant ON public.datasource_account_links + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_domains_tenant ON public.datasource_domains; +CREATE POLICY datasource_domains_tenant ON public.datasource_domains + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_group_bindings_tenant ON public.datasource_group_bindings; +CREATE POLICY datasource_group_bindings_tenant ON public.datasource_group_bindings + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_oauth_consumed_states_tenant ON public.datasource_oauth_consumed_states; +CREATE POLICY datasource_oauth_consumed_states_tenant ON public.datasource_oauth_consumed_states + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_object_manifests_tenant ON public.datasource_object_manifests; +CREATE POLICY datasource_object_manifests_tenant ON public.datasource_object_manifests + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_operation_attempts_tenant ON public.datasource_operation_attempts; +CREATE POLICY datasource_operation_attempts_tenant ON public.datasource_operation_attempts + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_source_operations_tenant ON public.datasource_source_operations; +CREATE POLICY datasource_source_operations_tenant ON public.datasource_source_operations + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY datasource_sources_tenant ON public.datasource_sources; +CREATE POLICY datasource_sources_tenant ON public.datasource_sources + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY delegation_grants_tenant ON public.delegation_grants; +CREATE POLICY delegation_grants_tenant ON public.delegation_grants + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY domain_events_tenant ON public.domain_events; +CREATE POLICY domain_events_tenant ON public.domain_events + FOR SELECT + TO PUBLIC + USING (((tenant_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY entitlement_overrides_tenant ON public.entitlement_overrides; +CREATE POLICY entitlement_overrides_tenant ON public.entitlement_overrides + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY executable_artifact_approvals_tenant ON public.executable_artifact_approvals; +CREATE POLICY executable_artifact_approvals_tenant ON public.executable_artifact_approvals + FOR ALL + TO app_tenant + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY gdpr_requests_user ON public.gdpr_requests; +CREATE POLICY gdpr_requests_user ON public.gdpr_requests + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY github_app_installations_tenant ON public.github_app_installations; +CREATE POLICY github_app_installations_tenant ON public.github_app_installations + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY github_app_setups_tenant ON public.github_app_setups; +CREATE POLICY github_app_setups_tenant ON public.github_app_setups + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY installations_tenant ON public.installations; +CREATE POLICY installations_tenant ON public.installations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY invitations_tenant ON public.invitations; +CREATE POLICY invitations_tenant ON public.invitations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY job_messages_request_enqueue ON public.job_messages; +CREATE POLICY job_messages_request_enqueue ON public.job_messages + FOR INSERT + TO app_tenant + WITH CHECK (((direction = 'outbox'::text) AND (replay_of IS NULL) AND (((scope_kind = 'tenant'::text) AND (organization_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) OR ((scope_kind = 'subject'::text) AND (subject_id = (NULLIF(current_setting('codefly.current_user_id'::text, true), ''::text))::uuid))))); + +DROP POLICY membership_integrity_findings_tenant ON public.membership_integrity_findings; +CREATE POLICY membership_integrity_findings_tenant ON public.membership_integrity_findings + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY mfa_backup_codes_user ON public.mfa_backup_codes; +CREATE POLICY mfa_backup_codes_user ON public.mfa_backup_codes + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY mfa_devices_user ON public.mfa_devices; +CREATE POLICY mfa_devices_user ON public.mfa_devices + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY mfa_login_transactions_user ON public.mfa_login_transactions; +CREATE POLICY mfa_login_transactions_user ON public.mfa_login_transactions + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY notifications_user ON public.notifications; +CREATE POLICY notifications_user ON public.notifications + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY onboarding_progress_user ON public.onboarding_progress; +CREATE POLICY onboarding_progress_user ON public.onboarding_progress + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY org_generic_settings_tenant ON public.org_generic_settings; +CREATE POLICY org_generic_settings_tenant ON public.org_generic_settings + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY org_identity_providers_tenant ON public.org_identity_providers; +CREATE POLICY org_identity_providers_tenant ON public.org_identity_providers + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY org_key_bindings_tenant ON public.org_key_bindings; +CREATE POLICY org_key_bindings_tenant ON public.org_key_bindings + FOR SELECT + TO app_tenant + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY org_settings_tenant ON public.org_settings; +CREATE POLICY org_settings_tenant ON public.org_settings + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY organization_activations_tenant ON public.organization_activations; +CREATE POLICY organization_activations_tenant ON public.organization_activations + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY organization_authorization_revisions_tenant_read ON public.organization_authorization_revisions; +CREATE POLICY organization_authorization_revisions_tenant_read ON public.organization_authorization_revisions + FOR SELECT + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY organization_members_tenant ON public.organization_members; +CREATE POLICY organization_members_tenant ON public.organization_members + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY organizations_self ON public.organizations; +CREATE POLICY organizations_self ON public.organizations + FOR ALL + TO PUBLIC + USING (((id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY principal_authorization_revisions_tenant_read ON public.principal_authorization_revisions; +CREATE POLICY principal_authorization_revisions_tenant_read ON public.principal_authorization_revisions + FOR SELECT + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY principals_access ON public.principals; +CREATE POLICY principals_access ON public.principals + FOR ALL + TO PUBLIC + USING ((((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)) OR ((id)::text = current_setting('codefly.current_user_id'::text, true)) OR (EXISTS ( SELECT 1 + FROM public.organization_members om + WHERE ((om.user_id = principals.id) AND ((om.org_id)::text = current_setting('codefly.current_tenant_id'::text, true))))))) + WITH CHECK ((((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)) OR ((id)::text = current_setting('codefly.current_user_id'::text, true)))); + +DROP POLICY record_shares_tenant ON public.record_shares; +CREATE POLICY record_shares_tenant ON public.record_shares + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY resource_follows_user ON public.resource_follows; +CREATE POLICY resource_follows_user ON public.resource_follows + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY role_assignments_polymorphic ON public.role_assignments; +CREATE POLICY role_assignments_polymorphic ON public.role_assignments + FOR ALL + TO PUBLIC + USING (((org_id IS NULL) OR ((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))) + WITH CHECK (((org_id IS NOT NULL) AND ((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))); + +DROP POLICY role_permissions_delete ON public.role_permissions; +CREATE POLICY role_permissions_delete ON public.role_permissions + FOR DELETE + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.roles + WHERE ((roles.id = role_permissions.role_id) AND (roles.org_id IS NOT NULL) AND ((roles.org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))))); + +DROP POLICY role_permissions_insert ON public.role_permissions; +CREATE POLICY role_permissions_insert ON public.role_permissions + FOR INSERT + TO PUBLIC + WITH CHECK ((EXISTS ( SELECT 1 + FROM public.roles + WHERE ((roles.id = role_permissions.role_id) AND (roles.org_id IS NOT NULL) AND ((roles.org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))))); + +DROP POLICY role_permissions_select ON public.role_permissions; +CREATE POLICY role_permissions_select ON public.role_permissions + FOR SELECT + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.roles + WHERE ((roles.id = role_permissions.role_id) AND ((roles.org_id IS NULL) OR ((roles.org_id)::text = current_setting('codefly.current_tenant_id'::text, true))))))); + +DROP POLICY roles_polymorphic ON public.roles; +CREATE POLICY roles_polymorphic ON public.roles + FOR ALL + TO PUBLIC + USING (((org_id IS NULL) OR ((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))) + WITH CHECK (((org_id IS NOT NULL) AND ((org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))); + +DROP POLICY scope_grants_tenant ON public.scope_grants; +CREATE POLICY scope_grants_tenant ON public.scope_grants + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY scope_nodes_tenant ON public.scope_nodes; +CREATE POLICY scope_nodes_tenant ON public.scope_nodes + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY sessions_user ON public.sessions; +CREATE POLICY sessions_user ON public.sessions + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY source_delegations_tenant ON public.source_delegations; +CREATE POLICY source_delegations_tenant ON public.source_delegations + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)) + WITH CHECK ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY source_read_revisions_tenant ON public.source_read_revisions; +CREATE POLICY source_read_revisions_tenant ON public.source_read_revisions + FOR ALL + TO PUBLIC + USING ((org_id = (NULLIF(current_setting('codefly.current_tenant_id'::text, true), ''::text))::uuid)); + +DROP POLICY subscriptions_tenant ON public.subscriptions; +CREATE POLICY subscriptions_tenant ON public.subscriptions + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY team_members_tenant ON public.team_members; +CREATE POLICY team_members_tenant ON public.team_members + FOR ALL + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.teams t + WHERE ((t.id = team_members.team_id) AND ((t.org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))))) + WITH CHECK ((EXISTS ( SELECT 1 + FROM public.teams t + WHERE ((t.id = team_members.team_id) AND ((t.org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))))); + +DROP POLICY team_membership_quarantine_tenant ON public.team_membership_quarantine; +CREATE POLICY team_membership_quarantine_tenant ON public.team_membership_quarantine + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY teams_tenant ON public.teams; +CREATE POLICY teams_tenant ON public.teams + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY usage_events_tenant ON public.usage_events; +CREATE POLICY usage_events_tenant ON public.usage_events + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY usage_totals_tenant ON public.usage_totals; +CREATE POLICY usage_totals_tenant ON public.usage_totals + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY user_consent_events_user ON public.user_consent_events; +CREATE POLICY user_consent_events_user ON public.user_consent_events + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY user_consent_preferences_user ON public.user_consent_preferences; +CREATE POLICY user_consent_preferences_user ON public.user_consent_preferences + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY user_identities_user ON public.user_identities; +CREATE POLICY user_identities_user ON public.user_identities + FOR ALL + TO PUBLIC + USING (((user_uuid)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_uuid)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY users_delete ON public.users; +CREATE POLICY users_delete ON public.users + FOR DELETE + TO PUBLIC + USING (((uuid)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY users_insert ON public.users; +CREATE POLICY users_insert ON public.users + FOR INSERT + TO PUBLIC + WITH CHECK (((uuid)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY users_select ON public.users; +CREATE POLICY users_select ON public.users + FOR SELECT + TO PUBLIC + USING (((uuid)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY users_update ON public.users; +CREATE POLICY users_update ON public.users + FOR UPDATE + TO PUBLIC + USING (((uuid)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((uuid)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY webauthn_ceremonies_user ON public.webauthn_ceremonies; +CREATE POLICY webauthn_ceremonies_user ON public.webauthn_ceremonies + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY webauthn_credentials_user ON public.webauthn_credentials; +CREATE POLICY webauthn_credentials_user ON public.webauthn_credentials + FOR ALL + TO PUBLIC + USING (((user_id)::text = current_setting('codefly.current_user_id'::text, true))) + WITH CHECK (((user_id)::text = current_setting('codefly.current_user_id'::text, true))); + +DROP POLICY webhook_deliveries_tenant ON public.webhook_deliveries; +CREATE POLICY webhook_deliveries_tenant ON public.webhook_deliveries + FOR ALL + TO PUBLIC + USING ((EXISTS ( SELECT 1 + FROM public.webhook_subscriptions ws + WHERE ((ws.id = webhook_deliveries.subscription_id) AND ((ws.org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))))) + WITH CHECK ((EXISTS ( SELECT 1 + FROM public.webhook_subscriptions ws + WHERE ((ws.id = webhook_deliveries.subscription_id) AND ((ws.org_id)::text = current_setting('codefly.current_tenant_id'::text, true)))))); + +DROP POLICY webhook_subscriptions_tenant ON public.webhook_subscriptions; +CREATE POLICY webhook_subscriptions_tenant ON public.webhook_subscriptions + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +DROP POLICY work_context_replay_tenant ON public.work_context_replay; +CREATE POLICY work_context_replay_tenant ON public.work_context_replay + FOR ALL + TO PUBLIC + USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) + WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); + +-- The function bodies that read the settings directly. + +CREATE OR REPLACE FUNCTION public.enqueue_job_message(p_direction text, p_scope_kind text, p_organization_id uuid, p_subject_id uuid, p_queue text, p_topic text, p_source text, p_idempotency_key text, p_ordering_key text, p_schema_version integer, p_payload bytea, p_content_type text, p_attributes jsonb, p_priority smallint, p_max_attempts integer, p_available_at timestamp with time zone, p_request_fingerprint bytea) + RETURNS TABLE(job_id uuid, stored_fingerprint bytea, inserted boolean) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ +DECLARE + caller_role TEXT; + inserted_id UUID; +BEGIN + caller_role := COALESCE( + NULLIF(current_setting('role', true), 'none'), + session_user::text + ); + + IF caller_role = 'app_tenant' THEN + IF p_direction <> 'outbox' THEN + RAISE EXCEPTION 'request traffic may enqueue outbox work only' + USING ERRCODE = 'insufficient_privilege'; + END IF; + IF ( + ( + p_scope_kind = 'tenant' + AND p_organization_id = NULLIF( + current_setting('codefly.current_tenant_id', true), '' + )::uuid + AND p_subject_id IS NULL + ) + OR ( + p_scope_kind = 'subject' + AND p_subject_id = NULLIF( + current_setting('codefly.current_user_id', true), '' + )::uuid + AND p_organization_id IS NULL + ) + ) IS NOT TRUE THEN + RAISE EXCEPTION 'job scope does not match the signed request scope' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role = 'app_control_plane' THEN + IF p_direction <> 'outbox' + OR p_scope_kind <> 'global' + OR p_organization_id IS NOT NULL + OR p_subject_id IS NOT NULL THEN + RAISE EXCEPTION 'control-plane traffic may enqueue global outbox work only' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role <> 'app_job_worker' THEN + RAISE EXCEPTION 'role % cannot enqueue jobs', caller_role + USING ERRCODE = 'insufficient_privilege'; + END IF; + + INSERT INTO public.job_messages ( + direction, + scope_kind, + organization_id, + subject_id, + queue, + topic, + source, + idempotency_key, + request_fingerprint, + ordering_key, + schema_version, + payload, + content_type, + attributes, + priority, + max_attempts, + available_at + ) VALUES ( + p_direction, + p_scope_kind, + p_organization_id, + p_subject_id, + p_queue, + p_topic, + p_source, + p_idempotency_key, + p_request_fingerprint, + p_ordering_key, + p_schema_version, + p_payload, + p_content_type, + p_attributes, + p_priority, + p_max_attempts, + COALESCE(p_available_at, CURRENT_TIMESTAMP) + ) + ON CONFLICT DO NOTHING + RETURNING id INTO inserted_id; + + IF inserted_id IS NOT NULL THEN + RETURN QUERY SELECT inserted_id, p_request_fingerprint, TRUE; + RETURN; + END IF; + + RETURN QUERY + SELECT message.id, message.request_fingerprint, FALSE + FROM public.job_messages AS message + WHERE message.direction = p_direction + AND message.scope_kind = p_scope_kind + AND message.organization_id IS NOT DISTINCT FROM p_organization_id + AND message.subject_id IS NOT DISTINCT FROM p_subject_id + AND message.queue = p_queue + AND message.source = p_source + AND message.idempotency_key = p_idempotency_key; + + IF NOT FOUND THEN + RAISE EXCEPTION 'job idempotency conflict could not be resolved' + USING ERRCODE = 'serialization_failure'; + END IF; +END +$function$; + +CREATE OR REPLACE FUNCTION public.identity_administered_organizations(p_user_id uuid) + RETURNS TABLE(administered_org_id uuid, eligible_administrators integer, other_active_members integer) + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ + SELECT held.org_id, + ( + SELECT count(*)::integer + FROM public.organization_members AS peer + JOIN public.users AS peer_holder + ON peer_holder.uuid = peer.user_id + WHERE peer.org_id = held.org_id + AND peer.role IN ('owner', 'admin') + AND peer_holder.status = 'active' + ), + ( + SELECT count(*)::integer + FROM public.organization_members AS peer + JOIN public.users AS peer_holder + ON peer_holder.uuid = peer.user_id + WHERE peer.org_id = held.org_id + AND peer.user_id <> p_user_id + AND peer_holder.status = 'active' + ) + FROM public.organization_members AS held + JOIN public.users AS holder + ON holder.uuid = held.user_id + WHERE held.user_id = p_user_id + AND p_user_id::text = pg_catalog.current_setting('codefly.current_user_id', true) + AND held.role IN ('owner', 'admin') + AND holder.status = 'active' + ORDER BY held.org_id +$function$; + +CREATE OR REPLACE FUNCTION public.lock_module_job_authority(stamp jsonb) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +BEGIN + PERFORM 1 FROM public.installations i + WHERE i.id::text = stamp->>'codefly.installation_id' + AND i.org_id::text = stamp->>'codefly.organization_id' + AND i.registry_principal_id::text = stamp->>'codefly.principal_id' + AND i.registry_binding_id = stamp->>'codefly.binding_id' + AND i.revision::text = stamp->>'codefly.installation_revision' + AND i.status = 'active' AND i.scope->>'capabilities' = 'true' + AND (current_setting('role', true) = 'app_job_worker' OR ( + current_setting('role', true) = 'app_tenant' AND ( + i.org_id::text = NULLIF(current_setting('codefly.current_tenant_id', true), '') OR + EXISTS (SELECT 1 FROM public.organization_members m + WHERE m.org_id = i.org_id + AND m.user_id::text = NULLIF(current_setting('codefly.current_user_id', true), ''))))) + FOR SHARE; + IF NOT FOUND THEN RAISE EXCEPTION 'revoked_authority' USING ERRCODE = 'check_violation'; END IF; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.module_job_authority_current(stamp jsonb) + RETURNS boolean + LANGUAGE sql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ + SELECT EXISTS ( + SELECT 1 FROM public.installations i + WHERE i.id::text = stamp->>'codefly.installation_id' + AND i.org_id::text = stamp->>'codefly.organization_id' + AND i.registry_principal_id::text = stamp->>'codefly.principal_id' + AND i.registry_binding_id = stamp->>'codefly.binding_id' + AND i.revision::text = stamp->>'codefly.installation_revision' + AND i.status = 'active' AND i.scope->>'capabilities' = 'true' + AND (current_setting('role', true) = 'app_job_worker' OR ( + current_setting('role', true) = 'app_tenant' AND ( + i.org_id::text = NULLIF(current_setting('codefly.current_tenant_id', true), '') OR + EXISTS (SELECT 1 FROM public.organization_members m + WHERE m.org_id = i.org_id + AND m.user_id::text = NULLIF(current_setting('codefly.current_user_id', true), ''))))) + ); +$function$; + +CREATE OR REPLACE FUNCTION public.organization_eligible_administrators(p_org_id uuid) + RETURNS SETOF uuid + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ + SELECT member.user_id + FROM public.organization_members AS member + JOIN public.users AS u + ON u.uuid = member.user_id + WHERE member.org_id = p_org_id + AND member.org_id::text = pg_catalog.current_setting('codefly.current_tenant_id', true) + AND member.role IN ('owner', 'admin') + AND u.status = 'active' +$function$; + +CREATE OR REPLACE FUNCTION public.organization_member_primary_email(p_user_id uuid) + RETURNS text + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ + SELECT u.primary_email + FROM public.users AS u + JOIN public.organization_members AS member + ON member.user_id = u.uuid + WHERE u.uuid = p_user_id + AND member.org_id::text = pg_catalog.current_setting('codefly.current_tenant_id', true) + AND u.status <> 'deleted' + LIMIT 1 +$function$; + +CREATE OR REPLACE FUNCTION public.publish_domain_event(p_id uuid, p_type text, p_source text, p_subject text, p_event_time timestamp with time zone, p_specversion text, p_datacontenttype text, p_dataschema text, p_data bytea, p_tenant_id uuid, p_boundary_id text, p_partition_key text, p_correlation_id text, p_causation_id text, p_actor_principal_id text, p_owner_principal_id text, p_traceparent text, p_schema_version integer, p_request_fingerprint bytea) + RETURNS TABLE(event_id uuid, stored_fingerprint bytea, inserted boolean) + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ +DECLARE + caller_role TEXT; + inserted_id UUID; +BEGIN + caller_role := COALESCE( + NULLIF(current_setting('role', true), 'none'), + session_user::text + ); + + IF caller_role = 'app_tenant' THEN + -- IS DISTINCT FROM, not <>: a transaction that never set codefly.current_tenant_id + -- (a user-scoped one, which runs as app_tenant and sets only + -- codefly.current_user_id) leaves the setting NULL, and `p_tenant_id <> NULL` + -- is NULL, not TRUE — so a plain comparison lets that transaction publish + -- for any tenant it names. The guard has to fail closed on an absent scope. + IF p_tenant_id IS NULL + OR p_tenant_id IS DISTINCT FROM NULLIF(current_setting('codefly.current_tenant_id', true), '')::uuid THEN + RAISE EXCEPTION 'event tenant does not match the signed request scope' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role = 'app_control_plane' THEN + -- The control plane publishes on behalf of a tenant. A platform-admin + -- mutation writes its audit record with RLS bypassed, and that record's + -- external domain event is what an outbound webhook subscription is + -- fanned out from, so refusing a tenant here would silently stop + -- delivering every privileged action to the endpoints subscribed to it. + -- The role already writes any tenant's audit rows; publishing the + -- matching event is the same authority, and the relay applies the tenant + -- gate again when it resolves subscriptions. + NULL; + ELSIF caller_role <> 'app_job_worker' THEN + RAISE EXCEPTION 'role % cannot publish domain events', caller_role + USING ERRCODE = 'insufficient_privilege'; + END IF; + + -- Serialize concurrent publishes within one partition so that the identity + -- seq assigned below is handed out in commit order, not merely in insert + -- order. The lock is transaction-scoped: it is held until the producer's + -- transaction commits or rolls back, which is exactly the window in which a + -- competing producer could otherwise take a higher seq and commit first. + -- Ordering is a per-partition guarantee, so the lock is per-partition; a + -- platform event with no partition key is unordered by construction + -- (eventOrdering returns no ordering key for it) and takes no lock, so it + -- never serializes against anything. + IF COALESCE(p_partition_key, '') <> '' THEN + PERFORM pg_advisory_xact_lock(hashtextextended('events:' || p_partition_key, 0)); + END IF; + + INSERT INTO public.domain_events ( + id, type, source, subject, event_time, specversion, datacontenttype, + dataschema, data, tenant_id, boundary_id, partition_key, correlation_id, + causation_id, actor_principal_id, owner_principal_id, traceparent, + schema_version, request_fingerprint + ) VALUES ( + p_id, p_type, p_source, COALESCE(p_subject, ''), p_event_time, + COALESCE(p_specversion, ''), COALESCE(p_datacontenttype, ''), + COALESCE(p_dataschema, ''), COALESCE(p_data, ''::bytea), p_tenant_id, + COALESCE(p_boundary_id, ''), COALESCE(p_partition_key, ''), + COALESCE(p_correlation_id, ''), COALESCE(p_causation_id, ''), + COALESCE(p_actor_principal_id, ''), COALESCE(p_owner_principal_id, ''), + COALESCE(p_traceparent, ''), COALESCE(p_schema_version, 1), + p_request_fingerprint + ) + ON CONFLICT (id) DO NOTHING + RETURNING id INTO inserted_id; + + IF inserted_id IS NOT NULL THEN + RETURN QUERY SELECT inserted_id, p_request_fingerprint, TRUE; + RETURN; + END IF; + + RETURN QUERY + SELECT existing.id, existing.request_fingerprint, FALSE + FROM public.domain_events AS existing + WHERE existing.id = p_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'domain event idempotency conflict could not be resolved' + USING ERRCODE = 'serialization_failure'; + END IF; +END +$function$; + +CREATE OR REPLACE FUNCTION public.record_membership_integrity_findings() + RETURNS integer + LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'public' +AS $function$ +DECLARE + outstanding integer; +BEGIN + -- An inventory has to see every organization, and organizations, + -- organization_members and users (administrator eligibility depends on + -- identity status) are all FORCE ROW LEVEL SECURITY — which subjects the + -- table owner to their policies as well. Those policies scope to + -- codefly.current_tenant_id and carry no bypass clause (a session setting must + -- never grant RLS authority; the role-hardening suite asserts no live + -- policy reads one). A caller that does not span organizations therefore + -- reads zero rows, records nothing, and returns 0 — a result identical to a + -- healthy platform and one nobody would think to question. Refuse instead. + IF current_user <> 'app_control_plane' THEN + RAISE EXCEPTION + 'record_membership_integrity_findings() must run as a role that spans every organization; % does not, and would record an empty backlog', + current_user + USING HINT = 'assume the control plane first: SET ROLE app_control_plane'; + END IF; + + WITH org_administration AS ( + -- One pass over the membership graph, so every finding below is decided + -- from the same per-organization facts rather than from repeated + -- correlated subqueries that could drift apart. + -- + -- "Eligible" is migration 133's definition, deliberately reproduced + -- rather than approximated: role IN ('owner','admin') AND the identity + -- is active. findIdentity admits only active identities and DeleteUser + -- is a soft delete that leaves the membership row standing, so counting + -- every administrative row regardless of identity status reports an + -- organization healthy when nobody can actually sign in and administer + -- it. That organization is precisely the backlog this inventory exists + -- to size. Migration 133's organization_eligible_administrators() is + -- not reused here: it is SECURITY DEFINER scoped to codefly.current_tenant_id, + -- so it answers for one tenant, and this is a cross-tenant inventory. + SELECT + o.id AS org_id, + o.owner_id AS owner_id, + ( + SELECT om.role FROM organization_members om + WHERE om.org_id = o.id AND om.user_id = o.owner_id + ) AS owner_membership_role, + ( + SELECT u.status::text FROM users u WHERE u.uuid = o.owner_id + ) AS owner_status, + count(*) FILTER ( + WHERE member.role IN ('owner', 'admin') + ) AS administrative_members, + count(*) FILTER ( + WHERE member.role IN ('owner', 'admin') AND holder.status = 'active' + ) AS eligible_administrators, + count(*) FILTER ( + WHERE member.role IN ('owner', 'admin') AND holder.status = 'active' + AND member.user_id = o.owner_id + ) AS owner_is_eligible + FROM organizations o + LEFT JOIN organization_members member ON member.org_id = o.id + LEFT JOIN users holder ON holder.uuid = member.user_id + GROUP BY o.id, o.owner_id + ), + current_findings AS ( + -- The findings are mutually exclusive by construction: one CASE over + -- one row per organization can only ever yield one of them. The + -- previous shape kept three disjoint WHERE clauses in step by hand, + -- which is the kind of agreement that silently stops holding. An + -- organization is therefore never counted twice, so the row count this + -- function returns is an organization count -- the number an operator + -- sizes the repair from. + SELECT + org_id, + CASE + -- No administrative membership exists at all. Repairing this + -- means choosing a user and granting them authority. + WHEN administrative_members = 0 + THEN 'organization_without_administrator' + -- Administrative memberships exist, but every holder is + -- deleted, suspended or otherwise not active. Materially + -- cheaper to repair -- reactivating one identity may be + -- enough -- and a different operator decision, which is why it + -- is reported apart from the case above rather than folded in. + WHEN eligible_administrators = 0 + THEN 'organization_without_an_eligible_administrator' + -- Somebody eligible can administer this organization, but it is + -- not the owner of record: either the owner holds no + -- administrative membership, or holds one whose identity is not + -- active. owner_status and membership_role tell those apart. + ELSE 'owner_of_record_is_not_an_administrator' + END AS finding, + jsonb_build_object( + 'owner_id', owner_id, + 'membership_role', owner_membership_role, + 'owner_status', owner_status, + 'administrative_members', administrative_members, + 'eligible_administrators', eligible_administrators + ) AS detail + FROM org_administration + WHERE administrative_members = 0 + OR eligible_administrators = 0 + OR owner_is_eligible = 0 + ), + resolved AS ( + DELETE FROM membership_integrity_findings f + WHERE NOT EXISTS ( + SELECT 1 FROM current_findings c + WHERE c.org_id = f.org_id AND c.finding = f.finding + ) + ) + INSERT INTO membership_integrity_findings (org_id, finding, detail) + SELECT org_id, finding, detail FROM current_findings + ON CONFLICT (org_id, finding) DO UPDATE SET detail = EXCLUDED.detail; + + SELECT count(*) INTO outstanding FROM membership_integrity_findings; + RETURN outstanding; +END +$function$; + +CREATE OR REPLACE FUNCTION public.sync_webhook_event_subscriptions(p_org_id uuid, p_webhook_subscription_id uuid, p_type_patterns text[]) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'pg_catalog', 'public', 'pg_temp' +AS $function$ +DECLARE + caller_role TEXT; + owner_org UUID; +BEGIN + caller_role := COALESCE( + NULLIF(current_setting('role', true), 'none'), + session_user::text + ); + + IF caller_role = 'app_tenant' THEN + IF p_org_id IS NULL + OR p_org_id IS DISTINCT FROM NULLIF(current_setting('codefly.current_tenant_id', true), '')::uuid THEN + RAISE EXCEPTION 'webhook subscription org does not match the signed request scope' + USING ERRCODE = 'insufficient_privilege'; + END IF; + ELSIF caller_role NOT IN ('app_control_plane', 'app_job_worker') THEN + RAISE EXCEPTION 'role % cannot manage webhook event subscriptions', caller_role + USING ERRCODE = 'insufficient_privilege'; + END IF; + + -- The endpoint must belong to the org the subscriptions are being bound to, + -- so a caller holding one org's scope cannot attach a subscription to another + -- org's endpoint by naming its id. + SELECT org_id INTO owner_org + FROM public.webhook_subscriptions + WHERE id = p_webhook_subscription_id; + IF owner_org IS NULL OR owner_org <> p_org_id THEN + RAISE EXCEPTION 'webhook subscription does not belong to this organization' + USING ERRCODE = 'insufficient_privilege'; + END IF; + + DELETE FROM public.event_subscriptions + WHERE webhook_subscription_id = p_webhook_subscription_id + AND type_pattern <> ALL(COALESCE(p_type_patterns, ARRAY[]::text[])); + + INSERT INTO public.event_subscriptions + (subscriber_principal_id, type_pattern, queue, delivery, org_id, webhook_subscription_id) + SELECT NULL::uuid, pattern, 'webhooks', 'webhook', p_org_id, p_webhook_subscription_id + FROM unnest(COALESCE(p_type_patterns, ARRAY[]::text[])) AS pattern + ON CONFLICT DO NOTHING; +END +$function$; + +-- Every partition of public.audit_events carries its own copy of the parent's +-- policies: PostgreSQL applies a partitioned table's policies only to a query +-- that names the parent, so a partition read by name is judged by its own. +-- Their names are not known to this file -- migration 34's +-- audit_events_secure_partition drops a partition's policies and clones the +-- parent's, which is the ledger's own mechanism for exactly this, and the one +-- the runtime partition creator already calls. Nothing else here knows how to +-- rewrite a policy whose name depends on the month. +DO $$ +DECLARE + part regclass; +BEGIN + FOR part IN + SELECT i.inhrelid::regclass + FROM pg_catalog.pg_inherits i + WHERE i.inhparent = 'public.audit_events'::regclass + ORDER BY i.inhrelid::regclass::text + LOOP + PERFORM public.audit_events_secure_partition(part); + END LOOP; +END; +$$; diff --git a/module/services/store/migrations/README.md b/module/services/store/migrations/README.md index e4cdf7206..7b545cbf2 100644 --- a/module/services/store/migrations/README.md +++ b/module/services/store/migrations/README.md @@ -26,6 +26,23 @@ rewrites the provenance. Never edit the baseline by hand: the provenance digest refuses it (`qualification/managed-database/stage.py`), and so does the reference gate. +## The two settings a policy reads + +A tenant-scoped policy reads `codefly.current_tenant_id`, and a user-scoped one +`codefly.current_user_id`. These are not names this repository chose: they are +the defaults of the platform's Postgres library +(codefly-dev/service-postgres, `libs/go/store.go`), which is what binds them for +a transaction opened through its scoped factory, and what every other store on +the platform reads back. `65_library_scope_settings` renamed the host's own +`app.current_org_id` / `app.current_user_id` onto them, over the 75 policies, 6 +audit partitions and 9 function bodies that read them. + +A consumer's own migration must use the same two names. A policy on any other +setting fails **quietly**: nothing binds it, `current_setting(…, true)` returns +NULL, the predicate is false, and the relation reads as empty for every caller +rather than raising. `../../../tools/rls-migration-gate.mjs` is what refuses +that, and it accepts only these two. + ## What CI proves The required **Base manifest integrity** context runs diff --git a/module/tools/rls-migration-gate.mjs b/module/tools/rls-migration-gate.mjs index 9978f4df0..2d709be56 100644 --- a/module/tools/rls-migration-gate.mjs +++ b/module/tools/rls-migration-gate.mjs @@ -45,9 +45,14 @@ const MODULE_ROOT = join(dirname(SCRIPT_PATH), ".."); const TENANT_COLUMNS = new Set(["org_id", "organization_id", "tenant_id"]); // A predicate is tenant-scoped iff it reads one of the request-scoped settings the -// api sets per transaction (org for tenant isolation, user for the symmetric -// user-scoped tables). Anything else (notably a bare `true`) is unconditional. -const SCOPING_SETTING = /current_setting\(\s*'app\.current_(?:org|user|tenant)_id'/; +// api sets per transaction (the tenant for tenant isolation, the user for the +// symmetric user-scoped tables). Anything else (notably a bare `true`) is +// unconditional. The two names are the platform Postgres library's own defaults +// — codefly-dev/service-postgres installs exactly these for a transaction opened +// through its scoped factory — so a policy on any other setting reads something +// nothing sets. The host carried names of its own (app.current_org_id, +// app.current_user_id) until migration 65 renamed them onto these. +const SCOPING_SETTING = /current_setting\(\s*'codefly\.current_(?:tenant|user)_id'/; const MUTATION_VERBS = ["UPDATE", "DELETE"]; @@ -149,7 +154,7 @@ function clauseExpr(rest, keyword) { } // Remove `--` line comments and `/* */` block comments without disturbing string -// or dollar-quoted bodies (a policy predicate contains 'app.current_org_id', a +// or dollar-quoted bodies (a policy predicate contains 'codefly.current_tenant_id', a // function body contains arbitrary text). function stripComments(sql) { let out = ""; @@ -479,7 +484,7 @@ export function analyzeSql(sql) { exactRolePredicate(expr) === `current_user = '${p.roles[0]}'` )) { errors.push( - `${name}: policy ${p.policy} has a ${clause} predicate that never references app.current_org_id/app.current_user_id — it may be accidentally unconditional`, + `${name}: policy ${p.policy} has a ${clause} predicate that never references codefly.current_tenant_id/codefly.current_user_id — it may be accidentally unconditional`, ); } } diff --git a/module/tools/rls-migration-gate.test.mjs b/module/tools/rls-migration-gate.test.mjs index fd2e3f170..c0a6c1146 100644 --- a/module/tools/rls-migration-gate.test.mjs +++ b/module/tools/rls-migration-gate.test.mjs @@ -15,8 +15,8 @@ ALTER TABLE t FORCE ROW LEVEL SECURITY; const forAllPolicy = ` CREATE POLICY t_tenant ON t - USING (org_id::text = current_setting('app.current_org_id', true)) - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); `; const appendOnlyTrigger = ` @@ -35,9 +35,9 @@ test("an append-only trigger with only SELECT/INSERT policies is unreachable on tenantSetup + ` CREATE POLICY t_sel ON t FOR SELECT - USING (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE POLICY t_ins ON t FOR INSERT - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); ` + appendOnlyTrigger; const errors = analyzeSql(sql); @@ -55,14 +55,14 @@ test("explicit per-verb policies covering all four verbs keep the trigger reacha tenantSetup + ` CREATE POLICY t_sel ON t FOR SELECT - USING (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE POLICY t_ins ON t FOR INSERT - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE POLICY t_upd ON t FOR UPDATE - USING (org_id::text = current_setting('app.current_org_id', true)) - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE POLICY t_del ON t FOR DELETE - USING (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)); ` + appendOnlyTrigger; assert.deepEqual(analyzeSql(sql), []); @@ -73,7 +73,7 @@ test("a state-machine trigger that returns NEW is not treated as append-only", ( tenantSetup + ` CREATE POLICY t_ins ON t FOR INSERT - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE FUNCTION enforce_state() RETURNS TRIGGER AS $$ BEGIN IF NEW.org_id IS NULL THEN RAISE EXCEPTION 'bad'; END IF; @@ -90,7 +90,7 @@ test("forced RLS is required, not just enabled", () => { CREATE TABLE t (org_id UUID); GRANT SELECT ON t TO app_tenant; ALTER TABLE t ENABLE ROW LEVEL SECURITY; - CREATE POLICY p ON t USING (org_id::text = current_setting('app.current_org_id', true)); + CREATE POLICY p ON t USING (org_id::text = current_setting('codefly.current_tenant_id', true)); `; const errors = analyzeSql(sql); assert.equal(errors.length, 1); @@ -118,8 +118,8 @@ test("a user-scoped predicate counts as tenant-scoped", () => { ALTER TABLE t ENABLE ROW LEVEL SECURITY; ALTER TABLE t FORCE ROW LEVEL SECURITY; CREATE POLICY p ON t - USING (user_id::text = current_setting('app.current_user_id', true)) - WITH CHECK (user_id::text = current_setting('app.current_user_id', true)); + USING (user_id::text = current_setting('codefly.current_user_id', true)) + WITH CHECK (user_id::text = current_setting('codefly.current_user_id', true)); `; assert.deepEqual(analyzeSql(sql), []); }); @@ -141,11 +141,11 @@ test("a nullable-org polymorphic FOR ALL policy passes", () => { CREATE POLICY t_poly ON t USING ( current_setting('app.bypass', true) = '1' - OR (org_id IS NOT NULL AND org_id::text = current_setting('app.current_org_id', true)) + OR (org_id IS NOT NULL AND org_id::text = current_setting('codefly.current_tenant_id', true)) ) WITH CHECK ( current_setting('app.bypass', true) = '1' - OR (org_id IS NOT NULL AND org_id::text = current_setting('app.current_org_id', true)) + OR (org_id IS NOT NULL AND org_id::text = current_setting('codefly.current_tenant_id', true)) ); `; assert.deepEqual(analyzeSql(sql), []); @@ -163,8 +163,8 @@ test("expands DO/FOREACH/format loops that apply one recipe to many tables", () EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t); EXECUTE format($f$ CREATE POLICY %I_tenant ON %I - USING (org_id::text = current_setting('app.current_org_id', true)) - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)) + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)) $f$, t, t); END LOOP; END $$; @@ -183,7 +183,7 @@ test("a DO loop that forgets FORCE is still caught after expansion", () => { EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); EXECUTE format($f$ CREATE POLICY %I_tenant ON %I - USING (org_id::text = current_setting('app.current_org_id', true)) + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) $f$, t, t); END LOOP; END $$; @@ -216,7 +216,7 @@ test("a JOIN ... USING (col) inside a predicate is not mistaken for the policy's WITH CHECK (EXISTS ( SELECT 1 FROM parent p JOIN grandparent g USING (gid) WHERE p.id = t.org_id - AND g.org_id::text = current_setting('app.current_org_id', true) + AND g.org_id::text = current_setting('codefly.current_tenant_id', true) )); `; assert.deepEqual(analyzeSql(sql), []); @@ -235,8 +235,8 @@ test("a restrictive-only policy does not admit an append-only-guarded verb", () tenantSetup + ` CREATE POLICY t_r ON t AS RESTRICTIVE - USING (org_id::text = current_setting('app.current_org_id', true)) - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); ` + appendOnlyTrigger; const errors = analyzeSql(sql); @@ -250,7 +250,7 @@ test("a DO block using plain EXECUTE literals to force RLS is recognized", () => DO $$ BEGIN EXECUTE 'ALTER TABLE t ENABLE ROW LEVEL SECURITY'; EXECUTE 'ALTER TABLE t FORCE ROW LEVEL SECURITY'; - EXECUTE 'CREATE POLICY p ON t USING (org_id::text = current_setting(''app.current_org_id'', true)) WITH CHECK (org_id::text = current_setting(''app.current_org_id'', true))'; + EXECUTE 'CREATE POLICY p ON t USING (org_id::text = current_setting(''codefly.current_tenant_id'', true)) WITH CHECK (org_id::text = current_setting(''codefly.current_tenant_id'', true))'; END $$; `; assert.deepEqual(analyzeSql(sql), []); @@ -262,7 +262,7 @@ test("a DO block whose EXECUTE literals forget FORCE is still caught", () => { GRANT SELECT ON t TO app_tenant; DO $$ BEGIN EXECUTE 'ALTER TABLE t ENABLE ROW LEVEL SECURITY'; - EXECUTE 'CREATE POLICY p ON t USING (org_id::text = current_setting(''app.current_org_id'', true))'; + EXECUTE 'CREATE POLICY p ON t USING (org_id::text = current_setting(''codefly.current_tenant_id'', true))'; END $$; `; const errors = analyzeSql(sql); @@ -275,8 +275,8 @@ test("doubled single quotes inside string literals do not break scanning", () => CREATE TABLE t (note TEXT DEFAULT 'it''s; ok)', org_id UUID); ALTER TABLE t ENABLE ROW LEVEL SECURITY; ALTER TABLE t FORCE ROW LEVEL SECURITY; CREATE POLICY p ON t - USING (note <> 'a'')b' AND org_id::text = current_setting('app.current_org_id', true)) - WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + USING (note <> 'a'')b' AND org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); `; assert.deepEqual(analyzeSql(sql), []); }); @@ -285,8 +285,8 @@ test("an append-only trigger on UPDATE OF a quoted column binds to the table, no const sql = tenantSetup + ` - CREATE POLICY s ON t FOR SELECT USING (org_id::text = current_setting('app.current_org_id', true)); - CREATE POLICY i ON t FOR INSERT WITH CHECK (org_id::text = current_setting('app.current_org_id', true)); + CREATE POLICY s ON t FOR SELECT USING (org_id::text = current_setting('codefly.current_tenant_id', true)); + CREATE POLICY i ON t FOR INSERT WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true)); CREATE FUNCTION reject() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'no'; END; $$ LANGUAGE plpgsql; CREATE TRIGGER t_ao BEFORE UPDATE OF "on" ON t FOR EACH ROW EXECUTE FUNCTION reject(); `; @@ -368,8 +368,8 @@ test("a partition declared in a migration owes its own row level security", () = ALTER TABLE t_2026_01 ENABLE ROW LEVEL SECURITY; ALTER TABLE t_2026_01 FORCE ROW LEVEL SECURITY; CREATE POLICY t_tenant ON t_2026_01 - USING (org_id::text = current_setting('app.current_org_id', true)) - WITH CHECK (org_id::text = current_setting('app.current_org_id', true));`; + USING (org_id::text = current_setting('codefly.current_tenant_id', true)) + WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true));`; assert.deepEqual(analyzeSql(partitionedTenant + secured), []); }); @@ -416,8 +416,8 @@ test("rlsGateErrors reads a real migration tree in version order", () => { writeFileSync( join(migrations, "2_rls.up.sql"), "ALTER TABLE t ENABLE ROW LEVEL SECURITY; ALTER TABLE t FORCE ROW LEVEL SECURITY;\n" + - "CREATE POLICY p ON t USING (org_id::text = current_setting('app.current_org_id', true)) " + - "WITH CHECK (org_id::text = current_setting('app.current_org_id', true));", + "CREATE POLICY p ON t USING (org_id::text = current_setting('codefly.current_tenant_id', true)) " + + "WITH CHECK (org_id::text = current_setting('codefly.current_tenant_id', true));", ); assert.deepEqual(rlsGateErrors(root), []); } finally { @@ -451,7 +451,7 @@ CREATE TABLE public.t (id uuid NOT NULL, org_id uuid NOT NULL); GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE public.t TO app_tenant; ALTER TABLE public.t ENABLE ROW LEVEL SECURITY; ALTER TABLE ONLY public.t FORCE ROW LEVEL SECURITY; -CREATE POLICY t_tenant ON public.t USING (((org_id)::text = current_setting('app.current_org_id'::text, true))) WITH CHECK (((org_id)::text = current_setting('app.current_org_id'::text, true))); +CREATE POLICY t_tenant ON public.t USING (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))) WITH CHECK (((org_id)::text = current_setting('codefly.current_tenant_id'::text, true))); CREATE POLICY app_job_worker_explicit_rows ON public.t TO app_job_worker USING ((CURRENT_USER = 'app_job_worker'::name)) WITH CHECK ((CURRENT_USER = 'app_job_worker'::name)); `; assert.deepEqual(analyzeSql(dumped), []); diff --git a/qualification/managed-database/run.py b/qualification/managed-database/run.py index b56e3a997..f8508a960 100644 --- a/qualification/managed-database/run.py +++ b/qualification/managed-database/run.py @@ -150,7 +150,7 @@ def check(name,body,want=None,error=None,user='postgres'): INSERT INTO webhook_deliveries(id,subscription_id,event_id,event_type,payload) SELECT id,id,id,'saas.auth.login','{}' FROM organizations; """) check('request_only_login','CREATE ROLE example_request LOGIN NOINHERIT NOSUPERUSER NOBYPASSRLS; GRANT app_tenant TO example_request;') - tenant="BEGIN; SET LOCAL ROLE app_tenant; SET LOCAL app.current_org_id='20000000-0000-0000-0000-000000000001'; SET LOCAL app.current_user_id='10000000-0000-0000-0000-000000000001'; " + tenant="BEGIN; SET LOCAL ROLE app_tenant; SET LOCAL codefly.current_tenant_id='20000000-0000-0000-0000-000000000001'; SET LOCAL codefly.current_user_id='10000000-0000-0000-0000-000000000001'; " check('tenant_own_org',tenant+'SELECT count(*) FROM organizations; ROLLBACK;','1') check('tenant_cross_org_denied',tenant+"SELECT count(*) FROM organizations WHERE slug='example-two'; ROLLBACK;",'0') check('tenant_own_user',tenant+'SELECT count(*) FROM users; ROLLBACK;','1') @@ -171,7 +171,7 @@ def check(name,body,want=None,error=None,user='postgres'): # while its current role is app_tenant. Only exact SET ROLE may do so. check('no_runtime_ddl',tenant+'CREATE TABLE public.denied(id int);',error='42501') check('accidental_tenant_grant_still_denied',"BEGIN; GRANT SELECT ON job_messages TO app_tenant; SET LOCAL ROLE app_tenant; SELECT count(*) FROM job_messages; ROLLBACK;",'0') - check('inherited_control_role_cannot_activate_policy',"BEGIN; GRANT app_control_plane TO app_tenant WITH INHERIT TRUE; SET LOCAL ROLE app_tenant; SET LOCAL app.current_org_id='20000000-0000-0000-0000-000000000001'; SET LOCAL app.current_user_id='10000000-0000-0000-0000-000000000001'; SELECT count(*) FROM users WHERE primary_email='two@example.com'; ROLLBACK;",'0') + check('inherited_control_role_cannot_activate_policy',"BEGIN; GRANT app_control_plane TO app_tenant WITH INHERIT TRUE; SET LOCAL ROLE app_tenant; SET LOCAL codefly.current_tenant_id='20000000-0000-0000-0000-000000000001'; SET LOCAL codefly.current_user_id='10000000-0000-0000-0000-000000000001'; SELECT count(*) FROM users WHERE primary_email='two@example.com'; ROLLBACK;",'0') for role in ROLES[1:]: login='example_'+role check('dedicated_'+role, 'CREATE ROLE '+login+' LOGIN NOINHERIT; GRANT '+role+' TO '+login+';') @@ -193,7 +193,7 @@ def check(name,body,want=None,error=None,user='postgres'): wrong=enqueue.replace('20000000-0000-0000-0000-000000000001','20000000-0000-0000-0000-000000000002') check('tenant_enqueue_wrong_org_denied',tenant+'SELECT * FROM '+wrong+';',error='42501') subject=enqueue.replace("'tenant','20000000-0000-0000-0000-000000000001',NULL", "'subject',NULL,'10000000-0000-0000-0000-000000000001'") - for shape,call,setting in [('tenant',enqueue,'app.current_org_id'),('subject',subject,'app.current_user_id')]: + for shape,call,setting in [('tenant',enqueue,'codefly.current_tenant_id'),('subject',subject,'codefly.current_user_id')]: check(shape+'_enqueue_valid_context',tenant+'SELECT inserted FROM '+call+'; ROLLBACK;','t',user='example_request') check(shape+'_enqueue_missing_context','BEGIN; SET LOCAL ROLE app_tenant; SELECT * FROM '+call+';',error='42501',user='example_request') check(shape+'_enqueue_empty_context',tenant+'SET LOCAL '+setting+"=''; SELECT * FROM "+call+';',error='42501',user='example_request') @@ -209,7 +209,7 @@ def check(name,body,want=None,error=None,user='postgres'): check('tenant_guarded_webhook_sync',tenant+'SELECT '+sync+'; SET LOCAL ROLE app_job_worker; SELECT count(*) FROM event_subscriptions; ROLLBACK;','1') check('tenant_guarded_webhook_remove',tenant+'SELECT '+sync+'; SELECT '+sync.replace("ARRAY['saas.auth.login']","ARRAY[]::text[]")+'; SET LOCAL ROLE app_job_worker; SELECT count(*) FROM event_subscriptions; ROLLBACK;','0') check('webhook_sync_missing_org_denied','BEGIN; SET LOCAL ROLE app_tenant; SELECT '+sync+';',error='42501',user='example_request') - check('webhook_sync_empty_org_denied',tenant+"SET LOCAL app.current_org_id=''; SELECT "+sync+';',error='42501',user='example_request') + check('webhook_sync_empty_org_denied',tenant+"SET LOCAL codefly.current_tenant_id=''; SELECT "+sync+';',error='42501',user='example_request') check('webhook_sync_cross_org_denied',tenant+'SELECT '+sync.replace('000000000001','000000000002')+';',error='42501') check('webhook_sync_wrong_endpoint_denied',tenant+"SELECT public.sync_webhook_event_subscriptions('20000000-0000-0000-0000-000000000001','20000000-0000-0000-0000-000000000002',ARRAY['saas.auth.login']);",error='42501') replay="public.replay_job_message((SELECT id FROM job_messages WHERE replay_of IS NULL),'example-replay',now(),decode(repeat('cc',32),'hex'))" @@ -289,7 +289,7 @@ def main(): for c,label in [(fresh,'principal'),(canonical,'superuser')]: qualify(c,label,tests) if args.package: - scope="BEGIN; SET LOCAL app.current_org_id='20000000-0000-0000-0000-000000000001'; SET LOCAL app.current_user_id='10000000-0000-0000-0000-000000000001'; " + scope="BEGIN; SET LOCAL codefly.current_tenant_id='20000000-0000-0000-0000-000000000001'; SET LOCAL codefly.current_user_id='10000000-0000-0000-0000-000000000001'; " assert sql(c,scope+'SELECT count(*) FROM organization_authorization_revisions; ROLLBACK;',user='example_reader').stdout.strip()=='1' assert sql(c,scope+"SELECT count(*) FROM organization_authorization_revisions WHERE org_id='20000000-0000-0000-0000-000000000002'; ROLLBACK;",user='example_reader').stdout.strip()=='0' assert '42501' in sql(c,scope+'UPDATE organization_authorization_revisions SET revision=revision+1;',user='example_reader',check=False).stderr From 063a5c2489531199e8033f65ca7fe243db74b343 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:44:53 -0500 Subject: [PATCH 2/2] fix(store): replace a reassigned function as its owner, not as the migrator Migration 65 replaced nine function bodies with CREATE OR REPLACE as the principal installing the ledger. Seven of them are not that principal's to replace: the baseline hands enqueue_job_message to app_job_worker and six others to app_control_plane, so the managed-database qualification refused the migration with "must be owner of function enqueue_job_message (42501)". Nothing caught this locally because a developer database is installed by a superuser, which owns everything and may replace anything. The principal a managed install gets is NOINHERIT NOSUPERUSER NOBYPASSRLS CREATEROLE owning the database: NOINHERIT is what makes the ownership check fail even though the role is a member, and CREATE OR REPLACE also wants CREATE on the schema, which an application role is deliberately not left holding. Each of the seven is now replaced as its owner, granted CREATE on the schema for that one statement and revoked again -- the idiom migration 64 established for exactly this, emitted by the same generator that produced the rest of the file, in both the up and the down half. No policy statement changed and the qualification was not relaxed. Replacing a function preserves its owner and its grants, so the catalog the migration produces is unchanged, which is why the fingerprint test still holds untouched; the schema ACL comes back byte-identical, with app_* roles holding USAGE on public and nothing more. The migrations README gains the section whose absence made the miss possible: how to replace a function the baseline reassigned, that a local superuser install cannot catch getting it wrong, and that the qualification which can needs only Docker and Go. Co-Authored-By: Claude Opus 5 --- .../65_library_scope_settings.down.sql | 78 +++++++++++++++-- .../65_library_scope_settings.up.sql | 85 +++++++++++++++++-- module/services/store/migrations/README.md | 37 ++++++++ 3 files changed, 188 insertions(+), 12 deletions(-) diff --git a/module/services/store/migrations/65_library_scope_settings.down.sql b/module/services/store/migrations/65_library_scope_settings.down.sql index e6aaa1eb2..86c5d3102 100644 --- a/module/services/store/migrations/65_library_scope_settings.down.sql +++ b/module/services/store/migrations/65_library_scope_settings.down.sql @@ -536,6 +536,13 @@ CREATE POLICY work_context_replay_tenant ON public.work_context_replay -- The function bodies that read the settings directly. +-- public.enqueue_job_message is owned by app_job_worker, not by the +-- principal that installs the ledger: only its owner may replace it, and +-- CREATE OR REPLACE needs CREATE on the schema, which an application role +-- is not left holding. Granted for this one statement and revoked again, +-- which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_job_worker; +SET LOCAL ROLE app_job_worker; CREATE OR REPLACE FUNCTION public.enqueue_job_message(p_direction text, p_scope_kind text, p_organization_id uuid, p_subject_id uuid, p_queue text, p_topic text, p_source text, p_idempotency_key text, p_ordering_key text, p_schema_version integer, p_payload bytea, p_content_type text, p_attributes jsonb, p_priority smallint, p_max_attempts integer, p_available_at timestamp with time zone, p_request_fingerprint bytea) RETURNS TABLE(job_id uuid, stored_fingerprint bytea, inserted boolean) LANGUAGE plpgsql @@ -650,7 +657,17 @@ BEGIN END IF; END $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_job_worker; + +-- public.identity_administered_organizations is owned by app_control_plane, +-- not by the principal that installs the ledger: only its owner may replace +-- it, and CREATE OR REPLACE needs CREATE on the schema, which an +-- application role is not left holding. Granted for this one statement and +-- revoked again, which is the idiom migration 64 established for exactly +-- this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.identity_administered_organizations(p_user_id uuid) RETURNS TABLE(administered_org_id uuid, eligible_administrators integer, other_active_members integer) LANGUAGE sql @@ -685,7 +702,16 @@ AS $function$ AND holder.status = 'active' ORDER BY held.org_id $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.lock_module_job_authority is owned by app_control_plane, not by +-- the principal that installs the ledger: only its owner may replace it, +-- and CREATE OR REPLACE needs CREATE on the schema, which an application +-- role is not left holding. Granted for this one statement and revoked +-- again, which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.lock_module_job_authority(stamp jsonb) RETURNS void LANGUAGE plpgsql @@ -710,7 +736,16 @@ BEGIN IF NOT FOUND THEN RAISE EXCEPTION 'revoked_authority' USING ERRCODE = 'check_violation'; END IF; END; $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.module_job_authority_current is owned by app_control_plane, not by +-- the principal that installs the ledger: only its owner may replace it, +-- and CREATE OR REPLACE needs CREATE on the schema, which an application +-- role is not left holding. Granted for this one statement and revoked +-- again, which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.module_job_authority_current(stamp jsonb) RETURNS boolean LANGUAGE sql @@ -733,7 +768,17 @@ AS $function$ AND m.user_id::text = NULLIF(current_setting('app.current_user_id', true), ''))))) ); $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.organization_eligible_administrators is owned by +-- app_control_plane, not by the principal that installs the ledger: only +-- its owner may replace it, and CREATE OR REPLACE needs CREATE on the +-- schema, which an application role is not left holding. Granted for this +-- one statement and revoked again, which is the idiom migration 64 +-- established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.organization_eligible_administrators(p_org_id uuid) RETURNS SETOF uuid LANGUAGE sql @@ -749,7 +794,17 @@ AS $function$ AND member.role IN ('owner', 'admin') AND u.status = 'active' $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.organization_member_primary_email is owned by app_control_plane, +-- not by the principal that installs the ledger: only its owner may replace +-- it, and CREATE OR REPLACE needs CREATE on the schema, which an +-- application role is not left holding. Granted for this one statement and +-- revoked again, which is the idiom migration 64 established for exactly +-- this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.organization_member_primary_email(p_user_id uuid) RETURNS text LANGUAGE sql @@ -765,7 +820,16 @@ AS $function$ AND u.status <> 'deleted' LIMIT 1 $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.publish_domain_event is owned by app_control_plane, not by the +-- principal that installs the ledger: only its owner may replace it, and +-- CREATE OR REPLACE needs CREATE on the schema, which an application role +-- is not left holding. Granted for this one statement and revoked again, +-- which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.publish_domain_event(p_id uuid, p_type text, p_source text, p_subject text, p_event_time timestamp with time zone, p_specversion text, p_datacontenttype text, p_dataschema text, p_data bytea, p_tenant_id uuid, p_boundary_id text, p_partition_key text, p_correlation_id text, p_causation_id text, p_actor_principal_id text, p_owner_principal_id text, p_traceparent text, p_schema_version integer, p_request_fingerprint bytea) RETURNS TABLE(event_id uuid, stored_fingerprint bytea, inserted boolean) LANGUAGE plpgsql @@ -854,6 +918,8 @@ BEGIN END IF; END $function$; +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; CREATE OR REPLACE FUNCTION public.record_membership_integrity_findings() RETURNS integer diff --git a/module/services/store/migrations/65_library_scope_settings.up.sql b/module/services/store/migrations/65_library_scope_settings.up.sql index 676f5f49d..62c6009ab 100644 --- a/module/services/store/migrations/65_library_scope_settings.up.sql +++ b/module/services/store/migrations/65_library_scope_settings.up.sql @@ -21,6 +21,13 @@ -- catalog's own rendering under an empty search_path -- which is how the -- baseline's text was produced, and what makes it schema-qualified -- with -- the two setting names substituted and nothing else touched. +-- +-- Seven of those nine functions the baseline reassigned to an application +-- role, so the installing principal no longer owns them and cannot replace +-- them. Each is replaced as its own owner, which holds CREATE on the schema +-- only for that statement -- migration 64's idiom, and the reason a +-- superuser install and a least-privileged one still converge on one +-- catalog. -- The policies. @@ -555,6 +562,13 @@ CREATE POLICY work_context_replay_tenant ON public.work_context_replay -- The function bodies that read the settings directly. +-- public.enqueue_job_message is owned by app_job_worker, not by the +-- principal that installs the ledger: only its owner may replace it, and +-- CREATE OR REPLACE needs CREATE on the schema, which an application role +-- is not left holding. Granted for this one statement and revoked again, +-- which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_job_worker; +SET LOCAL ROLE app_job_worker; CREATE OR REPLACE FUNCTION public.enqueue_job_message(p_direction text, p_scope_kind text, p_organization_id uuid, p_subject_id uuid, p_queue text, p_topic text, p_source text, p_idempotency_key text, p_ordering_key text, p_schema_version integer, p_payload bytea, p_content_type text, p_attributes jsonb, p_priority smallint, p_max_attempts integer, p_available_at timestamp with time zone, p_request_fingerprint bytea) RETURNS TABLE(job_id uuid, stored_fingerprint bytea, inserted boolean) LANGUAGE plpgsql @@ -669,7 +683,17 @@ BEGIN END IF; END $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_job_worker; + +-- public.identity_administered_organizations is owned by app_control_plane, +-- not by the principal that installs the ledger: only its owner may replace +-- it, and CREATE OR REPLACE needs CREATE on the schema, which an +-- application role is not left holding. Granted for this one statement and +-- revoked again, which is the idiom migration 64 established for exactly +-- this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.identity_administered_organizations(p_user_id uuid) RETURNS TABLE(administered_org_id uuid, eligible_administrators integer, other_active_members integer) LANGUAGE sql @@ -704,7 +728,16 @@ AS $function$ AND holder.status = 'active' ORDER BY held.org_id $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.lock_module_job_authority is owned by app_control_plane, not by +-- the principal that installs the ledger: only its owner may replace it, +-- and CREATE OR REPLACE needs CREATE on the schema, which an application +-- role is not left holding. Granted for this one statement and revoked +-- again, which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.lock_module_job_authority(stamp jsonb) RETURNS void LANGUAGE plpgsql @@ -729,7 +762,16 @@ BEGIN IF NOT FOUND THEN RAISE EXCEPTION 'revoked_authority' USING ERRCODE = 'check_violation'; END IF; END; $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.module_job_authority_current is owned by app_control_plane, not by +-- the principal that installs the ledger: only its owner may replace it, +-- and CREATE OR REPLACE needs CREATE on the schema, which an application +-- role is not left holding. Granted for this one statement and revoked +-- again, which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.module_job_authority_current(stamp jsonb) RETURNS boolean LANGUAGE sql @@ -752,7 +794,17 @@ AS $function$ AND m.user_id::text = NULLIF(current_setting('codefly.current_user_id', true), ''))))) ); $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.organization_eligible_administrators is owned by +-- app_control_plane, not by the principal that installs the ledger: only +-- its owner may replace it, and CREATE OR REPLACE needs CREATE on the +-- schema, which an application role is not left holding. Granted for this +-- one statement and revoked again, which is the idiom migration 64 +-- established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.organization_eligible_administrators(p_org_id uuid) RETURNS SETOF uuid LANGUAGE sql @@ -768,7 +820,17 @@ AS $function$ AND member.role IN ('owner', 'admin') AND u.status = 'active' $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.organization_member_primary_email is owned by app_control_plane, +-- not by the principal that installs the ledger: only its owner may replace +-- it, and CREATE OR REPLACE needs CREATE on the schema, which an +-- application role is not left holding. Granted for this one statement and +-- revoked again, which is the idiom migration 64 established for exactly +-- this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.organization_member_primary_email(p_user_id uuid) RETURNS text LANGUAGE sql @@ -784,7 +846,16 @@ AS $function$ AND u.status <> 'deleted' LIMIT 1 $function$; - +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +-- public.publish_domain_event is owned by app_control_plane, not by the +-- principal that installs the ledger: only its owner may replace it, and +-- CREATE OR REPLACE needs CREATE on the schema, which an application role +-- is not left holding. Granted for this one statement and revoked again, +-- which is the idiom migration 64 established for exactly this. +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; CREATE OR REPLACE FUNCTION public.publish_domain_event(p_id uuid, p_type text, p_source text, p_subject text, p_event_time timestamp with time zone, p_specversion text, p_datacontenttype text, p_dataschema text, p_data bytea, p_tenant_id uuid, p_boundary_id text, p_partition_key text, p_correlation_id text, p_causation_id text, p_actor_principal_id text, p_owner_principal_id text, p_traceparent text, p_schema_version integer, p_request_fingerprint bytea) RETURNS TABLE(event_id uuid, stored_fingerprint bytea, inserted boolean) LANGUAGE plpgsql @@ -873,6 +944,8 @@ BEGIN END IF; END $function$; +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; CREATE OR REPLACE FUNCTION public.record_membership_integrity_findings() RETURNS integer diff --git a/module/services/store/migrations/README.md b/module/services/store/migrations/README.md index 7b545cbf2..95279cf1f 100644 --- a/module/services/store/migrations/README.md +++ b/module/services/store/migrations/README.md @@ -26,6 +26,43 @@ rewrites the provenance. Never edit the baseline by hand: the provenance digest refuses it (`qualification/managed-database/stage.py`), and so does the reference gate. +### Replacing a function the baseline reassigned + +A migration runs as the principal that installs the ledger, and that principal +does **not** own every function in the schema: the baseline hands many of them +to an application role (`ALTER FUNCTION ... OWNER TO app_control_plane`, and +one to `app_job_worker`). `CREATE OR REPLACE FUNCTION` on one of those needs +to be the owner *and* to hold `CREATE` on the schema, which an application +role is deliberately not left holding. So replace it as its owner, granting +that privilege for the statement and taking it back: + +```sql +GRANT CREATE ON SCHEMA public TO app_control_plane; +SET LOCAL ROLE app_control_plane; +CREATE OR REPLACE FUNCTION public.example(...) ...; +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; +``` + +Migration 64 established the idiom and migration 65 generates it. Replacing +the function preserves its owner and its grants, so this changes nothing but +who executes the statement. + +**A local run will not catch getting this wrong.** A developer database is +installed by a superuser, which owns nothing and may replace everything, so +the migration applies cleanly and every test passes. The principal a managed +install actually gets is `NOSUPERUSER NOBYPASSRLS CREATEROLE` and `NOINHERIT`, +which refuses with `must be owner of function ...`. What proves it either way +is the managed-database qualification, which installs the same ledger as both +principals and requires one catalog from both: + +```sh +python3 qualification/managed-database/ci.py /tmp/managed-database-qualification.json +``` + +It needs only Docker and Go. Run it for any migration that replaces a +function, changes a grant, or touches ownership. + ## The two settings a policy reads A tenant-scoped policy reads `codefly.current_tenant_id`, and a user-scoped one