Skip to content

Commit 94924e0

Browse files
fix(plugin-audit): the parent-record read gates skip an object the caller cannot read without reading it, so no denial is logged for the skip (#23663)
Fixes #23279 Clause-②: no ## What this changes The `sys_activity` and `sys_audit_log` read gates (`packages/plugins/plugin-audit/src/parent-record-read-gate.ts`, `computeParentRecordFilter`) serve a row only when the caller can read the record it is about. They decide that once per parent object the query's rows name, by reading that object as the caller (`resolveReadableParentIds`, #20833 / PR #21069). For every parent object the caller holds no object-level read on, plugin-security's middleware refused that read and logged `[Security] Access denied` at WARN before the gate dropped the object. The gate now asks the security service's `canReadObject` first, for each parent object, with the caller's own envelope (minus the operation-private keys, exactly what the caller-scoped read gets). It then reads as the caller only the objects that check admits. An object it refuses gets no readable id, which is what its refused read gave it, so the emitted WHERE and the rows served do not change. It is just never read, so nothing is refused and nothing is logged. - **The check already existed.** `ISecurityService.canReadObject` is optional on the contract (`packages/spec/src/contracts/security-service.ts`). plugin-security registers it on its `security` service. It is the middleware's own object-level read gate, arm for arm: the principal-less and no-set deny baselines, the unresolved-posture refusal, the ADR-0066 D3 capability AND-gate, the `allowRead` CRUD grant and the ADR-0090 D10 delegator intersection. It answers without logging or throwing, and `can-read-object-admission.test.ts` pins it against the real middleware. `searchAll` already asks it the same way. So **plugin-security does not change**: no new method, and nothing is touched in `security-plugin.ts`. - **The check can only exclude objects.** An admitted object is still read as the caller, and that read still decides row scope, sharing, RLS and every later middleware arm. - **Fails closed.** A `canReadObject` that throws, or answers anything other than `true`, refuses that object. Its rows are excluded and the gate logs one warning naming the object. plugin-security's own implementation never throws: it catches internally and answers `false`. - **No check composed.** On a kernel without a security service, or with one whose contract omits the optional method, every parent object is read as the caller, as before. The engine's middleware still decides, so this state can leave the answer only as it is today, never widen it. This is the contract's "absence is a defined state" reading. It is also what keeps plugin-audit's existing integration suites, which compose no security plugin, unchanged. - **Wiring.** `AuditPlugin` resolves `ctx.getService('security')` on every read (the security plugin may register after this one) and passes the resolver to both installers. The installers and `computeParentRecordFilter` take it as a required argument. None of them is exported: `src/index.ts`, `package.json` and `src/objects/**` are unchanged, and `ParentObjectReadAdmission`, `ParentObjectReadAdmissionSource` and `computeParentRecordFilter` appear in neither `dist/index.d.ts` nor `dist/index.d.mts` (positive control: `installCommentReadVisibility` appears twice in each). - ⛔ **Not changed:** the level of plugin-security's denial log, and the gate does not catch or mute that log. A caller's own read of a skipped object is still refused and still logs exactly once. That is pinned below. ## Measured on a real stack (before and after) `packages/qa/dogfood/test/parent-read-gate-silent-skip.dogfood.test.ts` runs a real org-bound `bootStack` with the real `SecurityPlugin` and the real `AuditPlugin`. The member holds `org_member` and a no-wildcard app grant, plus an explicit set granting object-level read on `sys_activity` and `sys_audit_log`. The kernel logger every plugin context shares is spied on per request. | member request | denial WARN lines, base build (`c5dd7fce30`) | after | |:--|:--|:--| | `GET /data/sys_activity?top=20&sort=-timestamp` (the console header's feed) | 8 | 0 | | `GET /data/sys_activity?limit=1000` | 8 | 0 | | `GET /data/sys_audit_log?limit=1000` | 8 | 0 | The 8 lines are 4 objects × 2 engine reads: the list's rows and its `total` both run the gate. Two objects hit the CRUD arm ("operation 'find' on object … is not permitted for positions [org_member, everyone]"): `sys_user_permission_set` and `sys_setting_audit`. Two hit the capability arm ("requires capability [manage_platform_settings]"): `sys_platform_setting` and `sys_migration`. The HotCRM report counted 16 lines on a larger app; this fixture's stream names fewer platform objects. The mechanism is the same. **Same rows.** Each case first reads with the security service's `canReadObject` withdrawn (deleted from the registered service object, then restored). That is the contract's optional-method state, where the gate reads every parent as the caller, as the base did. The case then reads again with it in place, on the same boot and the same rows, and asserts the sorted ids are equal. The no-probe read must also log the denials for exactly the set of refused parents, so the comparison is armed. At the base build the id equality held and only the zero-denial assertion was red: 3 failed and 1 passed of 4. After the fix: 4 passed of 4. **A real denial still logs once.** The member's direct `GET /data/sys_user_permission_set` (CRUD arm) and `GET /data/sys_migration` (capability arm) each answer 403 and log exactly one denial line naming the object, both before and after. **Row-level narrowing stays silent.** `cmt_private` is readable at object level and owner-scoped at row level. The member gets 404 on the admin's record, which is an armed control. The gate reads that object as the member and logs nothing while doing it: the zero-denial reads above include it. ## Pins | file | what it holds | layer | |:--|:--|:--| | `plugin-audit/src/parent-record-read-admission.integration.test.ts` (new) | a real kernel, the real `AuditPlugin` and CRUD mirror, a real SQLite driver; the `security` stand-in registers AFTER the audit plugin. On both `sys_activity` and `sys_audit_log`: a member's list and total do not read the refused object (zero refusals), serve the same rows and total as the no-probe read (which refuses it twice), and ask the check once per parent object per read with the envelope minus `__readScope`. An admin still reads every vault row | PR CI | | `plugin-audit/src/activity-read-visibility.test.ts` (+8 cases) | a refused object is never read and the WHERE equals the one its refused read gave; an admitted object's own read still decides its ids; the envelope; every object refused gives the deny-all sentinel with zero reads; fail-closed on a throw (logged, never widening) and on any answer that is not `true`; no check composed (absent, null, a throwing resolver, a service without the method) reads every parent as before; resolution on every read | PR CI | | `plugin-audit/src/audit-log-read-visibility.test.ts` (+2 cases) | the ledger shares the seam: a refused object is not read, the outside-class rows are kept, and the WHERE is unchanged; a holder of the audit capability is exempt before the check is asked | PR CI | | `qa/dogfood/test/parent-read-gate-silent-skip.dogfood.test.ts` (new, committed red first at `9defa89117`) | the real pair end to end, as above | full battery | ## Ablations: old behaviour back, red, restore Each ablation ran through `scripts/ablation-replace.mjs`, from the committed fix (`9863e10294`). In every leg the anchor went from 1 to 0, the injected text from 0 to 1, and the blob changed. A trap restored the file by absolute path. The restore was proven each time: blob `3cee7dd179` equals HEAD and `git diff HEAD` is empty. | mutation | what it puts back | result (source-resolved plugin-audit suites, 45 cases) | |:--|:--|:--| | M1: the check is never asked | the old gate | 8 failed: 5 activity unit, 1 ledger unit, both integration cases | | M2: a throwing check admits | fail-open | 1 failed: the throw case | | M3: a truthy answer admits | a loose answer | 1 failed: the not-`true` case | | M4: the raw context is sent, with its private keys | a widened envelope | 3 failed: the envelope unit case and both integration cases | | (control, unmutated) | | 45 passed | **Leg B (dogfood, resolves plugin-audit from `dist/`), M1.** After the mutation the rebuild exited 0, and `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.mjs`. The dogfood pin then gave 3 failed and 1 passed of 4. After the restore the rebuild exited 0, the preflight with `--absent` reported the marker gone from all 6 built files and the tree clean, and the pin gave 4 passed of 4. ## Verification (head `262e8548de`, `origin/main` `6792be6ab` merged in) - `pnpm --filter @objectstack/plugin-audit test`: 49 files, 785 tests passed. - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0. That covers `tsc --noEmit`, the scripts program and `check:test-typecheck` over `tsconfig.test.json`, which includes `src/**/*`, so the new test files are in it (0 errors). - `pnpm --filter @objectstack/dogfood typecheck`: exit 0, 0 errors, with the dogfood closure built at this head. - Dogfood at this head: the new pin, plus `activity-parent-read-gate`, `audit-log-parent-read-gate`, `audit-log-audit-capability`, `activity-withheld-update`, `activity-field-values` and `audit-log-field-values`: 7 files, 55 tests passed. - Lint, declared narrowed: `eslint --no-inline-config --format json` over the 8 changed TypeScript files gives 8 files, 0 errors and 0 warnings. The whole-repo `pnpm lint` runs in CI. - Gate families from `dispatch-gates --commands` (70, derived at this head): 69 exit 0. `check:dual-build-cjs-loads` exits 3 with PREREQUISITE NOT MET, because it needs a whole-repo build. **NOT MEASURED** locally; CI runs it. `dispatch-gates --ran` with exit codes recorded: 70 derived, 69 run, 1 NOT-MEASURED, 0 UNRUN. - Changeset: `.changeset/23279-parent-read-gate-silent-skip.md`, a `patch` on `@objectstack/plugin-audit` only. plugin-security ships nothing new. The dogfood package is private. ## Acceptance notes - **Measured against the dispatch's mechanism notes.** The producer is as described. Each list request logs twice per object, because the rows and the `total` both run the gate. The `sys_migration` line is the middleware's ADR-0066 D3 capability arm, not a second producer. The object-level check did not have to be added: it is the existing `canReadObject`. One divergence from the suggested route: an *absent* check reads every parent as the caller, as before, rather than denying every object. Denying would change the rows on every kernel composed without a security service, plugin-audit's own integration suites among them. It is also not what the contract's optional-method rule asks. A check that *throws* or answers anything but `true` does deny. - **The same flood is likely at the other parent-derived gates. Read, not measured.** These call core's `resolveReadableParentIds` with one caller-scoped read per parent object: the `sys_comment` read gate (`comment-access-hooks.ts`, `installCommentReadVisibility`), `service-mailbox`'s `mail-read-visibility.ts`, and `service-storage`'s attachment read gate (`attachment-access-hooks.ts`). A caller listing rows whose parents include objects it holds no object-level read on would log one denial per object per read. The fix would be the same check before the caller-scoped read. The way to close it for every gate at once is an optional object-level check on `resolveReadableParentIds` itself, in `packages/core` (`domain:engine`), which this card leaves alone. Nobody has picked this up. - **No change to `content/docs/permissions/system-context.mdx`.** No new `isSystem` read site is added. - **Not measured:** a boot with more than one organization; an on-behalf-of (delegated) caller through the real stack. The check's delegator arm is plugin-security's, and its parity is pinned there. --- _Generated by [Claude Code](https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 462dff3 commit 94924e0

9 files changed

Lines changed: 765 additions & 18 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): the `sys_activity` and `sys_audit_log` read gates skip a parent object the caller cannot read without reading it, so the security plugin no longer logs a denial for each skipped object
6+
7+
Clause-②: no
8+
9+
Both gates serve a row only when the caller can read the record it is about. They decide that once per parent object the query's rows name, by reading that object as the caller. For every object the caller holds no object-level read on, plugin-security refused that read and logged `[Security] Access denied` at WARN: one line per object per read, and again for the list's `total`. The console header's activity feed reads `sys_activity` on every page view, so every non-admin page view logged a line for each platform object the stream names (`sys_migration`, `sys_platform_setting`, `sys_user_permission_set`, and others), and real denials were buried among them.
10+
11+
Each gate now asks the security service's `canReadObject` first, with the caller's own context. That is the security middleware's own object-level read check, and it answers without logging. The gate then reads only the objects it admits. An object it refuses contributes no rows, exactly as its refused read did before, so every caller is served the same rows, with the same `total`, as before. A caller's own read of such an object is still refused and still logged, once.
12+
13+
The check can only exclude objects. An admitted object is still read as the caller, and that read still decides row scope, sharing and RLS. If `canReadObject` throws, or answers anything other than `true`, that object's rows are excluded and the gate logs a warning naming the object. On a deployment without the security plugin, or with a security service that does not offer `canReadObject`, every parent object is read as the caller, as before.

