Skip to content

Commit d75ae97

Browse files
committed
fix(plugin-audit): keep ADR-0139's anchored names on the functions that carry those roles; pin membership over a prefix
computeWithheldUpdateFilter keeps its name as the withheld-row predicate builder, and isWithheldOnlyUpdate is the update half over changed_fields, called by keptRowsOfParent. A served field whose name is a prefix of a withheld one keeps no withheld-only update row. Claude-Session: https://claude.ai/code/session_01TyhsxCqpTkk1U5GLD1JETy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70ff93d commit d75ae97

4 files changed

Lines changed: 82 additions & 26 deletions

File tree

‎.changeset/23731-activity-changed-fields.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ The withheld-row rule is now a WHERE over those two columns, one branch per pare
2222

2323
The parent-field query guard covers both columns as it covers `metadata`: a reader withheld any field of the parent is refused a filter, sort, group or aggregate over `changed_fields` or `milestone_key` (`PERMISSION_DENIED` / 403).
2424

25-
`@objectstack/plugin-audit` exports the backfill, `backfillActivityChangedFields` (with `ACTIVITY_BACKFILL_PAGE_SIZE` and its report and engine types), and the planner, `planActivityStamp` (with `ACTIVITY_CHANGED_FIELDS_COLUMN`, `ACTIVITY_MILESTONE_KEY_COLUMN` and `ActivityStamp`). `computeWithheldUpdateFilter`, `isWithheldOnlyUpdate`, `isWithheldMilestone` and `isWithheldRow` were module-internal (never exported from the package) and are replaced by `computeWithheldRowFilter` and `keptRowsOfParent`.
25+
`@objectstack/plugin-audit` exports the backfill, `backfillActivityChangedFields` (with `ACTIVITY_BACKFILL_PAGE_SIZE` and its report and engine types), and the planner, `planActivityStamp` (with `ACTIVITY_CHANGED_FIELDS_COLUMN`, `ACTIVITY_MILESTONE_KEY_COLUMN` and `ActivityStamp`). Inside the module (none of it is exported from the package), `computeWithheldUpdateFilter` keeps its name and now builds the predicate, `isWithheldOnlyUpdate` keeps its name as the update half over `changed_fields`, and `isWithheldMilestone` / `isWithheldRow` are gone with the pre-scan they judged for.

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,32 @@ function currentMilestoneWatched(engine: ActivityRedactionEngine, object: string
372372
return declaredMilestoneWatchedFields(typeof engine.getSchema === 'function' ? engine.getSchema(object) : undefined);
373373
}
374374

