Skip to content

Commit 6279efe

Browse files
fix(plugin-security)!: security explain refuses a userId that no sys_user record has with 404 RECORD_NOT_FOUND (#26801)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XXaahUHvAiCTPBZ9rNgWxGn)_ Fixes #26641 Clause-②: no (narrowing) — `POST` / `GET /security/explain` and the in-process `explain()` refuse a request naming another user whose id no `sys_user` record has, which they answered with a verdict before. Nothing is widened. Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/explain.mdx` (the refusal and its place after the 403); no `skills/**` file teaches the explain door's `userId` ## 维护者速读 - **改了什么**:管理员在「访问解释」里查一个不存在的用户 id 时,服务端现在回 404 `RECORD_NOT_FOUND` 并点名该 id,不再回 200 `allowed: true`。 - **为什么改**:解释器为任何 id 都会合成一个基线成员身份,于是对「不存在的人」给出了「能访问」的结论,管理员据此判断是错的(QA #26639 第 4 条)。 - **风险与回滚**:只影响「解释他人」且 id 无对应用户这一条路径;检查顺序不变(匿名 401、无权解释他人者 403 都在存在性读取之前),不泄露 id 是否存在。回滚 = revert 本 PR。 - **先看**:`security-plugin-explain-access.ts`(新增的存在性检查及其位置)、`explain-engine.ts`(与委托人路径共用的 `readSysUserRow`)、`errors.ts`(`ExplainUserNotFoundError`)。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/security-plugin.test.ts -t 26641`,预期 3 条全绿。 - **需要你做的**:无,席位落地。 ## What changes - **The check.** `explainAccessForCaller` (`security-plugin-explain-access.ts`) now reads the explained id through `readSysUserRow` after the caller is admitted to explain someone else and before `buildContextForUser`. No row ⇒ `ExplainUserNotFoundError`: `code: 'RECORD_NOT_FOUND'`, `status` / `statusCode` 404, `userId`, and a message naming the id that says no principal was evaluated. - **One reader.** `readSysUserRow` (`explain-engine.ts`) is the `sys_user` lookup `resolveDelegatorContext` already made, lifted out unchanged (system context, `findOne` by id). The delegator path calls it inside its existing `try` and still reads a failed read as `missing`. The explain path does not catch: a store fault is not absence, so it propagates as a fault instead of being answered "this user does not exist". - **The code.** `RECORD_NOT_FOUND` / 404 is the standard catalog's pair ("Record with given ID not found", `errors.zod.ts`), the same pair `DetailRecordNotFoundError` in this package uses and `GET /data/sys_user/ID` answers. No ledger row, no spec change, no new code. The REST door serves it through its existing classified-refusal arm (#20603): a declared 4xx `code` + `status` keeps its own answer. No REST source changes. - **Order of checks (unchanged).** Anonymous ⇒ 401 at the door. A caller without `manage_users`, organization administration of the user's organization, or a covering delegated scope ⇒ the same 403 `PERMISSION_DENIED` for an unknown id as for an existing user, and no existence read is made. An organization administrator or delegated administrator naming an unknown id therefore gets the 403 (no membership or scope covers nobody); a `manage_users` holder or a system caller gets the 404. - **Unchanged.** Explaining yourself (no `userId`, or your own id) and explaining a user who exists. - **Fixture triage (1 file).** `org-admin-assignment-boundary.posture.test.ts` explained `u_target`, a member with no `sys_user` row (the boot leaves `sys_user` unprovisioned for its write cases), so the read hit "no such table" and the explanation now propagates that fault. Disposition: *add the declaration*: that one case provisions `sys_user` and inserts `u_target` before it explains; its assertions are untouched. - **Docs.** `content/docs/permissions/explain.mdx` names the refusal under "Another user". ## Why The QA run #26639 (observation 4) measured it twice on fresh showcase boots: the seed admin's `POST /api/v1/security/explain` with `{"object":"showcase_task","operation":"read","userId":"no_such_user_qa_123"}` answered `200 allowed: true`, principal `positions: ["everyone"]`, `permissionSets: ["showcase_member_default", "member_default"]`, while `GET /data/sys_user/no_such_user_qa_123` answered 404. Triage `6441402728` ruled: answer 404 for an unknown user, and pin it. The delegator path already states the rule this applies (`resolveDelegatorContext`: "The `sys_user` existence check is the only correct fail-closed point"). ## Tests - **HTTP door, showcase boot** (`packages/qa/dogfood/test/explain-unknown-user.dogfood.test.ts`, new, 5 cases): `[control]` the data door's 404 for the same id; `[unknown]` the seed admin is refused 404 `RECORD_NOT_FOUND` naming the id, for `read` and `create`, with no verdict in the body; `[anonymous]` 401; `[unprivileged]` a plain member gets the same 403 `PERMISSION_DENIED` for the unknown id as for the admin who exists; `[existing]` the admin explaining the member, and the member explaining themselves (no `userId`, and their own), answer 200 with that principal. - **Service** (`security-plugin.test.ts`, 3 cases in the ADR-0090 D6/D12 harness): the `manage_users` caller's refusal envelope (`code`, `status`, `statusCode`, `userId`, the id in the message) for `read` and `create`; a plain caller and a delegated admin both get `PermissionDeniedError` and `sys_user` is never asked about the unknown id; an existing user and a self-explanation by an id with no row still answer. - **REST envelope** (`security-explain-envelope.test.ts`, 1 case): a thrown `RECORD_NOT_FOUND` / 404 leaves both transports as the nested 404 `RECORD_NOT_FOUND` envelope, not the 500. ## Acceptance notes - **The Studio explain panel (asked by the order).** `AccessExplainPanel.tsx` (`ui/packages/app-shell`) is the only `ui/**` caller that sends `userId`; the grid and detail verdict hooks (`useRecordCrudVerdicts`, `useRecordEditable`) explain the signed-in user only and never name another id. The panel picks the user from a record picker, so an unknown id reaches it only for a user deleted after it was picked. Its non-403 branch reads `data.message || data.error`; on the server's nested envelope `error` is an object, and rendering it unmounts the panel. Measured in a throwaway render probe over the panel's own test harness (deleted, not committed): a 404 nested envelope ⇒ `document.body` is an empty `div`, no alert; the control, a flat `{ message }` 404 body ⇒ the alert "Explain failed — Unknown object x". This is NOT new with this PR: every non-403 refusal the door already sends (400 `VALIDATION_FAILED`, 404 `OBJECT_NOT_FOUND` for a mistyped object in the free-text input, 400 `INVALID_FILTER`, 500) takes the same branch. Reported to the seat as a finding; not fixed here. - **Overlap.** #26337 (ADR-0142 phase 2, open) edits all three source files of this diff. Its hunks are elsewhere: `errors.ts` adds a class after `ExplainObjectNotFoundError` (this PR adds one before it), `explain-engine.ts` at the deps interface and record attribution (this PR at `resolveDelegatorContext`), `security-plugin-explain-access.ts` at the imports, host interface and the sharing block (this PR inside the explain-another-user branch). #26698 (same package, runtime metadata) has no PR yet. - **A store fault while reading the id** propagates as a fault (the door's 500 / classified answer, as a resolver fault already did on this path); it is never answered as "this user does not exist". ## Verification Head `6c7cf979da` (the fix, then `origin/main` merged in: #26337 landed in the same three source files mid-run, merge clean). Every reading below was taken at this head unless it says otherwise; the first head `39a455551f` (before the merge) was measured the same way and was green throughout (full dogfood 303 files / 2510 tests). - **Red first, by ablation** (at `39a455551f`; the fix committed first; `scripts/ablation-replace.mjs` turned the check off behind a never-set env marker; plugin-security rebuilt; `ablation-dist-preflight.mjs` saw the marker in `dist/index.js` and `dist/index.mjs`): the dogfood `[unknown]` case red with exactly the card's answer, `expected 200 to be 404`, body `"allowed":true … "positions":["everyone"],"permissionSets":["showcase_member_default","member_default"]`; the service case red (`expected null to match object`). The other 4 dogfood cases and 2 service cases stayed green. Restored: blob equals HEAD, `git diff HEAD` empty, rebuilt, preflight `--absent` ✓, tree clean. - **Suites:** `pnpm --filter @objectstack/plugin-security test`: 231 files, 5032 passed, 45 skipped, exit 0. `pnpm --filter @objectstack/rest test`: 326 files, 6499 passed, 326 skipped, exit 0. Before the fixture triage, the plugin-security suite was red in exactly the 3 posture cases named above (and nowhere else). - **Gates:** `dispatch-gates.mjs --commands` on this diff derived 95 families; all 95 ran and exit 0 at this head (`--ran` reconciliation: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN). At the first head, three first exited non-zero for reasons outside the diff and were re-run green: `check-comment-mask-corpus` (exit 1: my throwaway UI probe file was deleted mid-sweep), `check:skill-examples` and `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: spec declarations and seven unrelated packages unbuilt; built, then green). Also green: `eslint --no-inline-config` on the 7 touched code files, `typecheck` and `check:test-typecheck` for plugin-security and rest, dogfood `typecheck`, `check-platform-checklist --changed-from origin/main`, `check-nul-bytes`. - **Full dogfood** (`(narrowing)` arm): `pnpm --filter @objectstack/dogfood test` under the verify lock, after `pnpm --filter '@objectstack/dogfood^...' build`: VERDICT command-exit 0, Test Files 303 passed / 3 skipped (306), Tests 2512 passed / 20 skipped (2532), no failure. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (spec untouched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on the touched files). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is Chinese by the repo's PR convention). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kdwdgQcbyZc22QQYf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f79ec28 commit 6279efe

9 files changed

Lines changed: 300 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: security explain refuses a `userId` that no `sys_user` record has with `404 RECORD_NOT_FOUND`, naming the id, instead of explaining the baseline principal the resolver builds for any id
6+
7+
Clause-②: no (narrowing) — `POST` / `GET /security/explain` and the in-process `explain()` refuse a request naming another user whose id no `sys_user` record has, which they answered with a verdict before. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the explain request's userId keeps its shape, and an id that names nobody is now refused instead of explained. A verdict about nobody had no meaning to keep, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing at the access-explanation door (`@objectstack/plugin-security`), served at `/security/explain` on both transports.
13+
14+
- **Before.** An administrator explaining another user by an id that no `sys_user` record has (a typo, a deleted user, an id from another deployment) was answered `200` with a full verdict, usually `allowed: true`: the authorization resolver gives any id the baseline position and permission sets. `GET /data/sys_user/ID` answered 404 for the same id.
15+
- **Now.** That request is refused `404 RECORD_NOT_FOUND`, and the message names the id. The id is read through the same `sys_user` existence read the on-behalf-of delegator path uses.
16+
- **Unchanged.** The order of checks: an anonymous caller is refused 401, and a caller who may not explain others gets the same 403 `PERMISSION_DENIED` whether the id exists or not, before any existence read. Explaining yourself (no `userId`, or your own) and explaining a user who exists answer as before.
17+
- **Who sees it.** A caller holding `manage_users` (or a system caller) who names an id that does not exist. An organization administrator or a delegated administrator naming such an id still gets the 403: no membership or scope covers a user who does not exist.
18+
19+
**The remedy.** None needed: pick the user from the directory, or check the id.

‎content/docs/permissions/explain.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ rule:
9696
`adminScope` whose business-unit subtree covers that user (ADR-0090 D12):
9797
an administrator who can rewire a user's grants may read why they resolve
9898
the way they do — and *only* a covering administrator. Anything else → 403.
99+
- **A `userId` no user has** — once the caller may explain that user, an id
100+
with no `sys_user` record is refused `404 RECORD_NOT_FOUND`, naming the id,
101+
instead of being explained as a baseline member. The 403 above comes first,
102+
so a caller who may not explain others cannot use explain to learn which ids
103+
exist.
99104

100105
A deployment without `@objectstack/plugin-security` answers 501.
101106

‎packages/plugins/plugin-security/src/errors.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,45 @@ export class MaskedValueWriteError extends Error {
341341
}
342342
}
343343