‎packages/plugins/plugin-audit/src/activity-read-visibility.test.ts‎

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { describe, it, expect, vi } from 'vitest';
1111
import { installActivityReadVisibility, parseActivityParent } from './activity-read-visibility.js';
1212
import { LEDGER_AUDIT_CAPABILITY } from './audit-log-read-visibility.js';
1313
import type { CommentAccessEngine, CommentReadMiddlewareCtx } from './comment-access-hooks.js';
14+
import type { ParentObjectReadAdmissionSource } from './parent-record-read-gate.js';
1415

1516
const DENY = { id: '__activity_parent_denied__' };
1617
const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() });
@@ -21,6 +22,8 @@ function install(opts: {
2122
/** parentObject -> readable ids (for any caller), or a thrower. */
2223
readable?: Record<string, string[] | (() => never)>;
2324
logger?: ReturnType<typeof silentLogger>;
25+
/** The security service's object-level read admission the gate resolves per read; absent: none composed. */
26+
admission?: ParentObjectReadAdmissionSource;
2427
}) {
2528
let mw!: Mw;
2629
const calls = { scans: [] as any[], probes: [] as Array<{ object: string; ids: string[]; context: any }> };
@@ -43,7 +46,7 @@ function install(opts: {
4346
},
4447
findOne: async () => null,
4548
};
46-
installActivityReadVisibility(engine, opts.logger ?? silentLogger());
49+
installActivityReadVisibility(engine, opts.logger ?? silentLogger(), opts.admission ?? (() => undefined));
4750
return { mw, calls };
4851
}
4952

