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
13 changes: 9 additions & 4 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,16 @@ implementation detail:
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
is derived at one site, and which anchors it reads depends on the
deployment's [tenancy posture](/docs/deployment/tenancy-modes). The
deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
**email-verified** `sys_user` row — is sufficient under every posture. An
**unscoped `admin_full_access` grant** row is sufficient only under
`single`; under the walled postures (`group` / `isolated`) it is no longer
an anchor — the set it names still grants what any held set grants, but it
confers no `PLATFORM_ADMIN` standing, so there standing comes from
configuration alone. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Expand Down
17 changes: 12 additions & 5 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@ Auto-seeded on boot by `plugin-security`
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:
standing is derived at a single site from **two anchors**. Which of them is
sufficient depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes):

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
Expand All @@ -177,10 +178,16 @@ sufficient:
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
This anchor is read under every posture.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.
row with `organization_id = NULL`, the original anchor. It confers standing
under the `single` posture only. Under the walled postures (`group` /
`isolated`) the row is no longer an anchor: the set it names still grants
what any held set grants, but it confers no `PLATFORM_ADMIN` standing, and
nothing is logged about it per request. If a walled deployment holding such a
row ends up with no usable configured administrator, the boot log says it has
zero platform administrators, names the account that holds the row, and points
at `OS_PLATFORM_OWNER_EMAIL` as the way to restore that account's standing.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
Expand Down
35 changes: 22 additions & 13 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -2403,11 +2403,11 @@
"title": "OS_PLATFORM_OWNER_EMAIL platform-admin anchor: only a VERIFIED stored-email match confers PLATFORM_ADMIN — unset, unverified match, non-match and malformed lists each confer nothing, fail closed",
"since": "v17",
"status": "active",
"revision": 1,
"revision": 2,
"priority": "P1",
"surface": "api",
"personas": [
"admin (seeded dev admin — grant-anchored via the unscoped admin_full_access row, the ADR-0068 D2 contrast; its address is seed-stamped verified per #11343)",
"admin (seeded dev admin — its address is seed-stamped verified per #11343. Under single, the posture this item boots, it is grant-anchored via the unscoped admin_full_access row that first-registrant promotion writes, the ADR-0068 D2 contrast; under a walled posture no such row is written and an existing one confers no platform-admin standing (#11663 L5), so it would stand only if OS_PLATFORM_OWNER_EMAIL named its address)",
"fresh member M (the config-anchor subject; a fresh sign-up's sys_user row is UNVERIFIED, which is exactly the unverified-match fixture)"
],
"fixtures": {
Expand All @@ -2417,20 +2417,20 @@
"a manage_platform_settings-floor probe route: GET /api/v1/datasources answers 401/403 BEFORE any service resolution (packages/services/service-datasource/src/admin-routes.ts; DATASOURCE_ADMIN_CAPABILITY), and admin_full_access carries that capability (packages/spec/src/identity/eval-user.zod.ts) — so the probe discriminates platform-admin standing cleanly"
],
"knownGaps": [
"the ENTITLED live leg needs a VERIFIED second account, and no stock HTTP path verifies one: email_verified is statically readonly at the user-context API (stripped — the write-path-guards class), a fresh sign-up reads unverified, and the seeded dev admin — whose address IS stamped verified at seed time (#11343, auth-plugin.ts) — also holds the unscoped admin_full_access grant, so its 200s can never attribute to the config anchor. Verify M's address via a verification-enabled boot with mail capture (identity-auth.email-verification-loop's fixture, itself a knownGap there) or a system-context stamp through the same isSystem doorway the seed itself uses; without either, clause 5's live leg scores blocked(fixture) and the entitled side rests on the unit pin"
"the ENTITLED live leg needs a VERIFIED second account, and no stock HTTP path verifies one: email_verified is statically readonly at the user-context API (stripped — the write-path-guards class), a fresh sign-up reads unverified, and the seeded dev admin — whose address IS stamped verified at seed time (#11343, auth-plugin.ts) — also holds the unscoped admin_full_access grant under single (the posture this item boots), so its 200s can never attribute to the config anchor. Verify M's address via a verification-enabled boot with mail capture (identity-auth.email-verification-loop's fixture, itself a knownGap there) or a system-context stamp through the same isSystem doorway the seed itself uses; without either, clause 5's live leg scores blocked(fixture) and the entitled side rests on the unit pin"
]
},
"steps": [
"boot showcase isolated with the variable UNSET; admin session; sign up fresh member M and record M's email; as M: GET /api/v1/datasources and GET /api/v1/auth/me/permissions (read systemPermissions) — the baseline refusals",
"boot showcase single (OS_TENANCY_POSTURE=single — also what a deployment with no tenancy configuration resolves to) with the variable UNSET — a walled posture (group / isolated) refuses to boot with it unset (plugin-auth init; out of scope here, see negative) — and keep that posture for every restart below; admin session; sign up fresh member M and record M's email; as M: GET /api/v1/datasources and GET /api/v1/auth/me/permissions (read systemPermissions) — the baseline refusals",
"restart with OS_PLATFORM_OWNER_EMAIL=<M's email> (M's row is unverified); as M repeat both probes",
"restart with OS_PLATFORM_OWNER_EMAIL=<an address matching no sys_user row>; repeat M's probes",
"restart with a malformed list, e.g. OS_PLATFORM_OWNER_EMAIL='<M's email>, not an address'; capture the [authz] refusal from the process log (it prints once, on the first derivation after the value is seen); as M repeat the probes — the WHOLE variable is refused, so even the well-formed entry confers nothing",
"entitled side (see knownGaps for the verified-member fixture): with M's row verified and the variable naming M, as M repeat the probes and read /api/v1/auth/me/permissions — expect the declared admin envelope; where the fixture is unavailable, run the unit pin and cite its output instead",
"with the variable unset again, drive one admin-standing derivation as the seeded admin (any authenticated request) and grep the process log for the once-per-process legacy-grant re-anchor pointer"
"the walled half of the legacy grant anchor: run the #11663 L5 posture-split unit pins named in clause 6 and cite their output. There is no live log line to grep — the once-per-process legacy-grant re-anchor pointer was retired with the walled anchor (#19136), and nothing replaces it on the request path; the only operator-facing line left is plugin-security's boot-time fail-closed error, which fires when a walled rig has no usable configured administrator"
],
"acceptance": [
{
"clause": "unset = ZERO config-derived administrators, and the ADR-0068 D2 unscoped-grant anchor stays the only door: M's datasource probe is refused server-side (401/403 per identity, never 200) and M's systemPermissions lacks manage_platform_settings, while the seeded admin's identical probe answers 200 (both sides of the gate)",
"clause": "unset = ZERO config-derived administrators, and under single the ADR-0068 D2 unscoped-grant anchor stays the only door: M's datasource probe is refused server-side (401/403 per identity, never 200) and M's systemPermissions lacks manage_platform_settings, while the seeded admin's identical probe answers 200 (both sides of the gate)",
"oracle": "api",
"verify": "M's GET /api/v1/datasources >=400 with the declared code; M's /auth/me/permissions systemPermissions array excludes manage_platform_settings; admin's same GET is 200",
"evidence": "both personas' traces"
Expand Down Expand Up @@ -2460,10 +2460,10 @@
"evidence": "test output (and, when the fixture exists, M's entitled traces)"
},
{
"clause": "additive, never subtractive — the legacy anchor is honoured and loudly re-pointed: with the variable unset, standing resting on the unscoped admin_full_access grant alone still resolves PLATFORM_ADMIN, and the once-per-process [authz] pointer names the OS_PLATFORM_OWNER_EMAIL config line that re-anchors it (reportLegacyPlatformAdminGrant, platform-admin.ts)",
"oracle": "log",
"verify": "seeded admin's request succeeds AND the process log carries the legacy-grant pointer naming the variable; it appears once — grep the whole log, not the tail",
"evidence": "the log excerpt + the admin trace"
"clause": "the legacy grant is an anchor under single ONLY, and silently so (#11663 L5): under single, standing resting on the unscoped admin_full_access grant alone still resolves PLATFORM_ADMIN (clauses 1 and 3 observe it live); under a walled posture — postureEnforcesWall, i.e. every posture except single, read from the REQUESTED posture — the same row confers no PLATFORM_ADMIN standing (the held set still grants its own capabilities; only the rung and the built-in platform_admin position go), while the config anchor keeps conferring on that same walled rig; and the request path logs nothing about the row on any posture",
"oracle": "test",
"verify": "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts — the '[#11663 L5] the legacy grant row is an anchor under `single`, and NOWHERE else', '[#11663 L5] the legacy anchor confers only on the rigs Choice 4A keeps it for' and '[#11663 L5] standing across postures' describes; packages/core/src/security/platform-admin.test.ts — '[#11663 L5] the legacy-grant deprecation pointer is retired' (both pointer symbols absent from the module and from the @objectstack/core security entry, with positive controls). The single half's own fate is #11979's (Choice 4B): if that lands, this clause is re-authored, not read as a regression",
"evidence": "test output"
}
],
"negative": [
Expand All @@ -2478,19 +2478,22 @@
"unverified match (nothing)",
"non-match (nothing)",
"comma-separated list — duplicates collapsed, trim+lowercase normalization (platform-admin.ts)",
"malformed entry (whole-variable refusal, loud once)"
"malformed entry (whole-variable refusal, loud once)",
"walled posture — the unscoped admin_full_access grant alone confers no PLATFORM_ADMIN standing (unit pin, clause 6)"
],
"traps": [
"wrong-persona",
"auth-state-leak"
],
"automated": {
"kind": "unit",
"ref": "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts (the acceptance criterion, all four fail-closed arms, the ⭐ stored-row-not-seed pin, the legacy-grant pointer loudness) + packages/core/src/security/platform-admin.test.ts (parse/refusal/memo) + packages/core/src/security/admin-standing-surface.test.ts. STILL MANUAL: the live-HTTP arms over a real boot (clauses 1-4 and 6) — the pins run the derivation in-process; this item's live legs prove the same verdicts through the mounted server"
"ref": "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts (the acceptance criterion, all four fail-closed arms, the ⭐ stored-row-not-seed pin, the #11663 L5 posture split of the legacy grant anchor) + packages/core/src/security/platform-admin.test.ts (parse/refusal/memo) + packages/core/src/security/admin-standing-surface.test.ts. STILL MANUAL: the live-HTTP arms over a real boot (clauses 1-4; clause 6 is test-only) — the pins run the derivation in-process; this item's live legs prove the same verdicts through the mounted server"
},
"source": [
"packages/core/src/security/resolve-authz-context.ts#hasPlatformAdminStanding (§6b-config — the config anchor inside the ONE derivation site; additive, never subtractive) + (hasPlatformAdminStanding, the id-shaped projection)",
"packages/core/src/security/platform-admin.ts#parsePlatformAdminEmails (parsePlatformAdminEmails whole-variable refusal, loud-once resolve, matchesConfiguredPlatformAdmin verified-only, reportLegacyPlatformAdminGrant)",
"packages/core/src/security/platform-admin.ts#parsePlatformAdminEmails (parsePlatformAdminEmails whole-variable refusal, resolvePlatformAdminEmails loud-once resolve, matchesConfiguredPlatformAdmin verified-only)",
"packages/core/src/security/resolve-authz-context.ts#legacyGrantAnchorRetired (#11663 L5 — the unscoped admin_full_access row derives PLATFORM_ADMIN only when the requested posture enforces no wall)",
"packages/spec/src/security/tenancy-posture.ts#postureEnforcesWall (walled = every posture except single)",
"packages/core/src/security/admin-standing-surface.ts (the env declared a non-table derivation input — no break-glass write can reach it)",
"packages/types/src/env.ts#PLATFORM_OWNER_EMAIL_ENV,200 (PLATFORM_OWNER_EMAIL_ENV / resolvePlatformOwnerEmail)",
"packages/plugins/plugin-auth/src/auth-plugin.ts (#11343 — the dev seed stamps its admin's address verified)",
Expand All @@ -2503,6 +2506,12 @@
"date": "2026-08-30",
"change": "new — the #11663 L2 deployment-config platform-admin anchor landed (#13146, 2026-08-29) with no checklist coverage. Env-conferred standing is a security gate and needs both sides asserted live: confer on a VERIFIED stored-row match only; nothing on unset / unverified / non-match / malformed (whole-variable fail-closed, loud once); plus the legacy-grant re-anchor pointer. Written as guard assertion per the shipped-guard posture; the entitled live leg is fixture-gapped (verified second account) and rests on the unit pin meanwhile",
"ref": "#13146"
},
{
"revision": 2,
"date": "2026-09-23",
"change": "re-anchored on the walled retirement of the legacy grant anchor (#19136, issue #19145): step 6 and clause 6 told the runner to grep the process log for the once-per-process re-anchor pointer and cited reportLegacyPlatformAdminGrant, both removed — clause 6 now states the posture split (an anchor under single only, silent everywhere) with the #11663 L5 unit pins as its oracle, and the source parenthetical names live symbols. The item's live legs move from isolated to single: an isolated boot with the variable unset refuses to start (plugin-auth init), and under a walled posture the seeded admin holds no grant-anchored standing, so clauses 1 and 3 and the seeded-admin both-sides control only hold under single. Persona and knownGap state the posture. The single half is described as it ships; its fate is #11979's",
"ref": "#19145"
}
]
},
Expand Down
Loading