Skip to content

Commit decc807

Browse files
fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check (#22529)
Fixes #22514 Clause-②: yes (widening) `POST /api/v1/security/explain` now answers an `update` of a `controlled_by_parent` record the way that record's own by-id `PATCH` is answered. The record verdict comes from the master-detail write check (ADR-0055) that the write path runs at step 2.8. explain reads it through `ISecurityService.checkControlledByParentWrite`, served since PR #22513, and keeps no second copy of the check or of its `controlled_by_parent` predicate. ## Reproduction (before) Measured on `origin/main` `ee8751d41`, with an untracked scratch dogfood probe on PR #22513's fixture (`cbp-parent-gates-fixture.ts`, org-bound boot, so the platform ownership floor `owner_only_writes` binds `org_member`). The probe was deleted after the reading. `cpg_contract` is `controlled_by_parent` under `cpg_account` (`public_read`, edited by its owner). | principal | request | explain `allowed` | explain `record` | the door | |:--|:--|:--|:--|:--| | member, cannot edit the master | update, child of the admin's master | `true` | `visible: false`, `decidedBy: 'rls'` | `PATCH` 403 `PERMISSION_DENIED` (master, row-level security) | | admin explaining that member (`userId`) | same | `true` | `visible: false`, `decidedBy: 'rls'` | (the member's `PATCH`: 403) | | member, edits the master, did not create the child | update, child of the member's own master | `true` | `visible: false`, `decidedBy: 'rls'` | `PATCH` **200** | | deleter, edits the master, did not create the child | delete, child of their own master | `true` | `visible: false`, `decidedBy: 'rls'` | `DELETE` **200** | | member | update, `cpg_board` (`public_read_write`) | `true` | `visible: true` | `PATCH` 200 | **What the reading says about the card.** The card's mechanism holds: explain never asks the master-detail write check, and its sharing gate (`canEdit`) abstains on `controlled_by_parent` and reads as writable. Its symptom needs restating. The card quotes `allowed: true`, which is the object-level field (may this principal update `cpg_contract` at all). By the published contract and the parity table in `explain-enforce-parity.test.ts`, a record question is answered by `record.visible`. On `main` that verdict was decided by the ownership floor, which the write path hands over to the master check through step 2.7's `masterGateCoversThisWrite` vouch. explain did not vouch, because it ran no master gate (the comment in the wiring said so). The result was a two-sided divergence: - the master's non-editor was refused on the wrong layer (`rls`), with no word about the master; - the master's editor was refused beside a `PATCH` that answers 200. ## What changed - `explain-engine.ts`: a new optional dependency, `checkControlledByParentWrite`. For every `update` of a record that exists, the engine asks it with the context being EXPLAINED. Asking for every update keeps the predicate in the member: `not_applicable` changes nothing. - `security-plugin.ts` (the `explain` wiring only): - It hands the engine the served member, `this.checkControlledByParentWrite`. That is the write path's own composition: the context prologue, step 2.8 for the principal and then for the delegator. - The update's Layer 1 now carries step 2.7's coverage vouch, `masterGateCoversThisWrite`, on step 2.7's condition (not on behalf of anyone). The ownership floor is handed over to the master check here exactly as the write path hands it. - `delete` keeps its floor, because explain asks no master check for it. - The knob's docblock lists this new caller. - `.changeset/22514-explain-cbp-update-master-check.md`: `minor` for `@objectstack/plugin-security`, not the `patch` the dispatch suggested. See the acceptance notes. ## Outcome to layer mapping This mirrors the gates PR #22513 built and the review `6085973866`'s truth table. The door proceeds on `allow` and `not_applicable`, and refuses on every other outcome. | member outcome | `record` verdict | `sharing` layer `record` | rest of the report | |:--|:--|:--|:--| | `allow` | the existing path's | unchanged | byte-identical to a deps bag without the member | | `not_applicable` | the existing path's | unchanged | byte-identical | | `deny` + `leg` | `visible: false`, `decidedBy: 'sharing'` | `excluded`; the detail names the leg (`object_permission` / `row_level_security` / `record_sharing` / `master_chain`) and the 403 | unchanged | | `unresolvable` + `reason` | `visible: false`, `decidedBy: 'sharing'` | `not_evaluated`; the detail names the reason and the status the update answers (422 / 404 / 422) | unchanged | | rejection (refused context, store fault) | `visible: false`, `decidedBy: 'sharing'` | `not_evaluated`, no predicate; fail-closed fault detail | unchanged | | an outcome outside the vocabulary | `visible: false`, `decidedBy: 'sharing'` | `not_evaluated` | unchanged | | member absent (a deps bag without it) | the existing path's | unchanged; claims nothing about a master | unchanged | - **Where the master is named.** The spec's layer vocabulary is closed, with no master layer. The refusing leg is named on the `sharing` layer, the record's write gate. `describeOwd`'s baseline wording stays, per triage. - **Precedence.** The record's own row-level security (`rls`) still decides first, because step 2.7 runs above step 2.8. The not-found answer for a record the principal cannot read (the read question) still overrides both. - **No master named on `allow`.** The member answers `allow` for a system context on ANY object, which is the write path's first exit. So `allow` is not evidence that the record's access derives from a master. - **Absent member.** Feature detection on the engine's optional dependency keeps today's answer, as in the gates (PM assumption 4 holds). - **The context.** It is the TARGET's, not the caller's, because `explainAccessForCaller` already hands the engine the explained user's context (PM assumption 2 holds, and it is pinned below). ## Pins | pin | where | proves | |:--|:--|:--| | parity, refused: the member cannot edit the master. `PATCH` 403 naming the master's row-level security; explain `visible: false`, `decidedBy: 'sharing'`, leg `row_level_security` named | `packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts` (REST explain beside the REST `PATCH`) | the card's pin, end to end | | target principal: the admin explains the member by `userId` and gets the member's refusal; the admin's own explain and `PATCH` admit | same dogfood file | the member is asked with the explained context | | control, the master's editor: explain `visible: true` beside `PATCH` 200, for a child they did not create, with the floor armed (`assertArmed`, `org_member`) | same dogfood file | the floor is handed over as the door hands it | | control, non-`controlled_by_parent`: `cpg_board` explain `visible: true` beside `PATCH` 200 | same dogfood file | unchanged | | every leg (`record_sharing`, `row_level_security`, `object_permission`, `master_chain`) refused beside a refused `PATCH`; four admitted updates explained writable beside an admitted `PATCH`; a system caller explaining the viewer by `userId` gets the viewer's `object_permission` refusal | `controlled-by-parent-write-member.test.ts` (the registered service, the member's own double) | the wiring, through `security.explain` | | each outcome's mapping, as in the table above. Also: `allow` and `not_applicable` are byte-identical (`toEqual`) to the report without the member, on `controlled_by_parent`, `public_read_write`, and private objects with the gate admitting and refusing. The record's own RLS decides first. The member is asked once, with the explained context object itself, and never for read, create, delete, transfer, export, an object-level request or a missing record | `explain-controlled-by-parent-write.test.ts` (engine, deps bag, no engine double) | the mapping | ## Ablations Each leg went through `scripts/ablation-replace.mjs` (wrap mode, with an anchor hit, and a restore proven by the blob equal to HEAD and an empty `git diff HEAD`). Then `pnpm --filter @objectstack/plugin-security build`, then `scripts/ablation-dist-preflight.mjs` (marker present in `dist/`), then the run. The restore leg rebuilt and ran the preflight with `--absent` (marker absent from all 6 built files, tree clean against HEAD). | ablation | mutation | predicted | observed | |:--|:--|:--|:--| | A: the `canEdit`-only verdict back | the explain wiring's `checkControlledByParentWrite` key renamed, so the engine sees no member | the refusal pins red, the controls green | dogfood: 2 red (`visible: true`, `decidedBy: 'object_crud'` beside the 403), 2 green. member suite: 2 red (the same shape for `c_other`), 33 green | | B: no floor hand-over | `masterGateCoversThisWrite` forced false | dogfood refusals red on `decidedBy`, the editor control red, the plugin suites green (no floor in their harness) | dogfood: 3 red (`decidedBy: 'rls'` on both refusals; the editor control `visible: false` beside `PATCH` 200), the board green. plugin suites: 35/35 green | Ablation B's first attempt is void: its marker was a comment, which the build strips, so the dist preflight could not prove the mutation landed (exit 1). It was re-run with a string-literal marker; the table reports the second run. ## Local verification (HEAD `16a937c47`) - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The test-layer program (`tsconfig.test.json`) lists both edited test files, counted with `--listFiles`. - `pnpm --filter @objectstack/plugin-security exec vitest run`: 192 files, 3994 passed, 45 skipped, exit 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Dogfood: `cbp-explain-master-write`, `cbp-parent-attachment-comment-gates`, `controlled-by-parent` and `showcase-invoice-cbp`: 4 files, 22 tests passed. The full dogfood suite (three CI shards) is declared to CI. - `node scripts/pm/dispatch-gates.mjs --commands`: 71 commands derived and 71 run, each exit code recorded. The `--ran` reconciliation reads "71 run, 0 NOT-MEASURED (a DERIVED zero)". `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3: eight unrelated packages had no `dist/`). After building those eight it answered exit 0, which is the reading recorded. - Lint, narrowed. CI's `pnpm lint` owns the full run. - Population: `eslint.config.mjs` lints `**/*.{ts,…}`, so the five changed `.ts` files are the touched population. - Count: `--format json` read 5 files, 0 errors, 0 warnings. - Invariance: the config enables no type-aware linting (no `parserOptions.project`, stated in the config itself) and loads no import-graph plugin, so this diff cannot move any untouched file's verdict. ## Acceptance notes - **Changeset level `minor`, not the dispatch's `patch`.** `ExplainEngineDeps` is exported from `@objectstack/plugin-security`'s index, and it gains an optional key. Under the WHICH LEVEL rule in `pr-automation.yml`'s Check Changeset step, an additive widening of a published package's public surface takes at least `minor`. The `Clause-②` line is `yes (widening)` (the seat amended the claim and line 2 at review): the optional deps key is a widening of a published type. `explain`'s response payload gains no key, and no runtime accept set widens. The same reading is the one the review on PR #22513 gave its additive types. - **`allowed` is unchanged.** It answers the object question and stays `true` for a member who holds `update` on the object. The record's bottom line is `record.visible`, as for every other record-level refusal (a private record the caller does not own reads the same way). - **`delete` keeps today's answer.** The member is declared for an UPDATE, so explain asks no master check for a delete and keeps the floor there. The measured gap is in the out-of-lane findings below. - **A context the write path refuses before any gate.** Examples: a principal-less context, permission sets that cannot be resolved, a dangling delegator. The member rejects for these on any object, and explain now reports the sharing layer `not_evaluated`, with the fault detail, on an update of an existing record. That is refuse-only and matches the door, which refuses these contexts first. explain's earlier layers (`object_crud`, the D10 delegator handling) already decide those records, so `decidedBy` does not move. The review on PR #22513 named the same shape for the gates. - **On-behalf-of.** The update's Layer 1 keeps the floor for a delegated context, as step 2.7 does. The member still runs both legs (principal, then delegator), as step 2.8 does. - **Cost.** One more permission-set resolution per update explain. It is a memo hit: the member is handed the same context object the engine resolved. - **Product effect.** Consoles that gate an Edit affordance on `record.visible` for an update now show it to a master editor who did not create the child, and hide it from a non-editor for the right reason. - **`transfer` was not measured.** explain's `transfer` record verdict asks the sharing gate, and the door runs step 2.8 for `transfer` too. This is a read-only inference, noted here and not filed. ## Out-of-lane findings (for the seat to file) 1. **class (a).** `explain` `delete` on a `controlled_by_parent` record asks no master-detail check and keeps the `owner_only_deletes` floor, which the door hands over to that check. - **reach** (public door, measured with the scratch probe at `ee8751d41`, unchanged at this head): a principal who edits the master and holds delete on the child, but did not create it, gets explain `delete` `record.visible: false`, `decidedBy: 'rls'` beside `DELETE /api/v1/data/cpg_contract/ID` 200. For the non-editor the refusal is reported on `rls` with no word about the master, beside a `DELETE` 403 that names the master's row-level security. - **Seam:** `spec:ISecurityService.checkControlledByParentWrite` (declared for an update only) → `runtime:explain-engine.ts applyRecordAttribution` (the delete branch asks `canDeleteRecord` alone). - **Route:** the member's contract would have to name `delete`, or a sibling would have to be declared (spec lane) before explain can ask it. The legs themselves are operation-independent in `assertControlledByParentWrite`. - **Dedupe words:** `explain delete controlled_by_parent master check`, `explain canDeleteRecord owner_only_deletes floor`, `checkControlledByParentWrite delete operation`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3af956 commit decc807

6 files changed

Lines changed: 576 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
`security/explain` answers an update of a `controlled_by_parent` record the way that record's own by-id update is answered: its record verdict now comes from the master-detail write check (ADR-0055), which it never asked.
6+
7+
- **What was wrong.** On such a record the sharing service's per-record gate abstains, because the effective model of `controlled_by_parent` is `public`, and explain read the abstention as "writable". It also kept the platform ownership floor (`owner_only_writes`) on the record, which the write path hands over to the master-detail write check. So `record.visible` for an update was decided by that floor (`decidedBy: 'rls'`) for every principal who did not create the record. That was refused, on the wrong layer and with no word about the master, for a principal who may not edit the master. It was also refused for a principal who may edit the master, beside a `PATCH` that answers `200`.
8+
- **What it does now.** For every update of a record that exists, explain asks the security service's `checkControlledByParentWrite` with the context it is explaining (another user's, when `userId` names one). A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the refusing leg (`object_permission`, `row_level_security`, `record_sharing` or `master_chain`) or the reason the check reached no verdict. A rejection of the check is reported fail-closed, as a dependency that throws already is. `allow` and `not_applicable` leave the report exactly as before. The update's row-level composition carries the write path's own master-gate coverage vouch, so the ownership floor is handed over to that check as the write path hands it, and never on behalf of a delegator.
9+
- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field, which answers whether the principal may update the object at all. `delete` is unchanged too: explain asks no master check for it and keeps its floor.
10+
- **Public surface.** `ExplainEngineDeps` gains the optional `checkControlledByParentWrite` dependency. A deps bag wired without it explains an update as before, and claims nothing about a master.

