Skip to content

feat(security): bind the last-admin standing-key lists to the authz resolver's measured read surface (#8734) - #8801

Merged
qq9340100 merged 1 commit into
mainfrom
claude/issue-8734-last-admin-standing-keys-gate
Aug 15, 2026
Merged

feat(security): bind the last-admin standing-key lists to the authz resolver's measured read surface (#8734)#8801
qq9340100 merged 1 commit into
mainfrom
claude/issue-8734-last-admin-standing-keys-gate

Conversation

@qq9340100

@qq9340100qq9340100 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Fixes#8734

plugin-auth's last-administrator guard (ADR-0024 D5.2) decides whether a pending write can empty the administrator population by testing the payload against three standing-key lists. A payload touching none of them is skipped without any reads — so a column resolveAuthzContext starts reading that a list omits is a write class the guard silently stops judging, on the one path whose failure mode is an installation-wide administrator lockout with no in-product recovery.

Nothing bound the two together. The correspondence lived in a comment, and it had already gone false once: #6084 wrote — naming active explicitly — that everything a permission-set write touches other than name is invisible to "who is an administrator". True when written; #8613 made active a resolution-time predicate and the sentence became false. Nothing mechanical would have caught it, because the guard's own tests stay green precisely when the guard is never consulted.

Which shape, and why

The card sketched three. Measured, then chosen:

  • Shape 2 (a single-source declaration both sides import) cannot carry the property on its own here.resolveAuthzContext takes ql: any, tryFind returns whole rows, and every column read is an untyped property access. No shared constant can force the resolver to consume it — a new row.newColumn compiles and runs whatever the constant says. The dependency direction does exist (plugin-auth already imports isGrantActive / isRowActive from @objectstack/core), so shape 2 is reachable; it is just not sufficient.
  • Shape 3 (a correspondence test) pins today's values unless it derives one side. PM assumption 4 is the right test of it: a fix that only pins today's values has not addressed the card.
  • Shape 1 (a check: script extracting column literals) would have to inline the helpers to work.active is never named at the resolver's own call site — isRowActive(r) reads it — and neither are the ADR-0091 bounds (isGrantActive(row, now)). A static extractor reading the caller and not the callee misses sys_permission_set.active and sys_position.active are unenforced too — both Deactivate dialogs promise access stops, and it does not #8613 for the same reason the comment did. It would also need real dataflow analysis to learn that ps.name in a for over psRows is a read of sys_permission_set.name.

What landed is shape 2 made enforceable by runtime observation, in two links:

  1. @objectstack/core declares ADMIN_STANDING_SURFACE beside the resolver — every table the administrator-derivation path reads, each classified derives or reads-only with its reason, and for the deriving tables every column read. It is asserted equal to what the real resolveAuthzContext reads, observed by driving it over a recording engine that records every property access and every where key per table. The declaration is a measurement, not a wish.
  2. plugin-auth consumes that measurement and requires every column to have an answer: standing-bearing (in a list) or excluded with the reason it cannot empty the administrator population. There is no third state — the third state is exactly what active was between break-glass 不变量的第四条路径无守卫:删/改名 admin_full_access 那条 sys_permission_set 行,一次废掉所有 platform admin #6084 and sys_permission_set.active and sys_position.active are unenforced too — both Deactivate dialogs promise access stops, and it does not #8613.

Observation also fixes the discoverability half the card names ("whoever next edits the resolver happening to remember the guard exists in another package"): link 1 lives in packages/core and fails in the resolver author's own pnpm test, on source, with no build.

The gate can fail — demonstrated, four ways

Each leg was run from a committed state and restored; the direction was predicted before running.

