Skip to content

Commit fbcc05f

Browse files
fix(plugin-audit): the compliance ledger's before/after snapshots serve a parent field only to a reader the security service serves that field (#21155) (#21171)
Fixes #21155 Clause-②: no The compliance ledger's create, update and delete rows now serve a parent record's field value only to a reader the security service serves that field unmasked. The full row stays at rest. The narrowing is applied at read time, keyed on the reading caller, through the same served-fields answer the activity stream's redaction uses. This implements triage's ruling on the card (`5929919761`): ledger readers are not field-unrestricted by default, and an auditor who must see every field is granted it by a set that unmasks those fields. The audit-capability question stays out of scope, as ruled. ## Measured first, on a real boot Re-measured on `main` at `bafb8c94`, with PR #21152 in, privately. The stack was `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and auth layers, plus `AuditPlugin`. One synthetic object carried one field of each class. Rows were written by the CRUD mirror from REST writes: a create, an update of all three fields, and the delete of a second record. One reader per class held exactly what makes its own class apply, plus the ledger read. The unmasking reader was the control. A fifth reader took its ledger read from the platform read-only set's wildcard, beside a set withholding one field: that is the card's measured door. Readings stay private to the dispatch. This table gives classes only. | Reader class | Data plane (reference) | Ledger rows before (list, by-id, projected list) | Ledger rows after | |---|---|---|---| | Field served masked | replaced | stored value in the create, update and delete snapshots | absent | | Capability-gated field (no mask) | key absent | stored value in all three | absent | | Field not granted by the reader's sets | key absent | stored value in all three | absent | | Platform read-only wildcard plus a withholding set (the card's door) | replaced, key absent, key absent | stored value of every class | absent | | Unmasking reader (control) | stored | stored | stored, byte-identical to the row at rest | In the "after" column each reader is still served the values of the classes that do not apply to it. The card named the create and update snapshots. The delete row's before-snapshot is the same column on the third record-write action and carried the same values, so it is narrowed too. ## What changed - `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts` (new): `installAuditLogFieldRedaction`, a `sys_audit_log` read middleware on `find` / `findOne` under a non-system context. On a row of a record-write action, each snapshot key the reader is not served is dropped, key by key. The record-write actions are create, update and delete: the vocabulary the mirror's `actionFor` returns, which is now typed by the same exported list. A reader served every key gets the stored bytes untouched. - `packages/plugins/plugin-audit/src/served-fields.ts` (new): the served-unmasked composition, now one helper both read seams use. `resolveServedFields` is the read projection intersected with the query-side answer, failing closed when the query-side answer is missing. The helper also holds the per-read memo, the key-by-key drop and the projection augmentation. ⛔ Nothing in `plugin-audit` derives masking. - `packages/plugins/plugin-audit/src/activity-field-redaction.ts`: moved onto that helper. Its exported names (`resolveServedFields` and its types) are unchanged. Behaviour is unchanged: its 19-case unit pin and 10-case dogfood pin stay green, and the log line names its seam. - `packages/plugins/plugin-audit/src/audit-plugin.ts`: mounts the ledger seam beside the activity one. One resolver looks up the security service on every read, for both seams. The no-middleware-seam warning names both. - `packages/plugins/plugin-audit/src/audit-writers.ts`: `actionFor` is typed by the shared record-write list, so a new record-write action does not compile until the read side judges it. - `content/docs/permissions/system-context.mdx`: row 45 names the new system-context early return. The census counts move 110 to 111, which `check-system-context-census` requires. - `.changeset/21155-ledger-snapshot-fields.md`: `patch` for `@objectstack/plugin-audit`. ### Fail closed - A record-write row's snapshot that is not a JSON object is dropped whole. - A record-write row that names no parent object loses both snapshots. - A row whose action cannot be read loses both snapshots. - An unexpected failure strips both snapshots from every row of the read. - A reader the service cannot answer masking for is served no field of the parent. - The security service's own "no answer" passes rows through, as the activity stream, the approval snapshot and the data plane do. That covers no service wired, and an object the service cannot resolve. ### Every ledger column judged, by writer - **Before/after snapshots on create, update and delete rows (the CRUD mirror):** the parent record's field map. Narrowed. - **Snapshots on `read`, `login`, `logout` and `import` rows:** written empty. Nothing to narrow. - **Snapshot on `config_change` rows (`service-settings`):** a digest of the setting, never its value. Served as written. - **Snapshots on `platform_admin_standing_change` rows (`plugin-security`):** the deployment's administrator roster, not a parent record's field map. Served as written. - **The metadata column on mirror rows:** the delegated-write attribution only. Served as written. - **The metadata column on `plugin-auth`'s admin create/update rows:** the admin's decisions, which include values of the created user's fields. Not narrowed here; see the acceptance notes (measured; producer-side; outside this card's file surface). - **The client-fingerprint columns on `login` / `logout` rows:** the ledger's own columns, governed by field-level security on the ledger object itself. - **Pointer, actor and tenant columns:** ids, the ledger's own. ### Read doors Every product door onto the ledger reads through the engine's generic data path: - the Setup "Audit Logs" object view; - the console's audit-log browser, which calls the generic list door (objectui `apps/console/src/pages/system/AuditLogPage.tsx`); - a record page's history tab, which calls the generic list door projected to the snapshot columns (objectui `packages/app-shell/src/views/RecordDetailView.tsx`). Both objectui files were read at the pinned `.objectui-sha`. No server route in this repository reads the ledger outside the engine. All three door shapes were measured: list, by-id and projected list. ## Pins - `packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts`, 15 cases. It uses the real `ObjectKernel`, `ObjectQLPlugin`, SQLite driver and the real `AuditPlugin` mount. The security-service double answers both contract members per reader. Rows are written by the real mirror (create, update, delete), plus rows in the other writers' shapes. The suite covers each class, the control's byte identity, the by-id and projected doors, the other actions served as written, each fail-closed branch, the no-answer pass-through and a system read. - `packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts`, 8 cases, on a real boot with the real security plugin, which is the only thing that distinguishes capability-gated from not granted. It has one reader per class plus the read-only-wildcard reader and the control. It is armed in `beforeAll` by `assertArmed`: the rows at rest carry every class, and the data plane serves each reader its class as declared. Each negative case reads the list, by-id and projected list doors. No test title states a value. ## Ablations: every negative pin, put back and shown red All legs went through `scripts/ablation-replace.mjs`. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. Every leg turned only its own cases red; all others stayed green. Unit legs, on the ledger file (15 cases) plus the activity file (19 cases): | Mutation | Red | |---|---| | mount removed | 9 of 15 ledger; 0 activity | | query-side intersection removed (shared helper) | 3 ledger (masked class) + 7 activity | | key narrowing off | 6 | | action discriminator opened | 1 (other actions served as written) | | non-object snapshot kept | 1 | | parentless row kept | 1 | | failure catch strips nothing | 1 | | missing query-side answer read as "nothing masked" (shared helper) | 1 ledger + 2 activity | | projection augmentation off | 1 | | added-column removal off | 1 | | untouched bytes re-serialised | 1 (control byte identity) | | no-answer pass-through closed | 1 | Dogfood legs, resolved through `dist`. Each leg rebuilt `plugin-audit`. `scripts/ablation-dist-preflight.mjs` proved the marker present in `dist`. The restore leg rebuilt again and proved the marker absent with `--absent` on a clean whole tree, then went 18/18 green: | Mutation | Red | |---|---| | mount removed | 4 of 8 (the three per-class negatives and the wildcard negative); the 3 preservation cases and the control stay green; the 10 activity cases stay green | | query-side intersection removed | 2 of 8 (masked class and the wildcard) + 2 activity (masked class) | ## Verification at head `f5289158` - `pnpm --filter @objectstack/plugin-audit test`: 30 files, 426 tests passed (411 before this change, plus 15). - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, and `check:test-typecheck` OK. - Dogfood `vitest --project isolated`, the new pin plus the activity sibling: 2 files, 18 tests passed. Dogfood typecheck: exit 0. - Consumer suites that read the ledger as callers: `admin-identity-audit-trail`, `auth-session-audit-trail`, `membership-actor-attribution`, `settings-config-change-audit` and `activity-parent-read-gate` (dogfood, 24 tests), plus `service-settings` `config-change-audit.test.ts` (16 tests). All green. - Gates: `dispatch-gates --repo objectstack-ai/objectstack --commands`, with no paths, derived 95 families. All 95 were run with exit codes captured before any pipe, and all exited 0. `--ran` reads 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Two families first answered PREREQUISITE NOT MET because packages had no `dist`: `check:skill-examples` and `check:dual-build-cjs-loads`. The packages they named were built, and both re-ran to exit 0. `check-system-context-census` was red until the census row landed. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 7 changed TS files reported 7 files, none ignored, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (0 `parserOptions.project` / `projectService`), so no untouched file's verdict can move. The repo-wide lint is CI's. - Size: 1155 changed lines (+1032 / -123) across 9 files against merge base `e35c40a5`. No governed surface. ## Acceptance notes - **Parent-record visibility on the ledger (measured, a policy question).** A reader whose sets grant the ledger read was served the ledger rows about a record the data plane answers 404 to that reader. After this change, the snapshots in those rows still carry every field the reader is not withheld at field level. The activity stream has a parent-record read gate; the ledger has none. Whether ledger reads take the parent's record gate, or the separate audit-capability narrowing triage named, is a ruling, not this card. It is reported to the seat. - **`plugin-auth`'s admin create-user row (measured).** Its decision metadata carries the created user's field values. A reader withheld one of those fields (and refused the record itself) was served that value through the row's metadata. After this change, the mirror's own create row about the same user no longer carries it. The producer is `plugin-auth/src/admin-user-endpoints.ts`, outside this card's surface. Narrowing it here would need a second derivation of which decision keys are field values. It is reported to the seat. - **Predicate over the stored snapshot text (measured, after this change).** As the withheld reader, a filter on a snapshot column answers by row presence: one row on a hit, none on a miss. The predicate is evaluated at rest, before any read-time redaction. This is the family of #21154, the activity stream's same question, and it is reported for folding there. A group or aggregate over the snapshot columns is the same family and was not measured. - **The cross-plugin hoist.** The approval snapshot (`plugin-approvals/src/payload-redaction.ts`) still carries its own copy of the served-unmasked composition. Hoisting it beside `served-fields.ts` is #21081's noted follow-up, not this card's. `plugin-approvals` is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f3b16fc commit fbcc05f

