Skip to content

Commit 099042a

Browse files
committed
feat(spec,plugin-audit): the compliance ledger's audit capability exempts its holder from the parent-record read gate (#21260)
WIP: declaration, default grant, the gate's one exemption, pins and docs. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d2bc644 commit 099042a

15 files changed

Lines changed: 579 additions & 18 deletions

‎content/docs/permissions/record-view-auditing.mdx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,13 @@ every field is `readonly`, so the ledger is never written through a form.
209209
Programmatic queries go through `services.data` against `sys_audit_log`. Outside
210210
system context a read returns a view row only when the caller can read the
211211
record it names, so neither the list view nor a query serves a view of a record
212-
the reader cannot open, or of a record that has since been deleted. Those rows
213-
stay stored, and a system-context read still returns them. Rows carry the
212+
the reader cannot open, or of a record that has since been deleted, unless the
213+
reader holds the `view_all_audit_log` capability. Its holder is served every
214+
ledger row its grant on `sys_audit_log` reaches, with each snapshot still
215+
narrowed by field-level security. Platform administrators hold it by default;
216+
anyone else holds it only through a permission set whose `systemPermissions`
217+
grant it. Those rows stay stored, and a system-context read still returns them.
218+
Rows carry the
214219
ADR-0057 `audit` lifecycle class: retained hot for
215220
90 days, then archived for seven years where an `archive` datasource is
216221
registered.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { describe, it, expect, vi } from 'vitest';
1111
import { installActivityReadVisibility, parseActivityParent } from './activity-read-visibility.js';
12+
import { LEDGER_AUDIT_CAPABILITY } from './audit-log-read-visibility.js';
1213
import type { CommentAccessEngine, CommentReadMiddlewareCtx } from './comment-access-hooks.js';
1314

1415
const DENY = { id: '__activity_parent_denied__' };
@@ -198,6 +199,16 @@ describe('installActivityReadVisibility', () => {
198199
expect(calls.probes[0].context).not.toHaveProperty('__expandRead');
199200
});
200201