LegMutationResult
Aremove active from PERMISSION_SET_STANDING_KEYS (the #8613 regression, inverted)plugin-auth RED: expected [ 'active' ] to deeply equal []
Badd a new predicate read ps.locked_out on the platform-admin pathcore RED: expected [ 'active', 'id', 'locked_out', …(5) ] to deeply equal [ 'active', 'id', 'name', …(4) ]
B2then declare locked_out in core (the natural next step)core green; plugin-auth RED: expected [ 'locked_out' ] to deeply equal []
Cresolver reads a new tablesys_admin_delegationcore RED on the table set; after classifying it derives, plugin-auth RED: expected [ 'sys_member', …(2) ] to include 'sys_admin_delegation'

Leg B also demonstrated the reverse direction on its way out: a column left declared after its read was removed goes red as a stale declaration.

Would this have caught #8613?

Yes, at the earlier link.#8613 added isRowActive(r) to the sys_permission_set read, which makes active observable — link 1 goes red on that PR, naming the column. Declaring it turns link 2 red, because active was in neither the list nor a ledger. Landing #8613 green would have required writing down, explicitly, that deactivating admin_full_access cannot empty the administrator population — which is false, and which is what the old comment asserted by accident. Leg A above is that scenario replayed.

Sibling lists: covered, all three

MEMBER_STANDING_KEYS, GRANT_STANDING_KEYS and PERMISSION_SET_STANDING_KEYS are all keyed into STANDING_KEYS_BY_TABLE and all three are checked. Keying by table is what also closes the table-level hole: a resolver deriving standing from a new table is invisible to any column-set comparison, since the table is absent from both sides (leg C).

Ruling 2: the guard is not narrowed, and cannot be by this gate

Every list keeps exactly the values it had; touchesAny is called with the same constants. The gate is one-directional by construction — it never asserts that a standing key is a column the resolver reads. That direction looks like the natural other half and is not: GRANT_STANDING_KEYS carries userId although the resolver reaches those rows through a where on user_id and never touches the camelCase spelling on the row. Enforcing it would pressure a break-glass guard to drop entries. This gate can only ever demand that the guard judges more.

Ruling 4: nothing asymmetric in the lists, one dormant finding next door

The lists are correct today — every column the resolver reads on the three deriving tables is either judged or has a stated reason it cannot matter, and no reason required inventing.

One thing surfaced that is not about the lists and was not touched here: in resolveUserAuthzGrants, isGrantActive is applied to sys_member rows only when building accessible_org_ids; the org-administration role projection that feeds positions is not window-filtered. Dormant — sys_member declares no valid_from / valid_until today — and it is a resolver question, not a guard one. Recorded separately as a finding in #8802, and captured in STANDING_KEY_EXCLUSIONS as the reason those bounds are not standing-bearing, with a note that the resolver leads and the list follows.

Verification

  • pnpm --filter @objectstack/core --filter @objectstack/plugin-auth test — core 838 passed (35 files), plugin-auth 1238 passed (54 files)
  • pnpm --filter @objectstack/plugin-auth typecheck — clean
  • Gate families re-derived with scripts/pm/dispatch-gates.mjs against the actual changed paths and run: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:kernel-hook-pairs, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check:nul-bytes, check:query-options-erasure, check:type-check-coverage, plus check-adr-0087-registration, check-changeset-no-major, check-empty-changeset — all pass.
  • check:type-check-debt (the --re-measure ratchet) — it refuses outright on an unbuilt worktree, so the full closure was built first (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and then it ran: 33 ledger entries re-measured, 1926 raw tsc errors, none above its recorded number. The one informational surplus it reports (@objectstack/lint, -1) is pre-existing and in a package this diff does not touch.

Verified at 5ae537580.


Generated by Claude Code

…ver's measured read surface (#8734)
The break-glass guard's three standing-key lists are a cache of which columns
resolveAuthzContext reads when deriving administrator standing. Nothing bound
them together — the correspondence was a comment, and it had already gone false
once (#6084's comment named 'active' as invisible; #8613 made it a
resolution-time predicate).
Two links replace the prose:
1. packages/core declares ADMIN_STANDING_SURFACE beside the resolver and asserts
it EQUALS what the real resolveAuthzContext reads, observed at runtime through
a recording engine (property accesses + where keys, per table). Observation
rather than static extraction because the reads that matter live in helpers:
isRowActive reads 'active', isGrantActive reads the ADR-0091 bounds.
2. plugin-auth exports its lists plus STANDING_KEYS_BY_TABLE /
STANDING_KEY_EXCLUSIONS, and a gate requires every measured column to be
either standing-bearing or excluded with a reason. No third state.
No guard behaviour changes: every list keeps its exact values, and the gate is
one-directional so it can only ever demand the guard judges more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 3:26am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/plugin-auth.

23 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/core)
  • content/docs/ai/knowledge-rag.mdx(via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/core)
  • content/docs/automation/webhooks.mdx(via @objectstack/core)
  • content/docs/concepts/north-star.mdx(via packages/core)
  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/core)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/kernel/services.mdx(via @objectstack/core)
  • content/docs/permissions/authentication.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/permissions/authorization.mdx(via packages/core)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/anatomy.mdx(via @objectstack/core)
  • content/docs/plugins/development.mdx(via @objectstack/core)
  • content/docs/plugins/index.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/core)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/core)