9 files changed

Lines changed: 1032 additions & 123 deletions

File tree

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 compliance ledger's before/after snapshots serve a parent field's value only to a reader the security service serves that field (#21155)
6+
7+
Clause-②: no
8+
9+
The CRUD mirror writes one `sys_audit_log` row per record write, once, as the system. A create row's after-snapshot, an update row's before/after snapshots of each changed field, and a delete row's before-snapshot carry the parent record's stored field values. The ledger is read through the generic data doors under its own object grant and tenant wall, so a reader whose permission sets grant the ledger read was served every snapshot key. This held for a field served masked to the reader, a field gated by `requiredPermissions` the reader does not hold, and a field a permission set the reader holds marks non-readable. The data plane answered the same reader masked or without the key.
10+
11+
Ledger readers are not field-unrestricted by default. The snapshots of create, update and delete rows are now narrowed at read time, keyed on the reading caller, through the security service's own answer: the read projection intersected with the query-side answer, whose difference the contract defines as exactly the fields served masked. Every key the reader is not served is dropped. A reader served every field reads the snapshots byte-identical to the row at rest, and system reads are unchanged. An auditor who must see every field is granted that by a permission set that unmasks those fields.
12+
13+
Rows of other actions are served as written: their snapshot columns are empty, a settings digest, or the administrator roster, and none of them is a parent record's field map. A create, update or delete row whose snapshot cannot be judged key by key (not a JSON object, or no parent object named) loses that snapshot. The activity stream's redaction and this one now share one served-fields helper.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **110
13+
because the flag is not one concept: it is a single boolean read at **111
1414
distinct sites across 19 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -138,7 +138,7 @@ that silently does not happen.
138138

139139
### 3. Sharing (`plugin-sharing`)
140140

141-
The largest single consumer — **17 of the 110 sites**.
141+
The largest single consumer — **17 of the 111 sites**.
142142

143143
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
144144
|:--|:---|:---|:---|
@@ -161,7 +161,7 @@ The largest single consumer — **17 of the 110 sites**.
161161
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` |
162162
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` |
163163
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` |
164-
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity read gate (its read AST) and the activity field redaction (the rows a read serves) | plugin-audit | Get: the whole activity row on `find` / `findOne` — the audit writer and the read gate's own pre-scan. Lose: comment visibility scoping, the narrowing of `sys_activity` to rows whose parent record the caller can read, and the redaction of a parent field's value from an activity row's text and recorded change | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction` |
164+
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity read gate (its read AST), the activity field redaction and the audit-log field redaction (the rows a read serves) | plugin-audit | Get: the whole activity row on `find` / `findOne` — the audit writer and the read gate's own pre-scan — and the whole `sys_audit_log` row, its before/after snapshots included. Lose: comment visibility scoping, the narrowing of `sys_activity` to rows whose parent record the caller can read, and the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction` |
165165
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
166166

167167
### 5. Actions, metadata plane, provenance, the organization wall
@@ -281,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
281281
independent decisions, and a seed loader plausibly wants the first two but not
282282
the third. The concept is nevertheless **staying as one boolean**:
283283

284-
- **Shipped semantics.** `isSystem` is a published contract with 110 read sites
284+
- **Shipped semantics.** `isSystem` is a published contract with 111 read sites
285285
in 19 packages. Splitting it is a breaking contract change across all of them.
286286
(The ruling was taken when the census read 80 sites in 18 packages; the count
287287
has grown, which strengthens rather than weakens the argument.)
@@ -355,16 +355,16 @@ still holds equal to the census on every pull request:
355355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
356356
| — parsed as a declaration | 22 | ✅ |
357357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
358-
| — parsed as a property **read** | 116 | ✅ |
358+
| — parsed as a property **read** | 117 | ✅ |
359359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
360360
| — the remainder: text inside comments and string literals | 358 | — |
361361
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
362-
| Of those reads: reads of `ExecutionContext.isSystem` | **110** | ✅ |
363-
| — behaviour-bearing (rows 1–61 above) | 107 | ✅ |
362+
| Of those reads: reads of `ExecutionContext.isSystem` | **111** | ✅ |
363+
| — behaviour-bearing (rows 1–61 above) | 108 | ✅ |
364364
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
365365
| Packages containing at least one elevation read | **19** | ✅ |
366-
| Files containing at least one elevation read | 48 | ✅ |
367-
| — the distinct symbols those reads live in — what this page anchors | 93 | ✅ |
366+
| Files containing at least one elevation read | 49 | ✅ |
367+
| — the distinct symbols those reads live in — what this page anchors | 94 | ✅ |
368368
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
369369

370370
The six rows marked — are a **dated decomposition, not a live claim**: they were
@@ -428,7 +428,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
428428
Renaming a symbol is now a loud red instead of a silent misdirection.
429429

430430
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
431-
cannot say WHICH read inside a function it means, and **8** of the **48**
431+
cannot say WHICH read inside a function it means, and **8** of the **49**
432432
anchored files hold more than one read inside a single symbol. So the population
433433
check runs per file at symbol granularity: every file the census finds a read in
434434
must be anchored, and the set of symbols this page cites into that file must

‎packages/plugins/plugin-audit/src/activity-field-redaction.ts‎

Lines changed: 22 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,22 @@
7878
* context-less programmatic calls are not redacted, as for the read gate.
7979
*/
8080

81-
import type { ISecurityService } from '@objectstack/spec/contracts';
8281
import { parseActivityParentObject, type ActivityMiddlewareEngine } from './activity-read-visibility.js';
82+
import {
83+
dropUnservedKeys,
84+
ensureJudgedColumnsProjected,
85+
isRecord,
86+
servedFieldsPerRead,
87+
type FieldRedactionLogger,
88+
type FieldVisibilitySource,
89+
} from './served-fields.js';
90+
91+
/**
92+
* The served-unmasked composition lives in `served-fields.ts`, shared with the
93+
* compliance ledger's redaction (#21155); re-exported here so this seam's
94+
* public names are unchanged.
95+
*/
96+
export { resolveServedFields } from './served-fields.js';
8397

8498
const ACTIVITY_OBJECT = 'sys_activity';
8599
const SYSTEM_CTX = { isSystem: true } as const;
@@ -106,69 +120,9 @@ type TextColumn = (typeof TEXT_COLUMNS)[number];
106120
const VALUE_BEARING_COLUMNS = ['summary', 'record_label', 'metadata'] as const;
107121

108122
/** The slice of the security contract this seam asks — the REAL interface. */
109-
export type ActivityFieldVisibilitySource = Pick<ISecurityService, 'getReadableFields' | 'getQueryableFields'>;
123+
export type ActivityFieldVisibilitySource = FieldVisibilitySource;
110124

111-
export interface ActivityRedactionLogger {
112-
warn(msg: string, meta?: unknown): void;
113-
}
114-
115-
/**
116-
* The fields of `object` this reader is served UNMASKED, or `undefined` when
117-
* this seam must not narrow.
118-
*
119-
* The same composition `plugin-approvals` serves its snapshot with
120-
* (`resolveReadableSnapshotFields`, #20964), asked as the reader:
121-
*
122-
* - no service, no object, or a read projection that answers `undefined` or
123-
* throws → `undefined`: the contract's "no answer", the data plane's own
124-
* fallback, and so not a reason to blank the stream;
125-
* - otherwise the read projection intersected with `getQueryableFields`.
126-
* When THAT answer cannot be had (absent member, `undefined`, a throw) the
127-
* contract obliges the consumer not to read its absence as "nothing is
128-
* masked": the read projection reports every masked field readable. So the
129-
* answer is `[]` — no field is served.
130-
*/
131-
export async function resolveServedFields(
132-
security: Partial<ActivityFieldVisibilitySource> | undefined,
133-
object: string | undefined,
134-
context: unknown,
135-
logger?: ActivityRedactionLogger,
136-
): Promise<string[] | undefined> {
137-
if (!security || typeof security.getReadableFields !== 'function') return undefined;
138-
const name = String(object ?? '').trim();
139-
if (!name) return undefined;
140-
let readable: string[] | undefined;
141-
try {
142-
readable = await security.getReadableFields(name, context as never);
143-
} catch (err) {
144-
logger?.warn(
145-
`[audit] activity field redaction: readable fields of '${name}' could not be resolved — ` +
146-
`serving its activity rows unnarrowed (${(err as Error)?.message ?? err})`,
147-
);
148-
return undefined;
149-
}
150-
if (readable === undefined) return undefined;
151-
152-
let unmasked: string[] | undefined;
153-
let reason = 'the security service has no masked-for-this-caller answer (getQueryableFields)';
154-
if (typeof security.getQueryableFields === 'function') {
155-
try {
156-
unmasked = await security.getQueryableFields(name, context as never);
157-
if (unmasked === undefined) reason = 'getQueryableFields answered undefined';
158-
} catch (err) {
159-
reason = `getQueryableFields threw: ${(err as Error)?.message ?? err}`;
160-
}
161-
}
162-
if (unmasked === undefined) {
163-
logger?.warn(
164-
`[audit] activity field redaction: cannot tell which fields of '${name}' are masked for this caller — ` +
165-
`serving no field value of it (fail closed): ${reason}`,
166-
);
167-
return [];
168-
}
169-
const keep = new Set(unmasked.map(String));
170-
return readable.filter((f) => keep.has(String(f)));
171-
}
125+
export type ActivityRedactionLogger = FieldRedactionLogger;
172126

173127
/** Parse a stored `metadata` string; anything but a JSON object is not ours. */
174128
function parseMetadata(raw: unknown): Record<string, unknown> | null {
@@ -181,9 +135,6 @@ function parseMetadata(raw: unknown): Record<string, unknown> | null {
181135
}
182136
}
183137

184-
const isRecord = (v: unknown): v is Record<string, unknown> =>
185-
!!v && typeof v === 'object' && !Array.isArray(v);
186-
187138
/**
188139
* Where a row's text came from:
189140
* - a field list per text column (the mirror's declaration);
@@ -229,12 +180,7 @@ export async function redactActivityRows(
229180
const list = (Array.isArray(rows) ? rows : rows ? [rows] : []) as unknown[];
230181
if (list.length === 0) return;
231182
// One answer per parent object per read, never one per row.
232-
const served = new Map<string, Promise<string[] | undefined>>();
233-
const servedFor = (object: string) => {
234-
let p = served.get(object);
235-
if (!p) served.set(object, (p = resolveServedFields(security, object, context, logger)));
236-
return p;
237-
};
183+
const servedFor = servedFieldsPerRead(security, context, logger, 'activity field redaction');
238184
const restricted = new Map<string, Promise<boolean>>();
239185
/** Is this reader served fewer fields of `object` than the system is? */
240186
const restrictedOn = (object: string, servedSet: Set<string>) => {
@@ -278,13 +224,7 @@ export async function redactActivityRows(
278224
let changed = false;
279225
for (const side of ['old', 'new'] as const) {
280226
const snapshot = metadata[side];
281-
if (!isRecord(snapshot)) continue;
282-
for (const key of Object.keys(snapshot)) {
283-
if (!servedSet.has(key)) {
284-
delete snapshot[key];
285-
changed = true;
286-
}
287-
}
227+
if (isRecord(snapshot) && dropUnservedKeys(snapshot, servedSet)) changed = true;
288228
}
289229
if (ACTIVITY_TEXT_SOURCES_KEY in metadata) {
290230
delete metadata[ACTIVITY_TEXT_SOURCES_KEY];
@@ -309,22 +249,9 @@ export async function redactActivityRows(
309249
/**
310250
* The text columns' provenance lives in `metadata`, and the parent in
311251
* `object_name`; a projection that names a value-bearing column without them
312-
* gets them added for the read and removed from what is served. Returns the
313-
* columns it added.
252+
* gets them added for the read and removed from what is served.
314253
*/
315-
function ensureProjected(ast: Record<string, unknown> | undefined): string[] {
316-
const fields = ast?.fields;
317-
if (!Array.isArray(fields) || fields.length === 0) return [];
318-
if (!fields.some((f) => (VALUE_BEARING_COLUMNS as readonly string[]).includes(String(f)))) return [];
319-
const added: string[] = [];
320-
for (const col of ['object_name', 'metadata']) {
321-
if (!fields.includes(col)) {
322-
fields.push(col);
323-
added.push(col);
324-
}
325-
}
326-
return added;
327-
}
254+
const JUDGED_BY = ['object_name', 'metadata'] as const;
328255

329256
/**
330257
* Install the `sys_activity` field-redaction middleware. `getSecurity` is
@@ -342,7 +269,7 @@ export function installActivityFieldRedaction(
342269
if ((ctx.operation !== 'find' && ctx.operation !== 'findOne') || !ctx.context || ctx.context.isSystem) {
343270
return next();
344271
}
345-
const added = ensureProjected(ctx.ast);
272+
const added = ensureJudgedColumnsProjected(ctx.ast, VALUE_BEARING_COLUMNS, JUDGED_BY);
346273
await next();
347274
const list = (Array.isArray(ctx.result) ? ctx.result : ctx.result ? [ctx.result] : []) as unknown[];
348275
try {

0 commit comments

Comments
 (0)