202+
it('[#21260] a holder of the ledger’s audit capability is narrowed here exactly like any caller', async () => {
203+
const scan = () => [{ object_name: 'crm_case', record_id: 'c1' }];
204+
for (const operation of ['find', 'findOne', 'count', 'aggregate'] as const) {
205+
const { mw, calls } = install({ scan, readable: { crm_case: [] } });
206+
const holder = { userId: 'u1', systemPermissions: [LEDGER_AUDIT_CAPABILITY] };
207+
expect((await read(mw, { operation, context: holder })).where).toEqual(DENY);
208+
expect(calls.scans).toHaveLength(1);
209+
}
210+
});
211+
201212
it('is inert on an engine without the middleware seam', () => {
202213
const engine: CommentAccessEngine = { registerHook: () => {}, find: async () => [], findOne: async () => null };
203214
expect(() => installActivityReadVisibility(engine, silentLogger())).not.toThrow();

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ import {
6262

6363
const ACTIVITY_OBJECT = 'sys_activity';
6464

65-
/** The activity stream's gate: every row naming no parent is excluded. */
65+
/**
66+
* The activity stream's gate: every row naming no parent is excluded, and no
67+
* caller is exempt. The compliance ledger's audit capability exempts its holder
68+
* from the ledger's gate only (#21260: the ruling names the ledger).
69+
*/
6670
const ACTIVITY_GATE: ParentRecordGate = {
6771
object: ACTIVITY_OBJECT,
6872
seam: 'activity read visibility',

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const SYS = { isSystem: true } as const;
4848
const MEMBER = { userId: 'u_member', tenantId: 'org_1', positions: ['org_member'] };
4949
const OTHER = { userId: 'u_other', tenantId: 'org_1', positions: ['org_member'] };
5050
const ADMIN = { userId: 'u_admin', tenantId: 'org_1', positions: ['org_admin'] };
51+
/** [#21260] A member holding the ledger's audit capability, and owning nothing. */
52+
const HOLDER = { userId: 'u_auditor', tenantId: 'org_1', positions: ['org_member'], systemPermissions: ['view_all_audit_log'] };
5153

5254
const ownedObject = {
5355
name: OWNED,
@@ -260,4 +262,20 @@ describe('[#21175] sys_audit_log read visibility — the plugin read path', () =
260262
const rows = await engine.find(LEDGER, { context: SYS });
261263
expect(rows.length).toBe(allRows.length);
262264
});
265+
266+
it('[#21260] a holder of the ledger audit capability is served every row, deleted and unreadable records included', async () => {
267+
const rows = await engine.find(LEDGER, { context: HOLDER });
268+
expect(keys(rows)).toEqual(keys(allRows));
269+
expect(await engine.count(LEDGER, {}, { context: HOLDER })).toBe(allRows.length);
270+
expect((await engine.findOne(LEDGER, {
271+
where: { object_name: OWNED, record_id: ids.mineGone, action: 'delete' }, context: HOLDER,
272+
}))?.record_id).toBe(ids.mineGone);
273+
});
274+
275+
it('[#21260] the same holder is still narrowed on the activity stream', async () => {
276+
const aboutGone = { record_id: { $in: [ids.mineGone, ids.boardGone, ids.theirs] } };
277+
const atRest = await engine.find('sys_activity', { where: aboutGone, context: SYS });
278+
expect(atRest.length).toBeGreaterThan(0);
279+
expect(await engine.find('sys_activity', { where: aboutGone, context: HOLDER })).toEqual([]);
280+
});
263281
});

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
*/
99

1010
import { describe, it, expect, vi } from 'vitest';
11-
import { installAuditLogReadVisibility, isLedgerRowAboutNoRecord } from './audit-log-read-visibility.js';
11+
import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec';
12+
import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security';
13+
import {
14+
LEDGER_AUDIT_CAPABILITY,
15+
installAuditLogReadVisibility,
16+
isLedgerRowAboutNoRecord,
17+
} from './audit-log-read-visibility.js';
1218
import type { CommentAccessEngine, CommentReadMiddlewareCtx } from './comment-access-hooks.js';
1319

1420
const DENY = { id: '__audit_log_parent_denied__' };
@@ -212,3 +218,45 @@ describe('installAuditLogReadVisibility', () => {
212218
expect(() => installAuditLogReadVisibility(engine, silentLogger())).not.toThrow();
213219
});
214220
});
221+
222+
describe('[#21260] the ledger audit capability', () => {
223+
const unreadable = () => [
224+
{ id: 'l1', action: 'delete', object_name: 'crm_case', record_id: 'c1' },
225+
{ id: 'l2', action: 'logout', object_name: 'sys_session', record_id: 's1' },
226+
];
227+
const holder = { userId: 'u1', systemPermissions: ['setup.access', LEDGER_AUDIT_CAPABILITY] };
228+
229+
it('is the capability the platform declares, platform-scoped, and grants platform administrators by default', () => {
230+
expect(PLATFORM_CAPABILITIES.find((c) => c.name === LEDGER_AUDIT_CAPABILITY)?.scope).toBe('platform');
231+
expect(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions).toContain(LEDGER_AUDIT_CAPABILITY);
232+
});
233+
234+
it('its holder is not narrowed, on any read operation, and nothing is scanned for it', async () => {
235+
for (const operation of ['find', 'findOne', 'count', 'aggregate'] as const) {
236+
const { mw, calls } = install({ scan: unreadable, readable: { crm_case: [], sys_session: [] } });
237+
const existing = { action: 'delete' };
238+
const { where, ran } = await read(mw, { operation, context: holder, ast: { object: 'sys_audit_log', where: existing } });
239+
expect(ran).toBe(true);
240+
expect(where).toBe(existing);
241+
expect(calls.scans).toEqual([]);
242+
expect(calls.probes).toEqual([]);
243+
}
244+
});
245+
246+
it('its holder’s broad read never meets the pre-scan bound', async () => {
247+
const logger = silentLogger();
248+
const rows = Array.from({ length: 2000 }, (_, i) => ({ id: `l${i}`, action: 'update', object_name: 'crm_case', record_id: `c${i}` }));
249+
const { mw, calls } = install({ scan: () => rows, readable: { crm_case: [] }, logger });
250+
expect((await read(mw, { context: holder })).where).toBeUndefined();
251+
expect(calls.scans).toEqual([]);
252+
expect(logger.warn).not.toHaveBeenCalled();
253+
});
254+
255+
it('a caller without it gets exactly the parent-record gate: other capabilities, a non-list, or none', async () => {
256+
for (const systemPermissions of [['setup.access', 'manage_users'], LEDGER_AUDIT_CAPABILITY, undefined]) {
257+
const { mw, calls } = install({ scan: unreadable, readable: { crm_case: [], sys_session: [] } });
258+
expect((await read(mw, { context: { userId: 'u1', systemPermissions } })).where).toEqual(DENY);
259+
expect(calls.scans).toHaveLength(1);
260+
}
261+
});
262+
});

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@
3232
* `update` on a `sys_user`, and the auth-event sink's `login` / `logout`,
3333
* which name the session (`auth-event-audit.ts`). Judged by the record gate.
3434
* A record that no longer exists is read by no caller, so its rows are
35-
* excluded for every caller that is not system context: every `delete` row,
36-
* every other row about a deleted record, and every `logout` row (sign-out
37-
* deletes the session it names).
35+
* excluded for every caller that is not system context and does not hold
36+
* the audit capability below: every `delete` row, every other row about a
37+
* deleted record, and every `logout` row (sign-out deletes the session it
38+
* names).
3839
* - **Rows about no record** — they name no `record_id` and their action is
3940
* not a record action: the run-level `import` (plugin-auth's user import),
4041
* `config_change` (service-settings) and `platform_admin_standing_change`
@@ -58,6 +59,18 @@
5859
* this gate keeps, a snapshot still serves a parent field only to a reader the
5960
* security service serves that field unmasked.
6061
*
62+
* ## The audit capability (#21260, ruling B on #21175)
63+
*
64+
* A caller holding {@link LEDGER_AUDIT_CAPABILITY} is not narrowed by this
65+
* gate: it is served the deletion and sign-out trail, the rows about records
66+
* it cannot open, and a broad read whole (the gate's pre-scan, and so its
67+
* bound, never runs for it). It still needs the ledger's own object grant,
68+
* and the field redaction still narrows every snapshot it is served. Platform
69+
* administrators hold it by default (`ADMIN_FULL_ACCESS_CAPABILITIES` in
70+
* `@objectstack/spec`); every other position only by explicit grant. The
71+
* exemption is the shared mechanism's (`ParentRecordGate.exemptCapability`),
72+
* declared here and nowhere else: the activity stream's gate declares none.
73+
*
6174
* System-context reads (the platform-admin standing boot reading its last
6275
* roster, the writers' own reads) and context-less programmatic calls are not
6376
* narrowed, as for every gate in this package: every real transport carries a
@@ -99,11 +112,20 @@ export function isLedgerRowAboutNoRecord(row: Record<string, unknown>): boolean
99112
return typeof action === 'string' && action !== '' && !RECORD_ACTIONS.has(action);
100113
}
101114

115+
/**
116+
* The compliance ledger's audit capability: its holder is exempt from this
117+
* gate. Declared in `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`) and
118+
* granted by `ADMIN_FULL_ACCESS_CAPABILITIES`; this spelling is pinned against
119+
* both in `audit-log-read-visibility.test.ts`.
120+
*/
121+
export const LEDGER_AUDIT_CAPABILITY = 'view_all_audit_log';
122+
102123
const LEDGER_GATE: ParentRecordGate = {
103124
object: LEDGER_OBJECT,
104125
seam: 'audit log read visibility',
105126
denyAll: { id: '__audit_log_parent_denied__' },
106127
outsideClass: { fields: ['action'], test: isLedgerRowAboutNoRecord },
128+
exemptCapability: LEDGER_AUDIT_CAPABILITY,
107129
};
108130

109131
/**

‎packages/plugins/plugin-audit/src/parent-record-read-gate.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@
4141
* rows that name NO record and that the gate states are not about one. Those
4242
* are kept by their stored id, so the branch can only match rows the pre-scan
4343
* saw. A gate that declares none (the activity stream) excludes them too.
44+
*
45+
* The one declared exemption is a gate's
46+
* {@link ParentRecordGate.exemptCapability}: a caller holding that capability
47+
* is not narrowed by the gate at all — no pre-scan, so no pre-scan bound, and
48+
* nothing ANDed in. Only this gate is skipped: the object's own grant, the other
49+
* read seams on it (a field redaction, a query guard) and every other gate
50+
* still apply. Held means the caller's resolved `systemPermissions`, the one
51+
* capability set the request's authorization resolver stamps on the execution
52+
* context and every other capability check reads. A gate that declares none
53+
* (the activity stream) exempts no caller.
4454
*/
4555

4656
import {
@@ -92,6 +102,22 @@ export interface ParentRecordGate {
92102
denyAll: Readonly<Record<string, unknown>>;
93103
/** Absent: every row that names no parent record is excluded. */
94104
outsideClass?: OutsideClassRows;
105+
/**
106+
* A capability whose holder this gate does not narrow. Absent: no caller is
107+
* exempt. Must name a capability the platform declares and grants
108+
* deliberately; the ledger's is pinned against `PLATFORM_CAPABILITIES`.
109+
*/
110+
exemptCapability?: string;
111+
}
112+
113+
/**
114+
* Whether the caller holds `capability`: its resolved `systemPermissions`, as
115+
* the request's authorization resolver stamped them on the execution context.
116+
* Absent, or not a list, is not held.
117+
*/
118+
function callerHoldsCapability(context: CommentReadMiddlewareCtx['context'], capability: string): boolean {
119+
const held = context?.systemPermissions;
120+
return Array.isArray(held) && held.includes(capability);
95121
}
96122

97123
/**
@@ -133,16 +159,20 @@ export function andIntoWhere(ctx: CommentReadMiddlewareCtx, filter: unknown): vo
133159

134160
/**
135161
* The parent-visibility WHERE for one read of `gate.object`: `null` when the
136-
* query matches no rows (nothing to narrow), one `$in` branch per parent
137-
* object holding the readable ids (plus the outside-class rows' ids), or the
138-
* gate's deny-all sentinel.
162+
* caller holds the gate's exempt capability or the query matches no rows
163+
* (nothing to narrow), one `$in` branch per parent object holding the readable
164+
* ids (plus the outside-class rows' ids), or the gate's deny-all sentinel.
139165
*/
140166
export async function computeParentRecordFilter(
141167
engine: CommentAccessEngine,
142168
ctx: CommentReadMiddlewareCtx,
143169
logger: CommentAccessLogger,
144170
gate: ParentRecordGate,
145171
): Promise<unknown | null> {
172+
// 0. The gate's declared exemption, decided before anything is scanned, so a
173+
// holder's broad read never meets the pre-scan bound.
174+
if (gate.exemptCapability && callerHoldsCapability(ctx.context, gate.exemptCapability)) return null;
175+
146176
// 1. The parent pairs the query would touch, read under SYSTEM context (the
147177
// caller may not see the rows yet; that is what is being decided). The
148178
// caller's own order rides along, so on a table larger than the scan

‎packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,11 @@ describe('sys_comment delete is moderation-shaped, not ownership-shaped (#8839)'
361361
* exact pre-#11965 inline value (comments elided); if this pin fails, the spec
362362
* export changed the declared capability set — that is a capability change
363363
* riding on a refactor card, and it must not land silently.
364+
*
365+
* One capability change has landed on purpose since, on its own card, and is
366+
* written into the literal below: `view_all_audit_log` (#21260, ruling B on
367+
* #21175 — platform administrators hold the compliance ledger's audit
368+
* capability by default).
364369
*/
365370
describe('admin_full_access imports the kernel capability declaration unchanged (#11965)', () => {
366371
it('parsed declaration deep-equals the pre-#11965 inline literal', () => {
@@ -386,11 +391,20 @@ describe('admin_full_access imports the kernel capability declaration unchanged
386391
'setup.access',
387392
'setup.write',
388393
'studio.access',
394+
// [#21260] added on purpose — see the docblock above.
395+
'view_all_audit_log',
389396
],
390397
});
391398
expect(setByName('admin_full_access')).toEqual(preMove);
392399
});
393400

401+
it('[#21260] no other shipped set carries the ledger audit capability: every other position holds it only by explicit grant', () => {
402+
const holders = (defaultPermissionSets as any[])
403+
.filter((s) => (s.systemPermissions ?? []).includes('view_all_audit_log'))
404+
.map((s) => s.name);
405+
expect(holders).toEqual(['admin_full_access']);
406+
});
407+
394408
it('the imported spec constant is the declaration content — no local fork', () => {
395409
const admin = setByName('admin_full_access');
396410
// Same values, sourced from the one spec-exported copy.

0 commit comments

Comments
 (0)