Uh oh!
There was an error while loading. Please reload this page.
fix(auth): resolve the id-shaped platform-admin question in one place - #11686
Conversation
`core/security/resolve-authz-context.ts` declares itself the single authority for authorization derivation and forbids every entry point from re-reading `sys_*_permission_set` itself. `plugin-auth/auth-manager.ts` did exactly that twice — inside the `customSession` callback, and in the predicate authorizing `/sso/register` and (through the impersonation oracle) `/admin/impersonate-user`. Both derivations are deleted. The authority gains `hasPlatformAdminStanding`, a projection of `resolveUserAuthzGrants` that reads the `PLATFORM_ADMIN` rung, and both call sites plus the session payload ask it. `auth-manager.ts` no longer names either grant table. Because the copies had drifted from the authority, the consolidation is also a tightening: an expired grant (ADR-0091) and a deactivated `admin_full_access` row (ADR-0049) stop authorizing either gate, and the set is resolved by id rather than by name over a page of the catalogue, so a large catalogue can no longer demote every platform admin at once. Fixes#10348Fixes#10949 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 29 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 454f81cf3dcb9d52dff89705d0fc31c3668670cd && git checkout 454f81cf3dcb9d52dff89705d0fc31c3668670cd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88b9d749a1eb76b53966b0081006d2c5bccb341d 725a6f4cead551e94a6b8f39a7ac304112b1b7e6 && git checkout -B drift-repro 88b9d749a1eb76b53966b0081006d2c5bccb341d && git merge --no-ff 725a6f4cead551e94a6b8f39a7ac304112b1b7e6
node scripts/docs-audit/affected-docs.mjs --json 88b9d749a1eb76b53966b0081006d2c5bccb341d
|
os-sam
commented
Aug 24, 2026
PM: docs-drift advisory dispositioned — no action. Plus one independently verified claim, and one question for the report.
The advisory lists 12 hand-written pages. Rather than re-read all 12, the check was aimed at the three things this diff could actually falsify:
⛔ The 7 release-owned pages were read-only and untouched. Verified independently, not read off the PR bodyWith a positive control on
Exactly as the description claims. "The file no longer knows how platform standing is stored" holds mechanically, and the control (non-zero on ⭐ One thing the description does not account for: Also still owed, and absent above: which input the consolidated derivation trusts. ⛔ Unchanged: this PR stays draft. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10348
Fixes#10949
⛔ Draft on purpose. This changes authorization derivation,
needs:contract-reviewis on #10348 and stays, and the review tier for that label is above this seat. Do not flip ready, do not arm auto-merge, do not queue.Executes option C as ruled by the maintainer on 2026-08-24 (#10348 comment 5393511449, 「四维分析一致的,接手你的建议。」): the two
plugin-authid-shaped platform-admin judges are deleted, and every caller askscore/security/resolve-authz-context.ts— the module whose own header states that each entry point must resolve authorization through it and never re-readsys_*_permission_setitself.What changed
packages/core/src/security/resolve-authz-context.tsgainshasPlatformAdminStanding(ql, userId, { nowMs? }), exported from@objectstack/core. It is a projection ofresolveUserAuthzGrants, not a new derivation: the answer is thePLATFORM_ADMINrung of the posture ladder, which derives from the unscopedadmin_full_accesscapability grant and nothing else.packages/plugins/plugin-auth/src/auth-manager.tsloses both derivations — thecustomSessionclosure and the body ofisPlatformAdminUserId. What remains at the latter name is this manager's engine binding over the authority: an empty-id guard, an engine lookup, and one call.Mechanically checkable, and stated in full so the arithmetic closes:
auth-manager.tsorigin/mainsys_user_permission_setsys_permission_setfind(adminSetThe six
find(that went are four engine reads (each judge readsys_user_permission_setthensys_permission_set) and twoArray.prototype.findcalls — one per judge, the in-memory name match over the fetched catalogue page that axis ② is about. Both of those were the initializer of a block-scopedconst adminSetdeclared and consumed entirely inside the function body being deleted, which is whyadminSetreaches 0: neither could have had a second consumer. The diff adds nofind(call to the file. The eight survivors readsys_environment,sys_member(twice),sys_two_factor,sys_sessionand two in-memory arrays — none of them platform standing.Three call sites land on the authority —
/sso/register's ADR-0024 before-hook, the/admin/impersonate-useroracle (both the caller and the protected-target question), and thecustomSessionpayload — so the session payload can no longer disagree with the gates about who a platform admin is.customSession's other derivation, the active-org role projection, is deliberately untouched: that is thesys_memberread still at line 3076, and pin 6 holds its verdicts constant.What the derivation trusts
hasPlatformAdminStanding(ql, userId, { nowMs? })takes an engine and a user id. Its options type is deliberately notResolveUserAuthzGrantsOptions: the caller-supplied seeds that type carries —seedEmail(which wins over thesys_userrow read) andseedPermissions— cannot be spelled at this call, and the function forwards onlynowMs. So the standing answer is a function of the stored rows keyed by that user id, and no part of a request can supply any part of its own verdict. TheseedEmailprecedence question is not reachable from this path, and the deleted judges did not consult an email either — the consolidation opens no new input channel.Why it is also a fix, and what it fixes
The deleted copies had drifted from the authority on the two axes #10949 records. The authority applies both checks before any derivation and resolves the set by id, so the divergence closes by absorption:
activeon the catalogue rowadmin_full_accessconfers nothingadmin_full_access⭐ #10949 closes on the pins, not on this paragraph — its triage instruction asked for exactly that. Each row above is pinned at both gates.
One behaviour widens, and it was ruled
The
customSessioncopy read without a system identity while the other read with one. The single authority reads as system, so on a strictly org-scoped deployment the session payload stops under-reporting platform admin. The maintainer accepted that in the same ruling: the fail-closed drift between the payload and the gates ends. Open-core composition is unaffected — the two reads reached identical rows there already.⛔ Nothing else widens. The
PLATFORM_ADMINrung derives from the unscoped capability grant alone, and the org boundary #10009/#10390 established is now pinned at both gates rather than assumed.The pins
packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts— 27 cases driving the real pipeline (Requestobjects throughAuthManager.handleRequest, real better-auth carrying the realadminand@better-auth/ssoplugins). Every refusal asserts status andcode(ADR-0112); every admission carries an in-test control proving the refusal is reachable on the same engine with the same body.TENANT_ADMIN-posture principal, an org-scoped grant and a standing-less member are refused at both gates;customSessionsuites are unchanged and green.These were written before the source edit and run on an otherwise-unmodified tree, with the expected signature recorded first. Nine cases failed, and exactly the nine predicted: the four axis-① refusals, the two axis-② admissions and the three payload-parity rows. The rest passed on the unmodified tree, which is what makes those nine a measurement rather than a harness that was always going to go green.
Deliberately not in scope
ensure-default-organization.tsasks the population question — which user is the platform admin — which a per-user predicate cannot express. Two independent measurements agree it is a different kind; it is untouched and its suite is green.packages/qa/dogfood/test/authz-conformance.matrix.tsenumerates every reader that turns a window-carrying row into authority, and never contained this instance. Deleting the readers makes the omission moot — that enumeration is now accurate for this instance — but it is still hand-maintained, so nothing here shows it could detect the next absent reader. Reported, not repaired: that is a separate call.packages/spec— zero ownership in this lane. Nothing here needed a spec edit.Verification
Union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path arguments, on a clean tree at725a6f4c; every exit code captured before any pipe. All 16 path-derived families plus the convention-triggered ones ran green, alongside@objectstack/plugin-auth(74 files / 1517 tests) and@objectstack/core(38 files / 936 tests).check:type-check-debt --re-measurere-measured 32 ledger entries over a fully built closure and reports none above its recorded number; no ledger entry was touched and--lowerwas never run.check:route-envelopeandcheck:dispatcher-error-vocabularywere run explicitly, each also with--self-test; the derivation named neither.Generated by Claude Code