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
security: POST /api/v1/security/explain with userId "" silently explains the caller instead of refusing #27442
Filing gate: ① a product defect found by a QA run (off-clause probe, RUNNER rule 8).
Reproduction
Boot the showcase: objectstack dev --ui --seed-admin. Sign in as admin@objectos.ai.
POST /api/v1/security/explain with body {"object":"showcase_private_note","operation":"read","userId":"","recordId":"ID"}, where ID is any showcase_private_note id.
Actual: 200. The response explains the CALLER (principal = admin), not the user that was asked about.
Mechanism
security-plugin-explain-access.ts (~L136) tests if (request.userId && request.userId !== callerContext?.userId). An empty string is falsy, so the request skips the cross-user authorization branch and the sys_user existence check (~L174), and falls through to self-explain. The response does name the principal, so it does not mislead outright. But an explain asked about "nobody" answers about somebody else.
Why it matters
A client that loses the selected user (an empty picker) gets a confident answer about the wrong principal. The unknown-id case was fixed by #26641; the empty-string case is the same door's remaining gap.
QA-source: #27441 · access-security.record-access-explain · observed beside the clauses
Read fresh. POST /api/v1/security/explain with userId: "" explains the caller instead of refusing. An admin reading the answer believes it describes someone else. From the 18.3.0 RC run ([sweep] v18.3.0 release test sweep #27377 §5); it is retested on the next wave.
Refuse an empty userId with 400. ⛔ An omitted userId keeps its documented meaning (the caller), and only an explicit empty string is refused.
Claim: PM loop round 3 · card thread
Session: session_01L6bhgUCizoYWQVJWjhdR6F (the seat); the card thread is session_01L6bhgTYZasLoBBaTy45mgo
Account: os-sales
Branch: claude/project-thread-sbdkgo (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: objectstack-issue-27442 in the card thread's own container
Domain: domain:server
Seat: domain:server#2
File surface: packages/rest/src/security-routes.ts (the explain route's userId read), packages/plugins/plugin-security/src/security-plugin-explain-access.ts (the explain target test), and tests (stop on breach; explain in the report)
Container & model: M, mode:thread, model: default (dispatch-gates --tier: no path-derived mandate), effort: high
Clause-②: no (narrowing)
Metadata-protocol: none
Responsibility: the security explain door (this lane) | the spec schema admits an empty string and the route drops it before the schema | any admin or agent asking to explain another user's access
Thread-read: 6491470512
Premise: holds (read on main 2026-10-29T18:14Z: the route drops an explicit "" before ExplainRequestSchema, and the plugin's truthy test then explains the caller)
Serial constraints cleared: none (no open PR edits either file). A contract review is owed before approval.
{
"issue": 27442,
"status": "done",
"branch": "claude/project-thread-sbdkgo",
"pr": "https://github.com/objectstack-ai/objectstack/pull/27714",
"session": "session_01L6bhgTYZasLoBBaTy45mgo (the card thread; the seat is session_01L6bhgUCizoYWQVJWjhdR6F)",
"premise_still_valid": true,
"summary": "Both PM assumptions measured true on main 5e2f3864: security-routes.ts dropped an explicit empty userId (src.userId !== '') before ExplainRequestSchema, whose userId is z.string().optional() and admits the empty string, and security-plugin-explain-access.ts then took the self-explain fold on its truthy test. The route now answers 400 VALIDATION_FAILED (existing ledgered code, the family's one refusal emitter, the same envelope as its schema refusals) when src.userId is the empty string, before any explain is computed; the userId spread became src.userId != null, so JSON null still reads as omitted (unchanged). GET counts as explicit: ?userId= names the key and the query parser hands the route the empty string (measured on the showcase boot), so GET and POST refuse alike. The engine enters the other-user arm when request.userId !== undefined, so a direct caller with an empty id gets the existing 403 PERMISSION_DENIED or, with manage_users, the 404 RECORD_NOT_FOUND an unknown id gets. Every in-tree caller of security.explain omits userId (rest-server, runtime automation, service-storage x2, plugin-webhooks, plugin-sharing, plugin-audit), so none moves. No packages/spec edit, no new code. Docs: content/docs/permissions/explain.mdx states the refusal. Head 821c2ced (fix 297d9907, origin/main 86f2afeb merged once); the card's labels and assignee were not touched.",
"tests": "Red first: security-routes.test.ts [#27442] 2 failed | 17 passed (expected 200 to be 400); security-plugin.test.ts -t 27442 1 failed (an explanation of the caller where the refusal was expected). Green on 821c2ced: rest full suite Test Files 344 passed (344), Tests 6945 passed | 344 skipped; plugin-security full suite Test Files 237 passed (237), Tests 5119 passed | 45 skipped; dogfood explain-unknown-user.dogfood.test.ts Tests 6 passed (6), new case [empty] pins POST userId empty and GET ?userId= as 400 VALIDATION_FAILED with no verdict and no principal, and the existing [existing] case pins omitted userId = caller and a real other id answered. rest and plugin-security typecheck exit 0 (check:test-typecheck 0 errors). Ablation 1, route: anchor if (src.userId === '') replaced by a globalThis marker via scripts/ablation-replace.mjs (anchor 1 -> 0, blob dddcada6 -> 523765f7), rest rebuilt, ablation-dist-preflight found the marker in packages/rest/dist/index.js and index.cjs; unit file 2 failed | 17 passed (expected 200 to be 400), dogfood file 1 failed | 5 passed (404 RECORD_NOT_FOUND naming user '' - the engine line holding once the door's is gone); restored blob == HEAD dddcada6, git diff HEAD empty, rebuilt, preflight --absent clean and tree clean. Ablation 2, engine: request.userId !== undefined reverted to the truthy request.userId (blob 72a499a8 -> 25380898), engine block 1 failed; restored blob == HEAD 72a499a8, git diff HEAD empty.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on 821c2ced derived 95 families; all run, exit captured before any pipe; --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. First readings that were prerequisites, rerun exit 0 after meeting them: check-plugin-teardown-shape --self-test exit 3 (pinned fixture 621a4876 fetched; 48 cases pass), spec check:docs exit 1 on the missing generated tree and check:skill-examples exit 3 (after spec gen:schema both 0), check:dual-build-cjs-loads exit 3 (after building studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge: 112 entries / 69 packages load). pnpm lint (repo-wide) VERDICT command-exit 0. PR check-runs: in_progress at report time (the PR was opened minutes ago); CI families not re-run locally.",
"dogfood_battery": "821c2ced · os-verify-lock VERDICT command-exit 0 (held 30m45s) · Test Files 352 passed | 3 skipped (355) · Tests 2845 passed | 20 skipped (2865) · no failures",
"mcp_calls": "3 - mcp__github__issue_read x3 (card get, comments x2); no MCP write tool",
"api_writes": "2 - fleet-write relay pr_create (objectstack-ai/objectstack#27714, draft, no assignees, no labels, no review request; read-back 8519 bytes sent = stored); post-stamped comment on #27442 (this report). git push is not a REST write.",
"open_questions": [],
"out_of_scope_findings": [
"deviation, not a finding: the PR body's attribution block and closing footer follow order-common.md section 6 (seat-2 thread link and the seat session), as on the seat's earlier card-thread PRs; this card thread's own session is named in the body prose (Acceptance notes).",
"noted, not filed: skills/objectstack-data/rules/security.md writes the explain query as ?object=&operation=&userId= (a parameter list with empty values); a reader copying it literally now gets 400 instead of a self-explanation. It already lacked a required object value, so it was never a working request; carrier: none, noted in the PR's Skills-docs line."
]
}
Activity
objectstack-fleet commentedon Oct 29, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 真挡得住的权限 | none | P②
Triage: p2 ·
domain:server·area:access· 2026-10-29T08:11Z⛔ Not a claim.
POST /api/v1/security/explainwithuserId: ""explains the caller instead of refusing. An admin reading the answer believes it describes someone else. From the 18.3.0 RC run ([sweep] v18.3.0 release test sweep #27377 §5); it is retested on the next wave.userIdwith 400. ⛔ An omitteduserIdkeeps its documented meaning (the caller), and only an explicit empty string is refused.bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Claim: PM loop round 3 · card thread
Session:
session_01L6bhgUCizoYWQVJWjhdR6F(the seat); the card thread issession_01L6bhgTYZasLoBBaTy45mgoAccount:
os-salesBranch:
claude/project-thread-sbdkgo(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree:
objectstack-issue-27442in the card thread's own containerDomain:
domain:serverSeat:
domain:server#2File surface:
packages/rest/src/security-routes.ts(the explain route'suserIdread),packages/plugins/plugin-security/src/security-plugin-explain-access.ts(the explain target test), and tests (stop on breach; explain in the report)Container & model:
M,mode:thread,model: default (dispatch-gates --tier: no path-derived mandate),effort: highClause-②: no (narrowing)
Metadata-protocol: none
Responsibility:
the security explain door (this lane) | the spec schema admits an empty string and the route drops it before the schema | any admin or agent asking to explain another user's accessThread-read: 6491470512
Premise: holds (read on main 2026-10-29T18:14Z: the route drops an explicit
""beforeExplainRequestSchema, and the plugin's truthy test then explains the caller)Serial constraints cleared: none (no open PR edits either file). A contract review is owed before approval.
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
os-dev-report
{ "issue": 27442, "status": "done", "branch": "claude/project-thread-sbdkgo", "pr": "https://github.com/objectstack-ai/objectstack/pull/27714", "session": "session_01L6bhgTYZasLoBBaTy45mgo (the card thread; the seat is session_01L6bhgUCizoYWQVJWjhdR6F)", "premise_still_valid": true, "summary": "Both PM assumptions measured true on main 5e2f3864: security-routes.ts dropped an explicit empty userId (src.userId !== '') before ExplainRequestSchema, whose userId is z.string().optional() and admits the empty string, and security-plugin-explain-access.ts then took the self-explain fold on its truthy test. The route now answers 400 VALIDATION_FAILED (existing ledgered code, the family's one refusal emitter, the same envelope as its schema refusals) when src.userId is the empty string, before any explain is computed; the userId spread became src.userId != null, so JSON null still reads as omitted (unchanged). GET counts as explicit: ?userId= names the key and the query parser hands the route the empty string (measured on the showcase boot), so GET and POST refuse alike. The engine enters the other-user arm when request.userId !== undefined, so a direct caller with an empty id gets the existing 403 PERMISSION_DENIED or, with manage_users, the 404 RECORD_NOT_FOUND an unknown id gets. Every in-tree caller of security.explain omits userId (rest-server, runtime automation, service-storage x2, plugin-webhooks, plugin-sharing, plugin-audit), so none moves. No packages/spec edit, no new code. Docs: content/docs/permissions/explain.mdx states the refusal. Head 821c2ced (fix 297d9907, origin/main 86f2afeb merged once); the card's labels and assignee were not touched.", "tests": "Red first: security-routes.test.ts [#27442] 2 failed | 17 passed (expected 200 to be 400); security-plugin.test.ts -t 27442 1 failed (an explanation of the caller where the refusal was expected). Green on 821c2ced: rest full suite Test Files 344 passed (344), Tests 6945 passed | 344 skipped; plugin-security full suite Test Files 237 passed (237), Tests 5119 passed | 45 skipped; dogfood explain-unknown-user.dogfood.test.ts Tests 6 passed (6), new case [empty] pins POST userId empty and GET ?userId= as 400 VALIDATION_FAILED with no verdict and no principal, and the existing [existing] case pins omitted userId = caller and a real other id answered. rest and plugin-security typecheck exit 0 (check:test-typecheck 0 errors). Ablation 1, route: anchor if (src.userId === '') replaced by a globalThis marker via scripts/ablation-replace.mjs (anchor 1 -> 0, blob dddcada6 -> 523765f7), rest rebuilt, ablation-dist-preflight found the marker in packages/rest/dist/index.js and index.cjs; unit file 2 failed | 17 passed (expected 200 to be 400), dogfood file 1 failed | 5 passed (404 RECORD_NOT_FOUND naming user '' - the engine line holding once the door's is gone); restored blob == HEAD dddcada6, git diff HEAD empty, rebuilt, preflight --absent clean and tree clean. Ablation 2, engine: request.userId !== undefined reverted to the truthy request.userId (blob 72a499a8 -> 25380898), engine block 1 failed; restored blob == HEAD 72a499a8, git diff HEAD empty.", "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on 821c2ced derived 95 families; all run, exit captured before any pipe; --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. First readings that were prerequisites, rerun exit 0 after meeting them: check-plugin-teardown-shape --self-test exit 3 (pinned fixture 621a4876 fetched; 48 cases pass), spec check:docs exit 1 on the missing generated tree and check:skill-examples exit 3 (after spec gen:schema both 0), check:dual-build-cjs-loads exit 3 (after building studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge: 112 entries / 69 packages load). pnpm lint (repo-wide) VERDICT command-exit 0. PR check-runs: in_progress at report time (the PR was opened minutes ago); CI families not re-run locally.", "dogfood_battery": "821c2ced · os-verify-lock VERDICT command-exit 0 (held 30m45s) · Test Files 352 passed | 3 skipped (355) · Tests 2845 passed | 20 skipped (2865) · no failures", "mcp_calls": "3 - mcp__github__issue_read x3 (card get, comments x2); no MCP write tool", "api_writes": "2 - fleet-write relay pr_create (objectstack-ai/objectstack#27714, draft, no assignees, no labels, no review request; read-back 8519 bytes sent = stored); post-stamped comment on #27442 (this report). git push is not a REST write.", "open_questions": [], "out_of_scope_findings": [ "deviation, not a finding: the PR body's attribution block and closing footer follow order-common.md section 6 (seat-2 thread link and the seat session), as on the seat's earlier card-thread PRs; this card thread's own session is named in the body prose (Acceptance notes).", "noted, not filed: skills/objectstack-data/rules/security.md writes the explain query as ?object=&operation=&userId= (a parameter list with empty values); a reader copying it literally now gets 400 instead of a self-explanation. It already lacked a required object value, so it was never a working request; carrier: none, noted in the PR's Skills-docs line." ] }objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
ACCEPT → PR #27714 comment 6508012695 · head
821c2ced· 2026-10-29T20:51Z · landing sent (contract review PASS6507706351). Follow-up: #27719 (an emptyobjectat the same door).