feat(plugin-security): verified platform owner bypasses the Layer 0 org wall - #13065
Conversation
…rg wall The org_id tenant filter is no longer appended for a session whose account is the VERIFIED env-declared platform owner (OS_PLATFORM_OWNER_EMAIL under the #11343 verified-email predicate — the same comparison the platform-admin elevation gate makes, now shared via platform-owner-wall-bypass.ts). Fail-closed in every direction: env unset / email mismatch / unverified match all wall exactly as before. Every wall-bypassing computation emits the stable audit event platform_owner_wall_bypass (structured warn-level log). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9e7531189e2737b84a8da8c917fa6e1d54112d4b && git checkout 9e7531189e2737b84a8da8c917fa6e1d54112d4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5cf61be1ff9ac233c5d9b8bac71e06428438d218 5a7679b0ead1d0362383881d07fab37b30062aa6 && git checkout -B drift-repro 5cf61be1ff9ac233c5d9b8bac71e06428438d218 && git merge --no-ff 5a7679b0ead1d0362383881d07fab37b30062aa6
node scripts/docs-audit/affected-docs.mjs --json 5cf61be1ff9ac233c5d9b8bac71e06428438d218
|
…eep the ADR-0123 D2 refusal The dogfood gate measured the write-twin lift breaking the org-less tenant-scoped write refusal (500 where the 403 naming the missing active organization belongs): the dogfood harness declares the seeded admin as OS_PLATFORM_OWNER_EMAIL on walled boots, so the org-less admin session was the verified owner and the write slipped past Layer 0 into a deeper failure. Director correction on the card: the ruling is about the wall's read FILTER; the door is READ-only. The write twin (and the by-id pre-image read, which carries the write operation name) keeps today's behaviour for everyone including the owner, the audit event fires on read bypasses only, and the two affected pins are inverted accordingly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk
`main` moved under the open PR and the page's live numbers went stale: * +5 line rot in `plugin-security/src/security-plugin.ts` (the import block #13065 added at :66 pushed every anchored read down five lines), plus the `field.zod.ts` `readonly` citation `--fix` re-anchored mechanically. * Six decomposition-table counts re-measured against the merged tree: lines 1804->1810, in tests 1010->1012, in sources 794->798, identifier appearances 809->813, keys 308->310, comments/strings 356->358. The census population itself did NOT move: 109 elevation read sites in 20 packages across 45 files, before and after. The gate, its criterion and its self-test are untouched -- the page's numbers were made true, not the check made lenient. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw
Fixes #12974
The ruled design
Maintainer ruling (2026-08-29, live director session, recorded on the card), verbatim and untranslated:
When plugin-security arms the Layer 0 organization wall on a READ, the
org_idfilter is NOT appended for a session whose account is the verified platform owner — theOS_PLATFORM_OWNER_EMAILidentity under the existing #11343 verified-email predicate. Everyone else's behaviour is byte-identical to before. cloud#1676's route-side detour stays as-is; the metadata-driven operator screens are served by this door. The subject-level entitlement door the card originally proposed (its proposal A) remains explicitly NOT ruled today, and #12704's object-level exemption arm is untouched — neither is widened here.Correction round (director ruling after the Dogfood Regression Gate red): the first delivery lifted the write-side twin too, and the dogfood farm measured that breaking the ADR-0123 D2 contract — the harness declares the seeded admin as
OS_PLATFORM_OWNER_EMAILon walled boots, so the org-less admin's tenant-scoped POST slipped past Layer 0 and surfaced as 500 where the 403 naming the missing active organization belongs (no-active-organization-write-refusal.dogfood.test.ts, reproduced locally red at fc001d8 before the correction). The ruling is about the wall's read FILTER on operator screens; the door is now READ-only. WRITES keep today's behaviour for everyone INCLUDING the owner — an org-less write would mint exactly the NULL-organization rows the platform is eliminating — and the by-id write pre-image read (which carries the write operation name through the same computation) stays walled with it.The four hard pieces, each with its evidence
1. Predicate —
isVerifiedPlatformOwnerSession(security-plugin.ts): the declared owner comes fromresolvePlatformOwnerEmail()(@objectstack/types); the authoritative answer is the server-sidesys_userrow read by the authenticatedcontext.userId(system-context by-id read, memoized per request context — the same memo pattern as the RLS membership staging), judged by the canonical #11343 comparison plusisEmailVerifiedUserRow. The comparison is extracted toplatform-owner-wall-bypass.tsand the elevation gate (bootstrap-platform-admin.ts) now consumes the SAME function, so the two can never drift — the module doc names the gate as its twin, andwalled-owner-operator-stamp.ts(plugin-auth) as the mirrored third site. Never a client-supplied claim:context.userIdcomes from the authenticated session or API key, and the fast-negative readscontext.email, whichresolveAuthzContextresolves server-side (the better-auth session record or thesys_userread) — it can only narrow (a mismatch walls without row I/O; a match still requires the row).Strongest-existing-signal choice (investigated per dispatch): the #12751 operator-provisioning stamp leaves no separate machine-readable owner marker — its entire output IS
sys_user.email_verifiedat creation. So the strongest verified-owner signal available at wall time is exactly the row predicate the elevation gate keys on: declared-email match + the verified-email allow-list. That is what the door reads.2. Fail-closed, pinned both directions — suite
packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts(13 pins):env UNSET: nobody bypasses — walled exactly as today, and no sys_user row is read(asserts the by-id read never happens)email MISMATCH: walled (fast negative, no row I/O)email matches but the account is NOT verified: still walled(absentemail_verified— the legacy-row shape)session email matches but the sys_user row is GONE: walled (fail closed)VERIFIED match: the org_id filter is NOT appended(read)org-LESS verified owner under isolated: no fail-closed READ deny sentinel(the cloud#1676 empty-screen shape, with the walled control alongside; the WRITE sentinel stays — next pin)group posture: the verified owner crosses the union wall too(with the member union control alongside)absent session email: the sys_user row is the authoritative answeran authored Layer 1 business-RLS policy still binds the verified owner(Layer 0 gone, Layer 1 intact)the WRITE-side Layer 0 twin is KEPT for the owner: the org wall and the org-less deny sentinel both stand(inverted in the correction round — the org-less owner write resolves the exact deny sentinel the ADR-0123 D2 403 derives from, and the org-carrying owner write keeps the equality wall)a wall-bypassing READ emits the stable event name; a walled read and an owner WRITE do notRed then green, round 1 (reverse verification of the door itself): measured with the fix committed at HEAD and the two runtime files checked out at the branch base e4902d2 (mutation proven on disk: bypass marker count 0 in the mutated file, diff naming exactly the two runtime files; restore proven:
git diff HEADempty and the working-tree blob hash equal to the HEAD blob). Pre-fix tree: 7 red / 6 green — the reds exactly the door, only-Layer-0 and audit pins (the owner IS walled today, reproducing the card); the greens exactly the fail-closed and shared-predicate pins.Red then green, round 2 (the dogfood correction):
no-active-organization-write-refusal.dogfood.test.tsreproduced locally RED at fc001d8 (AssertionError: expected 500 to be 403 — byte-identical to CI run 33228420540), GREEN at 5a7679b (6/6). The read-scoped pins: 13/13 green; full package suite 88 files / 1595 tests green. The other walled-posture dogfood suites also ran green locally at 5a7679b (rls-multitenant, attachments-permission-matrix, automation-toggle-tenant-scope, org-create-default-team, the four federated suites, showcase-external-autoconnect — one apparent red in a hand-batched run was a fixture DDL collision from co-scheduling two suites sharing the external fixture, green when run as CI schedules it).3. Audit — every wall-bypassing READ computation (the only bypass kind) emits a structured warn-level log with the stable event name
platform_owner_wall_bypass(named after the cloud precedentcross_org_admin_read), carrying event, object, operation, userId, organizationId, tenancy posture, and the suppressed filter; pinned that an owner WRITE emits nothing (no bypass happens there to attest). Which floor and why: plugin-audit is not wired into plugin-security today (no dependency in either direction), and itsauditservice ingress is a CLOSED auth-session vocabulary (login/logout) feeding thesys_audit_log.actionenum, which carries its own every-declared-action-has-a-writer pins — extending the ledger vocabulary is a scoped change of its own, not a rider here. The ruling records the structured warn log as the acceptable floor for exactly this shape; the stable event name makes a later ledger upgrade a sink change, not a rename.4. Single value — the door reads exactly
resolvePlatformOwnerEmail(): one env-declared identity, trimmed; no lists, no patterns, no new config keys. Multi-operator access stays on the route-side path.Where the branch lives
At the single arming site (
computeLayeredRlsFilter,security-plugin.ts), aftercomputeTenantLayer0Filterreturns: READ operation AND wall armed (theisolatedequality, thegroupunion, or the fail-closed deny sentinel) AND verified-owner session — then the Layer 0 half of the split becomes null and the audit event fires.computeTenantLayer0Filteritself is untouched (this is its only production call site). The write-side twincomputeWriteTenantCheckFilterno longer rides the bypass: theisWriteguard keeps every write computation — including the by-id pre-image read, which carries the write operation name — on today's wall, which is what feeds the ADR-0123 D2 refusal its deny-sentinel verdict. The owner probe is not consulted at all while the wall contributes nothing or the operation is a write — env unset answers false before any I/O. ONLY the read-side Layer 0 is lifted: object/field permissions, business RLS and the writecheckpath are structurally out of this branch's reach.Gates
Round 1 ran the full dispatch-gates union at fc001d8 (all exits 0 except
node scripts/pm/check-half-states.mjs, exit 3 — its own printed verdict: PREREQUISITE NOT MET, no GitHub credential in this container, "no reading at all"; not a measurement, CI runs it with credentials). Round 2 re-ran at the final head 5a7679b everything the correction moved:Measurement notes: the package
typecheckprogram compilessrc/**/*excluding**/*.test.ts(verified with --listFiles: the runtime module IS in the program; the test file — like every existing test in this package — is exercised by vitest, and the package carries no test-typecheck ledger). Both full-repo lint runs completed, so no narrowed-scope claim is being made anywhere.Changeset
.changeset/platform-owner-wall-bypass.md— minor on@objectstack/plugin-security(runtime behaviour change of a published plugin; new capability, fail-closed default keeps every existing deployment byte-identical until the env var is declared and the account verifies; text updated in the correction round to state the READ-only scope and the standing write refusal).Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk
Generated by Claude Code