Uh oh!
There was an error while loading. Please reload this page.
tests(identity): pin the admin credential clauses and a derived non-admin refusal sweep over the whole /admin/ surface - #9664
Conversation
…dmin refusal sweep (#9482) QA run #9401 scored `identity-auth.admin-lifecycle-operations` green on a pin covering exactly one of its ten clauses (C8, the audit-log shapes). Two new dogfood pins close the clauses that are automatable on this stack, and the item's `automated.ref` is re-scoped to say precisely what stays manual, what is now pinned, and what is blocked on a product decision rather than on a test. Derived non-admin refusal sweep. The clause is "the gate holds both ways for EVERY admin operation", so the route population is read off the RUNNING stack rather than listed: `honoApp.routes` for the 9 ObjectStack raw mounts that sit ahead of better-auth's catch-all, unioned with `auth.api` for the 24 endpoints the catch-all publishes. 31 routes, against the 6 the clause prose names. The derived set and the suite's classification table are checked for exact agreement in both directions, so a newly mounted /admin/ route fails the suite by name until someone records what a non-admin must get from it -- which is what makes this catch a silently unguarded new route rather than only today's. Every route is fired with a payload valid enough to REACH the authorization check. That is load-bearing: better-auth and the ObjectStack sso/unlock-user/ oauth2 mounts validate the body first, so an empty-body route-walk draws an identical 400 VALIDATION_ERROR for member and admin alike and asserts nothing about the gate while looking exactly like a passing security sweep. Credential clauses. create-user applies an EXPLICIT password supplied alongside generatePassword:true and returns no generated one, with a generatePassword-only create as the contrast so the clause cannot be satisfied by generatePassword being inert; the account carries its credential sys_account row. set-user-password rotates -- the original password is proven to work before the rotation, the new one after, the old one refused 401 INVALID_EMAIL_OR_PASSWORD. Both routes' refusals are asserted with the state read back: a forged create leaves no row, and a forged rotation leaves the victim's real password working. Measured and NOT pinned. C0/C3/C4/C5/C6 ride better-auth's own admin endpoints, which authorize on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 deliberately stopped synthesizing -- so they refuse the PLATFORM ADMIN himself (403 YOU_ARE_NOT_ALLOWED_TO_*). Filed as #9652 with the three options; not fixed here, because changing an admin route's behaviour is not a tests-only call. Neither side of that bucket's allowed half is asserted, so the fix will not have to edit these tests. C7's oracle is a screenshot and stays manual. Findings filed unassigned: #9652 (the gate mismatch), #9653 (the /admin/sso/* bridges carry no ObjectStack-side gate and their delegated gate is unprovable while SSO is off), #9654 (validation runs before authentication on the ObjectStack /admin/ mounts). Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. |
…9482) The `objectstack-gate` bucket no longer contains the /admin/sso/* routes, so the example of a post-gate semantic error no longer applies to them. Names the two measured cases instead: 200 for unlock-user, 404 RESOURCE_NOT_FOUND for toggle-disabled's unknown client. Comment only, no assertion changes. Co-Authored-By: Claude <noreply@anthropic.com>
os-zhuang
commented
Aug 18, 2026
PM review — accepted, ready + enqueued. Two questions returned: one answered here, one ⛔ not mine.
Verified myself, not taken on reportThe head is not the tree that was tested — and the dev said so. Green run was measured at Every Six required checks all Both ablations are real, each with ✅ Q2 — answered, and this one is a PM call: keep #9482 open, |
Part of #9482.
Deliberately
Part of, notFixes: this pins the clauses that are automatable on this stack and re-scopes the item honestly, but five of the item's clauses are blocked on a product decision (#9652) and one is a screenshot oracle. Closing #9482 on merge would lose that remaining half.QA run #9401 scored
identity-auth.admin-lifecycle-operationsgreen on a pin covering exactly one of its ten clauses (C8, the audit-log shapes).What lands
admin-route-nonadmin-refusal.dogfood.test.ts— C9's refusal half, over a DERIVED population.The clause is "the gate holds both ways for EVERY admin operation", and a test that hardcodes today's routes cannot pin that — it passes forever while route N+1 ships unguarded. So the population is read off the running stack:
honoApp.routes— ObjectStack raw mounts, registered ahead of better-auth's catch-all so they never appear inauth.apiauth.api— the better-auth endpoint table, the seamauth-route-ledger.conformance.test.tsalready usescreate-user,set-user-password)The item's clause-9 prose names six routes. The real surface is 31.
The derived set and the suite's classification table are checked for exact agreement in both directions: a route the stack serves with no entry fails by name, and an entry the stack no longer serves fails as stale. That is what puts route N+1 in scope automatically — it cannot be added without someone recording what a non-admin must get from it.
The payloads are load-bearing. Measured: better-auth, and the ObjectStack
sso/unlock-user/oauth2mounts, validate the request body before the authorization check. An empty-body/admin/ban-useranswers a plain member:byte-identical to what the platform admin gets. A route-walk built on empty bodies asserts nothing about authorization while looking exactly like a passing security sweep. Every route here is fired with a payload valid enough to reach the gate, and refusals assert
codeas well as status.admin-credential-lifecycle.dogfood.test.ts— C1 and C2, both sides.generatePassword: trueapplies the explicit one and returns no generated password — with ageneratePassword-only create as the contrast, so the clause cannot be satisfied bygeneratePasswordbeing inert. The account carries its credentialsys_accountrow.401 INVALID_EMAIL_OR_PASSWORD.What is NOT pinned, and why
#9652 in one line: better-auth's admin plugin authorizes on the legacy
user.role === 'admin'scalar, which ADR-0068 D2 deliberately stopped synthesizing — soban-user,unban-user,set-role,remove-user,impersonate-user,revoke-user-session(s),list-users,get-user,list-user-sessionsandupdate-userrefuse the platform admin himself with403 YOU_ARE_NOT_ALLOWED_TO_*. Measured on the seeded dev admin:sys_user.roleis'user',positionsis['user','platform_admin']. Confirmed by construction — writingrole = 'admin'onto that same row flips ban / unban / list-users / impersonate to 200 in the same boot, and the ban then persists and refuses the banned user's sign-in.Pinning a clause there would require changing an admin route's behaviour, which is not a tests-only call — so it is reported, not done. Neither side of that bucket's allowed half is asserted, so fixing #9652 will not have to edit these tests.
The four
/admin/sso/*routes are classifiedcapability-disabled: with SSO off, anonymous, member and admin receive identical capability errors, so authorization is not observable on them here. They carry a tripwire — member and admin must get the same answer — so the day SSO is enabled they go red and get reclassified rather than coasting on a meaningless green (#9653).Findings filed (unassigned, none fixed here)
user.role === 'admin'scalar ADR-0068 D2 stopped synthesizing #9652 — the ADR-0068 / better-auth admin-gate mismatch above. Fails closed; a broken admin capability, not an escalation. The console's Ban / Unban / Impersonate buttons aretype: 'api'proxies onto those routes, so they inherit it.finding: the/admin/sso/*bridges carry no ObjectStack-side gate and their delegated gate is unprovable while SSO is off.finding: validation runs before authentication on the ObjectStack/admin/mounts, so an anonymous caller can probe required-parameter shapes.Verification
Suite:
vitest runover both new files —Test Files 2 passed (2),Tests 12 passed (12), exit 0.Measured on the tree of
a4362e217. Head is2a3fb5835, which differs from it by seven//comment lines and nothing else — verified mechanically (every+/-line ingit diff a4362e217 2a3fb5835begins with//), so no executable code changed after the green run.Ablations. Dogfood resolves
@objectstack/plugin-auththroughdist/, so each leg rebuilt the package and proved the mutation reached the artifact withscripts/ablation-dist-preflight.mjsbefore its colour counted./admin/routePOST /api/v1/auth/admin/ablation-new-route: expected [ Array(1) ] to deeply equal []gateAdmincreate-userreturned200with a real user created,expected 200 to be 403--absent: marker gone from all 12 built filesThe first ablation is the one that matters for this card's premise: it demonstrates that a newly mounted
/admin/route is in scope automatically and fails until it is classified.Local gates, derived from the changed paths with
node scripts/pm/dispatch-gates.mjs(not recalled):The two new tests add no filesystem reads, so they are not cross-package escaping tests and need no new declaration — verified by query, with a control proving the search works (0 hits in the new files, 4 in the sibling escaping test). Neither file contains
.skip/.only/.todo, also verified with a control.Two legs deferred, stated rather than implied.
check:type-check-debtis NOT MEASURED locally: it refuses without the full workspace closure built, naming@objectstack/service-knowledgeas unbuilt, and that refusal means not-measured, never not-applicable. CI runs it with the closure built. And the universal-invariant assertion's own ablation was not run — the shared verify lock stayed contended (queue depth 8-10 for ~25 minutes). Its discrimination is inferred from thegateAdminablation, which produced exactly the condition it tests (a plain member receiving 2xx from an/admin/route) and was caught by the sibling assertion in the same file; it is not directly observed.CI convergence is the PM's, per the 2026-08-10 ruling.
Changeset
None: the diff is
packages/qa/dogfood/test/**(@objectstack/dogfoodisprivate: true) plus the checklist JSON. Nothing publishable changes, andscripts/check-empty-changeset.mjsrejects an empty one — so this carries theskip-changesetlabel instead.Generated by Claude Code