Skip to content

feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; platformAdmin audit service; legacy-grant deprecation pointer (L4) - #13514

Merged
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-11974-l4-plugin-security-walled-bootstrap
Aug 31, 2026
Merged

feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; platformAdmin audit service; legacy-grant deprecation pointer (L4)#13514
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-11974-l4-plugin-security-walled-bootstrap

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #11974

Part of #11663 (leg L4; design comment 5394453215 §6 row L4, maintainer acceptance 5404675670, bundle 1A/2B/3A/4A/5A/6A/7A — verbatim 「接受你的建议,继续」). #11973 (L3) remains open and is not addressed here; #11670 remains open.

What lands

Union of gates/tests re-run at the final commit d5eeda5 — all green (15 derived gate families incl. check:nul-bytes, plugin-security 90 test files / 1664 tests, package typecheck; downstream: verify harness posture suites 18/18, walled dogfood fixtures 18 passed / 3 env-skipped).

  1. Walled postures stop minting the grant row. bootstrapPlatformAdmin under group/isolated writes no sys_user_permission_set row, whatever accounts exist. Standing is config-derived at the one derivation site (resolve-authz-context.ts §6b-config, landed with L2/PR feat(core): anchor PLATFORM_ADMIN on a verified OS_PLATFORM_OWNER_EMAIL match, inside the one derivation site #13146 and fix(core,plugin-auth,plugin-security): every OS_PLATFORM_OWNER_EMAIL reader asks the ONE list-aware parser #13319) — each declared, verified account resolves PLATFORM_ADMIN at request time. single keeps first-user promotion and its grant row byte-for-byte (Choice 4A; over-denial pins kept).
  2. Read-only platformAdmin service (new platform-admin-service.ts, registered by SecurityPlugin): configuredEmails() (declared/refused/normalized list) + standing() (per entry: registered, verified, standing-holding user id). Frozen; no writable member (Choice 3A: no runtime mutation path). The walled bootstrap's per-boot standing log consumes the same implementation, so log and service cannot disagree.
  3. Deprecation log fires at this leg (pin [WIP] Fix error in step four of the action run #5). A detected legacy org-less human grant is pointed at the config path through core's reportLegacyPlatformAdminGrant — the SAME once-per-process latch the derivation-site reporter uses, so boot-time + request-time detection can never total two lines. Pinned: a seeded legacy grant produces exactly one line naming OS_PLATFORM_OWNER_EMAIL, the holder id and their address, across repeated bootstraps. Nothing is revoked (P5's honoured window).
  4. Explain agrees with enforcement (P8): new pin — a declared+verified admin with ZERO grant rows explains as PLATFORM_ADMIN through buildContextForUser (which delegates to the one resolver; core's own §6b-config pins cover the derivation side).
  5. Replay narrowed. shouldReplayBootstrapFor now fires only for sys_user insert/create under non-walled postures. The [security] 围墙提权只按未验证邮箱匹配 —— #11184 落地后仍存在一条更窄的同形路径:抢在 owner 之前用其邮箱注册即可被提权 #11343 update arm (email_verified/email) existed solely to re-attempt the walled elevation after the verifying write; that elevation is retired, and under walled postures no sys_user write can change the bootstrap's answer. Middleware-seam pin re-derived accordingly.
  6. The one-line local isEmailVerified alias is deleted (the predicate's canonical home is @objectstack/types, consumed via the shared standing implementation — the card's "promoted into core" premise was stale; nothing in packages/types or packages/core is touched).

Guard refusals: none deleted, none added

L3's last-admin-guard re-pricing is its own reviewed step and is untouched (as is all of packages/plugins/plugin-auth/**). The walled walled_owner_email_undeclared backstop refusal is kept with its exact reason string; walled_owner_not_registered / walled_owner_not_verified are not refusals removed but elevation outcomes whose decision moved to the derivation site — their distinctions survive as the standing report's per-entry registered/verified fields, and the fail-closed direction (unverified confers nothing) is enforced and pinned in core.

Behavior notes for the contract reviewer

  • Bootstrap return contract: under walled postures with a usable config the reason is now walled_config_derived (was walled_owner_not_registered/walled_owner_not_verified, or a grant write). already_have_admin is now a single-only outcome; under walled a pre-existing human grant row gets the deprecation pointer instead of a silent early exit. The only in-repo consumer reading the report (cli meta resync) reads seed/resync counters only — unaffected.
  • Seed-ownership handoff (claimSeedOwnership) no longer runs under walled postures — it was keyed on the promoted admin, and there is none. Walled/multi-org seed ownership is the enterprise organizations package's per-org bootstrap job (per auth-plugin.ts's posture split). single handoff unchanged.
  • Reader census for the stopped write (PM assumption 3, measured at 3800e42): plugin-auth's ensureDefaultOrganization is gated !postureEnforcesWall(...) and never runs under walled postures in this repo; last-admin-guard sees the same zero-grant-row population a fresh walled rig already has today before its owner registers (no new state); explain's provenance reads report nothing on empty tables; auto-org-admin-grant reads the permission-set rows, which this leg keeps minting (the org-less-row reap is L6, behind the census and auto-org-admin-grant resolves the organization_admin set id by name alone (limit 1, unscoped, process-cached), so walled org-admin grants can point at the organization-less row #11670's resolver scoping). Nothing revoked on existing rigs — the legacy read is honoured.
  • Known interim window (design H4, owned by L3 platform-admin re-anchor L3 (plugin-auth): re-point ensure-default-organization; re-price last-admin-guard as its own reviewed step #11973 — not addressed here): the enterprise organizations package (out of this repo) reuses ensureDefaultOrganization, which still finds "the platform admin" via grant rows and triggers on grant inserts. A FRESH walled rig running that package will not auto-create its default organization between this leg landing and L3's re-point. The design's §6 dependency table imposes no L3-before-L4 order (both depend on L2 only), and existing rigs keep their rows; flagged here so the review weighs it consciously.
  • The harness fixture item from the card was measured dead: walled harness rigs declare the dev admin as owner and the dev-admin seed already stamps email_verified: true, so config-derived standing works with zero fixture changes (packages/verify/src/harness.ts untouched).

Generated by Claude Code


Generated by Claude Code

…min grant row; platformAdmin audit service; legacy-grant deprecation pointer

Under walled postures bootstrapPlatformAdmin writes no sys_user_permission_set
row any more - standing is config-derived at the one derivation site
(resolve-authz-context 6b-config). single keeps first-user promotion and its
grant row byte-for-byte (Choice 4A). The walled branch now reports: a per-boot
standing log (one implementation with the new read-only platformAdmin service:
configuredEmails() + standing()), and a once-per-process deprecation pointer
for a detected legacy org-less grant, through the same latch the derivation-
site reporter uses, naming OS_PLATFORM_OWNER_EMAIL and the holder.

shouldReplayBootstrapFor narrows with the retired elevation: sys_user
insert/create under non-walled postures only - the #11343 update arm existed
solely to re-attempt the walled elevation after the verifying write, and under
walled postures no sys_user write can change the bootstrap's answer at all.

The local isEmailVerified alias is gone; the shared predicates
(isEmailVerifiedUserRow, normalizePlatformAdminEmail) are consumed directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 30 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/index.ts, packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ff3757697600591804e2135a1e58b7afaebe0a48.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/index.ts, packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 29 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • 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 ff3757697600591804e2135a1e58b7afaebe0a48packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea751a6939fcdda6df0f0c5fdb36b6dfe69a7e74 — the merge of head 033e01ba487fa898db77797010c75bf7a6bf0d5a into base ff3757697600591804e2135a1e58b7afaebe0a48, 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 ea751a6939fcdda6df0f0c5fdb36b6dfe69a7e74 && git checkout ea751a6939fcdda6df0f0c5fdb36b6dfe69a7e74
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff3757697600591804e2135a1e58b7afaebe0a48 033e01ba487fa898db77797010c75bf7a6bf0d5a && git checkout -B drift-repro ff3757697600591804e2135a1e58b7afaebe0a48 && git merge --no-ff 033e01ba487fa898db77797010c75bf7a6bf0d5a

node scripts/docs-audit/affected-docs.mjs --json ff3757697600591804e2135a1e58b7afaebe0a48

⚠️ 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 ff3757697600591804e2135a1e58b7afaebe0a48 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 30, 2026
…e bootstrap rewrite shifted security-plugin.ts line numbers

Pure line rot: the #11974 edit moved seven isSystem read sites (and one
NON_READ_ANCHORS citation target) by +19/+20 lines; every re-anchored line
is content-identical to its merge-base counterpart (measured pair by pair
before editing). check-system-context-census --fix refused mechanically
(#13490's false-refusal shape: it counts shifted NON_READ_ANCHORS
citations as read anchors), so the eight citations are re-anchored by hand
to proven-identical lines. Census verdict line is back byte-identical to
the merge base's: 109 sites, 145 anchors, 27 declared non-read.

Copy link
Copy Markdown
Collaborator Author

CI status: the census red is FIXED; the remaining red is not this PR's

domain:services PM seat (reviewer of record), session session_016ZC5rNQj3WEet5HAmmAkMs.

✅ Fixed — Lint & Repo Gates / check-system-context-census

d5eeda5 shifted security-plugin.ts by +19/+20 lines and the census page anchored the old coordinates. 033e01b re-anchors them. The eight displaced pairs were a constant shift — 1392→1412, 1414→1434, 1565→1585, 2491→2511, 3808→3827, 4295→4314, 4446→4465, 4524→4543 — i.e. pure line rot, not a population change. The gate refuses population changes rather than guessing, so its --fix path was safe by construction here.

⛔ Standing down on Test Core (3/6) — it is not this PR's, and here is the measurement

1 failed / 1124 passed, and the single failure is in a package this diff does not touch:

FAIL  packages/core/src/security/authz-store-unavailable.test.ts
  > CONTROL: the scanner finds transports at all, and finds THIS repo
Error: ENOENT: no such file or directory, stat
  '.../packages/services/service-datasource/tsup.config.bundled_45dgza009nw.mjs'
  ❯ walk  authz-store-unavailable.test.ts:198:9   →   if (statSync(full).isDirectory())

This PR's 10 files are entirely inside packages/plugins/plugin-security/src/ plus one changeset and the census page. It touches neither packages/core nor service-datasource.

Root cause, so this is not filed as an unexplained flake: the test's discoverTransports walker does readdirSync then statSync per entry, and tsup.config.bundled_<random>.mjs is tsup's transient bundled-config file — written at build start, deleted at build end. The same log shows the builds running concurrently with the tests and service-datasource:build finishing one second after the ENOENT:

00:48:44  core:test  FAIL … ENOENT … service-datasource/tsup.config.bundled_45dgza009nw.mjs
00:48:45  service-datasource:build:  ELIFECYCLE Command failed.

⇒ A TOCTOU race between the walker and any concurrent build. It fails a control assertion ("the scanner finds transports at all"), which is the instrument reporting itself broken, not a transport regression.

Filed as #13560 (deduped first — no open card carried this signature; the five nearest matches were all closed devx flakes). ⛔ No fix exists to port, so none was ported.

One re-run triggered — the single confirming re-run this lane's rules allow for a failure naming code the diff does not touch. ⚠️ If it reds again on the same signature, that is treated as real and re-diagnosed; "flake" is not a root cause and will not be used as one here.

Where this PR stands

Everything else is green, including Governed Surface Queue Guard, Check Changeset, Type Check (all four legs), both single-writer guards, and the other five Test Core shards.

⛔ The PR remains a draft carrying needs:contract-review and this seat will not flip it ready, enqueue it, or arm auto-merge — it measures claude-opus-5, below CONTRACT_REVIEW_TIER. Green CI does not change that; parking is about review, not CI.


Generated by Claude Code

Merged via the queue into main with commit b997272 Aug 31, 2026
56 of 58 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-11974-l4-plugin-security-walled-bootstrap branch August 31, 2026 01:36
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 tests tooling

Projects

None yet

3 participants