‎packages/plugins/plugin-security/src/controlled-by-parent-write-member.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,14 @@ async function boot(options: { faultOn?: string } = {}) {
248248
}
249249
};
250250
const member = (context: any, object: string, id: string) => serve(object, id, context);
251-
return { store, ctx, patch, member };
251+
/**
252+
* [ADR-0055] `security/explain`'s record-grained update verdict through the
253+
* registered service (`explainAccessForCaller`). `userId` explains another
254+
* user, as the REST route does; the caller is the second argument.
255+
*/
256+
const explainUpdate = (callerContext: any, object: string, recordId: string, userId?: string) =>
257+
security.explain({ object, operation: 'update', recordId, ...(userId ? { userId } : {}) }, callerContext);
258+
return { store, ctx, patch, member, explainUpdate };
252259
}
253260

254261
const rep = () => ({ userId: REP, tenantId: 'org-1', positions: [], permissions: ['cbpm_rep'] });
@@ -375,3 +382,40 @@ describe('[ADR-0055] checkControlledByParentWrite rejects where the write path r
375382
expect(rejection).toMatchObject({ code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 });
376383
});
377384
});
385+
386+
describe('[ADR-0055] security/explain answers an update as the PATCH does, from this same check', () => {
387+
const sharingDetailOf = (d: any): string => String(d.layers.find((l: any) => l.layer === 'sharing')?.record?.detail ?? '');
388+
389+
it('a refused update is explained refused on the leg the PATCH names; an admitted one is explained writable', async () => {
390+
const h = await boot();
391+
for (const [context, object, id, leg] of [
392+
[rep(), 'cbpm_contact', 'c_other', 'record_sharing'],
393+
[rep(), 'cbpm_region_note', 'rn_eu', 'row_level_security'],
394+
[viewer(), 'cbpm_contact', 'c_own', 'object_permission'],
395+
[rep(), 'cbpm_line', 'line_above_dangling', 'master_chain'],
396+
] as const) {
397+
expect(await h.patch(context, object, id), `PATCH ${object}/${id} by ${context.userId}`).not.toBe('admitted');
398+
const d = await h.explainUpdate(context, object, id);
399+
expect(d.record, `explain ${object}/${id} by ${context.userId}`).toEqual({ recordId: id, visible: false, decidedBy: 'sharing' });
400+
expect(sharingDetailOf(d)).toContain(`refuses this update on its '${leg}' leg`);
401+
}
402+
for (const [object, id] of [['cbpm_contact', 'c_own'], ['cbpm_contact', 'c_shared'], ['cbpm_region_note', 'rn_us'], ['cbpm_line', 'line_ok']] as const) {
403+
expect(await h.patch(rep(), object, id), `PATCH ${object}/${id}`).toBe('admitted');
404+
const d = await h.explainUpdate(rep(), object, id);
405+
expect(d.record, `explain ${object}/${id}`).toMatchObject({ recordId: id, visible: true });
406+
expect(sharingDetailOf(d)).not.toContain('master-detail write check');
407+
}
408+
});
409+
410+
it('explaining ANOTHER user asks the check for THAT user: the system caller would be admitted, the viewer it explains is not', async () => {
411+
const h = await boot();
412+
// Asked with the CALLER's context, the check would admit: a system context answers `allow`.
413+
expect(await h.member({ isSystem: true }, 'cbpm_contact', 'c_own')).toEqual({ outcome: 'allow' });
414+
// The viewer, explained by user id, resolves to the fallback set — READ only on the master.
415+
expect(await h.patch(viewer(), 'cbpm_contact', 'c_own')).not.toBe('admitted');
416+
const d = await h.explainUpdate({ isSystem: true }, 'cbpm_contact', 'c_own', VIEWER);
417+
expect(d.principal.userId).toBe(VIEWER);
418+
expect(d.record).toEqual({ recordId: 'c_own', visible: false, decidedBy: 'sharing' });
419+
expect(sharingDetailOf(d)).toContain("refuses this update on its 'object_permission' leg");
420+
});
421+
});
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0055] `security/explain`'s record-grained UPDATE verdict on a
4+
// `controlled_by_parent` record comes from the master-detail write check
5+
// (`ISecurityService.checkControlledByParentWrite`), the composition step 2.8
6+
// of the write path runs, and every outcome maps the way the door maps it.
7+
//
8+
// The per-record sharing gate (`canEditRecord`) cannot decide such an update:
9+
// plugin-sharing maps `controlled_by_parent` to `public`, so it abstains, and
10+
// its abstention reads as `true`. This file pins the engine's half — which
11+
// outcome refuses, what the report names, when the check is asked and with
12+
// which context — over a deps bag, so each cell isolates one outcome. The
13+
// served member's own parity with the PATCH is pinned in
14+
// `controlled-by-parent-write-member.test.ts`, and the whole REST answer beside
15+
// the REST PATCH in `packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts`.
16+
//
17+
// The mapping (the spec's own consumer rule): `allow` and `not_applicable`
18+
// proceed, so the report is exactly the one a deps bag without the check
19+
// gives; `deny` and `unresolvable` refuse, on the `sharing` layer (the
20+
// record's write gate), naming the leg or the reason; a rejection fails the
21+
// request, so it is reported fail-closed; an outcome outside the vocabulary
22+
// refuses too.
23+
import { describe, it, expect, vi } from 'vitest';
24+
import { PermissionSetSchema } from '@objectstack/spec/security';
25+
import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security';
26+
import type {
27+
ControlledByParentWriteDenialLeg,
28+
ControlledByParentWriteOutcome,
29+
ControlledByParentWriteUnresolvedReason,
30+
} from '@objectstack/spec/contracts';
31+
import { PermissionEvaluator } from './permission-evaluator.js';
32+
import { explainAccess, type ExplainEngineDeps } from './explain-engine.js';
33+
34+
const OBJECT = 'cbx_contract';
35+
const CBP_SCHEMA = { name: OBJECT, sharingModel: 'controlled_by_parent' };
36+
37+
const EDITOR = PermissionSetSchema.parse({
38+
name: 'cbx_editor',
39+
objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
40+
});
41+
42+
/** The principal being EXPLAINED. Kept as one object so a cell can assert the check received this very context. */
43+
const EXPLAINED = { userId: 'u_member', tenantId: 'org1', positions: ['org_member'], permissions: ['cbx_editor'] };
44+
const RECORD = { id: 'r1', organization_id: 'org1', owner_id: 'u_somebody' };
45+
46+
type Master = ExplainEngineDeps['checkControlledByParentWrite'];
47+
48+
function deps(opts: {
49+
schema?: Record<string, unknown>;
50+
master?: Master;
51+
canEdit?: boolean;
52+
record?: Record<string, unknown> | null;
53+
layer1?: Record<string, unknown> | null;
54+
} = {}): ExplainEngineDeps {
55+
return {
56+
ql: { getSchema: () => opts.schema ?? CBP_SCHEMA },
57+
resolveSets: async () => [EDITOR],
58+
evaluator: new PermissionEvaluator(),
59+
getObjectSecurityMeta: async () => ({
60+
isPrivate: false,
61+
requiredPermissions: { all: [], read: [], create: [], update: [], delete: [] },
62+
fieldRequiredPermissions: {},
63+
}),
64+
requiredCaps: () => [],
65+
computeRlsFilter: async () => null,
66+
getFieldMask: () => ({}),
67+
getPartialMaskRules: async () => ({}),
68+
baselinePermissionSets: ['member_default'],
69+
computeLayeredRlsFilter: async () => ({ layer0: { organization_id: 'org1' }, layer1: opts.layer1 ?? null }),
70+
fetchRecord: async () => (opts.record !== undefined ? opts.record : { ...RECORD }),
71+
sharingReadFilter: async () => null,
72+
listRecordShares: async () => [],
73+
// plugin-sharing on a `controlled_by_parent` record: it abstains, and the abstention reads as `true`.
74+
canEditRecord: async () => opts.canEdit ?? true,
75+
canDeleteRecord: async () => opts.canEdit ?? true,
76+
...(opts.master ? { checkControlledByParentWrite: opts.master } : {}),
77+
};
78+
}
79+
80+
const answering = (outcome: ControlledByParentWriteOutcome): Master => vi.fn(async () => outcome);
81+
82+
/** `null` asks the object-level question (no `recordId`). */
83+
const explainUpdate = (d: ExplainEngineDeps, recordId: string | null = 'r1') =>
84+
explainAccess(d, { object: OBJECT, operation: 'update', context: EXPLAINED, ...(recordId !== null ? { recordId } : {}) });
85+
86+
const sharingRecordOf = (d: ExplainDecision) => d.layers.find((l) => l.layer === 'sharing')?.record;
87+
88+
const LEGS: ControlledByParentWriteDenialLeg[] = ['object_permission', 'row_level_security', 'record_sharing', 'master_chain'];
89+
const REASONS: Array<[ControlledByParentWriteUnresolvedReason, string]> = [
90+
['master_detail_relation_missing', '422 INVALID_METADATA'],
91+
['record_not_found', '404 RECORD_NOT_FOUND'],
92+
['master_reference_missing', '422 MISSING_REQUIRED_FIELD'],
93+
];
94+
95+
describe('[ADR-0055] explain refuses an update the master-detail write check refuses', () => {
96+
for (const leg of LEGS) {
97+
it(`deny on '${leg}': the record is not writable, decided by the sharing layer, which names the leg`, async () => {
98+
const d = await explainUpdate(deps({ master: answering({ outcome: 'deny', leg }) }));
99+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' });
100+
const sharing = sharingRecordOf(d);
101+
expect(sharing?.outcome).toBe('excluded');
102+
expect(sharing?.detail).toContain(`refuses this update on its '${leg}' leg`);
103+
expect(sharing?.detail).toContain('403 PERMISSION_DENIED');
104+
});
105+
}
106+
107+
for (const [reason, answered] of REASONS) {
108+
it(`unresolvable '${reason}': not writable (fail closed), the sharing layer names the reason and the update's answer`, async () => {
109+
const d = await explainUpdate(deps({ master: answering({ outcome: 'unresolvable', reason }) }));
110+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' });
111+
const sharing = sharingRecordOf(d);
112+
expect(sharing?.outcome).toBe('not_evaluated');
113+
expect(sharing?.detail).toContain(`reaches no verdict ('${reason}')`);
114+
expect(sharing?.detail).toContain(answered);
115+
});
116+
}
117+
118+
it('a rejection (a refused context, a store fault) fails the request, so it is reported fail-closed with no predicate', async () => {
119+
const fault = Object.assign(new Error('datasource unavailable'), { code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 });
120+
const master: Master = vi.fn(async () => { throw fault; });
121+
const d = await explainUpdate(deps({ master }));
122+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' });
123+
const sharing = sharingRecordOf(d);
124+
expect(sharing?.outcome).toBe('not_evaluated');
125+
expect(sharing?.rowFilter).toBeUndefined();
126+
expect(sharing?.matchesRecord).toBeUndefined();
127+
expect(sharing?.detail).toContain('master-detail write check (controlled_by_parent, ADR-0055) could not be evaluated');
128+
});
129+
130+
it('an outcome outside the vocabulary refuses (fail closed), never reads as a pass', async () => {
131+
const d = await explainUpdate(deps({ master: answering({ outcome: 'maybe' } as unknown as ControlledByParentWriteOutcome) }));
132+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' });
133+
expect(sharingRecordOf(d)?.outcome).toBe('not_evaluated');
134+
});
135+
136+
it("the record's own row-level security still decides first (step 2.7 runs above step 2.8)", async () => {
137+
const d = await explainUpdate(deps({
138+
master: answering({ outcome: 'deny', leg: 'record_sharing' }),
139+
layer1: { id: 'not_this_record' },
140+
}));
141+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'rls' });
142+
});
143+
});
144+
145+
describe('[ADR-0055] a proceeding outcome changes nothing in the report', () => {
146+
it('allow: the report is exactly the one a deps bag without the check gives — the master is not named', async () => {
147+
const without = await explainUpdate(deps());
148+
const withAllow = await explainUpdate(deps({ master: answering({ outcome: 'allow' }) }));
149+
expect(withAllow).toEqual(without);
150+
expect(withAllow.record).toMatchObject({ visible: true });
151+
expect(sharingRecordOf(withAllow)?.detail).not.toContain('master');
152+
});
153+
154+
it('allow does not lift a refusal the rest of the pipeline makes (the sharing gate refuses)', async () => {
155+
const without = await explainUpdate(deps({ canEdit: false }));
156+
const withAllow = await explainUpdate(deps({ canEdit: false, master: answering({ outcome: 'allow' }) }));
157+
expect(withAllow).toEqual(without);
158+
expect(withAllow.record).toMatchObject({ visible: false });
159+
});
160+
161+
for (const [label, schema, canEdit] of [
162+
['public_read_write', { name: OBJECT, sharingModel: 'public_read_write' }, true],
163+
['private, the gate admits', { name: OBJECT, sharingModel: 'private' }, true],
164+
['private, the gate refuses', { name: OBJECT, sharingModel: 'private' }, false],
165+
] as const) {
166+
it(`not_applicable on a ${label} object: byte-identical to the report without the check`, async () => {
167+
const without = await explainUpdate(deps({ schema, canEdit }));
168+
const withCheck = await explainUpdate(deps({ schema, canEdit, master: answering({ outcome: 'not_applicable' }) }));
169+
expect(withCheck).toEqual(without);
170+
});
171+
}
172+
173+
it('absent check (a kernel without the member): the update keeps the sharing gate\'s answer and claims nothing about a master', async () => {
174+
const d = await explainUpdate(deps());
175+
expect(d.record).toMatchObject({ recordId: 'r1', visible: true });
176+
expect(sharingRecordOf(d)?.detail).not.toContain('master');
177+
});
178+
});
179+
180+
describe('[ADR-0055] when the check is asked, and for whom', () => {
181+
it('an update of a record that exists asks it once, for that record, with the EXPLAINED context', async () => {
182+
const master = answering({ outcome: 'allow' });
183+
await explainUpdate(deps({ master }));
184+
expect(master).toHaveBeenCalledTimes(1);
185+
expect(master).toHaveBeenCalledWith(OBJECT, 'r1', EXPLAINED);
186+
expect((master as ReturnType<typeof vi.fn>).mock.calls[0][2]).toBe(EXPLAINED);
187+
});
188+
189+
for (const operation of ['read', 'create', 'delete', 'transfer', 'export'] as ExplainOperation[]) {
190+
it(`a record-grained ${operation} does not ask it`, async () => {
191+
const master = answering({ outcome: 'deny', leg: 'object_permission' });
192+
await explainAccess(deps({ master }), { object: OBJECT, operation, context: EXPLAINED, recordId: 'r1' });
193+
expect(master).not.toHaveBeenCalled();
194+
});
195+
}
196+
197+
it('an object-level update (no recordId) does not ask it, and a record that does not exist is not asked about', async () => {
198+
const master = answering({ outcome: 'deny', leg: 'object_permission' });
199+
const objectLevel = await explainUpdate(deps({ master }), null);
200+
expect(objectLevel.record).toBeUndefined();
201+
const missing = await explainUpdate(deps({ master, record: null }), 'r_missing');
202+
expect(missing.record).toEqual({ recordId: 'r_missing', visible: false });
203+
expect(master).not.toHaveBeenCalled();
204+
});
205+
});

0 commit comments

Comments
 (0)