@@ -209,8 +212,120 @@ describe('installActivityReadVisibility', () => {
209212
}
210213
});
211214

215+
describe('the object-level read admission: a skipped object is not read as the caller', () => {
216+
/** Two parent objects; the caller-scoped read refuses `crm_secret` (the middleware's denial). */
217+
const scan = () => [
218+
{ object_name: 'crm_case', record_id: 'c1' },
219+
{ object_name: 'crm_case', record_id: 'c2' },
220+
{ object_name: 'crm_secret', record_id: 's1' },
221+
];
222+
const readable = {
223+
crm_case: ['c1'],
224+
crm_secret: () => {
225+
throw Object.assign(new Error('denied'), { code: 'PERMISSION_DENIED' });
226+
},
227+
};
228+
const admissionOf = (answer: (object: string) => unknown) => {
229+
const canReadObject = vi.fn(async (object: string) => answer(object) as boolean);
230+
return { canReadObject, source: () => ({ canReadObject }) };
231+
};
232+
const CASE_ONLY = { object_name: 'crm_case', record_id: { $in: ['c1'] } };
233+
234+
it('an object the admission refuses is never read, and the WHERE is the one its refused read gave', async () => {
235+
const without = install({ scan, readable });
236+
const before = (await read(without.mw)).where;
237+
expect(before).toEqual(CASE_ONLY);
238+
expect(without.calls.probes.map((p) => p.object).sort()).toEqual(['crm_case', 'crm_secret']);
239+
240+
const { canReadObject, source } = admissionOf((object) => object !== 'crm_secret');
241+
const { mw, calls } = install({ scan, readable, admission: source });
242+
expect((await read(mw)).where).toEqual(before);
243+
expect(calls.probes.map((p) => p.object)).toEqual(['crm_case']);
244+
expect(canReadObject.mock.calls.map(([object]) => object).sort()).toEqual(['crm_case', 'crm_secret']);
245+
});
246+
247+
it('an admitted object is still read as the caller, and that read decides its rows', async () => {
248+
const { source } = admissionOf(() => true);
249+
const { mw, calls } = install({ scan, readable: { crm_case: ['c1'], crm_secret: [] }, admission: source });
250+
expect((await read(mw)).where).toEqual(CASE_ONLY);
251+
expect(calls.probes.find((p) => p.object === 'crm_case')?.ids.sort()).toEqual(['c1', 'c2']);
252+
expect(calls.probes.map((p) => p.object).sort()).toEqual(['crm_case', 'crm_secret']);
253+
});
254+
255+
it('asks with the caller envelope, minus the operation-private keys — the envelope the parent read gets', async () => {
256+
const { canReadObject, source } = admissionOf(() => true);
257+
const { mw, calls } = install({ scan, readable: { crm_case: ['c1'], crm_secret: [] }, admission: source });
258+
await read(mw, {
259+
context: { userId: 'u1', positions: ['org_member'], __readScope: 'org', __expandRead: true } as any,
260+
});
261+
for (const [, context] of canReadObject.mock.calls as unknown as Array<[string, Record<string, unknown>]>) {
262+
expect(context).toEqual(calls.probes[0].context);
263+
expect(context).toMatchObject({ userId: 'u1', positions: ['org_member'] });
264+
expect(context).not.toHaveProperty('__readScope');
265+
expect(context).not.toHaveProperty('__expandRead');
266+
}
267+
});
268+
269+
it('every parent object refused: the deny-all sentinel, and no parent read at all', async () => {
270+
const { source } = admissionOf(() => false);
271+
const { mw, calls } = install({ scan, readable, admission: source });
272+
expect((await read(mw)).where).toEqual(DENY);
273+
expect(calls.probes).toEqual([]);
274+
});
275+
276+
it('fails closed: an admission that throws refuses that object, says so, and never widens', async () => {
277+
const logger = silentLogger();
278+
// The caller-scoped read WOULD serve crm_secret's record here — the
279+
// admission's failure must not fall open into it.
280+
const { source } = admissionOf((object) => {
281+
if (object === 'crm_secret') throw new Error('admission store unreachable');
282+
return true;
283+
});
284+
const { mw, calls } = install({ scan, readable: { crm_case: ['c1'], crm_secret: ['s1'] }, admission: source, logger });
285+
expect((await read(mw)).where).toEqual(CASE_ONLY);
286+
expect(calls.probes.map((p) => p.object)).toEqual(['crm_case']);
287+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("read admission for 'crm_secret' failed"));
288+
});
289+
290+
it('fails closed: an answer that is not a plain true refuses', async () => {
291+
for (const answer of [undefined, null, 'true', 1, {}]) {
292+
const { source } = admissionOf((object) => (object === 'crm_secret' ? answer : true));
293+
const { mw, calls } = install({ scan, readable: { crm_case: ['c1'], crm_secret: ['s1'] }, admission: source });
294+
expect((await read(mw)).where).toEqual(CASE_ONLY);
295+
expect(calls.probes.map((p) => p.object)).toEqual(['crm_case']);
296+
}
297+
});
298+
299+
it('no admission composed — no service, a throwing resolver, or no canReadObject — reads every parent as before', async () => {
300+
const sources: Array<ParentObjectReadAdmissionSource> = [
301+
() => undefined,
302+
() => null,
303+
() => {
304+
throw new Error("service 'security' not registered");
305+
},
306+
() => ({}),
307+
];
308+
for (const admission of sources) {
309+
const { mw, calls } = install({ scan, readable, admission });
310+
expect((await read(mw)).where).toEqual(CASE_ONLY);
311+
expect(calls.probes.map((p) => p.object).sort()).toEqual(['crm_case', 'crm_secret']);
312+
}
313+
});
314+
315+
it('resolves the service on every read, so one that registers after install is asked', async () => {
316+
let registered: { canReadObject?: (object: string) => Promise<boolean> } | undefined;
317+
const { mw, calls } = install({ scan, readable, admission: () => registered });
318+
await read(mw);
319+
expect(calls.probes.map((p) => p.object).sort()).toEqual(['crm_case', 'crm_secret']);
320+
registered = { canReadObject: async (object: string) => object !== 'crm_secret' };
321+
calls.probes.length = 0;
322+
expect((await read(mw)).where).toEqual(CASE_ONLY);
323+
expect(calls.probes.map((p) => p.object)).toEqual(['crm_case']);
324+
});
325+
});
326+
212327
it('is inert on an engine without the middleware seam', () => {
213328
const engine: CommentAccessEngine = { registerHook: () => {}, find: async () => [], findOne: async () => null };
214-
expect(() => installActivityReadVisibility(engine, silentLogger())).not.toThrow();
329+
expect(() => installActivityReadVisibility(engine, silentLogger(), () => undefined)).not.toThrow();
215330
});
216331
});

