Skip to content

Commit 2f2fa11

Browse files
fix(approvals): a snapshot field the reader is served masked is no longer served as stored (#20964) (#20993)
Fixes #20964 Clause-②: yes (widening) The approval payload snapshot is redacted at serve time, keyed on the reading caller (#10749). It narrowed by the security service's read projection, `getReadableFields`, alone. That projection counts a field whose `maskingRule` applies to the reader as readable, because the data plane serves the column with its value replaced. So the snapshot kept the field and served it as it was captured at submission. The redaction now also reads the security contract's query-side answer, `getQueryableFields` (landed for #20935 as `83480c6a`), and serves only the fields in both answers. A field served masked to this reader is dropped, with its derived label. There is no second derivation of the masking rule in `plugin-approvals`: who a rule applies to is the contract's answer, asked as the reader. ## Measured first (H1), by class The measurement used a real boot: `bootStack` with the real `SecurityPlugin`, `ObjectQL` and SQL driver, REST, automation, the record-change trigger and the approvals plugin. It had one synthetic object with one capability-gated masked field, routed to a position held by two approvers. The evidence is private to the dispatch. | Read, same record | Reader the rule applies to: before | after | Reader who lifts the rule (control): before | after | |---|---|---|---|---| | Approvals inbox, list | stored | absent | stored | stored | | Approvals inbox, item | stored | absent | stored | stored | | Generic data door on the request object | stored | absent | stored | stored | | Data plane read of the subject record (reference) | masked | masked | stored | stored | The "after" column is the dogfood pin below, green at this PR's head. ## The contract member read (H2) `ISecurityService.getQueryableFields`, in `packages/spec/src/contracts/security-service.ts`. - **By its declaration:** it is a subset of `getReadableFields`, and "the difference between the two is exactly the fields this caller sees masked". - **By `plugin-security`'s implementation:** the read projection keeps a field that is served masked, using the read path's own partial-mask set. The query-side answer reads the one query-guard derivation, which folds every masked-for-this-caller field in as non-queryable. Both start from the same field map (the evaluator, the `requiredPermissions` fold and the delegator intersection). Their difference is therefore exactly the read path's masked set. So the member names the masked set by complement, and the redaction reads it. Nothing in `packages/spec` or `plugin-security` changes. ## Drop, not mask (H3), and why The data plane answers this reader with the masked value. This PR drops the field instead, and that is a deliberate divergence in shape: - The contract publishes **which** fields are masked for a reader, not the masked **value**. Only `plugin-security`'s field masker produces the masked value. Reproducing it here would be the second copy of the masking rule that the triage forbids. Publishing it would be a new contract member, which belongs to the contract lanes and is outside this claim. - Dropping is the fail-closed side of the data plane's answer. It discloses strictly less than the masked value would (no kept characters, no length). It is also the shape this seam already serves for a field the reader may not read at all, so a drawer sees one "not for you" shape. ## Fail closed when the answer cannot be had The contract obliges a consumer that cannot get the query-side answer not to read its absence as "nothing is masked". When the source has no such member, answers `undefined` or throws, the redaction serves no snapshot field for that object and logs it. An unresolvable read projection still passes the snapshot through whole, as before (#3807). With the security plugin wired, the member is always present and answers a list whenever the read projection does. ## Changes - `packages/plugins/plugin-approvals/src/payload-redaction.ts`: `FieldVisibilitySource` gains the optional `getQueryableFields`. `resolveReadableSnapshotFields` answers the read projection intersected with it, or fails closed as above. Both doors call this one function, and so does the free-text predicate check (#11040), whose behaviour is unchanged. - `packages/plugins/plugin-approvals/src/approvals-plugin.ts`: the service door's bridge to the `security` service forwards the member as well. The generic data door was already handed the service itself. - `.changeset/20964-approval-snapshot-masked-field.md`: `patch`. It includes a note for a host that builds `ApprovalService` with its own field-visibility source. Measured: no such producer exists in the tree, and the plugin bridge is the only one. - **Surface note:** the claim's file list names `payload-redaction.ts` and "`payload-redaction-middleware.ts` only where the wiring needs it". The service door's wiring lives in `approvals-plugin.ts`, not in the middleware file. The middleware file needed no change. The unit pin and the dogfood pin both read the plugin wiring, and ablation B shows it is load-bearing. ## Pins (committed red before the fix) - `packages/plugins/plugin-approvals/src/approval-payload-masked-field.test.ts`, 13 cases. It uses a source double that answers both contract members per reader. Both doors drop the masked-for-this-caller field and keep the business fields. The derived label map is built and does not carry the dropped field. The unmasking reader is served the stored value (the control). The stored column keeps the whole row. Three fail-closed cases cover a missing, `undefined` and throwing answer, and the unresolvable read projection still passes through. The plugin's bridge forwards the answer. - Measured with the pins commit's (`a961b3310a`) source in the tree: 9 red and 4 green. The green cases are the two controls, the audit case and the unresolvable pass-through, which hold both before and after. - `packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts`, on a real boot as measured above, beside the inbox's existing route pin. It asserts by class: the reference (the data plane masks the field for this reader), the three approval reads (field absent for the reader the rule applies to, business field present), and the control (stored value on every read). Red at the pins commit and green after. - `approval-payload-redaction.test.ts` (the existing #10749 pins): its source double gains the member the real service now has. Fixture triage: add the missing declaration. No field there carries a masking rule, so the answer equals the read projection, and every assertion is unchanged. ## Ablation, predicted before running, at `15f97c152e` Both suites resolve `plugin-approvals` from **source**: the unit suite imports it relatively, and the dogfood isolated project aliases it to `src`. So no `dist` leg applies. Each mutation went through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob moved, marker counted on disk). Each was restored to a blob equal to HEAD with an empty `git diff HEAD`, and the tree was clean afterwards. - **A, the redaction ignores the query-side answer** (`payload-redaction.ts`). Predicted and observed: 9 red and 4 green in the new unit file. The green cases are the two controls, the audit case and the unresolvable pass-through. The 16 existing redaction cases stayed green, and the dogfood pin went red. - **B, the plugin bridge stops forwarding the member** (`approvals-plugin.ts`). Predicted and observed: only the bridge case is red (1 red and 28 green across both unit files). The dogfood pin went red: the service door fails closed and serves an empty snapshot to both readers. ## Verification Verification ran at `15f97c152e`. Line 2 is the measured `Clause-②` (H4): no export is added, and the one type addition is an optional member on an injected source. Any value of that member can only remove fields from what is served, never add one, and no input is refused. The other results (the derived gate set, the lint narrowing and the `--ran` reconciliation) are in the dev report on #20964, because this body is written once. - `@objectstack/plugin-approvals`: `test` 52 files and 804 passed. `typecheck` exit 0, including the test layer (no new debt). - The dogfood pin and the existing inbox override pin: 2 files and 2 passed. ## Acceptance notes - Main moved by four commits after this branch was cut (formula, the explain engine in `plugin-security`, analytics and PM tooling). None touches this surface, so the branch is not merged with main. - The masked value is not reproduced, so an approver who may see a field masked on the data plane sees no value for it in the approval drawer. Whether a contract member that serves the masked value is wanted is left to the seat. ## Patch rounds (the seat's append from the dev's report on #20964; the dev writes a body only once) ### Patch round 1 Head `e6c9b9d56b` (was `15f97c152e`): two commits, no merge of `main`. It applies the remedy in contract review `5922625982` (FAIL on the semver grade only) and changes nothing else. **What changed** - `.changeset/20964-approval-snapshot-masked-field.md`: - `@objectstack/plugin-approvals` goes from `patch` to `minor`. - `Clause-②: no` becomes `Clause-②: yes (widening)`, here and on this body's line 2. The seat edited line 2. - One sentence is added. It names the one public-surface addition: an optional `getQueryableFields(object, context)` member on the field-visibility source that `ApprovalServiceOptions.fieldVisibility` and `ApprovalService.attachFieldVisibility` accept. - Every other byte is unchanged, including the FROM → TO note to a self-composing host. - `packages/plugins/plugin-approvals/src/approval-free-text-scope.test.ts`: the file's visibility double gains the query-side member, in the same shape the first round gave the redaction test's double. No assertion changed. - Not touched: the fix, the service-door bridge, the unit pins and the dogfood pin. **Verification at `e6c9b9d56b`** (every run under the shared verify lock) - `@objectstack/plugin-approvals` tests: 52 files and 804 tests passed. `typecheck` exit 0. The test layer holds exactly at its ledger, and the edited file has 0 errors. - The fail-closed branch in the free-text file, measured once with a recording logger: - before (the double at `15f97c152e`): 11 warns over 13 passing tests; - after: 0 warns over the same 13. - Changeset gates: `check-changeset-no-major` exit 0, `check-adr-0087-registration` exit 0 (one non-breaking changeset), `check-changeset-fixed` exit 0. - The level axis, driven offline with a synthetic pull-request payload that declares `Clause-②: yes (widening)`: exit 0 on `HEAD`, and exit 1 on `15f97c152e` with the required-minor refusal. - `dispatch-gates --commands` over the 7-path branch diff: 67 derived, 67 run, every one exit 0. `--ran`: 0 not measured. - `main` is 9 commits past the branch point. None of them touches this diff's paths, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b84b240 commit 2f2fa11

7 files changed

Lines changed: 649 additions & 4 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-approvals': minor
3+
---
4+
5+
fix(approvals): a snapshot field the reader is served masked on the data plane is no longer served as stored (#20964)
6+
7+
Clause-②: yes (widening)
8+
9+
The approval payload snapshot is redacted at serve time by the security service's read projection, `getReadableFields`. That projection counts a field whose `maskingRule` applies to the reader as readable, because the data plane serves the column with its value replaced. So the snapshot kept such a field and served it as captured at submission. The redaction now also reads the security contract's `getQueryableFields`, which differs from the read projection by exactly the fields the reader is served masked, and drops those fields, with their derived labels, on both read doors: the approvals inbox reads and the generic data door on the request object. A reader for whom the masking rule is lifted still sees the stored value. The full snapshot stays at rest.
10+
11+
The field is dropped rather than masked. The contract names which fields are masked for a reader, not the masked value, and reproducing the mask in this plugin would be a second copy of the masking rule.
12+
13+
The one public-surface addition is an optional `getQueryableFields(object, context)` member on the field-visibility source that `ApprovalServiceOptions.fieldVisibility` and `ApprovalService.attachFieldVisibility` accept.
14+
15+
A host that constructs `ApprovalService` itself and passes its own `fieldVisibility` source: that source must now also answer `getQueryableFields` (delegate it to the `security` service). A source without it cannot say which readable fields are masked for the reader, so the redaction fails closed and serves no snapshot field. The approvals plugin's own wiring already forwards it.

‎packages/plugins/plugin-approvals/src/approval-free-text-scope.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ function makeVisibility(answers: Record<string, string[] | undefined>) {
147147
if (context?.isSystem) return Object.keys(ROW_A);
148148
return answers[object];
149149
},
150+
/**
151+
* [#20964] The contract's query-side answer, which the real service gives
152+
* beside the read projection. No field in this fixture carries a masking
153+
* rule, so it equals the read projection; a source WITHOUT it fails closed
154+
* (pinned in `approval-payload-masked-field.test.ts`). Not recorded in
155+
* `_calls`, which counts read-projection asks.
156+
*/
157+
async getQueryableFields(object: string, context?: any): Promise<string[] | undefined> {
158+
if (context?.isSystem) return Object.keys(ROW_A);
159+
return answers[object];
160+
},
150161
};
151162
}
152163

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20964] A snapshot field the reading caller is served MASKED is not served
5+
* as stored.
6+
*
7+
* ## What is pinned, and why each half is load-bearing
8+
*
9+
* The data plane serves a field whose `maskingRule` applies to the caller with
10+
* its value replaced. The security contract's read projection,
11+
* `getReadableFields`, therefore counts that field READABLE: it is a served
12+
* column. A snapshot redaction that narrows by the read projection alone keeps
13+
* the key, and with it the value as it was captured at submission time.
14+
*
15+
* The contract's query-side answer, `getQueryableFields` (#20935), is a subset of
16+
* the read projection that differs from it by exactly the fields this caller is
17+
* served masked. The redaction reads that answer and drops those keys. It does
18+
* not re-derive who a masking rule applies to, and it does not reproduce the
19+
* mask: the contract publishes WHICH fields are masked for a caller, not the
20+
* masked value.
21+
*
22+
* 1. **not served as stored** — for a caller the rule applies to, the key is
23+
* absent from the served snapshot, on both read doors (the service door and
24+
* the generic data door), and from the derived maps built from its keys.
25+
* 2. **control** — for a caller who holds what lifts the rule, the same key
26+
* carries the stored value. Without it, a redaction that dropped the field
27+
* for everyone would pass (1).
28+
* 3. **audit preserved** — the stored column still holds the whole row.
29+
* 4. **fail closed** — a source that cannot say which readable fields are
30+
* masked for this caller (the answer is missing, `undefined`, or throws) is
31+
* served no snapshot field, as the contract obliges a consumer that cannot
32+
* get the answer. An unresolvable read projection still passes the
33+
* snapshot through whole, exactly as before (#3807).
34+
* 5. **wiring** — the approvals plugin's bridge to the `security` service
35+
* forwards the query-side answer as well as the read projection.
36+
*
37+
* The source double below mirrors the contract's two answers per caller; the
38+
* real `SecurityPlugin` composition is pinned at the HTTP door in
39+
* `packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts`.
40+
* Fixtures are synthetic.
41+
*/
42+
43+
import { describe, it, expect, beforeEach } from 'vitest';
44+
import { ApprovalService } from './approval-service.js';
45+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
46+
import { type FieldVisibilitySource } from './payload-redaction.js';
47+
import { redactRowsInPlace } from './payload-redaction-middleware.js';
48+
49+
const OBJECT = 'probe_note';
50+
const RECORD = 'pn_1';
51+
const REQUEST = 'req_pn_1';
52+
const TENANT = 't1';
53+
/** An approver the field's masking rule applies to. */
54+
const MEMBER = 'probe_member';
55+
/** An approver on the same request who holds what lifts the rule. */
56+
const UNMASKER = 'probe_unmasker';
57+
/** The field served masked to MEMBER. */
58+
const MASKED_KEY = 'probe_code';
59+
60+
/** The submitted row, as the flow handed it over. Synthetic values. */
61+
const FULL_ROW = {
62+
id: RECORD,
63+
title: 'Synthetic note',
64+
amount: 10,
65+
[MASKED_KEY]: 'SYNTH-0000-VALUE',
66+
};
67+
const ALL_FIELDS = Object.keys(FULL_ROW);
68+
const BUSINESS = { id: RECORD, title: 'Synthetic note', amount: 10 };
69+
70+
type Answer = string[] | undefined;
71+
72+
/**
73+
* The contract's two answers for each caller: the read projection counts the
74+
* masked field readable (it is served, masked); the query-side answer leaves it
75+
* out for the caller the rule applies to and keeps it for the unmasker.
76+
*/
77+
const CONTRACT: Record<string, { readable: Answer; queryable: Answer }> = {
78+
[MEMBER]: { readable: ALL_FIELDS, queryable: ALL_FIELDS.filter((f) => f !== MASKED_KEY) },
79+
[UNMASKER]: { readable: ALL_FIELDS, queryable: ALL_FIELDS },
80+
};
81+
82+
function contractSource(opts: {
83+
queryable?: 'answer' | 'absent' | 'undefined' | 'throws';
84+
readable?: 'answer' | 'undefined';
85+
} = {}): FieldVisibilitySource {
86+
const userOf = (context: unknown) => String((context as { userId?: string } | undefined)?.userId ?? '');
87+
const source: FieldVisibilitySource = {
88+
async getReadableFields(_object: string, context?: unknown) {
89+
if (opts.readable === 'undefined') return undefined;
90+
return CONTRACT[userOf(context)]?.readable;
91+
},
92+
};
93+
const q = opts.queryable ?? 'answer';
94+
if (q !== 'absent') {
95+
source.getQueryableFields = async (_object: string, context?: unknown) => {
96+
if (q === 'throws') throw new Error('synthetic outage');
97+
if (q === 'undefined') return undefined;
98+
return CONTRACT[userOf(context)]?.queryable;
99+
};
100+
}
101+
return source;
102+
}
103+
104+
interface FakeRow { [k: string]: any }
105+
106+
/**
107+
* Engine double — reads and inserts only. This file's subject is the READ path,
108+
* which never reaches a write verb, so `update` / `delete` are deliberately not
109+
* declared (the same reasoning as `approval-payload-redaction.test.ts`).
110+
*/
111+
function makeEngine() {
112+
const tables: Record<string, FakeRow[]> = {};
113+
const ensure = (n: string) => (tables[n] ??= []);
114+
function matches(row: FakeRow, filter: any): boolean {
115+
if (!filter || typeof filter !== 'object') return true;
116+
for (const [k, v] of Object.entries(filter)) {
117+
if (k === '$or') { if (!(v as any[]).some((s) => matches(row, s))) return false; continue; }
118+
if (k === '$and') { if (!(v as any[]).every((s) => matches(row, s))) return false; continue; }
119+
const rv = row[k];
120+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
121+
if (!(v as any).$in.includes(rv)) return false; continue;
122+
}
123+
if (rv !== v) return false;
124+
}
125+
return true;
126+
}
127+
return {
128+
_tables: tables,
129+
async find(object: string, options?: any) {
130+
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
131+
return rows.slice(options?.offset ?? 0, (options?.offset ?? 0) + (options?.limit ?? 1000));
132+
},
133+
async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; },
134+
/** The subject object's labels, so the derived `payload_labels` map is really built. */
135+
getSchema(object: string) {
136+
if (object !== OBJECT) return undefined;
137+
return {
138+
name: OBJECT,
139+
label: 'Probe Note',
140+
fields: Object.fromEntries(ALL_FIELDS.map((f) => [f, { name: f, label: `Label ${f}` }])),
141+
};
142+
},
143+
};
144+
}
145+
146+
function seed(engine: ReturnType<typeof makeEngine>) {
147+
engine._tables[OBJECT] = [{ ...FULL_ROW, organization_id: TENANT }];
148+
engine._tables['sys_approval_request'] = [{
149+
id: REQUEST,
150+
organization_id: TENANT,
151+
process_name: 'flow:probe_review',
152+
object_name: OBJECT,
153+
record_id: RECORD,
154+
submitter_id: 'probe_submitter',
155+
status: 'pending',
156+
current_step: 'review',
157+
pending_approvers: [MEMBER, UNMASKER].join(','),
158+
payload_json: JSON.stringify(FULL_ROW),
159+
created_at: '2026-09-30T00:00:00Z',
160+
}];
161+
engine._tables['sys_approval_approver'] = [
162+
{ id: 'idx_m', request_id: REQUEST, approver: MEMBER, organization_id: TENANT },
163+
{ id: 'idx_u', request_id: REQUEST, approver: UNMASKER, organization_id: TENANT },
164+
];
165+
engine._tables['sys_approval_action'] = [];
166+
}
167+
168+
const asUser = (userId: string) => ({ userId, tenantId: TENANT, positions: [], permissions: [] }) as any;
169+
170+
const storedSnapshot = (engine: ReturnType<typeof makeEngine>) =>
171+
JSON.parse(String(engine._tables['sys_approval_request'][0].payload_json)) as Record<string, unknown>;
172+
173+
describe('[#20964] a snapshot field served masked to the reader is not served as stored — the service door', () => {
174+
let engine: ReturnType<typeof makeEngine>;
175+
let service: ApprovalService;
176+
177+
beforeEach(() => {
178+
engine = makeEngine();
179+
seed(engine);
180+
service = new ApprovalService({ engine: engine as any });
181+
});
182+
183+
it('(1) getRequest: the masked-for-this-caller key is not served, and the business fields still are', async () => {
184+
service.attachFieldVisibility(contractSource());
185+
const row = await service.getRequest(REQUEST, asUser(MEMBER));
186+
const payload = row!.payload as Record<string, unknown>;
187+
expect(payload).not.toHaveProperty(MASKED_KEY);
188+
expect(payload).toEqual(BUSINESS);
189+
});
190+
191+
it('(1) listRequests: the masked-for-this-caller key is not served', async () => {
192+
service.attachFieldVisibility(contractSource());
193+
const rows = await service.listRequests({ approverId: MEMBER }, asUser(MEMBER));
194+
expect(rows).toHaveLength(1);
195+
expect(rows[0].payload).toEqual(BUSINESS);
196+
});
197+
198+
it('(1) the derived label map built from the snapshot keys does not carry the masked-for-this-caller key', async () => {
199+
service.attachFieldVisibility(contractSource());
200+
const row = (await service.getRequest(REQUEST, asUser(MEMBER))) as any;
201+
// Built at all (the fixture's schema labels reach it) ...
202+
expect(row.payload_labels).toHaveProperty('title');
203+
// ... and without the key the redaction dropped.
204+
expect(Object.keys(row.payload_labels)).not.toContain(MASKED_KEY);
205+
const control = (await service.getRequest(REQUEST, asUser(UNMASKER))) as any;
206+
expect(control.payload_labels).toHaveProperty(MASKED_KEY);
207+
});
208+
209+
it('(2) control: a reader who holds what lifts the rule is served the stored value', async () => {
210+
service.attachFieldVisibility(contractSource());
211+
const row = await service.getRequest(REQUEST, asUser(UNMASKER));
212+
expect(row!.payload).toEqual(FULL_ROW);
213+
});
214+
215+
it('(3) audit preserved: the stored column still holds the whole row after a masked read', async () => {
216+
service.attachFieldVisibility(contractSource());
217+
await service.getRequest(REQUEST, asUser(MEMBER));
218+
expect(storedSnapshot(engine)).toEqual(FULL_ROW);
219+
});
220+
221+
it('(4) fail closed: a source with no query-side answer serves no snapshot field', async () => {
222+
service.attachFieldVisibility(contractSource({ queryable: 'absent' }));
223+
const row = await service.getRequest(REQUEST, asUser(UNMASKER));
224+
expect(row!.payload).toEqual({});
225+
});
226+
227+
it('(4) fail closed: a query-side answer of `undefined` serves no snapshot field', async () => {
228+
service.attachFieldVisibility(contractSource({ queryable: 'undefined' }));
229+
const row = await service.getRequest(REQUEST, asUser(UNMASKER));
230+
expect(row!.payload).toEqual({});
231+
});
232+
233+
it('(4) fail closed: a query-side answer that throws serves no snapshot field', async () => {
234+
service.attachFieldVisibility(contractSource({ queryable: 'throws' }));
235+
const row = await service.getRequest(REQUEST, asUser(UNMASKER));
236+
expect(row!.payload).toEqual({});
237+
});
238+
239+
it('(4) an unresolvable read projection still passes the snapshot through whole (#3807)', async () => {
240+
service.attachFieldVisibility(contractSource({ readable: 'undefined', queryable: 'absent' }));
241+
const row = await service.getRequest(REQUEST, asUser(MEMBER));
242+
expect(row!.payload).toEqual(FULL_ROW);
243+
});
244+
});
245+
246+
describe('[#20964] a snapshot field served masked to the reader is not served as stored — the generic data door', () => {
247+
const rowsFor = () => [{ id: REQUEST, object_name: OBJECT, payload_json: JSON.stringify(FULL_ROW) }];
248+
249+
it('(1) the raw snapshot string loses the masked-for-this-caller key and keeps the business fields', async () => {
250+
const rows = rowsFor();
251+
await redactRowsInPlace(rows, contractSource(), asUser(MEMBER));
252+
expect(JSON.parse(rows[0].payload_json)).toEqual(BUSINESS);
253+
});
254+
255+
it('(2) control: a reader who holds what lifts the rule is served the stored value', async () => {
256+
const rows = rowsFor();
257+
await redactRowsInPlace(rows, contractSource(), asUser(UNMASKER));
258+
expect(JSON.parse(rows[0].payload_json)).toEqual(FULL_ROW);
259+
});
260+
261+
it('(4) fail closed: a source with no query-side answer serves no snapshot field', async () => {
262+
const rows = rowsFor();
263+
await redactRowsInPlace(rows, contractSource({ queryable: 'absent' }), asUser(UNMASKER));
264+
expect(JSON.parse(rows[0].payload_json)).toEqual({});
265+
});
266+
});
267+
268+
describe('[#20964] the approvals plugin forwards the query-side answer to the redaction', () => {
269+
it('(5) the service door narrows by the `security` service\'s query-side answer', async () => {
270+
const engine = makeEngine();
271+
seed(engine);
272+
const services: Record<string, unknown> = { objectql: engine, security: contractSource() };
273+
const ctx: any = {
274+
getService: (name: string) => {
275+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
276+
return services[name];
277+
},
278+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
279+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
280+
};
281+
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);
282+
const approvals = services.approvals as ApprovalService;
283+
expect(approvals).toBeInstanceOf(ApprovalService);
284+
285+
const masked = await approvals.getRequest(REQUEST, asUser(MEMBER));
286+
expect(masked!.payload).toEqual(BUSINESS);
287+
const control = await approvals.getRequest(REQUEST, asUser(UNMASKER));
288+
expect(control!.payload).toEqual(FULL_ROW);
289+
});
290+
});

