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..86c5d3102 --- /dev/null +++ b/module/services/store/migrations/65_library_scope_settings.down.sql @@ -0,0 +1,1114 @@ +-- 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. + +-- 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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +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..62c6009ab --- /dev/null +++ b/module/services/store/migrations/65_library_scope_settings.up.sql @@ -0,0 +1,1140 @@ +-- 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. +-- +-- 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. + +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. + +-- 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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +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 + 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$; +RESET ROLE; +REVOKE CREATE ON SCHEMA public FROM app_control_plane; + +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..95279cf1f 100644 --- a/module/services/store/migrations/README.md +++ b/module/services/store/migrations/README.md @@ -26,6 +26,60 @@ 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 +`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