‎packages/plugins/plugin-audit/src/activity-read-visibility.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
PARENT_GATE_READ_OPS,
6464
andIntoWhere,
6565
computeParentRecordFilter,
66+
type ParentObjectReadAdmissionSource,
6667
parseParentObject,
6768
parseParentRecord,
6869
type ParentRecord,
@@ -117,11 +118,15 @@ export function parseActivityParent(row: Record<string, unknown> | null | undefi
117118
* Install the `sys_activity` read-visibility middleware.
118119
*
119120
* Inert on an engine without the middleware seam; `AuditPlugin` says so out
120-
* loud when that happens.
121+
* loud when that happens. `getAdmission` resolves, on every read, the security
122+
* service whose object-level read admission is asked before a parent object is
123+
* read as the caller (`parent-record-read-gate.ts`, "A skipped object is not
124+
* an access attempt").
121125
*/
122126
export function installActivityReadVisibility(
123127
engine: CommentAccessEngine,
124128
logger: CommentAccessLogger,
129+
getAdmission: ParentObjectReadAdmissionSource,
125130
): void {
126131
if (typeof engine.registerMiddleware !== 'function') return;
127132

@@ -133,7 +138,7 @@ export function installActivityReadVisibility(
133138
return next();
134139
}
135140
try {
136-
const filter = await computeParentRecordFilter(engine, ctx, logger, ACTIVITY_GATE);
141+
const filter = await computeParentRecordFilter(engine, ctx, logger, ACTIVITY_GATE, getAdmission);
137142
if (filter) andIntoWhere(ctx, filter);
138143
} catch (err) {
139144
// A filter-compute failure must never fall open into a leak.

‎packages/plugins/plugin-audit/src/audit-log-read-visibility.test.ts‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
isLedgerRowAboutNoRecord,
1717
} from './audit-log-read-visibility.js';
1818
import type { CommentAccessEngine, CommentReadMiddlewareCtx } from './comment-access-hooks.js';
19+
import type { ParentObjectReadAdmissionSource } from './parent-record-read-gate.js';
1920

2021
const DENY = { id: '__audit_log_parent_denied__' };
2122
const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() });
@@ -26,6 +27,8 @@ function install(opts: {
2627
/** parentObject -> readable ids (for any caller), or a thrower. */
2728
readable?: Record<string, string[] | (() => never)>;
2829
logger?: ReturnType<typeof silentLogger>;
30+
/** The security service's object-level read admission the gate resolves per read; absent: none composed. */
31+
admission?: ParentObjectReadAdmissionSource;
2932
}) {
3033
let mw!: Mw;
3134
const calls = { scans: [] as any[], probes: [] as Array<{ object: string; ids: string[] }> };
@@ -48,7 +51,7 @@ function install(opts: {
4851
},
4952
findOne: async () => null,
5053
};
51-
installAuditLogReadVisibility(engine, opts.logger ?? silentLogger());
54+
installAuditLogReadVisibility(engine, opts.logger ?? silentLogger(), opts.admission ?? (() => undefined));
5255
return { mw, calls };
5356
}
5457

@@ -120,6 +123,44 @@ describe('installAuditLogReadVisibility', () => {
120123
expect(calls.probes.map((p) => p.object)).toEqual(['crm_case']);
121124
});
122125

126+
it('asks the object-level read admission first: a refused object is never read, and the WHERE is unchanged', async () => {
127+
const scan = () => [
128+
{ id: 'l1', action: 'create', object_name: 'crm_case', record_id: 'c1' },
129+
{ id: 'l2', action: 'update', object_name: 'sys_migration', record_id: 'm1' },
130+
{ id: 'l3', action: 'config_change', object_name: 'sys_setting', record_id: null },
131+
];
132+
const readable = {
133+
crm_case: ['c1'],
134+
sys_migration: () => {
135+
throw Object.assign(new Error('denied'), { code: 'PERMISSION_DENIED' });
136+
},
137+
};
138+
const without = install({ scan, readable });
139+
const before = (await read(without.mw)).where;
140+
expect(without.calls.probes.map((p) => p.object).sort()).toEqual(['crm_case', 'sys_migration']);
141+
142+
const canReadObject = vi.fn(async (object: string) => object !== 'sys_migration');
143+
const { mw, calls } = install({ scan, readable, admission: () => ({ canReadObject }) });
144+
expect((await read(mw)).where).toEqual(before);
145+
expect(before).toEqual({
146+
$or: [{ object_name: 'crm_case', record_id: { $in: ['c1'] } }, { id: { $in: ['l3'] } }],
147+
});
148+
expect(calls.probes.map((p) => p.object)).toEqual(['crm_case']);
149+
expect(canReadObject.mock.calls.map(([object]) => object).sort()).toEqual(['crm_case', 'sys_migration']);
150+
});
151+
152+
it('a holder of the audit capability is exempt before the admission is asked', async () => {
153+
const canReadObject = vi.fn(async () => true);
154+
const { mw, calls } = install({
155+
scan: () => [{ id: 'l1', action: 'create', object_name: 'crm_case', record_id: 'c1' }],
156+
admission: () => ({ canReadObject }),
157+
});
158+
const { where } = await read(mw, { context: { userId: 'u1', systemPermissions: [LEDGER_AUDIT_CAPABILITY] } });
159+
expect(where).toBeUndefined();
160+
expect(calls.scans).toEqual([]);
161+
expect(canReadObject).not.toHaveBeenCalled();
162+
});
163+
123164
it('a record action that names no record is excluded, never kept as a row about none', async () => {
124165
const { mw, calls } = install({
125166
scan: () => [{ id: 'l1', action: 'update', object_name: 'crm_case', record_id: null }],
@@ -215,7 +256,7 @@ describe('installAuditLogReadVisibility', () => {
215256

216257
it('is inert on an engine without the middleware seam', () => {
217258
const engine: CommentAccessEngine = { registerHook: () => {}, find: async () => [], findOne: async () => null };
218-
expect(() => installAuditLogReadVisibility(engine, silentLogger())).not.toThrow();
259+
expect(() => installAuditLogReadVisibility(engine, silentLogger(), () => undefined)).not.toThrow();
219260
});
220261
});
221262

‎packages/plugins/plugin-audit/src/audit-log-read-visibility.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
PARENT_GATE_READ_OPS,
8686
andIntoWhere,
8787
computeParentRecordFilter,
88+
type ParentObjectReadAdmissionSource,
8889
type ParentRecordGate,
8990
} from './parent-record-read-gate.js';
9091

@@ -133,11 +134,15 @@ const LEDGER_GATE: ParentRecordGate = {
133134
* Install the `sys_audit_log` read-visibility middleware.
134135
*
135136
* Inert on an engine without the middleware seam; `AuditPlugin` says so out
136-
* loud when that happens.
137+
* loud when that happens. `getAdmission` resolves, on every read, the security
138+
* service whose object-level read admission is asked before a parent object is
139+
* read as the caller (`parent-record-read-gate.ts`, "A skipped object is not
140+
* an access attempt").
137141
*/
138142
export function installAuditLogReadVisibility(
139143
engine: CommentAccessEngine,
140144
logger: CommentAccessLogger,
145+
getAdmission: ParentObjectReadAdmissionSource,
141146
): void {
142147
if (typeof engine.registerMiddleware !== 'function') return;
143148

@@ -149,7 +154,7 @@ export function installAuditLogReadVisibility(
149154
return next();
150155
}
151156
try {
152-
const filter = await computeParentRecordFilter(engine, ctx, logger, LEDGER_GATE);
157+
const filter = await computeParentRecordFilter(engine, ctx, logger, LEDGER_GATE, getAdmission);
153158
if (filter) andIntoWhere(ctx, filter);
154159
} catch (err) {
155160
// A filter-compute failure must never fall open into a leak.

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { installAuditLogFieldRedaction } from './audit-log-field-redaction.js';
2323
import { installAuditLogReadVisibility } from './audit-log-read-visibility.js';
2424
import type { FieldVisibilitySource } from './served-fields.js';
2525
import { installParentFieldQueryGuards } from './parent-field-query-guard.js';
26+
import type { ParentObjectReadAdmission } from './parent-record-read-gate.js';
2627

2728
/**
2829
* [#8992] Read/view audit configuration — the per-object opt-in, closed.
@@ -492,7 +493,18 @@ export class AuditPlugin implements Plugin {
492493
},
493494
ctx.logger,
494495
);
495-
installActivityReadVisibility(engine as any, ctx.logger);
496+
// The parent-record gates ask the security service's object-level read
497+
// admission before they read a parent object as the caller, so an
498+
// object the caller cannot read is skipped without a logged denial
499+
// (`parent-record-read-gate.ts`). Resolved on every read, as below.
500+
const getReadAdmission = (): ParentObjectReadAdmission | undefined => {
501+
try {
502+
return ctx.getService<ParentObjectReadAdmission>('security');
503+
} catch {
504+
return undefined;
505+
}
506+
};
507+
installActivityReadVisibility(engine as any, ctx.logger, getReadAdmission);
496508
ctx.logger.info('AuditPlugin: sys_activity parent-record read visibility installed');
497509
// [#21081] …and of each row it keeps, the value-bearing columns serve a
498510
// parent field only to a reader the security service serves that field
@@ -524,7 +536,7 @@ export class AuditPlugin implements Plugin {
524536
// query guard above judges a query first (a refused query never pays
525537
// this gate's pre-scan), and the field redaction narrows only the rows
526538
// this gate keeps.
527-
installAuditLogReadVisibility(engine as any, ctx.logger);
539+
installAuditLogReadVisibility(engine as any, ctx.logger, getReadAdmission);
528540
ctx.logger.info('AuditPlugin: sys_audit_log parent-record read visibility installed');
529541
} else {
530542
ctx.logger.warn(

0 commit comments

Comments
 (0)