Skip to content

Commit b9e9609

Browse files
fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth (#20000)
Fixes #19986 Clause-②: no ## What changes `POST /api/v1/security/explain` with `{ object, operation: 'read', recordId }` now asks plugin-sharing's read filter with the same read depth the find path hands it. `decision.record.visible` for `read` equals whether the same caller's `find` returns the row. Enforcement admits and refuses exactly what it did before. Only the explain report changes. Landing: `packages/plugins/plugin-security/src/security-plugin.ts` (the `explainAccessForCaller` dependency wiring only), a new test file, the changeset, and one generated row in `scripts/engine-double-contract.pinned.json` (see "Surface note"). The step-3.6 post-image block and the seam declaration are untouched. The producer is the explain wiring, as the card says; no other package is involved. ## The two sides, quoted on `origin/main` `ccccdcc35e` before the change - **Explain** (`security-plugin.ts:4426`): `sharingReadFilter: (o: string, c: any) => sharing.buildReadFilter(o, c)`. The bare context, no `__readScope`. - **The find path, step 2.6** (`security-plugin.ts:2375`, `:2403`): for `find` / `findOne` / `count` / `aggregate` on a non-delegated context, `sc.__readScope` is set to `this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate })`. `permissionSets` comes from `resolvePermissionSetsForContext` and `secMeta` from `getObjectSecurityMeta`. plugin-sharing's `buildReadFilter` then reads it: `org` gives no owner filter (null), and a unit depth gives the unit's owners. - **The resolver already exists.** `resolveSharingReadFilter` (`security-plugin.ts:4490` after this change; it is the analytics path's seam) computes the depth from the same two inputs and the same evaluator call, stamps it, and calls `buildReadFilter`. Its docblock names the situation explain is in: no middleware runs on this path, so the depth is computed there. The change reuses it. Nothing new is computed. ## Reproduced before the change The new test file was committed first on the unfixed tree (`07f447e0c0`, base `ccccdcc35e`). It wires the real `SecurityPlugin`, the real `SharingService` and sharing middleware, and the real platform `member_default` seed. The object has an unset OWD (private). The one variable is the principal's read depth, on the same three rows: | principal (read depth) | row | explain `record` | find | |:--|:--|:--|:--| | `hr_reviewer` (`org`) | shared to someone else | `visible: false`, `decidedBy: sharing` | returns it | | `hr_reviewer` (`org`) | owned by someone else | `visible: false`, `decidedBy: sharing` | returns it | | `hr_reviewer` (`org`) | unshared | `visible: false`, `decidedBy: sharing` | returns it | | `team_lead` (`unit`) | owned inside the unit | `visible: false`, `decidedBy: sharing` | returns it | | `dept_reporter` (`own`), the control | read-shared / owned / unshared | `true` / `true` / `false` | returns / returns / refuses: agrees | Result: `Tests 7 failed | 17 passed (24)`. The seventh red is a second direction on the old binding. A `__readScope: 'org'` already present on the explained context decided the report (`dept_reporter` × unshared: explain `true`, find refused), because the bare context was passed straight through. ## The change - **The non-delegated `sharingReadFilter` binding** calls `resolveSharingReadFilter(o, { ...c, __readScope: undefined })`. The caller's own key is cleared first, so the depth is always the computed one, the way step 2.6 overwrites it. Where resolution yields nothing (no sets, or a failed resolution), the helper stamps nothing and plugin-sharing reads `own`, the safe direction. - **On-behalf-of contexts keep `sharing.buildReadFilter(o, c)` unchanged**, as #19984 did on the write side. The middleware's delegated read (the agent-leg depth, plus a second filter AND-ed in under the delegator's identity) is not modelled on explain's record path. Stamping the agent's own depth without that second filter could only widen the report. - `delegatedWrite` is renamed `actsOnBehalfOf`: it now guards read and write bindings, and `delegated` is already a name inside this function (the delegated-admin gate). ## Tests: `explain-read-verdict-inputs.test.ts` (24 cases) Every cell asserts that `record.visible` for `read` equals whether the find returns the row, and it asserts the find's own answer too. A cell cannot go green because both sides drifted. The find runs through both real middlewares, and the engine double evaluates the composed AST `where`. The double's matcher throws on an operator it does not know, so a refused cell cannot go green because the double silently answered `false`. Both sides read one hierarchy resolver: `unit` = the lead and the reporter. - Read matrix, private OWD: builtin admin × unshared; `hr_reviewer` (`org`) × shared / owned / unshared; `team_lead` (`unit`) × owned inside the unit / owned outside it / shared to someone else; `dept_reporter` (`own`) × read-shared / owned / unshared. Negative control: `dept_reporter` × unshared, refused by both, and explain names `sharing` (layer `excluded`). - Depth: the `org` reader's sharing layer is `admitted` with `rowFilter: null`. A `__readScope` the caller brings does not decide the report, in either direction: `dept_reporter` + `'org'` × unshared is refused by both, and `hr_reviewer` + `'own'` × unshared is admitted by both. - On-behalf-of: the context explain hands plugin-sharing's read filter carries no `__readScope`. - OWD control `public_read`: all nine principal × row cells admit on both sides. Unfixed tree: `Tests 7 failed | 17 passed (24)`. Fixed (`461187a0d2`) and at the final head: `Tests 24 passed (24)`. ## Ablations The fix was committed first (`461187a0d2`). Each mutation used `scripts/ablation-replace.mjs`: the anchor went from 1 hit to 0, the replacement from 0 to 1, and the blob changed on disk. After each restore the blob matched HEAD (`229605a70e3a`) and `git diff HEAD` was empty. The test imports `./security-plugin.js` from source, so there is no `dist/` hop and `ablation-dist-preflight` does not apply. Directions were predicted before each run. | ablation | mutation | result | |:--|:--|:--| | A | the old binding: `sharing.buildReadFilter(o, c)` for every context | `7 failed / 17 passed`: `hr_reviewer` × 3, `team_lead` × owned inside the unit, the `org` layer pin, and both caller-depth pins. The widening one reads `u_reporter × unshared × read: … expected true to be false`. The plain `visible: false` cells stay green, as predicted: the old binding is narrower and cannot widen. | | B | over-widening: every non-delegated context asked at `__readScope: 'org'` | `5 failed / 19 passed`: every `visible: false` pin (`team_lead` × unshared, `team_lead` × shared, `dept_reporter` × unshared, the negative control, the widening caller-depth pin), each `expected true to be false`. | | C | the on-behalf-of guard dropped | `1 failed / 23 passed`: the on-behalf-of pin (`no read depth stamped on a delegated context: expected true to be false`). | | D | the caller-key clear dropped: `resolveSharingReadFilter(o, c)` | `24 passed`, as predicted. The clear matters only where depth resolution yields nothing, and there explain's own CRUD layer already refuses, so `record.visible` cannot show it. It is declared, unpinned hygiene. | The first C attempt was a no-op. Its replacement text was a substring of its anchor, so `ablation-replace` saw the replacement count stay at 1, refused with exit 1, and ran no tests. The re-run with a distinct marker is the row above. ## Local verification: final head `8644de0cbe` - `pnpm --filter '@objectstack/plugin-security...' build`: exit 0. - Full plugin-security `vitest run`: `Test Files 124 passed`, `Tests 2365 passed`, at `6471f5564d`. Later commits add only the ledger row and a `main` merge whose source changes sit in `packages/mcp` and `packages/runtime`, outside plugin-security's build closure. The three explain suites re-ran at `8644de0cbe`: `Tests 138 passed (138)`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The new test is in `tsconfig.test.json`'s program (`--listFiles`: 1 hit) and `check:test-typecheck` is OK. - `dispatch-gates --commands` at `8644de0cbe`, reconciled with `--ran`: 70 derived, 68 run with exit 0, 2 NOT MEASURED. `check:dual-build-cjs-loads` and `check:type-check-debt` exit 3 (PREREQUISITE NOT MET): they need every workspace package's `dist/`, which CI's Build Core owns. `check:i18n` was measured after building its declared closure: exit 0, 9 packages in sync. - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: exit 0, 1 citation, resolves. - Narrowed eslint (`--no-inline-config --format json`) over the two changed TS files: 2 files, 0 errors, 0 warnings. Both files resolve a non-empty config under `--print-config`. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move the verdict of any untouched file. - Two `main` merges before opening (`ccccdcc35e` then `bfa23a8f49` then `615c0856ef`). Neither touched plugin-security, plugin-sharing, the ledger or the lockfile. #19988 had not landed. ## Surface note `scripts/engine-double-contract.pinned.json` gains one row (+5 lines: the new test file's `findOne` double). It was written by `node scripts/check-engine-double-contract.mjs --write`, which that gate prescribes for a new pinned double ("0 added or grown, 0 lost"). It lies outside the claim's declared file surface, the same as the three rows #19984 added. #19988 does not touch this file. ## Acceptance notes - **Explain's record path does not model the on-behalf-of read, before or after this PR.** That is the agent-leg depth intersection plus the delegator's filter AND-ed in. It was not measured here. #19984 recorded the same boundary for writes. - **Under a sharing-service fault, explain over-reports `read` visibility.** This predates the PR, which does not change it. `explain-engine.ts:958` catches a rejected `sharingReadFilter` into `null`, and the record matcher reads `null` as "no filter". A one-off probe on this branch made `sys_record_share` reads throw. `dept_reporter` × unshared then got explain `visible: true` (sharing `admitted`, `rowFilter: null`), while the same find threw. The old bare binding rejected into the same catch. This is reported for the seat to judge. It is not fixed here: the site is `explain-engine.ts`, outside this card's surface. - **The test double's nested reads** are not scoped by plugin-sharing's read filter. That boundary is recorded in `row-write-widener-composition.test.ts`, and no cell here depends on a nested read. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f05ea4 commit b9e9609

4 files changed

Lines changed: 387 additions & 7 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `security/explain` asks the sharing read filter with the caller's read depth, so a record's `read` verdict matches what the caller's `find` returns (#19986)
6+
7+
Clause-②: no
8+
9+
`POST /api/v1/security/explain` with `{ object, operation: 'read', recordId }` answered `decision.record.visible: false` (`decidedBy: 'sharing'`) on rows that the same caller's `find` returned. It happened on every object whose OWD is private (set explicitly, or left unset), for a caller whose read depth is wider than `own`. The find path hands the sharing service's read filter the caller's effective read depth. Explain asked the same filter without it, so a caller with `org` read depth was judged owner-only on every row it did not own, and a caller with unit read depth was judged owner-only on rows its unit owns.
10+
11+
Explain now passes the same read depth, computed the way the find path computes it. A read depth already present on the explained context no longer decides the report.
12+
13+
Unchanged:
14+
15+
- Enforcement: `find` admits and refuses exactly what it did before.
16+
- Writes (`update` / `delete`) and object-level explanations (no `recordId`).
17+
- A caller acting on behalf of another user (`onBehalfOf`): its record-level read explanation uses the same inputs as before.
18+
- Objects whose OWD is not private already matched and still do.
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `security/explain` for a READ must ask plugin-sharing's read filter with the
4+
// same read DEPTH the find path hands it, so its `record.visible` equals whether
5+
// the caller's `find` returns the row.
6+
//
7+
// The measured divergence: on a private-OWD object, explain answered
8+
// `record.visible: false` (`decidedBy: 'sharing'`) for `read` on rows the same
9+
// caller's `find` RETURNED, through the same stack. The middleware (step 2.6)
10+
// stamps `__readScope` on the context before plugin-sharing's `buildReadFilter`
11+
// reads it; explain asked the same filter with the bare context, so an `org`
12+
// reader got an owner-only filter where `find` got none, and a `unit` reader
13+
// got `owner_id = me` where `find` got the unit's owners. It fails CLOSED — the
14+
// report under-states visibility, enforcement is right — and it is the read
15+
// twin of the write-depth stamp in `explain-write-verdict-inputs.test.ts`.
16+
//
17+
// This file wires the REAL SecurityPlugin (middleware + explain entry point),
18+
// the REAL SharingService and sharing middleware, and the REAL platform
19+
// `member_default` seed over one in-memory engine, and asserts one property per
20+
// cell: explain's `record.visible` equals the find's outcome. A cell that
21+
// asserts only one side would pin nothing about the agreement this file exists
22+
// for.
23+
import { describe, it, expect, vi } from 'vitest';
24+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
25+
import { SharingService, buildSharingMiddleware } from '@objectstack/plugin-sharing';
26+
import { PermissionSetSchema } from '@objectstack/spec/security';
27+
import type { PermissionSet } from '@objectstack/spec/security';
28+
import { SecurityPlugin } from './security-plugin.js';
29+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
30+
31+
// ── metadata ───────────────────────────────────────────────────────────────
32+
33+
const OBJECT = 'kpi_entry_line';
34+
35+
const fieldsOf = (...names: string[]) =>
36+
Object.fromEntries(names.map((n) => [n, { name: n }]));
37+
38+
/**
39+
* `sharingModel` UNSET, which ADR-0090 D1 resolves to `private` (fail-closed
40+
* default), so plugin-sharing's owner-match is in play for every reader.
41+
*/
42+
const PRIVATE_SCHEMA = {
43+
name: OBJECT,
44+
fields: fieldsOf('id', 'name', 'value', 'owner_id', 'created_by', 'organization_id'),
45+
};
46+
47+
/** The OWD control: the object's own model opens reads. */
48+
const PUBLIC_READ_SCHEMA = { ...PRIVATE_SCHEMA, sharingModel: 'public_read' };
49+
50+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
51+
const ADMIN_FULL_ACCESS = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!;
52+
53+
const readerAt = (name: string, readScope: 'own' | 'unit' | 'org'): PermissionSet =>
54+
PermissionSetSchema.parse({
55+
name,
56+
objects: { [OBJECT]: { allowRead: true, readScope } },
57+
});
58+
59+
/** Read depth `org` — an HR reviewer who may read any line. */
60+
const HR_REVIEWER = readerAt('hr_reviewer', 'org');
61+
/** Read depth `unit` — a team lead who reads the lines their unit owns. */
62+
const TEAM_LEAD = readerAt('team_lead', 'unit');
63+
/** Read depth `own` — widened only by ownership or a share. The control. */
64+
const DEPT_REPORTER = readerAt('dept_reporter', 'own');
65+
66+
const PERMISSION_SETS: PermissionSet[] = [MEMBER_DEFAULT, ADMIN_FULL_ACCESS, HR_REVIEWER, TEAM_LEAD, DEPT_REPORTER];
67+
68+
// ── principals and rows ────────────────────────────────────────────────────
69+
70+
const U_ADMIN = 'u_admin';
71+
const U_HR = 'u_hr';
72+
const U_LEAD = 'u_lead';
73+
const U_REPORTER = 'u_reporter';
74+
75+
const ctxFor = (userId: string, permission: string) => ({
76+
userId, tenantId: 'org1', positions: ['org_member'], permissions: [permission],
77+
});
78+
79+
const ADMIN_CTX = ctxFor(U_ADMIN, 'admin_full_access');
80+
const HR_CTX = ctxFor(U_HR, 'hr_reviewer');
81+
const LEAD_CTX = ctxFor(U_LEAD, 'team_lead');
82+
const REPORTER_CTX = ctxFor(U_REPORTER, 'dept_reporter');
83+
84+
/**
85+
* The lead's unit is the lead and the reporter. Both sides consult this ONE
86+
* resolver through plugin-sharing, so a `unit` cell tests the depth input, not
87+
* two hierarchies.
88+
*/
89+
const UNIT_OWNERS = [U_LEAD, U_REPORTER];
90+
91+
type RowKey = 'shared' | 'owned' | 'unshared';
92+
93+
const ROWS: Record<RowKey, Record<string, unknown>> = {
94+
/** Owned by the admin; the reporter holds a `read` share on it. */
95+
shared: { id: 'kle_shared', name: 'Shared', value: 1, owner_id: U_ADMIN, created_by: U_ADMIN, organization_id: 'org1' },
96+
/** Owned by the reporter — inside the lead's unit. */
97+
owned: { id: 'kle_owned', name: 'Owned', value: 1, owner_id: U_REPORTER, created_by: U_ADMIN, organization_id: 'org1' },
98+
/** Neither shared with nor owned by the reporter or the lead's unit. */
99+
unshared: { id: 'kle_unshared', name: 'Unshared', value: 1, owner_id: U_ADMIN, created_by: U_ADMIN, organization_id: 'org1' },
100+
};
101+
102+
const READ_SHARE = {
103+
id: 'shr_read', object_name: OBJECT, record_id: ROWS.shared.id,
104+
recipient_type: 'user', recipient_id: U_REPORTER, access_level: 'read', source: 'manual',
105+
};
106+
107+
// ── in-memory engine ───────────────────────────────────────────────────────
108+
109+
function makeEngine(schema: Record<string, unknown>) {
110+
const tables: Record<string, any[]> = {
111+
[OBJECT]: (Object.keys(ROWS) as RowKey[]).map((k) => ({ ...ROWS[k] })),
112+
sys_record_share: [{ ...READ_SHARE }],
113+
};
114+
// `$or` / `$and` conjoin WITH their sibling keys, the way a real driver ANDs
115+
// them (#7620). An operator this double does not know THROWS rather than
116+
// comparing an object to a scalar: a matcher that silently answers `false`
117+
// would green every refused cell below for the wrong reason.
118+
const matches = (row: any, filter: any): boolean => {
119+
if (!filter || typeof filter !== 'object') return true;
120+
if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false;
121+
if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false;
122+
for (const [k, v] of Object.entries(filter)) {
123+
if (k === '$or' || k === '$and') continue;
124+
if (k.startsWith('$')) throw new Error(`engine double: unsupported operator '${k}'`);
125+
if (v != null && typeof v === 'object') {
126+
const ops = Object.keys(v as object);
127+
if (ops.length !== 1 || ops[0] !== '$in') {
128+
throw new Error(`engine double: unsupported predicate on '${k}': ${JSON.stringify(v)}`);
129+
}
130+
if (!(v as any).$in.includes(row[k])) return false;
131+
continue;
132+
}
133+
if (row[k] !== v) return false;
134+
}
135+
return true;
136+
};
137+
const middlewares: any[] = [];
138+
return {
139+
_tables: tables,
140+
_middlewares: middlewares,
141+
registerMiddleware: (mw: any) => middlewares.push(mw),
142+
getSchema: (name: string) => (name === OBJECT ? schema : undefined),
143+
async find(object: string, options: any = {}) {
144+
const rows = (tables[object] ??= []);
145+
return rows.filter((r) => matches(r, options.filter ?? options.where)).slice(0, options.limit ?? 1000);
146+
},
147+
async findOne(object: string, options: any = {}) {
148+
assertEngineFindOnePredicate(object, options);
149+
const rows = await this.find(object, { ...options, limit: 1 });
150+
return rows[0] ?? null;
151+
},
152+
};
153+
}
154+
155+
// ── the stack ──────────────────────────────────────────────────────────────
156+
157+
interface Stack {
158+
explain: (rowKey: RowKey, context: any) => Promise<any>;
159+
/** The caller's `find` for this one row, through BOTH real middlewares. */
160+
find: (rowKey: RowKey, context: any) => Promise<any[]>;
161+
/** Every context explain handed plugin-sharing's read filter. */
162+
readFilterContexts: any[];
163+
}
164+
165+
async function makeStack(opts: { schema?: Record<string, unknown> } = {}): Promise<Stack> {
166+
const schema = opts.schema ?? PRIVATE_SCHEMA;
167+
const engine = makeEngine(schema);
168+
const metadata = {
169+
get: async (_type: string, name: string) => (name === OBJECT ? schema : null),
170+
list: async () => PERMISSION_SETS,
171+
};
172+
let security: any;
173+
let sharing: SharingService;
174+
const services: Record<string, any> = {
175+
manifest: { register: vi.fn() },
176+
objectql: engine,
177+
metadata,
178+
// Org scoping active, so Layer 0 contributes a real tenant predicate.
179+
'org-scoping': { name: 'org-scoping' },
180+
get sharing() { return sharing; },
181+
};
182+
const ctx: any = {
183+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
184+
registerService: (name: string, impl: any) => { if (name === 'security') security = impl; },
185+
getService: (name: string) => {
186+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
187+
return services[name];
188+
},
189+
};
190+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
191+
await plugin.init(ctx);
192+
await plugin.start(ctx);
193+
if (!security) throw new Error('SecurityPlugin did not register the security service');
194+
195+
sharing = new SharingService({
196+
engine: engine as any,
197+
securityService: () => security,
198+
hierarchyResolver: () => ({
199+
resolveOwnerIds: async (input: { userId: string }, scope: string) =>
200+
(scope === 'unit' && input.userId === U_LEAD ? [...UNIT_OWNERS] : [input.userId]),
201+
}) as any,
202+
});
203+
const sharingMw = buildSharingMiddleware(sharing, ctx.logger);
204+
const securityMw = engine._middlewares[0];
205+
const idOf = (rowKey: RowKey) => String(ROWS[rowKey].id);
206+
207+
// Recorded for the on-behalf-of pin; the call itself goes through unchanged.
208+
const readFilterContexts: any[] = [];
209+
const buildReadFilter = sharing.buildReadFilter.bind(sharing);
210+
let explaining = false;
211+
sharing.buildReadFilter = async (o: string, c: any) => {
212+
if (explaining) readFilterContexts.push(c);
213+
return buildReadFilter(o, c);
214+
};
215+
216+
return {
217+
readFilterContexts,
218+
async explain(rowKey, context) {
219+
explaining = true;
220+
try {
221+
return await security.explain({ object: OBJECT, operation: 'read', recordId: idOf(rowKey) }, { ...context });
222+
} finally {
223+
explaining = false;
224+
}
225+
},
226+
async find(rowKey, context) {
227+
const where = { id: idOf(rowKey) };
228+
const opCtx: any = {
229+
object: OBJECT,
230+
operation: 'find',
231+
context: { ...context },
232+
options: { where },
233+
ast: { where },
234+
};
235+
let result: any[] | undefined;
236+
await securityMw(opCtx, async () => {
237+
await sharingMw(opCtx, async () => {
238+
result = await engine.find(OBJECT, { where: opCtx.ast.where });
239+
});
240+
});
241+
if (!result) throw new Error('a middleware swallowed the find');
242+
return result;
243+
},
244+
};
245+
}
246+
247+
/**
248+
* One cell: explain FIRST, then the find, then the agreement. The find's own
249+
* answer is asserted too, so a cell cannot go green by both sides drifting.
250+
*/
251+
async function expectCell(stack: Stack, rowKey: RowKey, context: any, expected: boolean): Promise<any> {
252+
const decision = await stack.explain(rowKey, context);
253+
const rows = await stack.find(rowKey, context);
254+
const found = rows.some((r) => r.id === ROWS[rowKey].id);
255+
const cell = `${context.userId} × ${rowKey} × read`;
256+
expect(found, `${cell}: the find returns the row`).toBe(expected);
257+
expect(
258+
decision.record?.visible,
259+
`${cell}: explain record.visible (decidedBy ${decision.record?.decidedBy}) must equal the find's outcome`,
260+
).toBe(found);
261+
return decision;
262+
}
263+
264+
// ───────────────────────────────────────────────────────────────────────────
265+
266+
describe('explain(read) agrees with the find, cell by cell (private OWD)', () => {
267+
const cells: Array<[string, RowKey, any, boolean]> = [
268+
['builtin admin', 'unshared', ADMIN_CTX, true],
269+
['hr_reviewer (org depth)', 'shared', HR_CTX, true],
270+
['hr_reviewer (org depth)', 'owned', HR_CTX, true],
271+
['hr_reviewer (org depth)', 'unshared', HR_CTX, true],
272+
['team_lead (unit depth), owned inside the unit', 'owned', LEAD_CTX, true],
273+
['team_lead (unit depth), owned outside the unit', 'unshared', LEAD_CTX, false],
274+
['team_lead (unit depth), shared to someone else', 'shared', LEAD_CTX, false],
275+
['dept_reporter (own depth) via read share', 'shared', REPORTER_CTX, true],
276+
['dept_reporter (own depth) as owner', 'owned', REPORTER_CTX, true],
277+
['dept_reporter (own depth), unshared and not owned', 'unshared', REPORTER_CTX, false],
278+
];
279+
it.each(cells)('%s × %s row', async (_label, rowKey, context, expected) => {
280+
await expectCell(await makeStack(), rowKey, context, expected);
281+
});
282+
283+
it('negative control: the refused cell is refused by the sharing layer explain reports', async () => {
284+
const decision = await expectCell(await makeStack(), 'unshared', REPORTER_CTX, false);
285+
expect(decision.record.decidedBy).toBe('sharing');
286+
expect(decision.layers.find((l: any) => l.layer === 'sharing').record.outcome).toBe('excluded');
287+
});
288+
});
289+
290+
describe('the read depth: explain stamps it the way step 2.6 does', () => {
291+
it('an org-depth reader is admitted by the sharing layer on an unshared, not-owned row', async () => {
292+
const decision = await expectCell(await makeStack(), 'unshared', HR_CTX, true);
293+
const sharingRecord = decision.layers.find((l: any) => l.layer === 'sharing').record;
294+
// `org` depth: plugin-sharing imposes no owner-match, which is what the
295+
// find saw (its AST carries only the id and tenant terms).
296+
expect(sharingRecord.outcome).toBe('admitted');
297+
expect(sharingRecord.rowFilter).toBeNull();
298+
});
299+
300+
it('a depth the CALLER brings does not decide — explain computes it, as step 2.6 overwrites it (widening)', async () => {
301+
// One variable: `__readScope` stamped on the explained context. The find
302+
// path overwrites any such key before plugin-sharing reads it, so it
303+
// refuses; explain must not be widened by it either.
304+
const forged = { ...REPORTER_CTX, __readScope: 'org' };
305+
await expectCell(await makeStack(), 'unshared', forged, false);
306+
});
307+
308+
it('a depth the CALLER brings does not decide — explain computes it, as step 2.6 overwrites it (narrowing)', async () => {
309+
const forged = { ...HR_CTX, __readScope: 'own' };
310+
await expectCell(await makeStack(), 'unshared', forged, true);
311+
});
312+
});
313+
314+
describe('the on-behalf-of path keeps its previous inputs', () => {
315+
it("explain hands plugin-sharing's read filter the delegated context unchanged", async () => {
316+
// The middleware's delegated read depth (the agent-leg intersection, plus
317+
// a second filter AND-ed in under the delegator's identity) is not
318+
// modelled on explain's record path. Stamping the AGENT's own depth here
319+
// without that second filter could only widen the report past what the
320+
// delegated find returns, so the delegated context is passed as it was.
321+
const stack = await makeStack();
322+
const delegated = { ...HR_CTX, onBehalfOf: { userId: U_REPORTER } };
323+
await stack.explain('unshared', delegated);
324+
expect(stack.readFilterContexts.length, 'explain consulted the read filter').toBeGreaterThan(0);
325+
for (const c of stack.readFilterContexts) {
326+
expect(c.onBehalfOf?.userId).toBe(U_REPORTER);
327+
expect('__readScope' in c, 'no read depth stamped on a delegated context').toBe(false);
328+
}
329+
});
330+
});
331+
332+
describe('OWD control: `public_read` — the object\'s own model opens reads', () => {
333+
const principals: Array<[string, any]> = [
334+
['hr_reviewer', HR_CTX],
335+
['team_lead', LEAD_CTX],
336+
['dept_reporter', REPORTER_CTX],
337+
];
338+
const rows: RowKey[] = ['shared', 'owned', 'unshared'];
339+
const cells = principals.flatMap(([label, context]) => rows.map((r) => [label, r, context] as const));
340+
it.each(cells)('%s × %s row: both admit', async (_label, rowKey, context) => {
341+
await expectCell(await makeStack({ schema: PUBLIC_READ_SCHEMA }), rowKey, context, true);
342+
});
343+
});

0 commit comments

Comments
 (0)