Skip to content

feat(plugin-security): verified platform owner bypasses the Layer 0 org wall - #13065

Merged
os-elon merged 3 commits into
mainfrom
claude/issue-12974-owner-wall-bypass
Aug 29, 2026
Merged

feat(plugin-security): verified platform owner bypasses the Layer 0 org wall#13065
os-elon merged 3 commits into
mainfrom
claude/issue-12974-owner-wall-bypass

Conversation

@os-elon

@os-elon os-elon commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #12974

The ruled design

Maintainer ruling (2026-08-29, live director session, recorded on the card), verbatim and untranslated:

能不能简单点,对于超级管理员,配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤

When plugin-security arms the Layer 0 organization wall on a READ, the org_id filter is NOT appended for a session whose account is the verified platform owner — the OS_PLATFORM_OWNER_EMAIL identity 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_EMAIL on 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. PredicateisVerifiedPlatformOwnerSession (security-plugin.ts): the declared owner comes from resolvePlatformOwnerEmail() (@objectstack/types); the authoritative answer is the server-side sys_user row read by the authenticated context.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 plus isEmailVerifiedUserRow. The comparison is extracted to platform-owner-wall-bypass.ts and 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, and walled-owner-operator-stamp.ts (plugin-auth) as the mirrored third site. Never a client-supplied claim: context.userId comes from the authenticated session or API key, and the fast-negative reads context.email, which resolveAuthzContext resolves server-side (the better-auth session record or the sys_user read) — 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_verified at 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 (absent email_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 answer
  • an 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 not
  • two pure-predicate pins on the shared comparison (trim + case-insensitivity + fail-closed shapes)

Red 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 HEAD empty 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.ts reproduced 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 precedent cross_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 its audit service ingress is a CLOSED auth-session vocabulary (login/logout) feeding the sys_audit_log.action enum, 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), after computeTenantLayer0Filter returns: READ operation AND wall armed (the isolated equality, the group union, 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. computeTenantLayer0Filter itself is untouched (this is its only production call site). The write-side twin computeWriteTenantCheckFilter no longer rides the bypass: the isWrite guard 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 write check path 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:

Gate (at 5a7679b) Exit
pnpm --filter @objectstack/dogfood exec vitest run test/no-active-organization-write-refusal.dogfood.test.ts (6/6) 0
pnpm --filter @objectstack/plugin-security test (88 files / 1595 tests) 0
pnpm --filter @objectstack/plugin-security typecheck 0
pnpm lint (full repo, eslint --no-inline-config) 0
pnpm check:engine-double-contract 0
pnpm check:where-matcher 0
pnpm check:query-options-erasure 0
pnpm check:type-check-coverage 0
pnpm check:type-check-debt (workspace built) 0
pnpm check:cross-package-test-inputs 0
pnpm check:i18n 0
pnpm check:i18n-stale-fill 0
pnpm check:nul-bytes 0
pnpm check:changeset-gate-self-tests 0
pnpm check:pm-half-states 0
pnpm check:objectui-changeset 0
pnpm check:objectql-double-limit 0
pnpm check:test-source-alias 0
node scripts/check-changeset-no-major.mjs 0
node scripts/check-empty-changeset.mjs 0
node scripts/check-adr-0087-registration.mjs 0
node scripts/check-keyed-text-bounds.mjs 0

Measurement notes: the package typecheck program compiles src/**/* 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.mdminor 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

claude added 2 commits August 29, 2026 01:46
…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
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 8 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol))
  • content/docs/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 29 pages)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5cf61be1ff9ac233c5d9b8bac71e06428438d218packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9e7531189e2737b84a8da8c917fa6e1d54112d4b — the merge of head 5a7679b0ead1d0362383881d07fab37b30062aa6 into base 5cf61be1ff9ac233c5d9b8bac71e06428438d218, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5cf61be1ff9ac233c5d9b8bac71e06428438d218 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@os-elon
os-elon added this pull request to the merge queue Aug 29, 2026
Merged via the queue into main with commit db39dfc Aug 29, 2026
36 checks passed
@os-elon
os-elon deleted the claude/issue-12974-owner-wall-bypass branch August 29, 2026 04:06
os-elon pushed a commit that referenced this pull request Aug 29, 2026
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants