Skip to content

Commit 7ebb543

Browse files
feat(spec,plugin-audit): the compliance ledger's audit capability exempts its holder from the parent-record read gate; platform administrators hold it by default (#21260) (#21296)
Fixes #21260 Clause-②: yes (widening) This executes ruling B on #21175 (comment `5942331027`, maintainer 「同意264」). The compliance ledger gets an audit capability, `view_all_audit_log`. A caller who holds it is exempt from the ledger's parent-record read gate. Field-level narrowing (PR #21171) still applies to that caller. Platform administrators hold it by default. Every other position holds it only by explicit grant. #21175 is the parent card. This PR does not close it (it is already closed). **Status.** Draft. `Clause-②: yes`, so an isolated contract-review-tier review is owed before enqueue. The PM runs it. ## Cross-lane surfaces, named before the change list - **`packages/spec/src/security/capabilities.ts`** (`domain:spec`). One new `PLATFORM_CAPABILITIES` entry. This is cross-lane, as the claim declares. - **`packages/spec/src/identity/eval-user.zod.ts`** (`domain:spec`). This file holds the default grant to platform administrators. - The claim expected the admin grant path to live in `plugin-security`. Measured: it lives here. `ADMIN_FULL_ACCESS_CAPABILITIES` is the one list that two consumers read: - plugin-security's `admin_full_access` set spreads it; - core's configured-owner envelope (`resolve-authz-context.ts`) reads it. - Adding the capability anywhere else would fork that list. It is reported here as the claim requires: "If that path lives outside `plugin-security`, the build reports it before touching it." - **`packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts`**. Test only, no source change in `plugin-security`. - Its pin on the admin set's exact capability list goes red on any capability change, by design. The literal now names the new capability, and its docblock says why. - A new pin says that no other shipped set carries the capability. ## What changed 1. **The declaration.** `PLATFORM_CAPABILITIES` gains `view_all_audit_log`: - label "View All Audit Log", `scope: 'org'` (see "Scope" below); - its description names exactly what it lifts and what still applies. - `PLATFORM_CAPABILITY_NAMES` derives from the list, so the seeder (`bootstrapSystemCapabilities`), the authoring lint and the anchor floor pick it up without a second edit. 2. **The default holder.** `ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions` lists it. Platform administrators hold it both ways: - through the `admin_full_access` grant; - through the configured-owner envelope. - There is no role-name test anywhere. 3. **The exemption.** `parent-record-read-gate.ts` is the shared mechanism. It gains one declared field, `ParentRecordGate.exemptCapability`, and one check at the top of `computeParentRecordFilter`, before anything is scanned or ANDed in. - The check reads the caller's resolved `systemPermissions`: the capability set that the request's authorization resolver (`resolveAuthzContext`, `assembleExecutionContext`) stamps on the execution context. - Every other runtime capability check outside plugin-security reads that same set, for example the `sys_record_share` read scope and the object-schema mask exemption. No new resolver is added. - Only the ledger gate declares the field (`LEDGER_AUDIT_CAPABILITY` in `audit-log-read-visibility.ts`). The activity stream's gate declares none and says so. 4. **The anchor floor (`high-privilege.ts`).** No edit. That file's platform floor is `PLATFORM_CAPABILITY_NAMES` itself, so declaring the capability puts it on the floor. - Reading: it unlocks ledger rows that the parent-record gate otherwise withholds. So the anchor-laundering argument applies, and a set carrying it must never bind to `everyone` or `guest`, even when an app declares a capability of the same name. - A pin in `high-privilege.test.ts` measures this. It refuses for `everyone` and for `guest`, with the name declared. 5. **Docs.** `content/docs/permissions/record-view-auditing.mdx`, the ledger reader sentence, which PR #21194 wrote. It now names the capability, who holds it by default, the field-level narrowing, and the organization bound. Old and new text are in the report. 6. **Pins made false by this ruling.** - `audit-log-parent-read-gate.dogfood.test.ts` said the admin is not served a deleted record's rows. The admin is now a default holder: that pin now asserts the reader is served none of them and the admin is served both. - The comment in `audit-log-field-values.dogfood.test.ts` is corrected the same way. 7. **Changeset.** `@objectstack/spec` minor and `@objectstack/plugin-audit` minor, carrying `Clause-②: yes (widening)`. ## Measured before the change (the gate as landed) Real boot through the public data doors: `bootStack`, the real SecurityPlugin, the SQL driver, REST, auth, and AuditPlugin's CRUD mirror and auth-event sink. - **At base `d2bc644f2`, with `dist` built from that tree.** A member holding the ledger grant was served: - none of the deleted record's rows; - `404` on the sign-out row; - `404` on a private record's row; - a total of 2 against 2,053 at rest on a broad read past the 2,000-row bound. - The platform administrator was served none of the deleted record's rows. Its resolved capability set held the seven existing capabilities. - **With only the new exemption line removed, so the gate is exactly as #21194 landed it.** Platform administrator and ledger-grant member, both: - deleted-record list 0; - `delete` row `404`, sign-out row `404`, ended session's sign-in row `404`; - broad read total 2 against 2,054 at rest. - This is A, as the dispatch expected. ## Scope: `org`, measured rather than assumed - The dispatch default was `platform` unless the build measured that the ledger is org-scoped. It is. - The registry provisions `organization_id` on `sys_audit_log`. - The CRUD mirror stamps each row with the record's organization. - Measured on a real `isolated` boot, with two organizations each created by its owner and the first owner granted the capability (the readings below are row counts): | Caller | Rows about the deleted record in its own org | Rows about the other org's deleted record | |---|---|---| | Holder (org A owner) | 2 | 0 | | Non-holder (org B owner) | 0 | 0 | | Platform administrator | 2 | 2 | - The capability lifts only the parent-record gate. The tenant wall still bounds a holder to its own organization's rows. The platform administrator reaches every organization through its own wall bypass, not through this capability. - This is now a pin, in the second block of the new dogfood file. ## Pins | Pin | Where | |---|---| | A holder is served the deleted-record rows (list and by id), the sign-out row, the ended session's sign-in row, a record it cannot open, and a broad read past the bound whole. | dogfood | | Field narrowing still applies to the holder: a withheld field's value is absent from the deleted record's served snapshots, a plain field's value is present, and the value is present at rest. | dogfood | | A non-holder (ledger grant only) gets exactly A: none of these rows, and a broad-read total below the bound. Its own record's rows are the control. | dogfood | | A platform administrator holds the capability by default, read through `resolveAuthzContext`, and is served the deleted-record, sign-out and broad-read rows. | dogfood | | The activity stream's gate is not exempted for a holder or for the admin. | dogfood; unit `activity-read-visibility.test.ts`; integration `audit-log-read-visibility.integration.test.ts` (real SQLite driver) | | The holder is bounded by its organization. | dogfood, second block | | On every read operation the holder is not narrowed and is not pre-scanned. Its broad read never meets the bound and logs no warning. A caller with other capabilities, a non-list or none gets the gate unchanged. The constant is pinned against `PLATFORM_CAPABILITIES` and `ADMIN_FULL_ACCESS_CAPABILITIES`. | unit `audit-log-read-visibility.test.ts` | | The holder is served every row, deleted and unjudgeable rows included, and the count matches. | integration | | The capability is on the anchor floor. | `high-privilege.test.ts` | | The admin grant carries it, declared `org`. | `platform-admin-capabilities.test.ts` | | Only `admin_full_access` among the shipped sets carries it. | `default-permission-sets.test.ts` | The pre-scan bound is pinned with a real fixture past it: 2,050 inserted rows plus the real ones. The constant is not shrunk. ## Ablations Each leg: mutate through `scripts/ablation-replace.mjs` (anchor must hit), run the src-aliased plugin-audit suites, rebuild `@objectstack/plugin-audit`, prove the mutation reached `dist/` with `scripts/ablation-dist-preflight.mjs`, run the dogfood file, restore with `git checkout HEAD -- ABSOLUTE_PATH` (blob hash equals HEAD and `git diff HEAD` is empty), rebuild, and prove the restore in `dist/`. A trap restores on EXIT, INT and TERM. Every leg turned red, as predicted. | Leg | Unit and integration | Dogfood | |---|---|---| | Exemption removed | 3 red: 2 unit holder pins, 1 integration holder pin | 8 red: every holder and admin pin, including the org-bound holder and admin pins. Non-holder and activity pins stayed green. | | Exemption applied to the activity gate | 2 red: unit activity pin, integration activity pin | 1 red: the activity pin | | Capability held by everyone | 19 red: the non-holder pin, plus every pre-existing ledger narrowing case | 2 red: both non-holder pins | The first run of the activity leg was a no-op. The replacement contained its own anchor, and the tool refused it with "anchor count moved 1 to 1". Its readings are void. It was re-run with an anchor the replacement does not contain, and the readings above are from that run. ## Gates and tests, at `d0a1903b7` - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 110 families. All 110 exited 0. - Two first answered `PREREQUISITE NOT MET` (exit 3) because sibling packages had no `dist/`: `check:skill-examples` and `check:dual-build-cjs-loads`. Both were re-run after building those packages and exited 0. - `--ran` reconciliation: "110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED (a DERIVED zero — all 110 recorded an exit code and none of them is 3)". - **Spec artifacts.** `pnpm --filter @objectstack/spec check:generated`: "All 15 generated artifacts are up to date". Nothing was regenerated, because no artifact moves for one new list entry. - **Tests.** - `@objectstack/spec`: 645 files, 18,316 passed. - `@objectstack/plugin-security`: 157 files, 3,407 passed. - `@objectstack/plugin-audit`: 35 files, 542 passed. - `@objectstack/core` `resolve-authz-context.platform-admin-config.test.ts`: 31 passed. - Dogfood, 4 ledger files at this head: 37 passed. - **Typecheck.** `typecheck` passed for spec, plugin-audit, plugin-security and dogfood, including each package's test-layer typecheck. - **Lint.** ESLint on the 14 touched TS files with `--no-inline-config --format json`: 14 files linted, 0 errors, 0 warnings. - The repo's `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`). So this diff cannot change the verdict on any untouched file. - The full `pnpm lint` is left to CI. ## Acceptance notes - **Agent principals (ADR-0090 D10).** The exemption reads `systemPermissions` as the shared resolver stamped them. An OAuth agent principal carries the delegating user's capabilities only when the user consented to `actions:execute`, and none otherwise (`assemble-execution-context.ts`). The `sys_record_share` read scope and the object-schema mask exemption read the same set and follow the same rule. This is a reading, recorded for the contract reviewer. It is not a defect filed by this PR. - **What a holder is served.** A holder is served rows about records of objects it holds no object-level read on. Field-level security still narrows the snapshots. This is the breadth "exempt from the parent-record gate" means, and the capability's description and the docs page both say so. - **`PLATFORM_ADMIN_ONLY_CAPABILITIES` in plugin-security is deliberately untouched.** The new capability is not a platform-admin marker: it is org-scoped, and an explicit grant must not confer platform standing. - **Base.** `origin/main` has moved since `d2bc644f2`. The incoming commits touch none of this PR's files, so they were not merged here. CI and the queue validate the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e53056 commit 7ebb543

16 files changed

Lines changed: 692 additions & 18 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
feat(spec,plugin-audit): the compliance ledger's audit capability, `view_all_audit_log`, exempts its holder from the ledger's parent-record read gate; platform administrators hold it by default (#21260)
7+
8+
Clause-②: yes (widening)
9+
10+
- **The capability.** `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`) gains `view_all_audit_log` ("View All Audit Log", `scope: 'org'`). It is seeded into `sys_capability` like every other curated capability, and a permission set grants it through `systemPermissions`. It is a platform capability, so an app that declares a capability of the same name cannot bind a set carrying it to the `everyone` or `guest` anchor.
11+
- **Who holds it.** `ADMIN_FULL_ACCESS_CAPABILITIES` (`@objectstack/spec`) now lists it, so platform administrators hold it by default: through the `admin_full_access` grant, and through the envelope a configured platform owner resolves to. No other shipped permission set carries it. Any other position holds it only through a permission set that grants it.
12+
- **What it does.** A read of `sys_audit_log` keeps only the rows whose parent record the caller can read. The holder skips that gate and is served every ledger row its grant on `sys_audit_log` reaches: rows about deleted records, sign-out rows, sign-in rows whose session has ended, and rows about records it cannot open. A broad read is served whole. The gate's 2,000-row pre-scan does not run for a holder, so the read is not cut off at that bound.
13+
- **What still applies to the holder.** The holder still needs object-level read on `sys_audit_log`. The field-level redaction still narrows every before/after snapshot it is served. Under a walled tenancy posture, the tenant wall still keeps the holder to its own organization's rows, which is why the capability is declared `org`.
14+
- **What it does not touch.** The activity stream (`sys_activity`) keeps its own parent-record gate for every caller, holders included. A non-holder's ledger reads are unchanged.
15+
16+
**Migration.** None: no metadata, code or configuration change is needed. Platform administrators get the deletion and sign-out trail back with no action. To give an auditor the trail, grant `view_all_audit_log` through `systemPermissions` in a permission set that also grants read on `sys_audit_log`.

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,14 @@ 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 (under a walled tenancy
215+
posture, its own organization's rows), with each snapshot still narrowed by
216+
field-level security. Platform administrators hold it by default;
217+
anyone else holds it only through a permission set whose `systemPermissions`
218+
grant it. Those rows stay stored, and a system-context read still returns them.
219+
Rows carry the
214220
ADR-0057 `audit` lifecycle class: retained hot for
215221
90 days, then archived for seven years where an `archive` datasource is
216222
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, org-scoped, and grants platform administrators by default', () => {
230+
expect(PLATFORM_CAPABILITIES.find((c) => c.name === LEDGER_AUDIT_CAPABILITY)?.scope).toBe('org');
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)