‎packages/plugins/plugin-approvals/src/approval-payload-redaction.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ function makeVisibility(answers: Record<string, string[] | undefined>, opts?: {
147147
if (context?.isSystem) return Object.keys(FULL_ROW);
148148
return answers[object];
149149
},
150+
/**
151+
* [#20964] The contract's query-side answer, which the real service gives
152+
* beside the read projection. No field in this fixture carries a masking
153+
* rule, so it equals the read projection; a source WITHOUT it fails closed
154+
* (pinned in `approval-payload-masked-field.test.ts`). Not recorded in
155+
* `_calls`, which counts read-projection asks.
156+
*/
157+
async getQueryableFields(object: string, context?: any): Promise<string[] | undefined> {
158+
if (context?.isSystem) return Object.keys(FULL_ROW);
159+
return answers[object];
160+
},
150161
};
151162
}
152163

‎packages/plugins/plugin-approvals/src/approvals-plugin.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,18 @@ export class ApprovalsServicePlugin implements Plugin {
246246
? sec.getReadableFields(object, context)
247247
: Promise.resolve(undefined);
248248
},
249+
// [#20964] The masked-for-this-caller answer, from the same service: the
250+
// read projection counts a masked field readable, so the service door
251+
// narrows by both. A service without the member answers `undefined`
252+
// here, which the redaction reads as "cannot tell" and fails closed on
253+
// (`resolveReadableSnapshotFields`). The generic data door below is
254+
// handed the service itself and reads the same member directly.
255+
getQueryableFields: (object: string, context?: unknown) => {
256+
const sec = fieldVisibility();
257+
return sec && typeof sec.getQueryableFields === 'function'
258+
? sec.getQueryableFields(object, context)
259+
: Promise.resolve(undefined);
260+
},
249261
});
250262

251263
// Record lock: block edits to a record while it has a pending request.

0 commit comments

Comments
 (0)