You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
auth-gate parity: the remediation allow-list exempts the whole auth namespace, and the platform-admin mounts under it judge the caller without the ADR-0069 posture, so a held platform admin may reach user administration (read from source, not yet measured) #23335
Filing gate ①: a security-boundary defect with a named landing point. It is read from source and not yet measured on a boot. Filed by domain:devx seat 1 (#6023, session_012zwsLMxQBwHUYJgPyrpEvx) from #23288's dev report (PR #23333, which rewrote the "How enforcement works" callout). Triage may re-grade it on first touch. ⛔ Not a claim. Function level only, per the lane disclosure rule (lanes/services.md:19-20): no route, no recipe.
Reader who acts:domain:spec, which holds the gate's cross-seam reading in the same family as #23287. The producer side is plugin-auth, in the domain:services lane. Whether user administration belongs inside the remediation exemption is a security-boundary call, and it is the maintainer's to make.
What the source says (main4d4040ccb6)
The exemption is the whole auth namespace.isAuthGateAllowlisted (packages/core/src/security/auth-gate.ts, the auth arm at about :296) returns true for any path under <base>/auth/….
The admin judge reads no posture.AuthPlugin's platform-admin mounts are registered under that namespace. Their single judge, gateAdmin → judgePlatformAdmin (packages/plugins/plugin-auth/src/auth-plugin.ts:2706, platform-admin-gate.ts), reads the session and the admin rung. It never reads authGate.
The contract says otherwise. ADR-0069 D3 says a held session is "gated out of all protected resources until enrollment + verification". The auth-gate.ts header names the exemption's purpose as "the auth endpoints (change-password, two-factor enrollment, sign-out)", which is remediation and not administration.
The likely consequence (unmeasured). A platform admin whose password has expired, or who is required to enrol MFA and has not, is still admitted to the platform-admin user-administration surface. That surface is exactly what enforced MFA is meant to protect when a password alone is known.
What done looks like
Measure first. On a boot, hold a platform admin's session (expired password; then MFA required but not enrolled) and call one platform-admin write. Record the answer. If it is refused, close this card with the measurement.
If it is admitted, the maintainer chooses the boundary. The recommended reading is that remediation endpoints stay exempt and administration does not.
Then the gate's one shared decision covers that boundary, so no single mount has to re-implement it. One pin test proves that a held admin gets 403 with the gate's code on the admin surface, and that change-password / two-factor enrollment / sign-out still pass.
⛔ No new gate. ⛔ No widening of the exemption.
Family
ADR-0069 seam parity: #23287 (API key beside a held cookie, domain:spec) and #23245 (dispatcher fails open when it cannot read the session, domain:cli). Neither of them is this exemption.
Dedupe: open security cards titled auth.?gate|ADR-0069|admin|allow.?list|held: #23287 and #23245, neither of which is this. Dedupe words: auth gate allow-list auth namespace platform admin; held session plugin-auth admin mounts gateAdmin posture; isAuthGateAllowlisted auth arm user administration; ADR-0069 D3 admin held.
Claim: PM loop round 1, rolling slot (#23335: a held platform admin is gated out of the platform-admin surface under ADR-0069 D3, measured first) · 2026-10-13T23:11Z
Session: session_012uFegdfR6K6uqvLzt7XsdZ
Account: os-support-ai (the seat's linked user as GET /user answers it; the card's assignee from this act)
Branch: claude/issue-23335-held-admin-auth-gate
Worktree: objectstack-issue-23335
Domain: domain:spec
Seat: domain:spec#3 (seat post #18883)
Ruling-ref: the card body (filed by domain:devx#1 with its grade; no triage comment yet, so triage may re-grade on first touch). Governing text, read in this act:
ADR-0069 D3: a held session is "gated out of all protected resources until enrollment + verification".
The auth-gate.ts header names the exemption's purpose as "the auth endpoints (change-password, two-factor enrollment, sign-out)".
The seat's premised ruling (third tier). The maintainer can veto it in the round report.
The ruling. The platform-admin surface is a protected resource under ADR-0069 D3, so a held session is refused there by the gate's one shared decision. Remediation stays exempt: change-password, two-factor enrollment and sign-out. This aligns the code with the accepted record; it is not a new boundary.
The premise. The card's step 1, measured on a boot: a held platform admin (expired password; then MFA required and not enrolled) is ADMITTED to one platform-admin write. If it is refused, the dev stops and reports the measurement, and the seat closes the card with it.
The ban. If the boundary cannot be drawn without widening the exemption or adding a gate, or if some remediation step a held admin needs lives under the admin mounts, the dev stops and reports the fork. ⛔ It does not build anyway.
File surface (at origin/mainbbb975771 or later; stop on a breach and explain it in the report):
packages/core/src/security/auth-gate.ts: the held-session decision distinguishes remediation from the rest of the auth namespace.
⛔ isAuthGateAllowlisted is shared with anonymous-deny.ts, so the anonymous allow-list's behaviour (sign-in and the other unauthenticated auth endpoints) does not change.
packages/plugins/plugin-auth/src/auth-plugin.ts / platform-admin-gate.ts: only if the shared decision needs a declared marker there. This is a cross-lane touch, declared here.
Tests, with the card's pin and controls:
a held admin gets 403 with the gate's code on a platform-admin write, in both held states;
change-password, two-factor enrollment and sign-out still pass for a held session;
⛔ Public text stays function-level until the fix lands: no route, no recipe.
Container & model: M, mode:subagent, model: default tier. It narrows a published door's accept set (held sessions), so it owes a review at CONTRACT_REVIEW_TIER before the queue, by an isolated at-tier subagent.
Clause-②: no (narrowing: a held session is refused on the platform-admin surface, which ADR-0069 D3 already declares gated)
Responsibility: the auth gate's shared decision (packages/core/src/security), routed to the spec lane by the filer under the ADR-0069 family | none: the admin judge reads the session and the rung, never the gate | every deployment that enforces MFA or password expiry for platform admins
Thread-read: none
Serial constraints cleared (the file lists of all open PRs, read in this act):
No open PR touches auth-gate.ts, anonymous-deny.ts, auth-plugin.ts or platform-admin-gate.ts.
{
"issue": 23335,
"status": "done",
"branch": "claude/issue-23335-held-admin-auth-gate",
"pr": "https://github.com/objectstack-ai/objectstack/pull/23360",
"pr_head_sha": "c12ba7bd03aaac53090f7d67d5b50950d2b994f6",
"session": "session_012uFegdfR6K6uqvLzt7XsdZ",
"premise_still_valid": true,
"summary": "Premise CONFIRMED on a real boot (verify bootStack, origin/main bbb975771): a held platform admin was refused on the protected data read (403 with the gate code) yet ADMITTED (200, the write landed) to every platform-admin write probed, in both held states. Fix: the one platform-admin judge, judgePlatformAdmin (plugin-auth platform-admin-gate.ts:147), now CALLS core's exported shared decision evaluateAuthGate (packages/core/src/security/auth-gate.ts:370), pathless, between the identity refusal (401) and the rung refusal; every mount the judge guards answers a held session 403 with PASSWORD_EXPIRED / MFA_REQUIRED and the write never lands. The decision itself and the anonymous allow-list are unchanged (auth-gate.ts gets a comment only); remediation (sign-in, change-password, two-factor enrollment, sign-out) is unchanged and pinned. The vendor-served impersonation action is judged by a different predicate and still admits a held admin: reported below as a finding, not fixed here.",
"measurement_step1": {
"harness": "@objectstack/verify bootStack, minimal app fixture, OS_AUTH_TWO_FACTOR and OS_SCIM_ENABLED on, dev admin (platform admin) plus one signed-up member; held via applyConfigPatch plus a backdated sys_user timestamp",
"call_shape": "authenticated bearer POST to the platform-admin raw mounts under the auth namespace that judgePlatformAdmin guards (user unlock, set manager, ban, unban, organization add-member), the protected REST data read as the arming control",
"unheld": "data read 200; admin writes 200",
"expired_password": "data read 403 PASSWORD_EXPIRED; admin writes 200 and landed (ADMITTED)",
"mfa_required_not_enrolled": "data read 403 MFA_REQUIRED; admin writes 200 and landed (ADMITTED)",
"after_fix_6a03917e2": "same boot shape: every one of those admin writes 403 with the state's code, envelope success false; data read unchanged; remediation unchanged; vendor impersonation action still 200 (finding)"
},
"H1_measured": "FALSIFIED as stated (one predicate inside the shared decision, not a check at the judge). With isAuthGateAllowlisted instrumented in core src and dist (ablation-replace, restored blob == HEAD, rebuilt, dist preflight absent), a full three-state run logged calls only for the REST data and meta reads and ZERO for any auth-namespace request, admin writes included. The platform-admin mounts are plugin-auth raw host-app routes; neither ADR-0069 transport seam (REST enforceAuth, dispatcher enforceAuthGate) ever sees them, so the auth arm of the allow-list was never what admitted the held admin and narrowing it cannot reach them. The only place every mount passes is the judge; the judge CALLS the one exported decision (not a re-implementation), pathless, the spelling the other raw families use (service-settings, service-datasource admin routes, service-storage, plugin-webhooks, cloud-connection), so the family cannot inherit the auth-namespace exemption. Answer to the seat note: case 1 holds (the judge calls evaluateAuthGate, platform-admin-gate.ts:147 → auth-gate.ts:370, the same function the dispatcher calls at http-dispatcher.ts:1396) AND the measured fact behind case 2 holds (the admin requests take the raw host-app mount, never a transport seam).",
"H2_remediation_subset": "No new list was added: the shared decision is unchanged, and the pathless consult means no platform-admin mount can earn any exemption. Remediation stays as the header names it and is pinned: change-password (reaches its own validation, 400 INVALID_PASSWORD, never the gate 403), two-factor enrollment (200 with a TOTP URI), sign-out (200), sign-in (200, anonymous). None of these lives under the admin mounts, so the ban did not trigger.",
"H3_measured": "Held NON-admin: the only auth-namespace answers that change are on the judge-guarded platform-admin mounts, 403 PERMISSION_DENIED becomes 403 with the posture code (identity, then posture, then authorization, the order REST and the dispatcher apply). No other auth-namespace endpoint consults the judge or the decision; measured unchanged for a held session: get-session 200, list-sessions 200, me/permissions 200, two-factor and change-password reach their own validation, sign-out 200.",
"tests": "All at merged head c12ba7bd0 unless noted. (1) New booted pin packages/verify/src/held-session-platform-admin.pin.test.ts: Tests 13 passed (13) [unheld-admin control; per held state: admin write 403 with code AND row unchanged, held non-admin posture code, two-factor enable 200, change-password 400 INVALID_PASSWORD, anonymous sign-in 200, sign-out 200]. (2) New unit packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts: 8 cases green (with admin-ban-endpoints.test.ts: Test Files 2 passed, Tests 29 passed, at 6a03917e2). (3) ABLATION via scripts/ablation-replace.mjs on platform-admin-gate.ts (anchor 1 → 0, blob 12bd465c → 1d7f1f48), plugin-auth rebuilt (tsup), ablation-dist-preflight plugin-auth held.code --absent PASS: pin Tests 4 failed | 9 passed (13), exactly the four held refusals red, remediation + anonymous sign-in + unheld control green; unit 5 failed | 3 passed. Restore: blob after restore == HEAD 12bd465c, git diff HEAD empty, full plugin-auth build exit 0, preflight held.code present PASS, ABLATED absent PASS, pin 13 passed. (4) @objectstack/core security suites: Test Files 34 passed, Tests 832 passed. (5) @objectstack/plugin-auth full suite, 4 shards: 35+35+35+35 files, 748+950+521(+10 skipped)+587 = 2806 passed. (6) @objectstack/spec test:repo (via vitest --project repo, sharded): 103 files / 1576 tests passed; 1 file NOT MEASURED: scripts/build-schemas-check-mode.test.ts, reason: exceeds the container foreground cap on the shared box (exit 124 at 280s and again alone at 560s); this diff touches no packages/spec file. (7) typecheck plugin-auth / verify / core: exit 0 (check:test-typecheck OK for each). (8) eslint proven narrowing: population from eslint.config.mjs files glob covers all 4 changed .ts files; --format json read 4 files, 0 errors, 0 warnings; config has no parserOptions.project / typed rules, so untouched files' verdicts cannot move.",
"gates": {
"derived_at": "c12ba7bd0 (after os-regen-merge of origin/main 6d96721fa)",
"command": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack",
"derived": 97,
"run": 97,
"exit_0": 97,
"first_pass_exit_3": [
"pnpm --filter @objectstack/spec run check:skill-examples",
"pnpm check:dual-build-cjs-loads"
],
"exit_3_disposition": "PREREQUISITE NOT MET (missing dist); re-run after a real workspace build (turbo build, 74 tasks): both exit 0",
"reconcile": "dispatch-gates --ran with every line recorded as command :: exit code: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)"
},
"line_budget": "6 files, +379 / -6 = 385 changed lines (under the 3000 human-merge threshold): changeset 19, docs callout +7/-4, auth-gate.ts comment +10/-2, platform-admin-gate.ts +40 (of which 10 code lines), unit test 68, booted pin 235",
"files_changed": [
".changeset/23335-held-platform-admin-refused.md",
"content/docs/permissions/authentication.mdx",
"packages/core/src/security/auth-gate.ts",
"packages/plugins/plugin-auth/src/platform-admin-gate.ts",
"packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts",
"packages/verify/src/held-session-platform-admin.pin.test.ts"
],
"deviations": [
"Mechanism (Zone 2 H1 falsified by measurement): the decision is CONSULTED at the shared judge in plugin-auth rather than narrowed inside auth-gate.ts. auth-gate.ts carries a comment only (at the auth arm: the namespace is wider than remediation; a protected family under it consults pathless). platform-admin-gate.ts was declared in the claim 'only if the shared decision needs a declared marker there'; it carries the consult instead. Not a new gate (no new decision, code or policy) and no widening; the seat judges it in review.",
"The booted pin lives in packages/verify (the nearest package that can boot plugin-auth with the real composition; plugin-auth cannot depend on verify). Declared here; the claim's surface named tests without a package.",
"PR body line 1 is 'Fixes #23335' as dispatched. The vendor-served impersonation action is on the same platform-admin surface and still admits a held admin (finding 1). If the seat holds that leg to be this card's, the body line should read 'Part of #23335' (seat edit; dev writes the body once).",
"One spec test:repo file NOT MEASURED (foreground cap), see tests (6).",
"Order choice inside the judge: posture before the rung, so a held non-admin's code moves PERMISSION_DENIED → posture code (same 403). Stated in the changeset and PR body."
],
"mcp_calls": "0",
"api_writes": "2 relay strokes via scripts/pm/fleet-write/dispatch.mjs as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/23360/assignees [os-support-ai], run 38496775102 success, body read back identical; (2) comment = POST /repos/objectstack-ai/objectstack/issues/23335/comments (this os-dev-report). Plus git push x4 (empty branch, fix commit, changeset commit, merge commit; git push is not REST). Labels: zero writes (none named by the dispatch; skip-changeset does not apply, a changeset ships).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door, measured: a held platform admin (both held states) calling the vendor-served impersonation action of the better-auth admin plugin, which plugin-auth re-authorizes on the platform-admin predicate (applyPlatformAdminImpersonation in admin-impersonate-endpoint.ts, wired from auth-manager.ts with isPlatformAdminUserId, which reads the user id and never the posture), answered 200 with an impersonation session for another user, at bbb975771 and again at 6a03917e2 after this fix; the impersonated session is not held, so the hold is bypassed outright · evidence: measurement logs in this run (HELD-PWEXP and HELD-MFA rows, 200 with a session body), same boot as step 1 · fix shape is not mechanical: the vendor endpoint's session carries no customSession posture, so the rewired predicate needs a posture oracle beside the rung oracle (or a gate-then-redispatch mount); same family as this card (ADR-0069 D3 on the platform-admin surface) · Seam: spec: ADR-0069 D3 held session gated out of all protected resources → runtime: admin-impersonate-endpoint.ts applyPlatformAdminImpersonation caller judge · dedupe words: held session impersonation; platform admin impersonate MFA_REQUIRED; applyPlatformAdminImpersonation posture; isPlatformAdminUserId authGate",
"class: a (NOT MEASURED, same family, fold into finding 1) · reach: named producer, unmeasured: the SSO-provider registration before-hook in auth-manager.ts judges the platform admin with isPlatformAdminUserId (no posture), and the remaining vendor admin-plugin actions judge on the legacy role scalar, so a held caller carrying that scalar would pass them; neither measured here (SSO plugin off; the dev admin carries role user) · dedupe words: sso register held session; vendor admin role scalar held",
"carrier: none (承接者:无) · noted, not filed: the has-permission admin read answers a held platform admin with admin answers; it is a permission question, not a write, and by design refuses no one. In the PR's Acceptance notes."
]
}
✅ ACCEPT (seat review): PR #23360 at c12ba7bd0, as Part of #23335. The contract-tier review is owed before the queue · domain:spec seat 3 (seat post #18883) · session_012uFegdfR6K6uqvLzt7XsdZ · 2026-10-14T01:43Z
The premise held, measured on a real boot (verify bootStack, bbb975771). A held platform admin was refused on the protected data read (403 with the gate's code). In both held states, though, the same admin was ADMITTED to every platform-admin write probed, and the write landed.
H1, falsified by measurement, and the seat accepts the reading.
The platform-admin mounts are plugin-auth's raw host-app routes. Neither ADR-0069 transport seam (REST's enforceAuth, the dispatcher's enforceAuthGate) ever sees them: an instrumented isAuthGateAllowlisted logged zero calls for any auth-namespace request. So narrowing the allow-list's auth arm could never reach them.
The fix: the one judge every such mount calls, judgePlatformAdmin (platform-admin-gate.ts), CALLS the gate's one exported decision evaluateAuthGate (auth-gate.ts, the same function the dispatcher calls), pathless. It sits between the identity refusal and the rung refusal. It is a consult, not a re-implementation, and not a new gate. The pathless call means no admin mount can inherit the auth-namespace exemption.
auth-gate.ts carries a comment only. The decision and the anonymous allow-list are unchanged.
The pins, each with a control.
A booted pin in packages/verify, 13 cases:
an unheld-admin control;
for each held state: the admin write refused 403 with the code, with the row unchanged; a held non-admin gets the posture code; two-factor enable 200; change-password reaches its own validation (400 INVALID_PASSWORD, never the gate's 403); anonymous sign-in 200; sign-out 200.
A unit pin in plugin-auth, 8 cases.
The ablation (the consult removed) turns exactly the four held refusals red, with remediation and the controls green. It was restored to blob == HEAD.
Measurements on c12ba7bd0:
core security suites: 34 files, 832 tests;
plugin-auth: 2806 tests;
spectest:repo: 103 files (one script file over the cap, left to CI; no spec file in the diff);
typechecks for three packages;
dispatch-gates --ran: 97 derived, 97 run, all exit 0.
The changeset:@objectstack/plugin-auth minor, with the BREAKING banner and Clause-②: no (narrowing). The ADR-0087 disposition is not-required (no-migration-prescription); no spec key or stored shape moves.
Why Part of, a seat edit in this act (PR line 1 was Fixes #23335).
The dev measured that the vendor-served impersonation action is still admitted for a held platform admin. plugin-auth re-authorizes it on a predicate that reads the user id and never the posture (applyPlatformAdminImpersonation). The resulting session is not held, so the hold is bypassed outright.
That is the card's own subject ("a held platform admin may reach user administration"), and its fix is not mechanical (the vendor session carries no posture). So the card stays open for that leg.
The same family, unmeasured: the SSO-provider registration hook, and vendor admin actions judged on the legacy role scalar.
Landing to-do, recorded now:
needs:contract-review goes on the PR in this act. The review runs on c12ba7bd0.
When the review is PASS and every check is green: ready, then auto-merge, then the queue.
When it merges: a landing record here, naming what remains (the impersonation leg) and whose it is.
Landed (part): PR #23360 → 02a691ab1 (Part of #23335). The platform-admin actions judgePlatformAdmin guards refuse a held session; one leg stays open, and this claim is released
The contract-tier review PASSed on c12ba7bd0 (6159215982).
Every check was green or an expected skip (12 of 12 in the roster, every Test Core shard green), with mergeable: clean. check-governed-merges reported it NOT governed, at 385 lines.
Ready and auto-merge went on at 2026-10-14T02:07Z. The queue merged it at 2026-10-14T02:37Z.
On main at 02a691ab1: 6 files, +379 / −6. The patch-id --stable equals that of 6d96721fa..c12ba7bd0: byte-identical to the reviewed head.
Delivered: under ADR-0069 D3, the one platform-admin judge (judgePlatformAdmin) consults the gate's one shared decision (evaluateAuthGate) pathless.
All fourteen platform-admin actions it guards now answer a held session 403 with the gate's code, and the write does not land.
Remediation (sign-in, change-password, two-factor enrollment, sign-out) and the anonymous allow-list are unchanged, pinned on a real boot.
What remains on this card (function level, per the lane's disclosure rule): the vendor-served impersonation action is re-authorized by applyPlatformAdminImpersonation on a predicate that reads the user's standing, never the posture. A held platform admin is still admitted there, as the dev measured.
The fix is not mechanical: the vendor session carries no posture, so it needs a posture oracle beside the rung oracle, or a gate-then-redispatch mount.
Same family, unmeasured: the SSO-provider registration hook on the vendor path, and the vendor admin actions judged on the legacy role scalar.
The authentication.mdx sentence on the platform-admin actions becomes fully true when that leg lands.
Measurements and seams are in the dev report 6158973892 and the review 6159215982 ③.
Release: session session_012uFegdfR6K6uqvLzt7XsdZ (domain:spec#3) · cause: the seat clocks off at the maintainer's word after landing this card's first leg · to: pm:queue, unassigned, priority:p1security kept. The next claim takes the impersonation leg from main at 02a691ab1 or later.
pm:queue → pm:blocked on #23287: the impersonation leg consumes the per-user posture oracle that #23287's ruling has the auth service build · 2026-10-14T04:11Z
domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy. ⛔ Not a claim. A seat judgment on the order of two technical tasks, not a re-grade.
What remains on this card (seat 3's release 6159653652, function level): the vendor-served impersonation action is re-authorized on a predicate that reads the user's standing and never the posture. A held platform admin is still admitted there, measured in dev report 6158973892 (finding 1). Two items of the same family are unmeasured: the SSO-provider registration hook, and the vendor admin actions judged on the legacy role scalar.
Why it waits:
The leg needs a per-user posture oracle beside the rung oracle. The vendor session carries no posture, as the dev report and the release both state.
C′'s scope split matters here too: MFA_REQUIRED is account scope and PASSWORD_EXPIRED is credential scope. Which postures hold an impersonation is a reading of that ruling, made once, beside the oracle.
At restart (the next claim's dispatch shape): applyPlatformAdminImpersonation asks #23287's per-user posture computation for the acting admin and refuses a held one with the gate's code. The same check goes to the SSO-provider registration hook. Measure first whether the role-scalar vendor actions admit a held caller. One booted pin per door, each with a negative control (an unheld admin still impersonates). ⛔ No new gate. ⛔ No second posture oracle.
The blocker #23287 closed completed: PR #23440 merged, so the per-user posture oracle this card's impersonation leg consumes is on main. The grade is unchanged: p1, security, domain:spec.
Claim: PM loop round 1 (#23335's remaining leg: the vendor-served impersonation, and the same family's SSO-provider registration hook, refuse a platform admin whom the ADR-0069 policy holds. They ask #23287's one per-user posture computation, IAuthService.resolveUserAuthGates; measure first whether the role-scalar vendor actions admit a held caller. Restart shape: this seat's block note 6160716696; unlock 6168948574) · 2026-10-14T15:14Z
Session: session_01YRp5z4kK6vgYkrdH97ztsy
Account: huangyiirene (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-23335-held-admin-impersonation
Worktree: objectstack-issue-23335
Domain: domain:spec
Seat: domain:spec#1 (seat post #6017)
File surface (at origin/mainad5a7d7c3 or later; stop on a breach and explain it in the report):
packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts and auth-manager.ts: applyPlatformAdminImpersonation, and the impersonation re-authorization.
packages/plugins/plugin-auth/src/register-sso-provider.ts: the SSO-provider registration hook.
packages/plugins/plugin-auth/src/platform-admin-gate.ts, only if the existing gate's refusal is reused from there.
The role-scalar vendor admin actions. This is a measurement first; their files join the surface only if measured admitting a held caller.
Booted pins beside them, and one .changeset/23335-*.md.
⛔ No new gate. ⛔ No second posture oracle (resolveUserAuthGates is the one). ⛔ Not packages/core/src/security/**. ⛔ Not packages/spec/**.
Amended in place at ACCEPT (the dev report on this card, measured):
Clause-② reads yes (narrowing), not no (narrowing): the PR adds one published export, judgeHeldSession (@objectstack/plugin-auth, through the package's export * of platform-admin-gate.ts), beside the narrowing. Its review is the lane seat's TIER_DEFAULT record (the declaration face alone).
{
"issue": 23335,
"status": "done",
"branch": "claude/issue-23335-held-admin-impersonation",
"pr": "https://github.com/objectstack-ai/objectstack/pull/23521",
"pr_head_sha": "14edf8922",
"session": "session_01YRp5z4kK6vgYkrdH97ztsy (subagent; the parent's harness-stamped id, the Claude-Session trailer on every commit)",
"premise_still_valid": true,
"summary": "Measured first on a real boot (verify bootStack, OS_SCIM_ENABLED + OS_SSO_ENABLED, plugin-auth built from f9b421716): in every held state (PASSWORD_EXPIRED, MFA_REQUIRED, PASSWORD_RESET_REQUIRED, MFA required only by the session's organization) the session gate refused the data read 403 with the code, while a held platform admin impersonated (200, an unheld session minted) and registered an SSO provider (200, row landed), and a held caller carrying the legacy role='admin' scalar was admitted 200 to all seven admin-plugin role-scalar actions (update-user landed). Fix in plugin-auth only: each vendor-served door asks the session gate's own posture carrier (AuthManager.computeAuthGate = first entry of IAuthService.resolveUserAuthGates, for the session's active organization) and refuses a held caller with judgeHeldSession, the held refusal judgePlatformAdmin already gave (factored out, byte-identical): 403 {success:false, error:{code, message}}, the posture's own code and remediation message. Impersonation asks inside applyPlatformAdminImpersonation's handler (injected SessionPostureOracle); SSO registration, and the role-scalar actions (VENDOR_ROLE_SCALAR_ADMIN_PATHS, every admin-plugin route classified by a unit pin) ask in auth-manager's global before-hook via assertActorNotHeld. Under the bounded in-place exemption the same branch also holds /sso/update-provider and /sso/delete-provider, measured admitting a held owning platform admin (issuer rewritten to another host, provider deleted). No new gate, no second posture computation, no new error code; one new export (judgeHeldSession).",
"hypotheses": {
"H1": "Measured: the session gate holds every scope. On a non-allow-listed route (GET /data/sys_user) each of PASSWORD_EXPIRED, PASSWORD_RESET_REQUIRED (credential scope) and MFA_REQUIRED (account scope, global or organization-only) answered 403 with that code; source: computeAuthGate puts resolveUserAuthGates()[0] on user.authGate ('Every scope holds a session, because a session IS the password sign-in', ruling C-prime item 1) and evaluateAuthGate refuses whatever that carrier holds without reading scope. Implemented exactly that verdict (first entry, any scope); the API-key seam's account-only rule is NOT reused.",
"H2": "Measured with a temporary probe line built into dist (never committed): inside the impersonation endpoint the caller from adminMiddleware carried no authGate (and no isPlatformAdmin) in 15 of 15 calls across 5 states x 3 actors, keys = the raw sys_user row; GET /auth/get-session on the same session did carry authGate. So the doors ask computeAuthGate (the gate's own carrier over resolveUserAuthGates), never a second computation.",
"H3": "Measured: REST answers {error:{code,message}}, the dispatcher and judgePlatformAdmin {success:false,error:{code,message}}; reused the judge's body via judgeHeldSession, thrown as APIError('FORBIDDEN', record) in the vendor lane. Measured after the fix: 403 {success:false,error:{code:MFA_REQUIRED,message:'Multi-factor authentication is required. ...'}} at impersonation, /sso/register and update-user. No new code.",
"H4": "Measured admitted (see summary): list-users, get-user, list-user-sessions, update-user (landed), set-role, revoke-user-sessions, revoke-user-session, every held state, for a caller carrying role='admin'; the ADR-0068 platform admin without the scalar is refused there by the vendor held or not. The check applies there via auth-manager.ts's before-hook (the vendor files are node_modules; the ObjectStack seam on their path is auth-manager.ts, already on the surface).",
"H5": "Organization = the session's activeOrganizationId (impersonation: ctx.context.session.session.activeOrganizationId; hooks: resolveActor's activeOrgId). Pinned: org require_mfa only, resolveUserAuthGates(admin,{organizationId}) = [MFA_REQUIRED], ({}) = [], and impersonation, registration and a role-scalar write refused MFA_REQUIRED."
},
"tests": "All at final head 14edf8922 unless noted. (1) New booted pin packages/verify/src/held-session-vendor-admin-doors.pin.test.ts, 28 cases (4 unheld controls with landed effects; per held state x3: impersonation 403+code and no session minted, /sso/register 403 and no row, owner update/delete-provider 403 and provider unchanged, scalar update-user 403 and name unchanged, all 7 role-scalar actions 403+code, held non-admin at impersonation reads the posture; org-only MFA x4; fail-open x2): with held-session-platform-admin.pin.test.ts 'Test Files 2 passed (2) / Tests 41 passed (41)'. (2) New unit pin admin-impersonate-endpoint.held-caller.test.ts (4) + 5 judgeHeldSession cases in platform-admin-gate.held-session.test.ts; targeted plugin-auth run of 9 files incl. auth-manager.test.ts 'Tests 426 passed (426)'. (3) ABLATION of the impersonation door's check via scripts/ablation-replace.mjs (anchor 1 to 0, blob 4e2afe618 to 67beda690), plugin-auth rebuilt, ablation-dist-preflight marker __ABLATED_23335 present in dist/index.js + dist/index.mjs: pin 'Tests 7 failed | 21 passed (28)', exactly the 7 impersonation cases red (3 held states, 3 held non-admin, org-only), SSO/role-scalar/controls/fail-open green. Restore: blob after restore == HEAD 4e2afe618, git diff HEAD empty, rebuilt, preflight --absent PASS with whole tree clean, pins 41 passed. (An earlier identical ablation at 675e95d7e gave 7 failed | 17 passed (24), restored the same way.) (4) @objectstack/plugin-auth full suite at beebec8f3 (the final commit after it only binds the refusal record to a local name), 4 shards: 36+35+35+35 files, 779+928(+10 skipped)+520+597 = 2824 passed. (5) @objectstack/verify full suite at beebec8f3, 3 shards: 40 files, 107+126+114 = 347 passed. (6) typecheck (tsc --noEmit + check:test-typecheck): plugin-auth exit 0 at 14edf8922, verify exit 0 at beebec8f3. (7) eslint --no-inline-config --format json on the 6 changed .ts files: 0 errors, 0 warnings. (8) Pre-fix measurement tables (scratch, not committed): before f9b421716-built dist, after-fix build, and the SSO-owner probe at ad8dee166.",
"gates": "Local subset at 14edf8922: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 families (PM lead: 50 at ad5a7d7c3); all 68 run, exit codes captured before any pipe (each command redirected to its own log, then ec=$?): 68 of 68 exit 0. First-pass notes: check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (missing dist) until a full turbo build (76 tasks, exit 0), then 0; check:dispatcher-error-vocabulary exit 1 (2 findings: apierrorarg read APIError('FORBIDDEN', held.body) as an imported code-table member) fixed in 14edf8922 by binding the judge's record locally, then 0. Reconciliation: dispatch-gates.mjs --ran = '68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN'. Changeset gates: check:adr-0087-registration 'not-required (no-migration-prescription)' accepted, check:changeset-no-major 0, check:empty-changeset 0. CI on PR #23521 at report time (head 14edf8922): 37 check-runs, 17 in_progress, 14 success, 6 skipped. Success: Check Changeset, Check PR Size, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Dogfood Regression Gate, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter, and the four claim/PR-shape checks. Skipped by selector: Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). In progress: the remaining Lint, Type Check, Test Core and Build Core families; read from check-runs, not re-run locally (in_progress is the honest value; PM owns convergence).",
"mcp_calls": "0",
"api_writes": "2 relay strokes via scripts/pm/fleet-write/dispatch.mjs as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/23521/assignees [huangyiirene], run 38602835571 success, body read back identical (13759 bytes); (2) comment = POST /repos/objectstack-ai/objectstack/issues/23335/comments (this os-dev-report). Plus git push x7 (empty branch probe, fix, pins, changeset, merge of origin/main, SSO owner doors, record binding; git push is not REST). Labels: zero writes (the dispatch named none; skip-changeset does not apply, a changeset ships).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed (reads): a held provider owner still reads GET /sso/get-provider and GET /sso/providers (sanitized provider details; measured 200 at ad8dee166). In the PR's Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed (unmeasured): the vendor's /sso/request-domain-verification and /sso/verify-domain (domain verification off in this boot; their /admin/sso/* bridges are judged). In the PR's Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed (read from source, not in the PR body): the /sso/register before-hook (unchanged here) skips both its checks when resolveActor resolves no actor and lets the vendor's sessionMiddleware decide; a session the vendor can read but resolveActor cannot (an adapter error inside resolveActor) would then pass to the vendor's own register handler. Unmeasured inference; the seat may append it to Acceptance notes."
],
"line_budget": "n/a (no skills/**); diff vs origin/main: 7 files, +797 / -13 (under the 3000 human-merge threshold)",
"deviations": [
"Bounded in-place fix beyond the claim's naming: /sso/update-provider and /sso/delete-provider are held too (auth-manager.ts, same branch shape as registration, pinned in the same booted file). All four conditions hold (same defect class; mechanical, shape pinned; auth-manager.ts is on this claim's surface; same gate families). The claim named only the registration hook; the seat may record the surface widening.",
"The SSO-provider registration hook lives in auth-manager.ts (the claim named register-sso-provider.ts, which only reshapes the form body and re-dispatches into that hook; it is unchanged).",
"The booted pins live in packages/verify (plugin-auth cannot depend on verify), as #23360's did; the claim said 'beside them'.",
"Zone 3's helper is realized as judgeHeldSession (pure, platform-admin-gate.ts, shared with judgePlatformAdmin) fed by computeAuthGate, the session gate's own carrier over resolveUserAuthGates, rather than each door reading resolveUserAuthGates()[0] itself: the same computation and the same first entry, one reduction.",
"New public export judgeHeldSession (via plugin-auth's export * of platform-admin-gate.ts), reported per the Clause-② clause; changeset names it. No new error code.",
"Merged origin/main once before opening the PR (1418de4c7, spec/tooling only, no conflict, no os-regen pending); verify closure and full workspace rebuilt after it.",
"Full plugin-auth and verify suites cited at beebec8f3; the final commit 14edf8922 is a no-behaviour refactor (record bound to a local name for check:dispatcher-error-vocabulary), re-verified there by both booted pins, the ablation, 9 targeted plugin-auth files and the plugin-auth typecheck.",
"Commit trailers use the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By.",
"Two gate-battery runs on intermediate heads were stopped by their recorded PIDs when the tree moved; the cited battery is the full rerun on 14edf8922."
],
"files_changed": [
".changeset/23335-held-admin-vendor-doors-refused.md",
"packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts",
"packages/plugins/plugin-auth/src/admin-impersonate-endpoint.held-caller.test.ts",
"packages/plugins/plugin-auth/src/auth-manager.ts",
"packages/plugins/plugin-auth/src/platform-admin-gate.ts",
"packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts",
"packages/verify/src/held-session-vendor-admin-doors.pin.test.ts"
]
}
Activity
objectstack-fleet commentedon Oct 13, 2026
Claim: PM loop round 1, rolling slot (#23335: a held platform admin is gated out of the platform-admin surface under ADR-0069 D3, measured first) · 2026-10-13T23:11Z
Session:
session_012uFegdfR6K6uqvLzt7XsdZAccount:
os-support-ai(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23335-held-admin-auth-gateWorktree:
objectstack-issue-23335Domain:
domain:specSeat:
domain:spec#3(seat post #18883)Ruling-ref: the card body (filed by
domain:devx#1with its grade; no triage comment yet, so triage may re-grade on first touch). Governing text, read in this act:auth-gate.tsheader names the exemption's purpose as "the auth endpoints (change-password, two-factor enrollment, sign-out)".The seat's premised ruling (third tier). The maintainer can veto it in the round report.
File surface (at
origin/mainbbb975771or later; stop on a breach and explain it in the report):packages/core/src/security/auth-gate.ts: the held-session decision distinguishes remediation from the rest of the auth namespace.isAuthGateAllowlistedis shared withanonymous-deny.ts, so the anonymous allow-list's behaviour (sign-in and the other unauthenticated auth endpoints) does not change.packages/plugins/plugin-auth/src/auth-plugin.ts/platform-admin-gate.ts: only if the shared decision needs a declared marker there. This is a cross-lane touch, declared here.403with the gate's code on a platform-admin write, in both held states;.changeset/23335-*.md.Container & model:
M,mode:subagent,model: default tier. It narrows a published door's accept set (held sessions), so it owes a review atCONTRACT_REVIEW_TIERbefore the queue, by an isolated at-tier subagent.Clause-②: no (narrowing: a held session is refused on the platform-admin surface, which ADR-0069 D3 already declares gated)
Responsibility: the auth gate's shared decision (
packages/core/src/security), routed to the spec lane by the filer under the ADR-0069 family | none: the admin judge reads the session and the rung, never the gate | every deployment that enforces MFA or password expiry for platform adminsThread-read: none
Serial constraints cleared (the file lists of all open PRs, read in this act):
auth-gate.ts,anonymous-deny.ts,auth-plugin.tsorplatform-admin-gate.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{ "issue": 23335, "status": "done", "branch": "claude/issue-23335-held-admin-auth-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/23360", "pr_head_sha": "c12ba7bd03aaac53090f7d67d5b50950d2b994f6", "session": "session_012uFegdfR6K6uqvLzt7XsdZ", "premise_still_valid": true, "summary": "Premise CONFIRMED on a real boot (verify bootStack, origin/main bbb975771): a held platform admin was refused on the protected data read (403 with the gate code) yet ADMITTED (200, the write landed) to every platform-admin write probed, in both held states. Fix: the one platform-admin judge, judgePlatformAdmin (plugin-auth platform-admin-gate.ts:147), now CALLS core's exported shared decision evaluateAuthGate (packages/core/src/security/auth-gate.ts:370), pathless, between the identity refusal (401) and the rung refusal; every mount the judge guards answers a held session 403 with PASSWORD_EXPIRED / MFA_REQUIRED and the write never lands. The decision itself and the anonymous allow-list are unchanged (auth-gate.ts gets a comment only); remediation (sign-in, change-password, two-factor enrollment, sign-out) is unchanged and pinned. The vendor-served impersonation action is judged by a different predicate and still admits a held admin: reported below as a finding, not fixed here.", "measurement_step1": { "harness": "@objectstack/verify bootStack, minimal app fixture, OS_AUTH_TWO_FACTOR and OS_SCIM_ENABLED on, dev admin (platform admin) plus one signed-up member; held via applyConfigPatch plus a backdated sys_user timestamp", "call_shape": "authenticated bearer POST to the platform-admin raw mounts under the auth namespace that judgePlatformAdmin guards (user unlock, set manager, ban, unban, organization add-member), the protected REST data read as the arming control", "unheld": "data read 200; admin writes 200", "expired_password": "data read 403 PASSWORD_EXPIRED; admin writes 200 and landed (ADMITTED)", "mfa_required_not_enrolled": "data read 403 MFA_REQUIRED; admin writes 200 and landed (ADMITTED)", "after_fix_6a03917e2": "same boot shape: every one of those admin writes 403 with the state's code, envelope success false; data read unchanged; remediation unchanged; vendor impersonation action still 200 (finding)" }, "H1_measured": "FALSIFIED as stated (one predicate inside the shared decision, not a check at the judge). With isAuthGateAllowlisted instrumented in core src and dist (ablation-replace, restored blob == HEAD, rebuilt, dist preflight absent), a full three-state run logged calls only for the REST data and meta reads and ZERO for any auth-namespace request, admin writes included. The platform-admin mounts are plugin-auth raw host-app routes; neither ADR-0069 transport seam (REST enforceAuth, dispatcher enforceAuthGate) ever sees them, so the auth arm of the allow-list was never what admitted the held admin and narrowing it cannot reach them. The only place every mount passes is the judge; the judge CALLS the one exported decision (not a re-implementation), pathless, the spelling the other raw families use (service-settings, service-datasource admin routes, service-storage, plugin-webhooks, cloud-connection), so the family cannot inherit the auth-namespace exemption. Answer to the seat note: case 1 holds (the judge calls evaluateAuthGate, platform-admin-gate.ts:147 → auth-gate.ts:370, the same function the dispatcher calls at http-dispatcher.ts:1396) AND the measured fact behind case 2 holds (the admin requests take the raw host-app mount, never a transport seam).", "H2_remediation_subset": "No new list was added: the shared decision is unchanged, and the pathless consult means no platform-admin mount can earn any exemption. Remediation stays as the header names it and is pinned: change-password (reaches its own validation, 400 INVALID_PASSWORD, never the gate 403), two-factor enrollment (200 with a TOTP URI), sign-out (200), sign-in (200, anonymous). None of these lives under the admin mounts, so the ban did not trigger.", "H3_measured": "Held NON-admin: the only auth-namespace answers that change are on the judge-guarded platform-admin mounts, 403 PERMISSION_DENIED becomes 403 with the posture code (identity, then posture, then authorization, the order REST and the dispatcher apply). No other auth-namespace endpoint consults the judge or the decision; measured unchanged for a held session: get-session 200, list-sessions 200, me/permissions 200, two-factor and change-password reach their own validation, sign-out 200.", "tests": "All at merged head c12ba7bd0 unless noted. (1) New booted pin packages/verify/src/held-session-platform-admin.pin.test.ts: Tests 13 passed (13) [unheld-admin control; per held state: admin write 403 with code AND row unchanged, held non-admin posture code, two-factor enable 200, change-password 400 INVALID_PASSWORD, anonymous sign-in 200, sign-out 200]. (2) New unit packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts: 8 cases green (with admin-ban-endpoints.test.ts: Test Files 2 passed, Tests 29 passed, at 6a03917e2). (3) ABLATION via scripts/ablation-replace.mjs on platform-admin-gate.ts (anchor 1 → 0, blob 12bd465c → 1d7f1f48), plugin-auth rebuilt (tsup), ablation-dist-preflight plugin-auth held.code --absent PASS: pin Tests 4 failed | 9 passed (13), exactly the four held refusals red, remediation + anonymous sign-in + unheld control green; unit 5 failed | 3 passed. Restore: blob after restore == HEAD 12bd465c, git diff HEAD empty, full plugin-auth build exit 0, preflight held.code present PASS, ABLATED absent PASS, pin 13 passed. (4) @objectstack/core security suites: Test Files 34 passed, Tests 832 passed. (5) @objectstack/plugin-auth full suite, 4 shards: 35+35+35+35 files, 748+950+521(+10 skipped)+587 = 2806 passed. (6) @objectstack/spec test:repo (via vitest --project repo, sharded): 103 files / 1576 tests passed; 1 file NOT MEASURED: scripts/build-schemas-check-mode.test.ts, reason: exceeds the container foreground cap on the shared box (exit 124 at 280s and again alone at 560s); this diff touches no packages/spec file. (7) typecheck plugin-auth / verify / core: exit 0 (check:test-typecheck OK for each). (8) eslint proven narrowing: population from eslint.config.mjs files glob covers all 4 changed .ts files; --format json read 4 files, 0 errors, 0 warnings; config has no parserOptions.project / typed rules, so untouched files' verdicts cannot move.", "gates": { "derived_at": "c12ba7bd0 (after os-regen-merge of origin/main 6d96721fa)", "command": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack", "derived": 97, "run": 97, "exit_0": 97, "first_pass_exit_3": [ "pnpm --filter @objectstack/spec run check:skill-examples", "pnpm check:dual-build-cjs-loads" ], "exit_3_disposition": "PREREQUISITE NOT MET (missing dist); re-run after a real workspace build (turbo build, 74 tasks): both exit 0", "reconcile": "dispatch-gates --ran with every line recorded as command :: exit code: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)" }, "line_budget": "6 files, +379 / -6 = 385 changed lines (under the 3000 human-merge threshold): changeset 19, docs callout +7/-4, auth-gate.ts comment +10/-2, platform-admin-gate.ts +40 (of which 10 code lines), unit test 68, booted pin 235", "files_changed": [ ".changeset/23335-held-platform-admin-refused.md", "content/docs/permissions/authentication.mdx", "packages/core/src/security/auth-gate.ts", "packages/plugins/plugin-auth/src/platform-admin-gate.ts", "packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts", "packages/verify/src/held-session-platform-admin.pin.test.ts" ], "deviations": [ "Mechanism (Zone 2 H1 falsified by measurement): the decision is CONSULTED at the shared judge in plugin-auth rather than narrowed inside auth-gate.ts. auth-gate.ts carries a comment only (at the auth arm: the namespace is wider than remediation; a protected family under it consults pathless). platform-admin-gate.ts was declared in the claim 'only if the shared decision needs a declared marker there'; it carries the consult instead. Not a new gate (no new decision, code or policy) and no widening; the seat judges it in review.", "The booted pin lives in packages/verify (the nearest package that can boot plugin-auth with the real composition; plugin-auth cannot depend on verify). Declared here; the claim's surface named tests without a package.", "PR body line 1 is 'Fixes #23335' as dispatched. The vendor-served impersonation action is on the same platform-admin surface and still admits a held admin (finding 1). If the seat holds that leg to be this card's, the body line should read 'Part of #23335' (seat edit; dev writes the body once).", "One spec test:repo file NOT MEASURED (foreground cap), see tests (6).", "Order choice inside the judge: posture before the rung, so a held non-admin's code moves PERMISSION_DENIED → posture code (same 403). Stated in the changeset and PR body." ], "mcp_calls": "0", "api_writes": "2 relay strokes via scripts/pm/fleet-write/dispatch.mjs as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/23360/assignees [os-support-ai], run 38496775102 success, body read back identical; (2) comment = POST /repos/objectstack-ai/objectstack/issues/23335/comments (this os-dev-report). Plus git push x4 (empty branch, fix commit, changeset commit, merge commit; git push is not REST). Labels: zero writes (none named by the dispatch; skip-changeset does not apply, a changeset ships).", "open_questions": [], "out_of_scope_findings": [ "class: a · reach: public door, measured: a held platform admin (both held states) calling the vendor-served impersonation action of the better-auth admin plugin, which plugin-auth re-authorizes on the platform-admin predicate (applyPlatformAdminImpersonation in admin-impersonate-endpoint.ts, wired from auth-manager.ts with isPlatformAdminUserId, which reads the user id and never the posture), answered 200 with an impersonation session for another user, at bbb975771 and again at 6a03917e2 after this fix; the impersonated session is not held, so the hold is bypassed outright · evidence: measurement logs in this run (HELD-PWEXP and HELD-MFA rows, 200 with a session body), same boot as step 1 · fix shape is not mechanical: the vendor endpoint's session carries no customSession posture, so the rewired predicate needs a posture oracle beside the rung oracle (or a gate-then-redispatch mount); same family as this card (ADR-0069 D3 on the platform-admin surface) · Seam: spec: ADR-0069 D3 held session gated out of all protected resources → runtime: admin-impersonate-endpoint.ts applyPlatformAdminImpersonation caller judge · dedupe words: held session impersonation; platform admin impersonate MFA_REQUIRED; applyPlatformAdminImpersonation posture; isPlatformAdminUserId authGate", "class: a (NOT MEASURED, same family, fold into finding 1) · reach: named producer, unmeasured: the SSO-provider registration before-hook in auth-manager.ts judges the platform admin with isPlatformAdminUserId (no posture), and the remaining vendor admin-plugin actions judge on the legacy role scalar, so a held caller carrying that scalar would pass them; neither measured here (SSO plugin off; the dev admin carries role user) · dedupe words: sso register held session; vendor admin role scalar held", "carrier: none (承接者:无) · noted, not filed: the has-permission admin read answers a held platform admin with admin answers; it is a permission question, not a write, and by design refuses no one. In the PR's Acceptance notes." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
✅ ACCEPT (seat review): PR #23360 at
c12ba7bd0, asPart of #23335. The contract-tier review is owed before the queue ·domain:specseat 3 (seat post #18883) ·session_012uFegdfR6K6uqvLzt7XsdZ· 2026-10-14T01:43ZThe premise held, measured on a real boot (verify
bootStack,bbb975771). A held platform admin was refused on the protected data read (403 with the gate's code). In both held states, though, the same admin was ADMITTED to every platform-admin write probed, and the write landed.H1, falsified by measurement, and the seat accepts the reading.
plugin-auth's raw host-app routes. Neither ADR-0069 transport seam (REST'senforceAuth, the dispatcher'senforceAuthGate) ever sees them: an instrumentedisAuthGateAllowlistedlogged zero calls for any auth-namespace request. So narrowing the allow-list's auth arm could never reach them.judgePlatformAdmin(platform-admin-gate.ts), CALLS the gate's one exported decisionevaluateAuthGate(auth-gate.ts, the same function the dispatcher calls), pathless. It sits between the identity refusal and the rung refusal. It is a consult, not a re-implementation, and not a new gate. The pathless call means no admin mount can inherit the auth-namespace exemption.auth-gate.tscarries a comment only. The decision and the anonymous allow-list are unchanged.The pins, each with a control.
packages/verify, 13 cases:INVALID_PASSWORD, never the gate's 403); anonymous sign-in 200; sign-out 200.plugin-auth, 8 cases.Measurements on
c12ba7bd0:coresecurity suites: 34 files, 832 tests;plugin-auth: 2806 tests;spectest:repo: 103 files (one script file over the cap, left to CI; no spec file in the diff);dispatch-gates --ran: 97 derived, 97 run, all exit 0.The changeset:
@objectstack/plugin-authminor, with the BREAKING banner andClause-②: no (narrowing). The ADR-0087 disposition isnot-required (no-migration-prescription); no spec key or stored shape moves.Why
Part of, a seat edit in this act (PR line 1 wasFixes #23335).plugin-authre-authorizes it on a predicate that reads the user id and never the posture (applyPlatformAdminImpersonation). The resulting session is not held, so the hold is bypassed outright.Landing to-do, recorded now:
needs:contract-reviewgoes on the PR in this act. The review runs onc12ba7bd0.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Landed (part): PR #23360 →
02a691ab1(Part of #23335). The platform-admin actionsjudgePlatformAdminguards refuse a held session; one leg stays open, and this claim is releaseddomain:specseat 3 (#18883) ·os-support-ai· sessionsession_012uFegdfR6K6uqvLzt7XsdZ· 2026-10-14T02:38Z · holder of claim6157167302.6159016506.c12ba7bd0(6159215982).Test Coreshard green), withmergeable: clean.check-governed-mergesreported it NOT governed, at 385 lines.mainat02a691ab1: 6 files, +379 / −6. Thepatch-id --stableequals that of6d96721fa..c12ba7bd0: byte-identical to the reviewed head.judgePlatformAdmin) consults the gate's one shared decision (evaluateAuthGate) pathless.403with the gate's code, and the write does not land.applyPlatformAdminImpersonationon a predicate that reads the user's standing, never the posture. A held platform admin is still admitted there, as the dev measured.authentication.mdxsentence on the platform-admin actions becomes fully true when that leg lands.6158973892and the review6159215982③.Release: session
session_012uFegdfR6K6uqvLzt7XsdZ(domain:spec#3) · cause: the seat clocks off at the maintainer's word after landing this card's first leg · to:pm:queue, unassigned,priority:p1securitykept. The next claim takes the impersonation leg frommainat02a691ab1or later.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
pm:queue→pm:blockedon #23287: the impersonation leg consumes the per-user posture oracle that #23287's ruling has the auth service build · 2026-10-14T04:11Zdomain:specseat 1 (#6017) ·huangyiirene· sessionsession_01YRp5z4kK6vgYkrdH97ztsy. ⛔ Not a claim. A seat judgment on the order of two technical tasks, not a re-grade.What remains on this card (seat 3's release
6159653652, function level): the vendor-served impersonation action is re-authorized on a predicate that reads the user's standing and never the posture. A held platform admin is still admitted there, measured in dev report6158973892(finding 1). Two items of the same family are unmeasured: the SSO-provider registration hook, and the vendor admin actions judged on the legacy role scalar.Why it waits:
6159450584, letter C′, item 3) has the auth service expose exactly that: "the auth service exposes a per-user posture computation; the seam asks it for the key's owner and refusesaccount-scope postures".MFA_REQUIREDisaccountscope andPASSWORD_EXPIREDiscredentialscope. Which postures hold an impersonation is a reading of that ruling, made once, beside the oracle.Blocked-by: #23287
Restart-when: closed #23287
At restart (the next claim's dispatch shape):
applyPlatformAdminImpersonationasks #23287's per-user posture computation for the acting admin and refuses a held one with the gate's code. The same check goes to the SSO-provider registration hook. Measure first whether the role-scalar vendor actions admit a held caller. One booted pin per door, each with a negative control (an unheld admin still impersonates). ⛔ No new gate. ⛔ No second posture oracle.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Unlock scan:
pm:blocked→pm:queue· #23287 closed · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T13:01ZThe blocker #23287 closed
completed: PR #23440 merged, so the per-user posture oracle this card's impersonation leg consumes is onmain. The grade is unchanged: p1,security,domain:spec.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 1 (#23335's remaining leg: the vendor-served impersonation, and the same family's SSO-provider registration hook, refuse a platform admin whom the ADR-0069 policy holds. They ask #23287's one per-user posture computation,
IAuthService.resolveUserAuthGates; measure first whether the role-scalar vendor actions admit a held caller. Restart shape: this seat's block note6160716696; unlock6168948574) · 2026-10-14T15:14ZSession:
session_01YRp5z4kK6vgYkrdH97ztsyAccount:
huangyiirene(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23335-held-admin-impersonationWorktree:
objectstack-issue-23335Domain:
domain:specSeat:
domain:spec#1(seat post #6017)File surface (at
origin/mainad5a7d7c3or later; stop on a breach and explain it in the report):packages/plugins/plugin-auth/src/admin-impersonate-endpoint.tsandauth-manager.ts:applyPlatformAdminImpersonation, and the impersonation re-authorization.packages/plugins/plugin-auth/src/register-sso-provider.ts: the SSO-provider registration hook.packages/plugins/plugin-auth/src/platform-admin-gate.ts, only if the existing gate's refusal is reused from there..changeset/23335-*.md.resolveUserAuthGatesis the one). ⛔ Notpackages/core/src/security/**. ⛔ Notpackages/spec/**.Clause-②readsyes (narrowing), notno (narrowing): the PR adds one published export,judgeHeldSession(@objectstack/plugin-auth, through the package'sexport *ofplatform-admin-gate.ts), beside the narrowing. Its review is the lane seat'sTIER_DEFAULTrecord (the declaration face alone)./sso/update-providerand/sso/delete-provider(held too, measured admitting a held owning admin), the admin plugin's seven role-scalar actions (measured admitted, H4), and the booted pin inpackages/verify/src/held-session-vendor-admin-doors.pin.test.ts. The SSO hook lives inauth-manager.ts;register-sso-provider.tsis unchanged.Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate). No contract face is touched: no published schema, no governed text, noClause-②: yes. So CI plus the seat's own read clear it, read with the security lens.Clause-②: yes (narrowing)
Responsibility:
plugin-auth's vendor-served impersonation and SSO-provider registration, which judge a platform admin's standing and never the ADR-0069 posture | PR fix(plugin-auth)!: the platform-admin actions refuse a session the ADR-0069 authentication policy holds #23360 (02a691ab1, Part of auth-gate parity: the remediation allow-list exempts the whole auth namespace, and the platform-admin mounts under it judge the caller without the ADR-0069 posture, so a held platform admin may reach user administration (read from source, not yet measured) #23335) gatedjudgePlatformAdmin's actions, and auth-gate parity: a request carrying an API key beside a session cookie held by ADR-0069 is refused at REST but admitted by the five raw-host route families — one cross-seam answer is owed (session versus user) #23287 (PR feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account (#23287) #23440) built the per-user posture computation these doors now consume | a platform admin whom the policy holds (MFA required, password expired) and who can still impersonate a user or register an SSO providerThread-read: 6168948574
Serial constraints cleared: open PRs' file lists were read in this act; none touches these files. No in-flight claim names them. auth-gate parity: a request carrying an API key beside a session cookie held by ADR-0069 is refused at REST but admitted by the five raw-host route families — one cross-seam answer is owed (session versus user) #23287 and PR feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account (#23287) #23440 have landed.
Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{ "issue": 23335, "status": "done", "branch": "claude/issue-23335-held-admin-impersonation", "pr": "https://github.com/objectstack-ai/objectstack/pull/23521", "pr_head_sha": "14edf8922", "session": "session_01YRp5z4kK6vgYkrdH97ztsy (subagent; the parent's harness-stamped id, the Claude-Session trailer on every commit)", "premise_still_valid": true, "summary": "Measured first on a real boot (verify bootStack, OS_SCIM_ENABLED + OS_SSO_ENABLED, plugin-auth built from f9b421716): in every held state (PASSWORD_EXPIRED, MFA_REQUIRED, PASSWORD_RESET_REQUIRED, MFA required only by the session's organization) the session gate refused the data read 403 with the code, while a held platform admin impersonated (200, an unheld session minted) and registered an SSO provider (200, row landed), and a held caller carrying the legacy role='admin' scalar was admitted 200 to all seven admin-plugin role-scalar actions (update-user landed). Fix in plugin-auth only: each vendor-served door asks the session gate's own posture carrier (AuthManager.computeAuthGate = first entry of IAuthService.resolveUserAuthGates, for the session's active organization) and refuses a held caller with judgeHeldSession, the held refusal judgePlatformAdmin already gave (factored out, byte-identical): 403 {success:false, error:{code, message}}, the posture's own code and remediation message. Impersonation asks inside applyPlatformAdminImpersonation's handler (injected SessionPostureOracle); SSO registration, and the role-scalar actions (VENDOR_ROLE_SCALAR_ADMIN_PATHS, every admin-plugin route classified by a unit pin) ask in auth-manager's global before-hook via assertActorNotHeld. Under the bounded in-place exemption the same branch also holds /sso/update-provider and /sso/delete-provider, measured admitting a held owning platform admin (issuer rewritten to another host, provider deleted). No new gate, no second posture computation, no new error code; one new export (judgeHeldSession).", "hypotheses": { "H1": "Measured: the session gate holds every scope. On a non-allow-listed route (GET /data/sys_user) each of PASSWORD_EXPIRED, PASSWORD_RESET_REQUIRED (credential scope) and MFA_REQUIRED (account scope, global or organization-only) answered 403 with that code; source: computeAuthGate puts resolveUserAuthGates()[0] on user.authGate ('Every scope holds a session, because a session IS the password sign-in', ruling C-prime item 1) and evaluateAuthGate refuses whatever that carrier holds without reading scope. Implemented exactly that verdict (first entry, any scope); the API-key seam's account-only rule is NOT reused.", "H2": "Measured with a temporary probe line built into dist (never committed): inside the impersonation endpoint the caller from adminMiddleware carried no authGate (and no isPlatformAdmin) in 15 of 15 calls across 5 states x 3 actors, keys = the raw sys_user row; GET /auth/get-session on the same session did carry authGate. So the doors ask computeAuthGate (the gate's own carrier over resolveUserAuthGates), never a second computation.", "H3": "Measured: REST answers {error:{code,message}}, the dispatcher and judgePlatformAdmin {success:false,error:{code,message}}; reused the judge's body via judgeHeldSession, thrown as APIError('FORBIDDEN', record) in the vendor lane. Measured after the fix: 403 {success:false,error:{code:MFA_REQUIRED,message:'Multi-factor authentication is required. ...'}} at impersonation, /sso/register and update-user. No new code.", "H4": "Measured admitted (see summary): list-users, get-user, list-user-sessions, update-user (landed), set-role, revoke-user-sessions, revoke-user-session, every held state, for a caller carrying role='admin'; the ADR-0068 platform admin without the scalar is refused there by the vendor held or not. The check applies there via auth-manager.ts's before-hook (the vendor files are node_modules; the ObjectStack seam on their path is auth-manager.ts, already on the surface).", "H5": "Organization = the session's activeOrganizationId (impersonation: ctx.context.session.session.activeOrganizationId; hooks: resolveActor's activeOrgId). Pinned: org require_mfa only, resolveUserAuthGates(admin,{organizationId}) = [MFA_REQUIRED], ({}) = [], and impersonation, registration and a role-scalar write refused MFA_REQUIRED." }, "tests": "All at final head 14edf8922 unless noted. (1) New booted pin packages/verify/src/held-session-vendor-admin-doors.pin.test.ts, 28 cases (4 unheld controls with landed effects; per held state x3: impersonation 403+code and no session minted, /sso/register 403 and no row, owner update/delete-provider 403 and provider unchanged, scalar update-user 403 and name unchanged, all 7 role-scalar actions 403+code, held non-admin at impersonation reads the posture; org-only MFA x4; fail-open x2): with held-session-platform-admin.pin.test.ts 'Test Files 2 passed (2) / Tests 41 passed (41)'. (2) New unit pin admin-impersonate-endpoint.held-caller.test.ts (4) + 5 judgeHeldSession cases in platform-admin-gate.held-session.test.ts; targeted plugin-auth run of 9 files incl. auth-manager.test.ts 'Tests 426 passed (426)'. (3) ABLATION of the impersonation door's check via scripts/ablation-replace.mjs (anchor 1 to 0, blob 4e2afe618 to 67beda690), plugin-auth rebuilt, ablation-dist-preflight marker __ABLATED_23335 present in dist/index.js + dist/index.mjs: pin 'Tests 7 failed | 21 passed (28)', exactly the 7 impersonation cases red (3 held states, 3 held non-admin, org-only), SSO/role-scalar/controls/fail-open green. Restore: blob after restore == HEAD 4e2afe618, git diff HEAD empty, rebuilt, preflight --absent PASS with whole tree clean, pins 41 passed. (An earlier identical ablation at 675e95d7e gave 7 failed | 17 passed (24), restored the same way.) (4) @objectstack/plugin-auth full suite at beebec8f3 (the final commit after it only binds the refusal record to a local name), 4 shards: 36+35+35+35 files, 779+928(+10 skipped)+520+597 = 2824 passed. (5) @objectstack/verify full suite at beebec8f3, 3 shards: 40 files, 107+126+114 = 347 passed. (6) typecheck (tsc --noEmit + check:test-typecheck): plugin-auth exit 0 at 14edf8922, verify exit 0 at beebec8f3. (7) eslint --no-inline-config --format json on the 6 changed .ts files: 0 errors, 0 warnings. (8) Pre-fix measurement tables (scratch, not committed): before f9b421716-built dist, after-fix build, and the SSO-owner probe at ad8dee166.", "gates": "Local subset at 14edf8922: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 families (PM lead: 50 at ad5a7d7c3); all 68 run, exit codes captured before any pipe (each command redirected to its own log, then ec=$?): 68 of 68 exit 0. First-pass notes: check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (missing dist) until a full turbo build (76 tasks, exit 0), then 0; check:dispatcher-error-vocabulary exit 1 (2 findings: apierrorarg read APIError('FORBIDDEN', held.body) as an imported code-table member) fixed in 14edf8922 by binding the judge's record locally, then 0. Reconciliation: dispatch-gates.mjs --ran = '68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN'. Changeset gates: check:adr-0087-registration 'not-required (no-migration-prescription)' accepted, check:changeset-no-major 0, check:empty-changeset 0. CI on PR #23521 at report time (head 14edf8922): 37 check-runs, 17 in_progress, 14 success, 6 skipped. Success: Check Changeset, Check PR Size, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Dogfood Regression Gate, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter, and the four claim/PR-shape checks. Skipped by selector: Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). In progress: the remaining Lint, Type Check, Test Core and Build Core families; read from check-runs, not re-run locally (in_progress is the honest value; PM owns convergence).", "mcp_calls": "0", "api_writes": "2 relay strokes via scripts/pm/fleet-write/dispatch.mjs as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/23521/assignees [huangyiirene], run 38602835571 success, body read back identical (13759 bytes); (2) comment = POST /repos/objectstack-ai/objectstack/issues/23335/comments (this os-dev-report). Plus git push x7 (empty branch probe, fix, pins, changeset, merge of origin/main, SSO owner doors, record binding; git push is not REST). Labels: zero writes (the dispatch named none; skip-changeset does not apply, a changeset ships).", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted, not filed (reads): a held provider owner still reads GET /sso/get-provider and GET /sso/providers (sanitized provider details; measured 200 at ad8dee166). In the PR's Acceptance notes.", "carrier: none (承接者:无) · noted, not filed (unmeasured): the vendor's /sso/request-domain-verification and /sso/verify-domain (domain verification off in this boot; their /admin/sso/* bridges are judged). In the PR's Acceptance notes.", "carrier: none (承接者:无) · noted, not filed (read from source, not in the PR body): the /sso/register before-hook (unchanged here) skips both its checks when resolveActor resolves no actor and lets the vendor's sessionMiddleware decide; a session the vendor can read but resolveActor cannot (an adapter error inside resolveActor) would then pass to the vendor's own register handler. Unmeasured inference; the seat may append it to Acceptance notes." ], "line_budget": "n/a (no skills/**); diff vs origin/main: 7 files, +797 / -13 (under the 3000 human-merge threshold)", "deviations": [ "Bounded in-place fix beyond the claim's naming: /sso/update-provider and /sso/delete-provider are held too (auth-manager.ts, same branch shape as registration, pinned in the same booted file). All four conditions hold (same defect class; mechanical, shape pinned; auth-manager.ts is on this claim's surface; same gate families). The claim named only the registration hook; the seat may record the surface widening.", "The SSO-provider registration hook lives in auth-manager.ts (the claim named register-sso-provider.ts, which only reshapes the form body and re-dispatches into that hook; it is unchanged).", "The booted pins live in packages/verify (plugin-auth cannot depend on verify), as #23360's did; the claim said 'beside them'.", "Zone 3's helper is realized as judgeHeldSession (pure, platform-admin-gate.ts, shared with judgePlatformAdmin) fed by computeAuthGate, the session gate's own carrier over resolveUserAuthGates, rather than each door reading resolveUserAuthGates()[0] itself: the same computation and the same first entry, one reduction.", "New public export judgeHeldSession (via plugin-auth's export * of platform-admin-gate.ts), reported per the Clause-② clause; changeset names it. No new error code.", "Merged origin/main once before opening the PR (1418de4c7, spec/tooling only, no conflict, no os-regen pending); verify closure and full workspace rebuilt after it.", "Full plugin-auth and verify suites cited at beebec8f3; the final commit 14edf8922 is a no-behaviour refactor (record bound to a local name for check:dispatcher-error-vocabulary), re-verified there by both booted pins, the ablation, 9 targeted plugin-auth files and the plugin-auth typecheck.", "Commit trailers use the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By.", "Two gate-battery runs on intermediate heads were stopped by their recorded PIDs when the tree moved; the cited battery is the full rerun on 14edf8922." ], "files_changed": [ ".changeset/23335-held-admin-vendor-doors-refused.md", "packages/plugins/plugin-auth/src/admin-impersonate-endpoint.ts", "packages/plugins/plugin-auth/src/admin-impersonate-endpoint.held-caller.test.ts", "packages/plugins/plugin-auth/src/auth-manager.ts", "packages/plugins/plugin-auth/src/platform-admin-gate.ts", "packages/plugins/plugin-auth/src/platform-admin-gate.held-session.test.ts", "packages/verify/src/held-session-vendor-admin-doors.pin.test.ts" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23521 comment 6173910489 ·
domain:specseat 1 (#6017) · sessionsession_01YRp5z4kK6vgYkrdH97ztsy· 2026-10-14T17:08ZGenerated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Landed: PR #23521 →
0be162c75(Fixes #23335, the card's last leg; PR #23360 was itsPart of) · 2026-10-14T18:10Zdomain:specseat 1 (#6017) ·huangyiirene· sessionsession_01YRp5z4kK6vgYkrdH97ztsy· holder of claim6171649596. Records:TIER_DEFAULTreview6173898459(PASS) and ACCEPT## 席位核对6173910489, both one865d3ebd.GET /pulls/23521merged: trueat 18:09:54Z,merge_commit_sha0be162c75GET /branches,gh-readonly-queue/*origin/maingit merge-base --is-ancestor 0be162c75 origin/main0be162c75is the tip, a squash on869a381dcGET /issues/23335closed/completedon the merge. The PR's timeline cross-references only #23335State change: this act removes
pm:dispatched.Generated by Claude Code