diff --git a/.changeset/21388-activity-withheld-update-row.md b/.changeset/21388-activity-withheld-update-row.md new file mode 100644 index 00000000000..cb77a15be71 --- /dev/null +++ b/.changeset/21388-activity-withheld-update-row.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-audit': patch +--- + +fix(plugin-audit): an activity row recording an update whose every changed field the reader is withheld is no longer served to that reader, on any listing face + +Clause-②: no + +A `sys_activity` row's recorded change (`metadata.old` / `metadata.new`) is narrowed key by key for each reader, through the security service's served-fields answer. An update whose every changed field the reader is withheld still reached that reader as a row with an empty change, and its summary, actor and timestamp said that the record changed, and when. An org member holding object-level `sys_activity` read was served that row for each sign-in stamp on a colleague's identity record (`last_login_at`), and for each failed-sign-in counter bump, lockout, password-change stamp and MFA-required stamp. + +Such a row is now withheld from that reader as a row: + +- **What counts as one.** An update row (its stored change has both an `old` and a `new` side) whose stored change had at least one key, where the reader is served none of those keys. The keys are read from the STORED change, not the redacted one. +- **What is unaffected.** A create or a delete keeps its row. A row whose stored change is empty on both sides (an update that touched only `internal` fields) is unaffected. A mixed update keeps its row, with the served keys only. A reader served every field (an administrator) still reads every row with its change, within the pre-scan's bound. A system-context read is not narrowed. +- **Every face agrees.** The rule is a WHERE built from a system-context pre-scan on `find`, `findOne`, `count` and `aggregate`. So a list's `total`, its pages, a by-id read (`404`) and a grouped count agree with the rows served. A pre-scan that reaches its 2,000-row bound answers a broad read from the rows it judged, for every reader, administrators included, and logs a warning. The remedy is to scope the query by `object_name` and `record_id`. + +No migration: no key, export or config changes. A reader the security service gives no answer for (no security plugin wired) is not narrowed, as before. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e8452c623e3..6c1ad8a6859 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -161,7 +161,7 @@ The largest single consumer — **17 of the 114 sites**. | 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` | | 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` | | 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` | -| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer and each read gate's own pre-scan — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, 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, and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `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/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` | +| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer, each read gate's own pre-scan and the activity redaction's — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, 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, the withholding of an activity row recording an update whose every changed field the reader is withheld (on `count` and `aggregate` too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `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/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` | | 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` | ### 5. Actions, metadata plane, provenance, the organization wall diff --git a/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts b/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts index 53178a63ba2..e3c0c9b1ca1 100644 --- a/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts +++ b/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts @@ -62,6 +62,7 @@ const V = { unserved1: 'AFRUNSERVEDONE93', unserved2: 'AFRUNSERVEDTWO94', open1: 'AFROPENONE95', open2: 'AFROPENTWO96', open3: 'AFROPENTHREE97', title: 'AFRTITLE98', + earlier1: 'AFREARLIERONE89', earlier2: 'AFREARLIERTWO88', }; const itemObject = { @@ -186,7 +187,12 @@ describe('[#21081] sys_activity value-bearing columns are served through the sec for (const row of [ { type: 'updated', summary: 'earlier mirror row', record_label: 'earlier label', - metadata: JSON.stringify({ old: { f_unserved: V.unserved1 }, new: { f_unserved: V.unserved2 } }), + // A MIXED change, so a restricted reader keeps the row: a change + // withheld whole is withheld as a row (#21388, its own file). + metadata: JSON.stringify({ + old: { f_unserved: V.unserved1, f_open: V.earlier1 }, + new: { f_unserved: V.unserved2, f_open: V.earlier2 }, + }), }, { type: 'note', summary: 'app row with context', metadata: JSON.stringify({ channel: 'email' }) }, { type: 'note', summary: 'app row without context' }, @@ -307,7 +313,7 @@ describe('[#21081] sys_activity value-bearing columns are served through the sec }); it('a row in the mirror’s earlier shape (no provenance) keeps no text for a restricted reader, and keeps it for the control', async () => { - const earlier = (rows: Row[]) => rows.find((r) => r.type === 'updated' && typeof r.metadata === 'string' && !('text_sources' in JSON.parse(r.metadata)) && JSON.parse(r.metadata).new && Object.keys(JSON.parse(r.metadata).new).join() === 'f_unserved'); + const earlier = (rows: Row[]) => rows.find((r) => r.type === 'updated' && typeof r.metadata === 'string' && !('text_sources' in JSON.parse(r.metadata)) && JSON.parse(r.metadata).new && Object.keys(JSON.parse(r.metadata).new).sort().join() === 'f_open,f_unserved'); const control = earlier(await read(CONTROL)); expect(control).toHaveProperty('summary'); expect(control).toHaveProperty('record_label'); @@ -334,7 +340,12 @@ describe('[#21081] sys_activity value-bearing columns are served through the sec const rows = byChange(await read(NO_QUERYABLE_READER)); const blob = JSON.stringify(rows); for (const v of [V.masked1, V.masked2, V.unserved1, V.unserved2, V.open1, V.open2]) expect(blob).not.toContain(v); - expect(rows.allTracked).not.toHaveProperty('summary'); + // Served no field, the reader keeps the create row with no text composed + // from one, and is withheld every update row whose change had keys + // (#21388: a change withheld whole is withheld as a row). + expect(rows.created).toBeTruthy(); + expect(rows.created).not.toHaveProperty('summary'); + expect(rows.allTracked).toBeUndefined(); }); it('a system read is not redacted', async () => { diff --git a/packages/plugins/plugin-audit/src/activity-field-redaction.ts b/packages/plugins/plugin-audit/src/activity-field-redaction.ts index d55e8960533..85e46084168 100644 --- a/packages/plugins/plugin-audit/src/activity-field-redaction.ts +++ b/packages/plugins/plugin-audit/src/activity-field-redaction.ts @@ -65,10 +65,44 @@ * `metadata.old` / `metadata.new`, if it has them, are narrowed like the * mirror's, because that shape IS record field values. * + * ## An update whose every recorded change is withheld is withheld as a row (#21388) + * + * Narrowing key by key leaves one thing behind. An UPDATE row whose recorded + * change had keys, every one of which this reader is not served, still reached + * the reader as a row with an empty change, and its summary, actor and + * timestamp said that the record changed, and when. That is how an org peer + * read each sign-in time of a colleague: a sign-in stamps identity fields the + * peer is withheld. So such a row is withheld from that reader as a ROW: + * + * - An update row is one whose stored change has both sides (`old` and `new` + * are records). A create (`old` null) and a delete (`new` null) keep their + * rows: their existence is the record's own, which the read gate decides. + * - "Had keys" reads the STORED change, never the redacted one. A row whose + * stored change is empty on both sides (an update that touched only + * `internal` fields, which the writer omits) is empty for every reader, and + * is unaffected. + * - "Withheld" is the answer this redaction narrows by: a key this reader is + * not served. One answer for both, so a row is withheld exactly when the + * redaction would leave its change empty. A reader the service gives no + * answer for is narrowed by neither. ⛔ No object or field is named here. + * + * It is a WHERE, not a post-read drop, built the way the read gate builds its + * own and on the same four reads (`find`, `findOne`, `count`, `aggregate`). A + * pre-scan of the rows the query would touch, under SYSTEM context and in the + * caller's order, judges each one, and the ids it withholds are ANDed out of + * the query (`{ id: { $nin: WITHHELD } }`). So a list's `total`, its pages, a + * by-id read and a grouped count all agree with the rows served; a count that + * kept the row would leak the same timing. A pre-scan that reaches its bound + * fails CLOSED, as the read gate's does: the rows beyond the window cannot be + * judged, so the read is answered from the judged rows alone + * (`{ id: { $in: KEPT } }`), and a warn says so. + * * ## Fail closed * * An unexpected failure strips every value-bearing column from the rows of the - * read rather than serving them unredacted — the sibling read gate's rule. The + * read rather than serving them unredacted — the sibling read gate's rule. A + * withheld-update pre-scan that fails denies the read, as the read gate does. + * The * security service's own "no answer" (no service wired, or an unresolvable * read projection) passes rows through, exactly as the approval snapshot and * the data plane itself do; a reader the service cannot answer MASKING for is @@ -78,7 +112,9 @@ * context-less programmatic calls are not redacted, as for the read gate. */ +import type { CommentAccessEngine } from './comment-access-hooks.js'; import { parseActivityParentObject, type ActivityMiddlewareEngine } from './activity-read-visibility.js'; +import { PARENT_GATE_READ_OPS, PARENT_GATE_SCAN_LIMIT, andIntoWhere } from './parent-record-read-gate.js'; import { dropUnservedKeys, ensureJudgedColumnsProjected, @@ -166,6 +202,103 @@ function stripValueBearing(row: Record): void { for (const col of VALUE_BEARING_COLUMNS) delete row[col]; } +/** Names this seam in its log lines and in the served-fields answer's. */ +const REDACTION_SEAM = 'activity field redaction'; + +/** One read's served-unmasked answer, per parent object. */ +type ServedFor = (object: string) => Promise; + +/** The engine slice the withheld-update rule's pre-scan needs. */ +export type ActivityRedactionEngine = ActivityMiddlewareEngine & Pick; + +/** The columns the withheld-update pre-scan reads: the row, its parent object + * and its stored change. */ +const WITHHELD_SCAN_COLUMNS = ['id', 'object_name', 'metadata'] as const; + +/** No real row matches it: the withheld-update rule's fail-closed answer. */ +const WITHHELD_DENY_ALL = { id: '__activity_withheld_update_denied__' } as const; + +/** + * [#21388] Whether an activity row's STORED change is an update every one of + * whose keys a reader served `served` is withheld. False for a create or a + * delete (one side is not a record), for a change empty on both sides (empty + * for every reader), and for anything that is not a recorded change. + * Exported for direct testing. + */ +export function isWithheldOnlyUpdate(metadata: Record | null, served: ReadonlySet): boolean { + if (!metadata) return false; + const before = metadata.old; + const after = metadata.new; + if (!isRecord(before) || !isRecord(after)) return false; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + if (keys.size === 0) return false; + for (const key of keys) if (served.has(key)) return false; + return true; +} + +/** + * [#21388] The WHERE that withholds, from one read, every update row whose + * stored change is withheld whole from the reader `servedFor` answers for: + * `null` when the read withholds nothing, `{ id: { $nin: WITHHELD } }` when the + * pre-scan saw every row the read can touch, and `{ id: { $in: KEPT } }` when + * it reached its bound (fail closed: an unjudged row is never served). + * Exported for direct testing. + */ +export async function computeWithheldUpdateFilter( + engine: Pick, + ast: Record, + servedFor: ServedFor, + logger: ActivityRedactionLogger, +): Promise { + // The rows the read would touch, under SYSTEM context (the reader may not be + // served their change; that is what is being decided). The caller's own + // order rides along, so a bounded window is the one the caller pages through. + const orderBy = ast.orderBy; + const candidates = await engine.find(ACTIVITY_OBJECT, { + where: (ast.where as Record | undefined) ?? {}, + fields: [...WITHHELD_SCAN_COLUMNS], + ...(Array.isArray(orderBy) && orderBy.length > 0 ? { orderBy } : {}), + limit: PARENT_GATE_SCAN_LIMIT, + context: { ...SYSTEM_CTX }, + }); + if (!candidates.length) return null; + + const servedSets = new Map | undefined>(); + const withheld: unknown[] = []; + const kept: unknown[] = []; + for (const row of candidates) { + let isWithheld = false; + const object = parseActivityParentObject(row); + if (object) { + if (!servedSets.has(object)) { + const answer = await servedFor(object); + servedSets.set(object, answer === undefined ? undefined : new Set(answer.map(String))); + } + const served = servedSets.get(object); + isWithheld = served !== undefined && isWithheldOnlyUpdate(parseMetadata(row.metadata), served); + } + const id = row.id; + const usable = (typeof id === 'string' || typeof id === 'number') && String(id) !== ''; + // A row is excluded by its stored id, so a withheld row without one + // cannot be excluded at all: deny the read rather than serve it. + if (!usable) { + if (isWithheld) return WITHHELD_DENY_ALL; + continue; + } + (isWithheld ? withheld : kept).push(id); + } + + if (candidates.length >= PARENT_GATE_SCAN_LIMIT) { + logger.warn( + `[audit] ${REDACTION_SEAM}: the withheld-update pre-scan hit the ${PARENT_GATE_SCAN_LIMIT}-row cap; ` + + 'this broad read is answered from the rows it judged (fail-closed) and may omit visible rows — ' + + 'scope the query by object_name and record_id', + ); + return kept.length ? { id: { $in: kept } } : WITHHELD_DENY_ALL; + } + return withheld.length ? { id: { $nin: withheld } } : null; +} + /** * Redact the value-bearing columns of the activity rows one read is about to * hand back, as `context`. Mutates the rows in place (they are the read's own @@ -176,11 +309,14 @@ export async function redactActivityRows( security: ActivityFieldVisibilitySource | undefined, context: unknown, logger?: ActivityRedactionLogger, + /** The read's own answer, when the caller already holds one: the middleware + * shares it with the withheld-update rule, so both judge by ONE answer. */ + served?: ServedFor, ): Promise { const list = (Array.isArray(rows) ? rows : rows ? [rows] : []) as unknown[]; if (list.length === 0) return; // One answer per parent object per read, never one per row. - const servedFor = servedFieldsPerRead(security, context, logger, 'activity field redaction'); + const servedFor = served ?? servedFieldsPerRead(security, context, logger, REDACTION_SEAM); const restricted = new Map>(); /** Is this reader served fewer fields of `object` than the system is? */ const restrictedOn = (object: string, servedSet: Set) => { @@ -257,23 +393,44 @@ const JUDGED_BY = ['object_name', 'metadata'] as const; * Install the `sys_activity` field-redaction middleware. `getSecurity` is * resolved on every read: the security plugin may register after this one. * Inert on an engine without the middleware seam; `AuditPlugin` says so. + * + * [#21388] Before the read runs, the same middleware withholds every update row + * whose stored change is withheld whole from this reader, as a WHERE on all + * four reads (`computeWithheldUpdateFilter`). `AuditPlugin` registers it after + * the read gate, so the gate's parent filter is already in the WHERE its + * pre-scan reads. */ export function installActivityFieldRedaction( - engine: ActivityMiddlewareEngine, + engine: ActivityRedactionEngine, getSecurity: () => ActivityFieldVisibilitySource | undefined, logger: ActivityRedactionLogger, ): void { if (typeof engine.registerMiddleware !== 'function') return; engine.registerMiddleware( async (ctx, next) => { - if ((ctx.operation !== 'find' && ctx.operation !== 'findOne') || !ctx.context || ctx.context.isSystem) { - return next(); + if (!ctx.context || ctx.context.isSystem) return next(); + const security = getSecurity(); + // One answer per read, shared by the row rule and the redaction below. + const servedFor = servedFieldsPerRead(security, ctx.context, logger, REDACTION_SEAM); + if (security && ctx.ast && PARENT_GATE_READ_OPS.has(ctx.operation)) { + try { + const filter = await computeWithheldUpdateFilter(engine, ctx.ast, servedFor, logger); + if (filter) andIntoWhere(ctx, filter); + } catch (err) { + // A pre-scan failure must never fall open into the timing leak. + logger.warn( + `[audit] ${REDACTION_SEAM}: the withheld-update pre-scan failed, denying all ` + + `(${(err as Error)?.message ?? err})`, + ); + andIntoWhere(ctx, WITHHELD_DENY_ALL); + } } + if (ctx.operation !== 'find' && ctx.operation !== 'findOne') return next(); const added = ensureJudgedColumnsProjected(ctx.ast, VALUE_BEARING_COLUMNS, JUDGED_BY); await next(); const list = (Array.isArray(ctx.result) ? ctx.result : ctx.result ? [ctx.result] : []) as unknown[]; try { - await redactActivityRows(list, getSecurity(), ctx.context, logger); + await redactActivityRows(list, security, ctx.context, logger, servedFor); } catch (err) { // A redaction failure must never fall open into a leak. logger.warn( diff --git a/packages/plugins/plugin-audit/src/activity-read-visibility.ts b/packages/plugins/plugin-audit/src/activity-read-visibility.ts index b4b9318afad..2b7a8509f12 100644 --- a/packages/plugins/plugin-audit/src/activity-read-visibility.ts +++ b/packages/plugins/plugin-audit/src/activity-read-visibility.ts @@ -47,6 +47,15 @@ * System-context reads (the audit writer, engine self-reads) and context-less * programmatic calls on bare kernels are not narrowed, as for comments: every * real transport carries a context. + * + * [#21388] One more per-reader ROW rule rides this mechanism: an update row + * whose stored change is withheld whole from the reader is withheld as a row, + * by a WHERE built from a SYSTEM pre-scan on the same four reads, so a list's + * total, its pages and a grouped count agree with the rows served. It judges by + * the security service's field answer, so it lives with the field redaction + * (`activity-field-redaction.ts`, `computeWithheldUpdateFilter`), whose + * middleware `AuditPlugin` registers after this one: its pre-scan reads the + * WHERE this gate has already narrowed. */ import type { CommentAccessEngine, CommentAccessLogger, CommentReadMiddlewareCtx } from './comment-access-hooks.js'; diff --git a/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts b/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts new file mode 100644 index 00000000000..eb99484e462 --- /dev/null +++ b/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21388] An update activity row whose every recorded change is withheld from + * the reader is withheld from that reader as a ROW, on every listing face. + * + * The field redaction (`activity-field-redaction.ts`, #21081) narrows a row's + * recorded change key by key. An update whose every key the reader is withheld + * then reached the reader as a row with an empty change, and its summary, + * actor and timestamp said when the record changed: an org peer read each + * sign-in time of a colleague that way. The ruled rule: + * + * - an update row whose STORED change had keys, every one of which the reader + * is withheld, is withheld from that reader; + * - a row whose stored change was empty for everyone is unaffected; + * - creates and deletes keep their rows; + * - the count and every listing face agree with the rows served. + * + * ## Why a real engine, a real driver and the real plugin + * + * The rule is a WHERE the redaction's middleware ANDs into the read, so whether + * it selects the right rows, and whether `count`, `aggregate`, a page and a + * by-id read agree with `find`, is a question about the Filter Protocol as a + * driver executes it. The rows are written by the real CRUD mirror, stored by a + * real SQLite driver and read back through the real middleware chain mounted by + * `AuditPlugin` at `kernel:ready`. + * + * ## The one stand-in + * + * The security service, answering the two contract members the redaction + * asks, per reader, in the contract's shape. Which declaration produces which + * answer is the security plugin's derivation, pinned on a real boot in the + * dogfood suite (`activity-withheld-update.dogfood.test.ts`). + * + * ⚠️ Disclosure discipline: no test title states a value. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { AuditPlugin } from './audit-plugin.js'; +import { computeWithheldUpdateFilter, isWithheldOnlyUpdate } from './activity-field-redaction.js'; +import { PARENT_GATE_SCAN_LIMIT } from './parent-record-read-gate.js'; + +const ACTIVITY = 'sys_activity'; +/** A record the member reads with two fields withheld. */ +const ITEM = 'awu_item'; +/** A record the member reads with NO field served: every change is withheld. */ +const SEALED = 'awu_sealed'; +const HARNESS_PACKAGE = 'com.objectstack.audit.test.activity-withheld-update'; + +const SYS = { isSystem: true } as const; +/** Withheld `f_admin` and `f_admin2` of the item, and every field of the sealed record. */ +const MEMBER = { userId: 'u_member', tenantId: 'org_1', positions: ['org_member'] }; +/** Served every field: the admin. */ +const ADMIN = { userId: 'u_admin', tenantId: 'org_1', positions: ['org_admin'] }; + +const WITHHELD = new Set(['f_admin', 'f_admin2']); + +const itemObject = { + name: ITEM, + label: 'Withheld Update Item', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + f_open: { name: 'f_open', label: 'Open', type: 'text' as const }, + f_admin: { name: 'f_admin', label: 'Admin One', type: 'text' as const }, + f_admin2: { name: 'f_admin2', label: 'Admin Two', type: 'text' as const }, + f_internal: { name: 'f_internal', label: 'Internal', type: 'text' as const, internal: true }, + }, +}; +const sealedObject = { + name: SEALED, + label: 'Withheld Update Sealed', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + s_admin: { name: 's_admin', label: 'Sealed Admin', type: 'text' as const }, + }, +}; + +type Row = Record; +const meta = (r: Row | undefined) => (typeof r?.metadata === 'string' ? JSON.parse(r.metadata) : null); +const changeKeys = (r: Row | undefined) => { + const m = meta(r); + return [...new Set([...Object.keys(m?.old ?? {}), ...Object.keys(m?.new ?? {})])].sort(); +}; +const ORDER = [{ field: 'timestamp', order: 'asc' as const }, { field: 'id', order: 'asc' as const }]; + +describe('[#21388] an update whose every recorded change is withheld from the reader is withheld as a row', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + const ids: Record = {}; + /** The mirror rows, classified ONCE from the at-rest read. */ + const rowIds: Record = {}; + + const allFields = (object: string): string[] => + Object.keys(((engine as any).getSchema(object)?.fields ?? {}) as Record); + + /** The double: the two contract members, answered per reader. */ + const security = { + async getReadableFields(object: string, context?: any): Promise { + const all = allFields(object); + if (context?.userId === MEMBER.userId) { + if (object === ITEM) return all.filter((f) => !WITHHELD.has(f)); + if (object === SEALED) return []; + } + return all; + }, + async getQueryableFields(object: string, context?: any): Promise { + return security.getReadableFields(object, context); + }, + }; + + const scoped = (object: string, record: string) => ({ object_name: object, record_id: record }); + const read = (context: any, object = ITEM, record = ids.item) => + engine.find(ACTIVITY, { where: scoped(object, record), orderBy: ORDER, context }) as Promise; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use({ + name: 'test.security-double', + version: '0.0.0', + init: async (ctx: any) => ctx.registerService('security', security), + start: async () => {}, + } as any); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject(itemObject as any, HARNESS_PACKAGE); + engine.registry.registerObject(sealedObject as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + + // Every row below is written by the real audit writer's CRUD mirror. + const update = (object: string, id: string, patch: Row) => + engine.update(object, patch, { where: { id }, context: SYS }); + ids.item = (await engine.insert( + ITEM, + { name: 'item', f_open: 'o1', f_admin: 'a1', f_admin2: 'b1', f_internal: 'i1' }, + { context: SYS }, + )).id; + await update(ITEM, ids.item, { f_admin: 'a2', f_admin2: 'b2' }); // withheld whole + await update(ITEM, ids.item, { f_open: 'o2', f_admin: 'a3' }); // mixed + await update(ITEM, ids.item, { f_internal: 'i2' }); // empty for everyone + await update(ITEM, ids.item, { f_open: 'o3' }); // served whole + ids.sealed = (await engine.insert(SEALED, { name: 'sealed', s_admin: 's1' }, { context: SYS })).id; + await update(SEALED, ids.sealed, { s_admin: 's2' }); + + const atRest = await read(SYS); + const idOf = (pick: (r: Row) => boolean) => String(atRest.find(pick)?.id ?? ''); + const keys = (r: Row) => changeKeys(r).join(); + rowIds.created = idOf((r) => r.type === 'created'); + rowIds.withheld = idOf((r) => r.type === 'updated' && keys(r) === 'f_admin,f_admin2'); + rowIds.mixed = idOf((r) => r.type === 'updated' && keys(r) === 'f_admin,f_open' && meta(r).new.f_open === 'o2'); + rowIds.empty = idOf((r) => r.type === 'updated' && keys(r) === ''); + rowIds.served = idOf((r) => r.type === 'updated' && keys(r) === 'f_open' && meta(r).new.f_open === 'o3'); + const sealedRows = (await engine.find(ACTIVITY, { where: { object_name: SEALED }, context: SYS })) as Row[]; + rowIds.sealedCreated = String(sealedRows.find((r) => r.type === 'created')?.id ?? ''); + rowIds.sealedUpdated = String(sealedRows.find((r) => r.type === 'updated')?.id ?? ''); + }, 120_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([kernel.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); + } + }, 30_000); + + // ── the scene ─────────────────────────────────────────────────────────── + + it('control: at rest, the mirror wrote every class of row this file reasons about', async () => { + for (const [name, id] of Object.entries(rowIds)) expect(id, name).toBeTruthy(); + const atRest = await read(SYS); + const byId = (id: string) => atRest.find((r) => r.id === id); + // The empty-for-everyone row really is empty at rest: the writer omits an + // `internal` field from both sides, so this is not a redaction artefact. + expect(meta(byId(rowIds.empty))).toMatchObject({ old: {}, new: {} }); + expect(changeKeys(byId(rowIds.withheld))).toEqual(['f_admin', 'f_admin2']); + expect(atRest).toHaveLength(5); + }); + + // ── the card's three pins ─────────────────────────────────────────────── + + it('the member gets no row for an update whose every recorded key it is withheld', async () => { + const rows = await read(MEMBER); + expect(rows.map((r) => r.id)).not.toContain(rowIds.withheld); + }); + + it('the admin gets that row, with its change', async () => { + const row = (await read(ADMIN)).find((r) => r.id === rowIds.withheld); + expect(row).toBeTruthy(); + expect(changeKeys(row)).toEqual(['f_admin', 'f_admin2']); + }); + + it('a mixed update is still served to the member, with the served key and without the withheld one', async () => { + const row = (await read(MEMBER)).find((r) => r.id === rowIds.mixed); + expect(row).toBeTruthy(); + expect(changeKeys(row)).toEqual(['f_open']); + }); + + // ── what the rule leaves alone ────────────────────────────────────────── + + it('a row whose recorded change was empty for everyone is unaffected', async () => { + expect((await read(MEMBER)).map((r) => r.id)).toContain(rowIds.empty); + }); + + it('a create keeps its row even when every recorded key is withheld', async () => { + // A delete keeps its row by the same shape test (`isWithheldOnlyUpdate` + // below); on a read it is the parent gate's, which serves no row about a + // record that no longer exists. + const rows = (await engine.find(ACTIVITY, { where: { object_name: SEALED }, context: MEMBER })) as Row[]; + expect(rows.map((r) => r.id)).toEqual([rowIds.sealedCreated]); + expect(changeKeys(rows[0])).toEqual([]); + expect(rowIds.sealedUpdated).toBeTruthy(); + }); + + it('the member is served exactly the other rows', async () => { + expect((await read(MEMBER)).map((r) => r.id)).toEqual( + [rowIds.created, rowIds.mixed, rowIds.empty, rowIds.served], + ); + }); + + // ── every listing face agrees with the rows served ────────────────────── + + it('count: the member total agrees with the served rows', async () => { + const rows = await read(MEMBER); + expect(await engine.count(ACTIVITY, { where: scoped(ITEM, ids.item) }, { context: MEMBER })).toBe(rows.length); + expect(await engine.count(ACTIVITY, { where: scoped(ITEM, ids.item) }, { context: ADMIN })).toBe(rows.length + 1); + }); + + it('aggregate: a grouped count agrees with the served rows', async () => { + const groups = await engine.aggregate(ACTIVITY, { + where: scoped(ITEM, ids.item), + groupBy: ['type'], + aggregations: [{ function: 'count', alias: 'n' }], + context: MEMBER, + }); + const byType = Object.fromEntries(groups.map((g: Row) => [g.type, Number(g.n)])); + expect(byType).toEqual({ created: 1, updated: 3 }); + }); + + it('pages: walking the list one row at a time serves the same rows, and the withheld one on no page', async () => { + const walked: string[] = []; + for (let offset = 0; offset < 10; offset++) { + const page = (await engine.find(ACTIVITY, { + where: scoped(ITEM, ids.item), orderBy: ORDER, limit: 1, offset, context: MEMBER, + })) as Row[]; + if (page.length === 0) break; + walked.push(String(page[0].id)); + } + expect(walked).toEqual((await read(MEMBER)).map((r) => String(r.id))); + }); + + it('findOne: the withheld row is absent by id for the member and present for the admin', async () => { + expect(await engine.findOne(ACTIVITY, { where: { id: rowIds.withheld }, context: MEMBER })).toBeNull(); + expect((await engine.findOne(ACTIVITY, { where: { id: rowIds.mixed }, context: MEMBER }))?.id).toBe(rowIds.mixed); + expect((await engine.findOne(ACTIVITY, { where: { id: rowIds.withheld }, context: ADMIN }))?.id).toBe(rowIds.withheld); + }); + + it('a system read is not narrowed', async () => { + expect((await read(SYS)).map((r) => r.id)).toContain(rowIds.withheld); + }); +}); + +describe('[#21388] isWithheldOnlyUpdate reads the stored change', () => { + const served = new Set(['open']); + + it('an update every key of which is withheld', () => { + expect(isWithheldOnlyUpdate({ old: { a: 1 }, new: { a: 2, b: 3 } }, served)).toBe(true); + }); + + it('a key on either side that is served keeps the row', () => { + expect(isWithheldOnlyUpdate({ old: { a: 1, open: 1 }, new: { a: 2 } }, served)).toBe(false); + expect(isWithheldOnlyUpdate({ old: {}, new: { open: 1 } }, served)).toBe(false); + }); + + it('a change empty on both sides, a create, a delete and a non-change are not withheld', () => { + expect(isWithheldOnlyUpdate({ old: {}, new: {} }, served)).toBe(false); + expect(isWithheldOnlyUpdate({ old: null, new: { a: 1 } }, served)).toBe(false); + expect(isWithheldOnlyUpdate({ old: { a: 1 }, new: null }, served)).toBe(false); + expect(isWithheldOnlyUpdate({ channel: 'email' }, served)).toBe(false); + expect(isWithheldOnlyUpdate(null, served)).toBe(false); + }); +}); + +describe('[#21388] computeWithheldUpdateFilter — the pre-scan bound fails closed', () => { + const update = (i: number, key: string) => ({ + id: `r${i}`, + object_name: 'obj', + metadata: JSON.stringify({ old: { [key]: 1 }, new: { [key]: 2 } }), + }); + const engineOf = (rows: Row[]) => ({ find: vi.fn(async () => rows) }); + const servedFor = async () => ['open']; + + it('under the bound: the withheld ids are ANDed out, and nothing else', async () => { + const logger = { warn: vi.fn() }; + const rows = [update(1, 'secret'), update(2, 'open')]; + expect(await computeWithheldUpdateFilter(engineOf(rows), { where: { a: 1 } }, servedFor, logger)) + .toEqual({ id: { $nin: ['r1'] } }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('nothing withheld: no filter at all', async () => { + const logger = { warn: vi.fn() }; + expect(await computeWithheldUpdateFilter(engineOf([update(1, 'open')]), {}, servedFor, logger)).toBeNull(); + }); + + it('at the bound: only the judged, kept rows are served, and that is said', async () => { + const logger = { warn: vi.fn() }; + const rows = Array.from({ length: PARENT_GATE_SCAN_LIMIT }, (_, i) => update(i, i === 0 ? 'secret' : 'open')); + const filter = (await computeWithheldUpdateFilter(engineOf(rows), {}, servedFor, logger)) as Row; + expect(filter.id.$in).toHaveLength(PARENT_GATE_SCAN_LIMIT - 1); + expect(filter.id.$in).not.toContain('r0'); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('the pre-scan reads as the system, in the caller order, within the bound', async () => { + const engine = engineOf([]); + const orderBy = [{ field: 'timestamp', order: 'desc' }]; + await computeWithheldUpdateFilter(engine, { where: { a: 1 }, orderBy }, servedFor, { warn: vi.fn() }); + expect(engine.find).toHaveBeenCalledWith(ACTIVITY, expect.objectContaining({ + where: { a: 1 }, orderBy, limit: PARENT_GATE_SCAN_LIMIT, context: { isSystem: true }, + })); + }); +}); diff --git a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts new file mode 100644 index 00000000000..b5e8b03d585 --- /dev/null +++ b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21388] An org peer is not served the activity rows of a colleague's +// identity updates whose every recorded change it is withheld: no sign-in +// stamp, no failed-sign-in counter. On a real boot, at the HTTP door, on every +// listing face. +// +// ## What the card measured +// +// Since #21237 the identity object's `Admin` field group is withheld from an +// org peer key by key, also inside the activity stream's recorded change. Each +// sign-in still stamped the colleague's identity row with an update the peer +// was served as a row: an empty change, a summary, an actor and a timestamp, +// so the peer read WHEN each sign-in happened. The lockout counter, the +// password-change stamp and the MFA stamp are the same class: updates of +// withheld fields only. +// +// ## The ruled mechanism (triage, direction 1) +// +// The activity read side withholds, from a reader, an update row whose stored +// change had keys and every one of them is withheld from that reader. It is a +// WHERE built by a SYSTEM pre-scan, so the list's `total`, its pages, a by-id +// read and a grouped count all agree with the rows served. A mixed update keeps +// its row with the served key; an admin keeps every row with its change. +// +// Fixtures are synthetic. ⚠️ No test title states a value. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { assertArmed, armedWhen } from './armed.js'; + +const SYS = { isSystem: true } as const; +const DOMAIN = 'withheld-update-21388.verify.test'; +const PASS = 'Withheld!Pass123'; + +/** The declared group, read off the identity object's declaration — never listed here. */ +const GROUP: string[] = Object.entries(SysUser.fields as Record) + .filter(([, field]) => field.group === 'Admin') + .map(([name]) => name) + .sort(); + +/** Object-level activity read, granted to the member and the admin for the activity door. */ +const activityReadSet = PermissionSetSchema.parse({ + name: 'withheld_update_21388_activity_read', + label: 'Activity read (fixture)', + objects: { sys_activity: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }, +}); + +type Row = Record; + +/** The keys an activity row's recorded change (`metadata.old` / `.new`) carries. */ +function changeKeys(row: Row | undefined): string[] { + let md: any = row?.metadata; + if (typeof md === 'string') { + try { md = JSON.parse(md); } catch { md = {}; } + } + return [...new Set([...Object.keys(md?.old ?? {}), ...Object.keys(md?.new ?? {})])].sort(); +} + +describe('[#21388] an org peer is withheld the activity rows of a colleague’s withheld-only identity updates, on every face', () => { + let stack: VerifyStack; + let ql: any; + const token: Record = {}; + const uid: Record = {}; + /** Classified once from the at-rest read: the rows whose change is group keys only, and the mixed row. */ + const withheldIds: string[] = []; + let mixedId = ''; + + const plain = async (object: string, where: Record, limit = 50): Promise => { + const rows = await ql.find(object, { where, limit, context: SYS }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); + }; + const atRest = () => plain('sys_activity', { object_name: 'sys_user', record_id: uid.colleague }, 200); + const filter = () => encodeURIComponent(JSON.stringify({ object_name: 'sys_user', record_id: uid.colleague })); + const list = async (who: string, extra = '') => { + const res = await stack.apiAs(token[who], 'GET', `/data/sys_activity?$filter=${filter()}&$orderby=timestamp asc${extra}`); + const body = (await res.json()) as any; + return { status: res.status, rows: (body.records ?? []) as Row[], total: body.total as number, hasMore: body.hasMore as boolean }; + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack as unknown as Parameters[0], { + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, activityReadSet] }), + extraPlugins: [new AuditPlugin()], + }); + token.admin = await stack.signIn(); // the seeded platform admin + ql = await stack.kernel.getServiceAsync('objectql'); + + const org = await ql.insert('sys_organization', { name: 'Withheld Update Fixture', slug: 'withheld-update-21388' }, { context: SYS }); + const orgId = String(org.id); + const memberOf = async (userId: string, role: string) => { + const existing = await plain('sys_member', { user_id: userId }, 5); + if (existing.length > 0) { + await ql.update('sys_member', { id: existing[0].id, organization_id: orgId, role }, { context: SYS }); + } else { + await ql.insert('sys_member', { user_id: userId, organization_id: orgId, role }, { context: SYS }); + } + for (const s of await plain('sys_session', { user_id: userId }, 20)) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } + }; + uid.admin = String((await plain('sys_user', { email: 'admin@objectos.ai' }, 1))[0]?.id ?? ''); + await memberOf(uid.admin, 'member'); + for (const who of ['colleague', 'member']) { + const email = `${who}@${DOMAIN}`; + token[who] = await stack.signUp(email, PASS, `Withheld Update ${who}`); + uid[who] = String((await plain('sys_user', { email }, 1))[0]?.id ?? ''); + await memberOf(uid[who], 'member'); + } + const [activitySet] = await plain('sys_permission_set', { name: activityReadSet.name }, 1); + expect(activitySet, 'fixture permission set seeded').toBeTruthy(); + for (const who of ['member', 'admin']) { + await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set_id: activitySet.id }, { context: SYS }); + } + + // The colleague signs in twice through the auth door (two sign-in stamps), + // fails one sign-in with lockout accounting on (a counter bump), and is + // renamed together with a group field (the mixed control). + await stack.signIn(`colleague@${DOMAIN}`, PASS); + await stack.signIn(`colleague@${DOMAIN}`, PASS); + const auth = await stack.kernel.getServiceAsync('auth'); + auth.applyConfigPatch({ lockoutThreshold: 5, lockoutDurationMinutes: 40 }); + const failed = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `colleague@${DOMAIN}`, password: 'Not-The-Pass-000' }), + }); + expect(failed.ok, 'the failed sign-in is refused').toBe(false); + await ql.update('sys_user', { id: uid.colleague, name: 'Withheld Update Renamed', ban_reason: 'SYNTHETICREASON21388' }, { context: SYS }); + + for (const row of await atRest()) { + const keys = changeKeys(row); + if (row.type !== 'updated' || keys.length === 0) continue; + if (keys.every((k) => GROUP.includes(k))) withheldIds.push(String(row.id)); + else if (keys.includes('name') && keys.some((k) => GROUP.includes(k))) mixedId = String(row.id); + } + + await assertArmed([ + armedWhen({ + control: 'the declared group is non-empty and names the sign-in stamp and the lockout counter', + disarmedBy: 'a renamed or emptied group would classify no row as withheld, and every absence below would pass over nothing', + observe: async () => ({ size: GROUP.length, stamp: GROUP.includes('last_login_at'), counter: GROUP.includes('failed_login_count') }), + armed: (o) => o.size > 0 && o.stamp && o.counter, + describe: (o) => `group of ${o.size}; stamp in it: ${o.stamp}; counter in it: ${o.counter}`, + }), + armedWhen({ + control: 'at rest, the mirror wrote the two sign-in stamps, the counter bump and the mixed rename', + disarmedBy: 'a fixture write that never landed would let "no such row is served" pass on rows that do not exist', + observe: async () => { + const rows = await atRest(); + const keysOf = (id: string) => changeKeys(rows.find((r) => String(r.id) === id)); + return { + stamps: withheldIds.filter((id) => keysOf(id).includes('last_login_at')).length, + counter: withheldIds.filter((id) => keysOf(id).includes('failed_login_count')).length, + mixed: mixedId !== '', + }; + }, + armed: (o) => o.stamps >= 2 && o.counter >= 1 && o.mixed, + describe: (o) => `stamp rows: ${o.stamps}; counter rows: ${o.counter}; mixed row: ${o.mixed}`, + }), + armedWhen({ + control: 'the member really reads the colleague row (the org-peer visibility policy applies)', + disarmedBy: 'a member who could not read the record would be served no activity about it at all, and every absence below would pass on the parent gate', + observe: async () => (await stack.apiAs(token.member, 'GET', `/data/sys_user/${uid.colleague}`)).status, + armed: (status) => status === 200, + describe: (status) => `status ${status}`, + }), + ]); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('list: the member is served no withheld-only update row, and the total agrees with the rows', async () => { + const { status, rows, total, hasMore } = await list('member'); + expect(status).toBe(200); + expect(rows.length).toBeGreaterThan(0); + expect(rows.map((r) => String(r.id)).filter((id) => withheldIds.includes(id))).toEqual([]); + expect(total).toBe(rows.length); + expect(hasMore).toBe(false); + }); + + it('list: the mixed update is still served to the member, with the served key and no group key', async () => { + const row = (await list('member')).rows.find((r) => String(r.id) === mixedId); + expect(row, 'mixed row served').toBeTruthy(); + expect(changeKeys(row)).toEqual(['name']); + }); + + it('pages: one row per page, the member walks exactly the listed rows, and every page reports the same total', async () => { + const full = await list('member'); + const walked: string[] = []; + for (let skip = 0; skip < full.rows.length + 2; skip++) { + const page = await list('member', `&$top=1&$skip=${skip}`); + expect(page.total).toBe(full.total); + if (page.rows.length === 0) break; + walked.push(String(page.rows[0].id)); + expect(page.hasMore).toBe(skip + 1 < full.total); + } + expect(walked).toEqual(full.rows.map((r) => String(r.id))); + }); + + it('by id: each withheld-only row answers 404 to the member', async () => { + for (const id of withheldIds) { + expect((await stack.apiAs(token.member, 'GET', `/data/sys_activity/${id}`)).status, id).toBe(404); + } + expect((await stack.apiAs(token.member, 'GET', `/data/sys_activity/${mixedId}`)).status).toBe(200); + }); + + it('query: a filtered query and a grouped count agree with the served rows', async () => { + const served = await list('member'); + const where = { object_name: 'sys_user', record_id: uid.colleague }; + const q = await stack.apiAs(token.member, 'POST', '/data/sys_activity/query', { where }); + expect(q.status).toBe(200); + const qb = (await q.json()) as any; + expect((qb.records ?? []).map((r: Row) => String(r.id)).sort()).toEqual(served.rows.map((r) => String(r.id)).sort()); + const g = await stack.apiAs(token.member, 'POST', '/data/sys_activity/query', { + where, groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n' }], + }); + expect(g.status).toBe(200); + const groups = ((await g.json()) as any).records ?? []; + expect(groups.reduce((sum: number, row: Row) => sum + Number(row.n), 0)).toBe(served.total); + }); + + it('the admin keeps every row, each withheld-only row with its recorded change', async () => { + const admin = await list('admin'); + const member = await list('member'); + expect(admin.status).toBe(200); + const ids = admin.rows.map((r) => String(r.id)); + for (const id of withheldIds) expect(ids, id).toContain(id); + for (const row of admin.rows.filter((r) => withheldIds.includes(String(r.id)))) { + expect(changeKeys(row).length).toBeGreaterThan(0); + expect(changeKeys(row).every((k) => GROUP.includes(k))).toBe(true); + } + expect(admin.total).toBe(member.total + withheldIds.length); + }); +});