5 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/core, @objectstack/plugin-auth)
  • content/docs/releases/v12.mdx(via @objectstack/core)
  • content/docs/releases/v15.mdx(via @objectstack/core)
  • content/docs/releases/v17.mdx(via @objectstack/core)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 15, 2026
@qq9340100Claude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, domain:identity seat

The strongest card of the round, and it beat its own brief.

Ruling 1 — the property, not a particular mechanism: satisfied, and all three sketched shapes were rejected on measurement rather than taste. The decisive finding is about shape 1, the extraction script I thought was probably cheapest: active is never named at the resolver's own call siteisRowActive(r) reads it, and the ADR-0091 bounds hide the same way inside isGrantActive(row, now). A static extractor reading the caller and not the callee would have missed #8613 for exactly the same reason the comment did. That is the card's own failure mode reproduced inside the proposed fix, and catching it before building is worth more than the build.

Shape 2 was reachable but insufficient, also measured: resolveAuthzContext takes ql: any and every column read is an untyped property access, so no shared constant can force the resolver to consume it — row.newColumn compiles and runs whatever the constant says. What landed is shape 2 made enforceable by runtime observation: packages/core declares ADMIN_STANDING_SURFACE and asserts it equal to what the real resolver reads, observed by driving it over a recording engine that captures every property access and every where key per table. The declaration is a measurement, not a wish. I did not think of this and would have accepted something weaker.

Ruling 3 — the gate must be demonstrated to fail: four legs, each from a committed state, direction predicted first. Leg A replays #8613 inverted; leg B adds a new predicate read; leg B2 is the one that proves the design — declaring the new column in core turns core green and then turns plugin-auth red, which is the two-link property working rather than one check passing. Leg C covers a new table, the hole no column-set comparison can see because the table is absent from both sides. Leg B also showed the reverse: a declaration left behind after its read is removed goes red as stale.

"Would this have caught #8613?" — answered, not asserted. Yes, at the earlier link, and the reasoning is the right one: landing #8613 green would have required explicitly writing down that deactivating admin_full_access cannot empty the administrator population — which is false, and which the old comment asserted by accident. Forcing the false claim into the open is precisely what a prose comment could never do.

Ruling 2 — guard not narrowed: VERIFIED from the diff. Every list keeps its exact values; the only change to those lines is export. Better, the gate is one-directional by construction — it never asserts a standing key is a column the resolver reads, and the reason is concrete: GRANT_STANDING_KEYS carries userId while the resolver reaches those rows through a where on user_id and never touches the camelCase spelling. Enforcing that direction would pressure a break-glass guard to drop entries. This gate can only ever demand the guard judges more. That is the correct asymmetry for a safety guard and it was reasoned out rather than stumbled into.

Ruling 4 — lists correct today, and the one thing found next door was reported, not fixed.isGrantActive is applied to sys_member only when building accessible_org_ids; the org-administration role projection feeding positions is unfiltered. Dormant (no valid_from/valid_until on sys_member today), a resolver question rather than a guard one, filed as #8802 — verified present with finding + domain:identity. Recording it in STANDING_KEY_EXCLUSIONS as the stated reason those bounds are not standing-bearing, with a note that the resolver leads and the list follows, is exactly the right place for it.

It also solved the discoverability half I did not rule on. The card's real complaint was "whoever next edits the resolver happening to remember the guard exists in another package". Link 1 lives in packages/core and fails in the resolver author's own pnpm test, on source, with no build — so the person who breaks it is the person who sees it.

All three sibling lists covered, keyed by table, which is what closes the table-level hole rather than only the column-level one.

check:type-check-debt refused on an unbuilt worktree and was reported as such, then the full closure was built (70/70) and it ran to a real reading.

Flip held until every gate job concludes success on its own reading.


Generated by Claude Code

@qq9340100
qq9340100 marked this pull request as ready for review August 15, 2026 03:48
@qq9340100
qq9340100 added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit f8eb736Aug 15, 2026
29 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8734-last-admin-standing-keys-gate branch August 15, 2026 04:02
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nothing binds the last-admin-guard standing-key lists to what the authz resolver actually reads — the correspondence is prose only

2 participants

@qq9340100@claude