375+
/**
376+
* [#21388, ADR-0139 D4] The update half of the withheld-row rule — whether a
377+
* stamped row is a withheld-only update — as a predicate over `changed_fields`
378+
* for a reader served `served`. An update row with a non-empty `changed_fields`
379+
* none of whose members this reader is served is withheld; so the predicate is
380+
* written as the rows it KEEPS: `changed_fields` is empty (a create, a delete, a
381+
* note, an update empty for everyone), or it holds a served field — one
382+
* `$contains` per served field. On a multi-value column `$contains` is
383+
* MEMBERSHIP (`filter.zod.ts`, the JSON-stored column paragraph), so a served
384+
* field whose name is a prefix of a withheld one never keeps the row.
385+
*
386+
* Written positively, never as a `$not` of the withheld rows: `$not` is
387+
* NULL-safe per leaf, so it would re-admit an unstamped row; the stamped
388+
* conjunct beside it in {@link keptRowsOfParent} decides those.
389+
* Exported for direct testing.
390+
*/
391+
export function isWithheldOnlyUpdate(served: readonly string[]): Record<string, unknown> {
392+
const members = [...new Set(served)].sort();
393+
return {
394+
$or: [
395+
{ [ACTIVITY_CHANGED_FIELDS_COLUMN]: { $empty: true } },
396+
...members.map((f) => ({ [ACTIVITY_CHANGED_FIELDS_COLUMN]: { $contains: f } })),
397+
],
398+
};
399+
}
400+
375401
/**
376402
* [ADR-0139 D4, E6] The rows of ONE parent object `P` a reader served
377403
* `served` keeps, as a predicate over the two writer-stamped columns
@@ -380,11 +406,11 @@ function currentMilestoneWatched(engine: ActivityRedactionEngine, object: string
380406
* - **stamped** — `changed_fields` is not null. An unstamped row (written
381407
* before the column existed and not yet backfilled, or one the planner
382408
* could not judge) is withheld: ADR-0139 D4's answer for an unjudged row.
383-
* - **the update half** (#21388) — `changed_fields` is empty, or it contains
384-
* a field this reader is served: one `$contains` per served field. So an
385-
* update whose every recorded key is withheld is withheld, and a create, a
386-
* delete, a note, and an update that touched only `internal` fields (empty
387-
* for everyone) are kept.
409+
* - **the update half** (#21388, {@link isWithheldOnlyUpdate}) —
410+
* `changed_fields` is empty, or it contains a field this reader is served:
411+
* one `$contains` per served field. So an update whose every recorded key is
412+
* withheld is withheld, and a create, a delete, a note, and an update that
413+
* touched only `internal` fields (empty for everyone) are kept.
388414
* - **the milestone half** (#22786, ruled letter B) — `milestone_key` is null
389415
* (no milestone fired), or it EQUALS the key of P's current
390416
* `activityMilestones` declaration AND this reader is served every field
@@ -400,19 +426,13 @@ export function keptRowsOfParent(
400426
watched: readonly string[],
401427
): Record<string, unknown> {
402428
const servedSet = new Set(served);
403-
const members = [...servedSet].sort();
404429
const key = milestoneKeyOf(watched);
405430
const milestoneServed = key !== null && watched.every((f) => servedSet.has(f));
406431
return {
407432
$and: [
408433
{ object_name: parent },
409434
{ [ACTIVITY_CHANGED_FIELDS_COLUMN]: { $null: false } },
410-
{
411-
$or: [
412-
{ [ACTIVITY_CHANGED_FIELDS_COLUMN]: { $empty: true } },
413-
...members.map((f) => ({ [ACTIVITY_CHANGED_FIELDS_COLUMN]: { $contains: f } })),
414-
],
415-
},
435+
isWithheldOnlyUpdate(served),
416436
milestoneServed
417437
? { $or: [{ [ACTIVITY_MILESTONE_KEY_COLUMN]: { $null: true } }, { [ACTIVITY_MILESTONE_KEY_COLUMN]: key }] }
418438
: { [ACTIVITY_MILESTONE_KEY_COLUMN]: { $null: true } },
@@ -439,7 +459,7 @@ export function keptRowsOfParent(
439459
* nothing can be judged: the read is denied, and that is said once.
440460
* Exported for direct testing.
441461
*/
442-
export async function computeWithheldRowFilter(
462+
export async function computeWithheldUpdateFilter(
443463
engine: ActivityRedactionEngine,
444464
ast: Record<string, unknown>,
445465
servedFor: ServedFor,
@@ -573,7 +593,7 @@ const JUDGED_BY = ['object_name', 'metadata'] as const;
573593
*
574594
* [#21388, #22786] Before the read runs, the same middleware ANDs the
575595
* withheld-row predicate into the WHERE of all four reads
576-
* (`computeWithheldRowFilter`, ADR-0139 E6). `AuditPlugin` registers it after
596+
* (`computeWithheldUpdateFilter`, ADR-0139 E6). `AuditPlugin` registers it after
577597
* the read gate; the two are independent conjuncts.
578598
*/
579599
export function installActivityFieldRedaction(
@@ -591,7 +611,7 @@ export function installActivityFieldRedaction(
591611
const restrictedFor = restrictedPerRead(security);
592612
if (security && ctx.ast && PARENT_GATE_READ_OPS.has(ctx.operation)) {
593613
try {
594-
const filter = await computeWithheldRowFilter(engine, ctx.ast, servedFor, logger);
614+
const filter = await computeWithheldUpdateFilter(engine, ctx.ast, servedFor, logger);
595615
if (filter) andIntoWhere(ctx, filter);
596616
} catch (err) {
597617
// A failure to build the rule must never fall open into the timing leak.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
* rows, on the same four reads, so a list's total, its pages and a grouped
6464
* count agree with the rows served. It judges by the security service's field
6565
* answer, so it lives with the field redaction (`activity-field-redaction.ts`,
66-
* `computeWithheldRowFilter`), whose middleware `AuditPlugin` registers after
66+
* `computeWithheldUpdateFilter`), whose middleware `AuditPlugin` registers after
6767
* this one. [ADR-0139 E6] It is a predicate over two writer-stamped columns
6868
* (`changed_fields`, `milestone_key`), one branch per parent object this gate
6969
* enumerates ({@link ACTIVITY_GATE}): it reads no row and composes no leaf, so

‎packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,16 @@ import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
4646
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
4747

4848
import { AuditPlugin } from './audit-plugin.js';
49-
import { computeWithheldRowFilter, keptRowsOfParent } from './activity-field-redaction.js';
49+
import { computeWithheldUpdateFilter, keptRowsOfParent } from './activity-field-redaction.js';
5050
import { planActivityStamp } from './activity-changed-fields.js';
5151

5252
const ACTIVITY = 'sys_activity';
5353
/** A record the member reads with two fields withheld. */
5454
const ITEM = 'awu_item';
5555
/** A record the member reads with NO field served: every change is withheld. */
5656
const SEALED = 'awu_sealed';
57+
/** [ADR-0139 E6] A served field whose name is a prefix of a withheld one: membership, not substring. */
58+
const PREFIX = 'awu_prefix';
5759
const HARNESS_PACKAGE = 'com.objectstack.audit.test.activity-withheld-update';
5860

5961
const SYS = { isSystem: true } as const;
@@ -84,6 +86,16 @@ const sealedObject = {
8486
},
8587
};
8688

89+
const prefixObject = {
90+
name: PREFIX,
91+
label: 'Withheld Update Prefix',
92+
fields: {
93+
name: { name: 'name', label: 'Name', type: 'text' as const },
94+
f: { name: 'f', label: 'F', type: 'text' as const },
95+
f_secret: { name: 'f_secret', label: 'F Secret', type: 'text' as const },
96+
},
97+
};
98+
8799
type Row = Record<string, any>;
88100
const meta = (r: Row | undefined) => (typeof r?.metadata === 'string' ? JSON.parse(r.metadata) : null);
89101
const changeKeys = (r: Row | undefined) => {
@@ -109,6 +121,7 @@ describe('[#21388] an update whose every recorded change is withheld from the re
109121
if (context?.userId === MEMBER.userId) {
110122
if (object === ITEM) return all.filter((f) => !WITHHELD.has(f));
111123
if (object === SEALED) return [];
124+
if (object === PREFIX) return all.filter((f) => f !== 'f_secret');
112125
}
113126
return all;
114127
},
@@ -154,6 +167,7 @@ describe('[#21388] an update whose every recorded change is withheld from the re
154167
engine.registerDriver(driver, true);
155168
engine.registry.registerObject(itemObject as any, HARNESS_PACKAGE);
156169
engine.registry.registerObject(sealedObject as any, HARNESS_PACKAGE);
170+
engine.registry.registerObject(prefixObject as any, HARNESS_PACKAGE);
157171
await engine.syncSchemas();
158172

159173
// Every row below is written by the real audit writer's CRUD mirror.
@@ -183,6 +197,14 @@ describe('[#21388] an update whose every recorded change is withheld from the re
183197
rowIds.sealedCreated = String(sealedRows.find((r) => r.type === 'created')?.id ?? '');
184198
rowIds.sealedUpdated = String(sealedRows.find((r) => r.type === 'updated')?.id ?? '');
185199

200+
// [ADR-0139 E6] `f` is served, `f_secret` is withheld, and `f` is a prefix of it.
201+
ids.prefix = (await engine.insert(PREFIX, { name: 'prefix', f: 'p1', f_secret: 's1' }, { context: SYS })).id;
202+
await update(PREFIX, ids.prefix, { f_secret: 's2' }); // withheld whole
203+
await update(PREFIX, ids.prefix, { f: 'p2' }); // served whole
204+
const prefixRows = (await engine.find(ACTIVITY, { where: { object_name: PREFIX }, context: SYS })) as Row[];
205+
rowIds.prefixWithheld = String(prefixRows.find((r) => changeKeys(r).join() === 'f_secret')?.id ?? '');
206+
rowIds.prefixServed = String(prefixRows.find((r) => changeKeys(r).join() === 'f')?.id ?? '');
207+
186208
// [ADR-0139 E6] An app's own row, inserted with no stamp of its own: the
187209
// before-insert hook stamps it `[]` (not a recorded change), so it is kept.
188210
rowIds.note = String(
@@ -384,6 +406,20 @@ describe('[#21388] an update whose every recorded change is withheld from the re
384406
expect(rows.map((r) => r.id)).toEqual([rowIds.withheld]);
385407
});
386408

409+
it('[ADR-0139 E6] membership, not substring: an update of only a withheld field stays withheld though a served field\'s name is its prefix', async () => {
410+
const atRest = (await engine.find(ACTIVITY, { where: { object_name: PREFIX }, context: SYS })) as Row[];
411+
expect(atRest.find((r) => r.id === rowIds.prefixWithheld)?.changed_fields).toEqual(['f_secret']);
412+
const served = ((await engine.find(ACTIVITY, {
413+
where: scoped(PREFIX, ids.prefix), orderBy: ORDER, context: MEMBER,
414+
})) as Row[]).map((r) => r.id);
415+
expect(served).not.toContain(rowIds.prefixWithheld);
416+
expect(served).toContain(rowIds.prefixServed);
417+
expect(await engine.count(ACTIVITY, { where: scoped(PREFIX, ids.prefix) }, { context: MEMBER })).toBe(served.length);
418+
// Control: the administrator is served both.
419+
const admin = ((await engine.find(ACTIVITY, { where: scoped(PREFIX, ids.prefix), context: ADMIN })) as Row[]).map((r) => r.id);
420+
expect(admin).toEqual(expect.arrayContaining([rowIds.prefixWithheld, rowIds.prefixServed]));
421+
});
422+
387423
it('a system read is not narrowed', async () => {
388424
expect((await read(SYS)).map((r) => r.id)).toContain(rowIds.withheld);
389425
});
@@ -418,7 +454,7 @@ describe('[ADR-0139 E6] planActivityStamp — the one derivation of the stamp',
418454
});
419455
});
420456

421-
describe('[ADR-0139 E6] keptRowsOfParent / computeWithheldRowFilter — a predicate, no row read', () => {
457+
describe('[ADR-0139 E6] keptRowsOfParent / computeWithheldUpdateFilter — a predicate, no row read', () => {
422458
const engineWith = (objects: Array<{ name: string; activityMilestones?: unknown }>) => ({
423459
registry: { getAllObjects: () => objects },
424460
getSchema: (name: string) => objects.find((o) => o.name === name),
@@ -445,26 +481,26 @@ describe('[ADR-0139 E6] keptRowsOfParent / computeWithheldRowFilter — a predic
445481
it('a parent the service gives no answer for is kept whole; with no answer for any, no filter at all', async () => {
446482
const engine = engineWith([{ name: 'a' }, { name: 'b' }]);
447483
const servedFor = async (o: string) => (o === 'a' ? ['x'] : undefined);
448-
expect(await computeWithheldRowFilter(engine, {}, servedFor, { warn: vi.fn() })).toEqual({
484+
expect(await computeWithheldUpdateFilter(engine, {}, servedFor, { warn: vi.fn() })).toEqual({
449485
$or: [keptRowsOfParent('a', ['x'], []), { object_name: 'b' }],
450486
});
451-
expect(await computeWithheldRowFilter(engine, {}, async () => undefined, { warn: vi.fn() })).toBeNull();
487+
expect(await computeWithheldUpdateFilter(engine, {}, async () => undefined, { warn: vi.fn() })).toBeNull();
452488
expect(engine.find).not.toHaveBeenCalled();
453489
});
454490

455491
it('a read pinned to one parent object is judged against that parent alone', async () => {
456492
const engine = engineWith([{ name: 'a' }, { name: 'b' }]);
457493
const servedFor = vi.fn(async () => ['x']);
458494
const where = { $and: [{ object_name: 'b' }, { record_id: '1' }] };
459-
expect(await computeWithheldRowFilter(engine, { where }, servedFor, { warn: vi.fn() })).toEqual(
495+
expect(await computeWithheldUpdateFilter(engine, { where }, servedFor, { warn: vi.fn() })).toEqual(
460496
keptRowsOfParent('b', ['x'], []),
461497
);
462498
expect(servedFor).toHaveBeenCalledTimes(1);
463499
});
464500

465501
it('the audit and activity objects themselves are not enumerated (the read gate excludes their rows)', async () => {
466502
const engine = engineWith([{ name: 'sys_activity' }, { name: 'sys_audit_log' }, { name: 'a' }]);
467-
expect(await computeWithheldRowFilter(engine, {}, async () => ['x'], { warn: vi.fn() })).toEqual(
503+
expect(await computeWithheldUpdateFilter(engine, {}, async () => ['x'], { warn: vi.fn() })).toEqual(
468504
keptRowsOfParent('a', ['x'], []),
469505
);
470506
});
@@ -473,10 +509,10 @@ describe('[ADR-0139 E6] keptRowsOfParent / computeWithheldRowFilter — a predic
473509
const logger = { warn: vi.fn() };
474510
const find = vi.fn();
475511
// No registry view: only a row reader, which the rule must not call.
476-
const engine = { find } as unknown as Parameters<typeof computeWithheldRowFilter>[0];
477-
const filter = await computeWithheldRowFilter(engine, {}, async () => ['x'], logger);
512+
const engine = { find } as unknown as Parameters<typeof computeWithheldUpdateFilter>[0];
513+
const filter = await computeWithheldUpdateFilter(engine, {}, async () => ['x'], logger);
478514
expect(filter).toEqual({ id: '__activity_withheld_row_denied__' });
479-
await computeWithheldRowFilter(engine, {}, async () => ['x'], logger);
515+
await computeWithheldUpdateFilter(engine, {}, async () => ['x'], logger);
480516
expect(logger.warn).toHaveBeenCalledTimes(1);
481517
expect(find).not.toHaveBeenCalled();
482518
});

0 commit comments

Comments
 (0)