344+
/**
345+
* [#26641] `explain` was asked about ANOTHER user, by an id no `sys_user` row
346+
* has → `404 RECORD_NOT_FOUND`, naming the id.
347+
*
348+
* The explainer builds the explained principal through the authorization
349+
* resolver, which synthesises the baseline anchor and sets for ANY id. So an
350+
* id that names nobody (a typo, a deleted user, an id from another deployment)
351+
* was explained as a baseline member and answered `200 allowed: true`: a
352+
* verdict about a person who does not exist. As with an unknown object
353+
* ({@link ExplainObjectNotFoundError}), nothing can be evaluated, so the honest
354+
* answer is a refusal, not a verdict.
355+
*
356+
* `RECORD_NOT_FOUND` at 404 is the standard catalog's pair for "the record with
357+
* this id does not exist", the answer `GET /data/sys_user/ID` gives for the
358+
* same id, so it needs no ledger row. The REST door serves it through its
359+
* classified-refusal arm, which keeps a declared 4xx `code` and `status`.
360+
*
361+
* Only a caller already admitted to explain someone else reaches this check,
362+
* so it tells nobody new which ids exist: an anonymous caller is refused 401
363+
* at the door and a caller who may not explain others gets their 403 before
364+
* any existence read. The prefix is not the `[Security] Access denied`
365+
* matcher, and the class carries both `status` and `statusCode`, for the
366+
* reasons the classes above record.
367+
*/
368+
export class ExplainUserNotFoundError extends Error {
369+
readonly code = 'RECORD_NOT_FOUND';
370+
readonly status = 404;
371+
readonly statusCode = 404;
372+
readonly userId: string;
373+
constructor(userId: string) {
374+
super(
375+
`[Security] Unknown user: explain was asked about user '${userId}', but no sys_user record has that id. `
376+
+ `No principal was evaluated, so this is NOT an access decision.`,
377+
);
378+
this.name = 'ExplainUserNotFoundError';
379+
this.userId = userId;
380+
}
381+
}
382+
344383
/**
345384
* [#18253] `explain` was asked about an object name that no declaration on this
346385
* runtime answers to → `404 OBJECT_NOT_FOUND`.

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,24 @@ export async function buildContextForUser(
790790
};
791791
}
792792

793+
/**
794+
* [#26641] The `sys_user` row a user id names, read under the system context,
795+
* or `null` when no row has that id. The one existence read of a user the
796+
* explainer makes: {@link resolveDelegatorContext} asks it of a delegator, and
797+
* the explain API (`explainAccessForCaller`) asks it of the user it was asked
798+
* to explain. Each needs it for the same reason: {@link buildContextForUser}
799+
* resolves a principal for ANY id, because the resolver synthesises the
800+
* baseline anchor and sets for an id no row has.
801+
*
802+
* A read that throws is not absence, so this function does not catch: each
803+
* caller decides what a failed read means. The delegator path fails closed and
804+
* reads it as `missing`; the explain API lets it propagate as a fault rather
805+
* than answer that the user does not exist.
806+
*/
807+
export async function readSysUserRow(ql: any, userId: string): Promise<any | null> {
808+
return (await ql.findOne('sys_user', { where: { id: userId }, context: SYSTEM_CTX })) ?? null;
809+
}
810+
793811
/**
794812
* [ADR-0090 D10] Result of resolving the delegator behind an on-behalf-of
795813
* principal. `none` = no delegation link; `missing` = the link names a user that
@@ -864,7 +882,7 @@ export async function resolveDelegatorContext(
864882
if (!oboId) return { kind: 'none' };
865883
let user: any = null;
866884
try {
867-
user = await ql.findOne('sys_user', { where: { id: oboId }, context: SYSTEM_CTX });
885+
user = await readSysUserRow(ql, oboId);
868886
} catch {
869887
user = null;
870888
}

‎packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ describe('[ADR-0090 D12, amended 2026-10-17] the organization administrator assi
247247
it('the delegable-scope report offers the org admin its in-boundary positions only, and explain admits it for its member', async () => {
248248
const h = await boot(posture);
249249
const security = h.plugin as any;
250+
// [#26641] Explain answers only for a user a `sys_user` row carries, so
251+
// the explained member gets one here (the boot leaves the table
252+
// unprovisioned for the write cases above).
253+
await h.engine.syncObjectSchema(SysUser.name);
254+
await h.engine.insert('sys_user', { id: 'u_target', name: 'Target', email: 'u_target@example.test' }, { context: { isSystem: true } } as any);
250255
for (const context of [CALLERS.orgAdmin, CALLERS.orgAdminNoBypass]) {
251256
const sets = await security.resolvePermissionSetsForContext(context);
252257
const report = await security.delegatedAdminGate.describeDelegableScope(sets, context);

‎packages/plugins/plugin-security/src/security-plugin-explain-access.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,15 @@ import { OWNERSHIP_QUEUE_SERVICE, type AuthoredRowWriteOperation, type IOwnershi
7272
import type { ExplainDecision, ExplainOperation, PermissionSet } from '@objectstack/spec/security';
7373
import type { SecurityPlugin } from './security-plugin.js';
7474
import { absentUnderCallerRead, requiredCapsForOperation, unreadableUnderCallerRead } from './security-plugin.js';
75-
import { PermissionDeniedError } from './errors.js';
75+
import { ExplainUserNotFoundError, PermissionDeniedError } from './errors.js';
7676
import { grantRowPolicySpeaks } from './external-owd-grant-stamp.js';
7777
import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js';
7878
import {
7979
buildContextForUser,
8080
delegatedOrganizationRefusal,
8181
explainAccessForAsker,
8282
intersectDelegatedScope,
83+
readSysUserRow,
8384
resolveDelegatorContext,
8485
} from './explain-engine.js';
8586

@@ -164,6 +165,15 @@ export async function explainAccessForCaller(
164165
}
165166
}
166167
}
168+
// [#26641] ...and only a user who exists is explained. The resolver
169+
// synthesises the baseline anchor and sets for ANY id, so an id no
170+
// `sys_user` row has was explained as a baseline member, `allowed: true`.
171+
// Asked here, after the caller is admitted and never before, so a caller
172+
// who may not explain others learns nothing about which ids exist; read
173+
// through the delegator path's own existence read (`readSysUserRow`).
174+
if (!(await readSysUserRow(this.ql, request.userId))) {
175+
throw new ExplainUserNotFoundError(request.userId);
176+
}
167177
// [#20515] Resolved in the CALLER's organization — the one the explain
168178
// right above was checked in. Grants scoped to any other organization do
169179
// not apply there, and with no active organization only global grants do.

‎packages/plugins/plugin-security/src/security-plugin.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3621,6 +3621,68 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => {
36213621
expect(self.principal.userId).toBe('u_east_1');
36223622
});
36233623

3624+
// [#26641] Explaining ANOTHER user by an id no `sys_user` row has is refused
3625+
// `404 RECORD_NOT_FOUND`, naming the id: the resolver synthesises a baseline
3626+
// principal for any id, so it used to answer a verdict about nobody. The
3627+
// existence read runs only after the caller is admitted to explain others.
3628+
describe('[#26641] an id no sys_user row has', () => {
3629+
const GHOST = 'u_no_such_user_26641';
3630+
/** The ids `sys_user` was asked about, in order. */
3631+
const userReads = (plugin: any): string[] => {
3632+
const seen: string[] = [];
3633+
const ql = (plugin as any).ql;
3634+
const findOne = ql.findOne.bind(ql);
3635+
ql.findOne = async (object: string, opts: any) => {
3636+
if (object === 'sys_user') seen.push(String(opts?.where?.id));
3637+
return findOne(object, opts);
3638+
};
3639+
return seen;
3640+
};
3641+
3642+
it('a caller holding manage_users is refused 404 RECORD_NOT_FOUND naming the id', async () => {
3643+
const { plugin } = await boot();
3644+
const caller = { userId: 'u_hr', positions: [], permissions: ['hr_admin'] };
3645+
for (const operation of ['read', 'create']) {
3646+
const err: any = await plugin
3647+
.explainAccessForCaller({ object: 'task', operation, userId: GHOST }, caller)
3648+
.then(() => null, (e) => e);
3649+
expect(err, `${operation}: refused, not answered`).toMatchObject({
3650+
name: 'ExplainUserNotFoundError', code: 'RECORD_NOT_FOUND', status: 404, statusCode: 404, userId: GHOST,
3651+
});
3652+
expect(err.message).toContain(`'${GHOST}'`);
3653+
}
3654+
});
3655+
3656+
it('a caller who may not explain others gets their 403 first, and no existence read is made', async () => {
3657+
const { plugin } = await boot();
3658+
const seen = userReads(plugin);
3659+
for (const caller of [
3660+
{ userId: 'u_east_1', positions: [], permissions: [] },
3661+
{ userId: 'u_delegate', positions: [], permissions: ['sub_admin'] },
3662+
]) {
3663+
await expect(
3664+
plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: GHOST }, caller),
3665+
`${caller.userId}: the same refusal an existing user outside their reach gets`,
3666+
).rejects.toMatchObject({ name: 'PermissionDeniedError', code: 'PERMISSION_DENIED' });
3667+
}
3668+
expect(seen, 'the unknown id is never looked up for a caller refused the right').not.toContain(GHOST);
3669+
});
3670+
3671+
it('an existing user is still explained, and a self-explanation is never refused for its id', async () => {
3672+
const { plugin } = await boot();
3673+
const hr = { userId: 'u_hr', positions: [], permissions: ['hr_admin'] };
3674+
const other = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, hr);
3675+
expect(other.principal.userId).toBe('u_west_1');
3676+
// Explaining yourself is unchanged: no `userId`, or your own, explains the
3677+
// request's own context, whether or not a row carries its id.
3678+
const ghostSelf = { userId: GHOST, positions: [], permissions: [] };
3679+
for (const request of [{}, { userId: GHOST }]) {
3680+
const self = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', ...request }, ghostSelf);
3681+
expect(self.principal.userId).toBe(GHOST);
3682+
}
3683+
});
3684+
});
3685+
36243686
// [#20515] Explaining ANOTHER user resolves that user's grants in the CALLER's
36253687
// organization — the one the explain right was checked in. A grant scoped to
36263688
// an organization applies only while that organization is the one resolved
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26641 — `POST /security/explain` naming a `userId` that no `sys_user` row
5+
* has is refused `404 RECORD_NOT_FOUND`, naming the id, on the showcase boot.
6+
*
7+
* ## The defect
8+
*
9+
* The explainer builds the explained principal through the authorization
10+
* resolver, which synthesises the baseline anchor and sets for ANY id. So the
11+
* seed admin explaining `no_such_user_qa_123` was answered `200 allowed: true`
12+
* with principal `positions: ["everyone"]` and the member baseline sets, while
13+
* `GET /data/sys_user/no_such_user_qa_123` answered 404: a verdict about a
14+
* person who does not exist.
15+
*
16+
* ## The order of checks, pinned with the refusal
17+
*
18+
* The existence read runs only after the caller is admitted to explain someone
19+
* else, so the explain door cannot be used to learn which ids exist:
20+
*
21+
* - `[anonymous]` no session → 401, whatever id the body names.
22+
* - `[unprivileged]` a plain member naming the unknown id gets the same 403
23+
* `PERMISSION_DENIED` it gets naming a user who exists.
24+
* - `[unknown]` the seed admin naming the unknown id → 404 `RECORD_NOT_FOUND`
25+
* naming it, for `read` and `create`, consistent with the data door's 404.
26+
* - `[existing]` the seed admin explaining the member who exists is answered
27+
* as before (200, the principal is that member), and explaining yourself is
28+
* unchanged.
29+
*/
30+
31+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
32+
import { type VerifyStack } from '@objectstack/verify';
33+
import { bootShowcase } from './showcase-boot.js';
34+
import { showcaseAppDefaultSecurity } from './showcase-security.js';
35+
36+
const SYS = { isSystem: true } as const;
37+
const OBJECT = 'showcase_task';
38+
const GHOST = 'no_such_user_qa_123';
39+
const MEMBER_EMAIL = 'explain-unknown-user.member.26641@verify.test';
40+
41+
describe('#26641: security explain refuses a userId no sys_user row has', () => {
42+
let stack: VerifyStack;
43+
let adminTok = '';
44+
let memberTok = '';
45+
let adminId = '';
46+
let memberId = '';
47+
48+
/** `POST /security/explain` as whoever holds `token` (`null` = no session). */
49+
const explain = async (token: string | null, body: Record<string, unknown>) => {
50+
const res = token === null
51+
? await stack.api('/security/explain', {
52+
method: 'POST',
53+
headers: { 'Content-Type': 'application/json' },
54+
body: JSON.stringify(body),
55+
})
56+
: await stack.apiAs(token, 'POST', '/security/explain', body);
57+
const text = await res.text();
58+
let json: any = null;
59+
try { json = JSON.parse(text); } catch { /* the status still reports */ }
60+
return { status: res.status, text, json };
61+
};
62+
63+
beforeAll(async () => {
64+
stack = await bootShowcase({ security: showcaseAppDefaultSecurity() });
65+
adminTok = await stack.signIn(); // the seeded admin first, so the sign-up below is a plain member
66+
memberTok = await stack.signUp(MEMBER_EMAIL);
67+
const ql: any = await stack.kernel.getServiceAsync('objectql');
68+
memberId = String((await ql.findOne('sys_user', { where: { email: MEMBER_EMAIL }, context: SYS }))?.id ?? '');
69+
expect(memberId, 'the signed-up member exists').toBeTruthy();
70+
expect(await ql.findOne('sys_user', { where: { id: GHOST }, context: SYS }), 'no row carries the probe id').toBeFalsy();
71+
const me = await explain(adminTok, { object: OBJECT, operation: 'read' });
72+
expect(me.status, `admin self-explain: ${me.text}`).toBe(200);
73+
adminId = String(me.json?.principal?.userId ?? '');
74+
expect(adminId, 'the admin self-explanation names its principal').toBeTruthy();
75+
}, 300_000);
76+
77+
afterAll(async () => {
78+
await stack?.stop?.();
79+
});
80+
81+
it('[control] the data door answers 404 for the same id', async () => {
82+
const res = await stack.apiAs(adminTok, 'GET', `/data/sys_user/${GHOST}`);
83+
expect(res.status).toBe(404);
84+
});
85+
86+
it('[unknown] the seed admin is refused 404 RECORD_NOT_FOUND naming the id, for read and create', async () => {
87+
for (const operation of ['read', 'create']) {
88+
const r = await explain(adminTok, { object: OBJECT, operation, userId: GHOST });
89+
expect(r.status, `${operation}: ${r.text}`).toBe(404);
90+
expect(r.json?.success, r.text).toBe(false);
91+
expect(r.json?.error?.code, r.text).toBe('RECORD_NOT_FOUND');
92+
expect(String(r.json?.error?.message ?? ''), r.text).toContain(GHOST);
93+
expect(r.json?.allowed, 'no verdict is answered').toBeUndefined();
94+
}
95+
});
96+
97+
it('[anonymous] no session is refused 401 before anything about the id', async () => {
98+
const r = await explain(null, { object: OBJECT, operation: 'read', userId: GHOST });
99+
expect(r.status, r.text).toBe(401);
100+
});
101+
102+
it('[unprivileged] a plain member gets the same 403 for the unknown id as for a user who exists', async () => {
103+
const unknown = await explain(memberTok, { object: OBJECT, operation: 'read', userId: GHOST });
104+
const existing = await explain(memberTok, { object: OBJECT, operation: 'read', userId: adminId });
105+
for (const [label, r] of [['unknown id', unknown], ['existing user', existing]] as const) {
106+
expect(r.status, `${label}: ${r.text}`).toBe(403);
107+
expect(r.json?.error?.code, `${label}: ${r.text}`).toBe('PERMISSION_DENIED');
108+
}
109+
});
110+
111+
it('[existing] the seed admin explaining the member who exists, and anyone explaining themselves, is answered', async () => {
112+
const other = await explain(adminTok, { object: OBJECT, operation: 'read', userId: memberId });
113+
expect(other.status, other.text).toBe(200);
114+
expect(other.json?.principal?.userId).toBe(memberId);
115+
expect(typeof other.json?.allowed).toBe('boolean');
116+
for (const body of [{ object: OBJECT, operation: 'read' }, { object: OBJECT, operation: 'read', userId: memberId }]) {
117+
const self = await explain(memberTok, body);
118+
expect(self.status, self.text).toBe(200);
119+
expect(self.json?.principal?.userId).toBe(memberId);
120+
}
121+
});
122+
});

0 commit comments

Comments
 (0)