Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions AUTHZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'
);
```
Expand All @@ -240,7 +240,7 @@ when the setting is missing. So:

| Setting | Effect on a per-tenant query |
|---|---|
| `app.current_org_id = '<uuid>'` | Rows where `org_id = uuid` are visible. |
| `codefly.current_tenant_id = '<uuid>'` | Rows where `org_id = uuid` are visible. |
| `app.bypass = '1'` | All rows visible (workers + platform admin). |
| Neither set | **Zero rows** — fail-closed. |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions CLAIM_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 16 additions & 14 deletions module/DATABASE_AUTHORITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion module/JOBS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions module/services/accounts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion module/services/accounts/AUTHZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion module/services/accounts/code/pkg/auth/pg/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion module/services/accounts/code/pkg/auth/pg/session_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions module/services/accounts/code/pkg/business/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions module/services/accounts/code/pkg/business/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading