Repository navigation
fix(rest)!: security/explain refuses an explicit empty userId with 400 instead of explaining the caller - #27714
Conversation
An empty userId names nobody. The route dropped it before the schema, and the engine's truthy test then explained the caller. The route now answers 400 VALIDATION_FAILED for it on both transports, and the engine sends any userId that is not omitted through the other-user arm. An omitted userId still explains the caller. Claude-Session: https://claude.ai/code/session_01L6bhgTYZasLoBBaTy45mgo Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 31 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 9b2a6035b1007e8305dc0181bf1bca32f81bfc4c && git checkout 9b2a6035b1007e8305dc0181bf1bca32f81bfc4c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c5d51f0303718c40d2cc7410adb060c483aba5ab 821c2ced1dd2b12b9f4babbbf56f1aca7ee5a6aa && git checkout -B drift-repro c5d51f0303718c40d2cc7410adb060c483aba5ab && git merge --no-ff 821c2ced1dd2b12b9f4babbbf56f1aca7ee5a6aa
node scripts/docs-audit/affected-docs.mjs --json c5d51f0303718c40d2cc7410adb060c483aba5ab
|
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head 821c2ce · 2026-10-29T20:20Z |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: Inputs: card #27442 (body; comments 6491470512, 6504756487, 6507415474), PR #27714 body and file list, the net diff ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
② Semver level
③ Boundary flags
④ Design judgmentPer in-scope file, judged against the cited code at the head and the card's scenario.
Sibling reading for the seat to file (outside this card, not in the diff, read only — not measured here): Findings: must-fix — none. should-fix — none. Nits — the route comment does not name Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F 维护者速读(席位终稿)
席位意见: ACCEPT。按分诊「只拒显式空串」实现,门和插件两层都堵上;收窄已过契约审查,同族的空 席位核对ACCEPT · head
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Requested by Sales · project thread
Fixes #27442
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: updated here —
content/docs/permissions/explain.mdxnow says an emptyuserIdis refused400 VALIDATION_FAILED, beside "omituserIdto explain yourself". No file underskills/**teaches an emptyuserId:skills/objectstack-data/rules/security.mdnames the query parameters as a list (?object=&operation=&userId=) and says nothing about an empty value, so no skill line changes.维护者速读
GET/POST /api/v1/security/explain)收到显式的空userId("userId": ""或?userId=)时,改为回400 VALIDATION_FAILED,不再悄悄解释调用者本人;不传userId仍解释调用者本人。userId、传真实用户 id、传不存在的 id 三种行为与 main 一致。引擎侧同步改为userId !== undefined才算「解释自己」,直接调用引擎的代码传空串会得到 403 或 404。回滚即还原security-routes.ts与security-plugin-explain-access.ts各一处。packages/rest/src/security-routes.ts新增的src.userId === ''拒绝分支;packages/plugins/plugin-security/src/security-plugin-explain-access.ts的request.userId !== undefined判断。pnpm --filter @objectstack/rest exec vitest run src/security-routes.test.ts -t 27442,预期 3/3 通过。What changes
@objectstack/rest).GET/POST {basePath}/security/explainanswers400 VALIDATION_FAILEDwhenuserIdis the empty string, through the family's one refusal emitter (the same ADR-0112 envelope and code its schema refusals answer), before any explanation is computed. Before, the route dropped an explicit""along with an omitted value, so the request fell through to a self-explanation of the caller.?userId=names the key, and the query parser hands the route""(measured on the showcase boot), so it is refused the same way. Only a request that does not nameuserIdat all, or sends JSONnull, is "explain me", as before.@objectstack/plugin-security).explainAccessForCallerenters the other-user arm whenrequest.userId !== undefined(was a truthy test). An empty id therefore meets the existing gates there: a caller without the right to explain others gets the same403 PERMISSION_DENIED, and amanage_userscaller gets the404 RECORD_NOT_FOUNDan unknown id gets. A direct caller of the engine can no longer reach the self-explain fold with"". Every in-tree caller ofsecurity.explainomitsuserId(measured:rest-server.ts,runtime/domains/automation.ts,service-storagex2,plugin-webhooks,plugin-sharing,plugin-audit), so none of them moves.VALIDATION_FAILEDis an existing ledgered code, andExplainRequestSchemais untouched (itsz.string().optional()admits"", which is why the refusal sits at the route).content/docs/permissions/explain.mdxstates the refusal next to "omituserIdto explain yourself".Tests
packages/rest/src/security-routes.test.ts— new block[#27442]: POSTuserId: ""→400,success: false,error.codeVALIDATION_FAILED, noallowed,explainnever called; GET?userId=→ the same,explainnever called; omitteduserId(POST and GET) still callsexplainwithout one, and a real other id is still passed through.packages/plugins/plugin-security/src/security-plugin.test.ts— new block[#27442]: amanage_userscaller askinguserId: ""getsExplainUserNotFoundError(RECORD_NOT_FOUND, 404) instead of an explanation of itself; a plain member getsPermissionDeniedError(403); an omitteduserIdstill explains the caller.packages/qa/dogfood/test/explain-unknown-user.dogfood.test.ts— new case[empty]on the showcase boot (the security explain answers 200 allowed:true for a userId that matches no sys_user row, built from the synthesized baseline principal #26641 sibling, same boot, no new one): the seed admin's POSTuserId: ""and GET?userId=both answer400 VALIDATION_FAILEDwith no verdict and no principal. The file's existing[existing]case keeps pinning that an omitteduserIdexplains the caller and a real other id is answered.Acceptance notes
200(expected 200 to be 400) and the engine case answered an explanation of the caller.git diff HEADempty):if (src.userId === '')replaced by a never-true marker, rebuilt,ablation-dist-preflightproved the marker inpackages/rest/dist/index.{js,cjs}): the unit file goes2 failed | 17 passed(expected 200 to be 400) and the dogfood file1 failed | 5 passed, answering404 RECORD_NOT_FOUNDnaming user''— the engine's own line holding once the door's is gone. Rebuilt after restore; preflight--absentclean.request.userId &&: the engine block goes1 failed(expected { allowed: true, … }to be the refusal).401first, then501when no explain service, then this400with the schema refusals.nullin a POST body keeps reading as omitted (unchanged); the ruling refuses only an explicit empty string.claude/project-thread-sbdkgo(claim 6504756487 names it); card-thread sessionsession_01L6bhgTYZasLoBBaTy45mgo.Verification
All readings on head
821c2ced(the fix297d9907merged withorigin/main86f2afeb), in a worktree ofobjectstack-ai/objectstack, underscripts/pm/os-verify-lock.sh.Unit suites and typecheck
pnpm --filter @objectstack/rest exec vitest run:Test Files 344 passed (344),Tests 6945 passed | 344 skipped (7289).pnpm --filter @objectstack/plugin-security exec vitest run:Test Files 237 passed (237),Tests 5119 passed | 45 skipped (5164).pnpm --filter @objectstack/rest typecheckandpnpm --filter @objectstack/plugin-security typecheck(includingcheck:test-typecheck, 0 errors): exit 0.security-routes.test.ts2 failed | 17 passed(expected 200 to be 400); the engine block1 failed(an explanation of the caller where the refusal was expected).Door (dogfood) and the full battery
pnpm --filter @objectstack/dogfood exec vitest run test/explain-unknown-user.dogfood.test.ts:Tests 6 passed (6).pnpm --filter @objectstack/dogfood testafter the closure build (turbo run build --filter='@objectstack/dogfood^...'), no shard, no path:Test Files 352 passed | 3 skipped (355),Tests 2845 passed | 20 skipped (2865), lock VERDICT command-exit 0.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 95 families; each was run and its exit code captured before any pipe;--ranreconciliation:95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.check-plugin-teardown-shape --self-test(pinned fixture commit fetched, 48 cases pass), speccheck:docsandcheck:skill-examples(aftergen:schema),check:dual-build-cjs-loads(after building the seven packages it listed with nodist/).pnpm lint(repo-wideeslint . --no-inline-config): exit 0.packages/spec,docs/adr/**,skills/**or.github/**path is touched. The claim measured no open PR editing either source file.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F