diff --git a/packages/agent/src/audit-trail/README.md b/packages/agent/src/audit-trail/README.md index 2684d8b535..badd18c4f8 100644 --- a/packages/agent/src/audit-trail/README.md +++ b/packages/agent/src/audit-trail/README.md @@ -190,12 +190,48 @@ no longer exists, so this only refuses a still-existing, out-of-scope id: once a deleted, anyone who can read the collection can see its history, reconstructed state, or correlated operations, scope aside — inspecting what was deleted is much of the point of an audit trail. -One exception: on the per-record history route, a `delete` row's `previousValues` is the record's -full last known state — if that state itself would have failed the caller's scope (e.g. it belonged -to a team the caller isn't scoped to), `previousValues` is withheld from that row (replaced with -`{}`) while the row itself — that a deletion happened, by whom and when — stays visible. This -doesn't yet extend to the `/state` route's reconstructed value for the same case; closing that -consistently is a separate, larger decision. +One exception: once a record is gone, the values its rows captured while it existed are still tested +against the caller's scope. A `delete` row's `previousValues` is the record's full last known state — +if that state itself would have failed the scope (e.g. it belonged to a team the caller isn't scoped +to), it is withheld (replaced with `{}`) while the row itself — that a deletion happened, by whom and +when — stays visible. **Each side of a row is tested against its own values**, so an `update` keeps +the side that is in scope and loses the one that isn't. `action` / `action_failed` rows hold a +submitted form and a result summary rather than column values, so the scope doesn't apply to them. + +The same test covers **every route that serves those values**: the history route, the two +correlation lookups, and `/state`, whose whole answer is the reconstruction — it returns +`{ "data": null }` when the reconstructed record fails the scope, rather than handing back through +one route what another withheld. + +The record is re-checked once the audit rows are in hand, on all three routes. The audit trail lives +in its own database, often its own engine, so no single snapshot spans the permission check and the +audit read; a record deleted in between is treated as a request starting a moment later would have +treated it, and one moved out of the caller's scope in between is refused. The extra read only +happens for a scoped caller — with no scope there is nothing to withhold. + +That test only runs when the snapshot can actually answer it. The capture keeps the writable columns +(plus the packed record id), so a scope reaching for anything else — a read-only column, a relation, +a field stored redacted — has no honest answer in the snapshot and the values are withheld rather +than matched against a missing key: absent is not the same as passing. + +Primary keys are the exception, read back from the row's own id — but only for the side that id +speaks for. A row is filed under the identity the record ended up with, so its id answers for a +`create`, a `delete` and the new side of an `update`, never for what an update moved away from. A +key the snapshot never carried is read-only and cannot have moved, so it is filled on either side; a +key the trail **redacts** is writable and can have moved, so it is filled only where the row's id is +authoritative. + +What that costs depends on what the row holds, because only a confirmed `update` stores a diff: + +- **a confirmed `update`** carries a column only when it changed, so a redacted primary key is + present exactly when the key moved. The previous side is withheld on real moves and nowhere else. +- **a pending `update`** carries the whole writable column set, so a redacted primary key is present + whether or not the key moved. Its previous side is withheld either way, until the row is confirmed. +- **`/state`** reconstructs a gone record from a `delete` row, which also carries the whole column + set — so any gone record whose writable primary key is redacted is withheld there, move or no move. + +Carrying the id the key moved from is what would buy that precision back; until then the loss is +values withheld from a caller entitled to them, never the reverse. ### `GET /forest/_audit-trail/{collection}/{recordId}` — per-record history diff --git a/packages/agent/src/audit-trail/instrument.ts b/packages/agent/src/audit-trail/instrument.ts index 44384815d6..79572c28f5 100644 --- a/packages/agent/src/audit-trail/instrument.ts +++ b/packages/agent/src/audit-trail/instrument.ts @@ -221,7 +221,12 @@ const redactValues = ( if (!redactedFields.length) return values; const result = { ...values }; - for (const field of redactedFields) if (field in result) result[field] = REDACTED; + + // Own properties only: `'toString' in result` is true of every object, so a `redact` config + // naming an `Object.prototype` member would write a placeholder for a column the row never had. + for (const field of redactedFields) { + if (Object.prototype.hasOwnProperty.call(result, field)) result[field] = REDACTED; + } return result; }; diff --git a/packages/agent/src/audit-trail/record-visibility.ts b/packages/agent/src/audit-trail/record-visibility.ts new file mode 100644 index 0000000000..e759e6ff28 --- /dev/null +++ b/packages/agent/src/audit-trail/record-visibility.ts @@ -0,0 +1,94 @@ +import type { Collection, ConditionTree } from '@forestadmin/datasource-toolkit'; +import type { Context } from 'koa'; + +import { + ConditionTreeFactory, + PaginatedFilter, + Projection, + SchemaUtils, +} from '@forestadmin/datasource-toolkit'; + +import IdUtils from '../utils/id'; +import QueryStringParser from '../utils/query-string'; + +export async function recordExists( + collection: Collection, + packedId: string, + context: Context, + permissionScope: ConditionTree | null, +): Promise { + const id = IdUtils.unpackId(collection.schema, packedId); + const filter = new PaginatedFilter({ + conditionTree: ConditionTreeFactory.intersect( + ConditionTreeFactory.matchIds(collection.schema, [id]), + permissionScope, + ), + }); + + const records = await collection.list( + QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }), + filter, + new Projection(...SchemaUtils.getPrimaryKeys(collection.schema)), + ); + + return records.length > 0; +} + +export type RecordVisibility = { + /** False → the id still exists and fails the permission scope; the caller must be denied entirely (404). */ + visible: boolean; + /** + * True once the id no longer exists at all, under any scope. `visible` is then true for a + * different reason than "the record matches the scope": there is nothing left to check + * existence against. It doesn't mean every value the audit trail holds for that record is safe + * to hand back to this caller — a scoped-down field value captured while the record still + * existed can still fall outside the caller's permission scope, and callers should check that separately + * (e.g. by re-evaluating the permission scope's `ConditionTree` against a delete row's `previousValues`) + * before deciding how much of the record's captured data to surface. + */ + goneEntirely: boolean; +}; + +// A record-level permission scope can't be evaluated against a record that no longer exists, so a caller whose +// access is scoped down is only denied when the id currently exists and fails that permission scope — once it's +// genuinely gone there is nothing left to scope against, and showing that it existed (including that +// it was deleted, by whom and when) is much of the point of an audit trail. +// The caller passes the permission scope it will also withhold with, rather than this reading its +// own: two lookups can disagree if the scope cache turns over between them, and the answer here +// decides what that scope is then applied to. +export default async function checkRecordVisibility( + collection: Collection, + packedId: string, + context: Context, + permissionScope: ConditionTree | null, +): Promise { + if (!permissionScope) return { visible: true, goneEntirely: false }; + + if (await recordExists(collection, packedId, context, permissionScope)) { + return { visible: true, goneEntirely: false }; + } + + const existsOutsidePermissionScope = await recordExists(collection, packedId, context, null); + + return { visible: !existsOutsidePermissionScope, goneEntirely: !existsOutsidePermissionScope }; +} + +/** + * The record can be deleted — or moved out of the caller's permission scope — between the check + * that authorized the request and the audit read that answers it: the audit trail lives in its own + * database, often its own engine, so no single snapshot spans both. Re-reads once the rows are in + * hand, and returns null when there is nothing to re-read: a caller with no scope has nothing to + * withhold, and a record already gone at the first check has already had the withholding applied — + * an id recreated in the meantime would only widen what is served, never what is hidden. + */ +export async function recheckRecordVisibility( + collection: Collection, + packedId: string, + context: Context, + permissionScope: ConditionTree | null, + wasGoneEntirely: boolean, +): Promise { + if (!permissionScope || wasGoneEntirely) return null; + + return checkRecordVisibility(collection, packedId, context, permissionScope); +} diff --git a/packages/agent/src/audit-trail/scope.ts b/packages/agent/src/audit-trail/scope.ts deleted file mode 100644 index 9f8a68528c..0000000000 --- a/packages/agent/src/audit-trail/scope.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { ForestAdminHttpDriverServices } from '../services'; -import type { Collection, ConditionTree } from '@forestadmin/datasource-toolkit'; -import type { Context } from 'koa'; - -import { - ConditionTreeFactory, - PaginatedFilter, - Projection, - SchemaUtils, -} from '@forestadmin/datasource-toolkit'; - -import IdUtils from '../utils/id'; -import QueryStringParser from '../utils/query-string'; - -export async function recordExists( - collection: Collection, - packedId: string, - context: Context, - scope: ConditionTree | null, -): Promise { - const id = IdUtils.unpackId(collection.schema, packedId); - const filter = new PaginatedFilter({ - conditionTree: ConditionTreeFactory.intersect( - ConditionTreeFactory.matchIds(collection.schema, [id]), - scope, - ), - }); - - const records = await collection.list( - QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }), - filter, - new Projection(...SchemaUtils.getPrimaryKeys(collection.schema)), - ); - - return records.length > 0; -} - -export type RecordVisibility = { - /** False → the id still exists and fails the scope; the caller must be denied entirely (404). */ - visible: boolean; - /** - * True once the id no longer exists at all, under any scope. `visible` is then true for a - * different reason than "the record matches the scope": there is nothing left to check - * existence against. It doesn't mean every value the audit trail holds for that record is safe - * to hand back to this caller — a scoped-down field value captured while the record still - * existed can still fall outside the caller's scope, and callers should check that separately - * (e.g. by re-evaluating the scope's `ConditionTree` against a delete row's `previousValues`) - * before deciding how much of the record's captured data to surface. - */ - goneEntirely: boolean; -}; - -// A record-level scope can't be evaluated against a record that no longer exists, so a caller whose -// access is scoped down is only denied when the id currently exists and fails that scope — once it's -// genuinely gone there is nothing left to scope against, and showing that it existed (including that -// it was deleted, by whom and when) is much of the point of an audit trail. -export default async function checkRecordVisibility( - services: ForestAdminHttpDriverServices, - collection: Collection, - packedId: string, - context: Context, -): Promise { - const scope = await services.authorization.getScope(collection, context); - - if (!scope) return { visible: true, goneEntirely: false }; - - if (await recordExists(collection, packedId, context, scope)) { - return { visible: true, goneEntirely: false }; - } - - const existsOutsideScope = await recordExists(collection, packedId, context, null); - - return { visible: !existsOutsideScope, goneEntirely: !existsOutsideScope }; -} diff --git a/packages/agent/src/audit-trail/withhold.ts b/packages/agent/src/audit-trail/withhold.ts new file mode 100644 index 0000000000..60ac08d489 --- /dev/null +++ b/packages/agent/src/audit-trail/withhold.ts @@ -0,0 +1,166 @@ +import type { AuditRecord } from './types'; +import type { Collection, ConditionTree, Logger } from '@forestadmin/datasource-toolkit'; + +import { SchemaUtils } from '@forestadmin/datasource-toolkit'; + +import { REDACTED } from './instrument'; +import IdUtils from '../utils/id'; + +export type Withholding = { + collection: Collection; + permissionScope: ConditionTree; + timezone: string; + logger?: Logger; +}; + +/** + * Whether the row's own packed id answers for the side being tested. + * + * `false` for the previous side of an `update`: the capture files a row under the identity the + * record ended up with, so on an update that moved the primary key the row's id describes the new + * state only. It is also `false` for `/state`, whose reconstruction can sit on the far side of a + * move the route cannot see. + */ +type IdAnswersForSide = boolean; + +// Only a snapshot that answers every field the permission scope asks about is worth matching. The +// capture keeps the writable columns, so a permission scope reaching for anything else — a +// read-only column, a relation — reads `undefined` there and would answer for a value the row never +// held: `status != 'private'` matches on the missing key and releases it. +// +// The snapshot reaching this point has already had its unanswerable fields removed, so a missing key +// is the whole test — see `answerableSnapshot`. +// +// Own properties only: `'toString' in snapshot` is true of every object, so a permission scope on a +// column named after one of `Object.prototype`'s members would otherwise resolve against the +// prototype. +export function permissionScopeAccepts( + snapshot: Record, + { collection, permissionScope, timezone }: Withholding, +): boolean { + const values = snapshot ?? {}; + const answered = permissionScope.projection.every(field => + Object.prototype.hasOwnProperty.call(values, field), + ); + + return answered && permissionScope.match(values, collection, timezone); +} + +/** + * What this side of a row can honestly answer about. + * + * A redacted value answers nothing, so it is dropped rather than matched against the placeholder: + * the field then reads as unanswered, which withholds. + * + * The packed id then fills in the primary keys the snapshot cannot supply, but only where the row's + * id is authoritative for this side: + * + * - **absent** means the key is read-only — the capture keeps every writable column — and a + * read-only key cannot move, so the row's id answers for it on either side. Always filled. + * - **redacted** means the key is writable, so it can have moved. The row's id then answers only for + * the side it was filed under. Filling the other side would judge the previous state by an id it + * never had, and a permission scope on the new id would release values captured under the old one. + * + * Declining to fill over-withholds an update's previous side whenever its primary key is redacted — + * the same "absent is not the same as passing" direction as everything else here. Carrying the + * id it moved from is what would remove that cost; see PRD-1321. + */ +function answerableSnapshot( + values: Record, + decoded: Record | null, + idAnswersForSide: IdAnswersForSide, +): Record { + const entries = Object.entries(values ?? {}); + const answered = Object.fromEntries(entries.filter(([, value]) => value !== REDACTED)); + + if (!decoded) return answered; + + const redacted = new Set( + entries.filter(([, value]) => value === REDACTED).map(([field]) => field), + ); + const fill = Object.fromEntries( + Object.entries(decoded).filter(([field]) => idAnswersForSide || !redacted.has(field)), + ); + + return { ...fill, ...answered }; +} + +/** + * The row's packed id as a `{ key: value }` map, or null when there is none to read — no id at all + * (a pending create), or one the current schema can no longer unpack. + * + * Absence is `null`, not falsiness: `''` is a legal value for a string primary key, and it packs + * and unpacks like any other. Reading it as "no id" would leave the key unanswered and blank the + * values of a record whose id genuinely is the empty string. + */ +export function decodePrimaryKeys( + packedId: string | null, + collection: Collection, + logger?: Logger, +): Record | null { + if (packedId === null || packedId === undefined) return null; + + try { + const names = SchemaUtils.getPrimaryKeys(collection.schema); + const ids = IdUtils.unpackId(collection.schema, packedId); + + return Object.fromEntries(names.map((name, index) => [name, ids[index]])); + } catch (error) { + // The id the row was filed under no longer fits the collection's key: a renamed or retyped + // primary key, or a corrupted row. Withholding is the safe answer and the caller gets it + // either way, but a live audit read failing to decode its own ids is worth saying out loud. + logger?.('Warn', `Audit trail: cannot unpack record id "${packedId}" (${error.message})`); + + return null; + } +} + +/** The snapshot a permission scope should be evaluated against, for one side of one row. */ +export function snapshotFor( + values: Record, + packedId: string | null, + { collection, logger }: Pick, + idAnswersForSide: IdAnswersForSide = true, +): Record { + return answerableSnapshot( + values, + decodePrimaryKeys(packedId, collection, logger), + idAnswersForSide, + ); +} + +/** + * Blanks the captured values a caller's record-level permission scope does not cover, keeping the row itself: + * that it happened, by whom and when stays visible either way. + * + * Each side is tested against its own snapshot. An `update` carries a partial diff, so a permission scope on a + * column it never touched is simply unanswerable there and withheld by the same rule as everything + * else — which is why this needs no special case beyond `action` rows, whose two columns hold a + * submitted form and a result summary rather than column values. + */ +export default function withholdOutsidePermissionScope( + entries: AuditRecord[], + withholding: Withholding, +): AuditRecord[] { + return entries.map(entry => { + if (entry.operation === 'action' || entry.operation === 'action_failed') return entry; + + // Decoded once per row rather than once per side: the two sides read the same id, and a row + // whose id no longer decodes should say so once. + const decoded = decodePrimaryKeys(entry.recordId, withholding.collection, withholding.logger); + + const side = (values: Record, idAnswersForSide: IdAnswersForSide) => + permissionScopeAccepts(answerableSnapshot(values, decoded, idAnswersForSide), withholding) + ? values ?? {} + : {}; + + return { + ...entry, + // The row is filed under the identity the record ended up with, so its id answers for the + // new side of an update and for a create or a delete — never for what an update moved away + // from. + previousValues: side(entry.previousValues, entry.operation !== 'update'), + newValues: side(entry.newValues, true), + }; + }); +} diff --git a/packages/agent/src/routes/access/audit-trail-correlation.ts b/packages/agent/src/routes/access/audit-trail-correlation.ts index f4b3b2781f..17aef48285 100644 --- a/packages/agent/src/routes/access/audit-trail-correlation.ts +++ b/packages/agent/src/routes/access/audit-trail-correlation.ts @@ -1,15 +1,28 @@ +import type { AuditRecord } from '../../audit-trail'; import type { ForestAdminHttpDriverServices } from '../../services'; import type { AgentOptionsWithDefaults } from '../../types'; -import type { DataSource } from '@forestadmin/datasource-toolkit'; +import type { Collection, ConditionTree, DataSource } from '@forestadmin/datasource-toolkit'; import type Router from '@koa/router'; import type { Context } from 'koa'; import { ValidationError } from '@forestadmin/datasource-toolkit'; -import checkRecordVisibility from '../../audit-trail/scope'; +import checkRecordVisibility, { + recheckRecordVisibility, +} from '../../audit-trail/record-visibility'; +import withholdOutsidePermissionScope from '../../audit-trail/withhold'; import { HttpCode, RouteType } from '../../types'; +import QueryStringParser from '../../utils/query-string'; import BaseRoute from '../base-route'; +type Target = { + collection: string; + recordId: string; + collectionObject: Collection; + permissionScope: ConditionTree | null; + goneEntirely: boolean; +}; + export default class AuditTrailCorrelationRoute extends BaseRoute { readonly type = RouteType.PrivateRoute; @@ -43,7 +56,10 @@ export default class AuditTrailCorrelationRoute extends BaseRoute { correlationKey: context.params.correlationKey, }); - context.response.body = { data: history }; + const data = await this.withhold(history, target, context); + if (!data) return; + + context.response.body = { data }; } public async handleBatch(context: Context): Promise { @@ -58,15 +74,56 @@ export default class AuditTrailCorrelationRoute extends BaseRoute { ? await store.listByCorrelations({ collection, recordId, correlationKeys }) : []; - context.response.body = { data: history }; + const data = await this.withhold(history, target, context); + if (!data) return; + + context.response.body = { data }; + } + + // Same rule as the per-record history route: these routes return the same rows, so a gone + // record's captured values are tested against the caller's permission scope here too. Without it the + // values the history route withholds come back through a correlation lookup. + // The record can go away — or move out of the caller's permission scope — while the audit read is + // in flight, so the decision is taken again on the way out rather than reused from the check that + // authorized the request. Returns null once it has issued the 404. + private async withhold( + entries: AuditRecord[], + target: Target, + context: Context, + ): Promise { + // An empty answer has nothing to withhold, so it does not earn a second read of the record. + if (!entries.length) return entries; + + const after = await recheckRecordVisibility( + target.collectionObject, + target.recordId, + context, + target.permissionScope, + target.goneEntirely, + ); + + if (after && !after.visible) { + context.throw(HttpCode.NotFound, 'Record does not exists'); + + return null; + } + + const gone = after ? after.goneEntirely : target.goneEntirely; + + if (!target.permissionScope || !gone) return entries; + + return withholdOutsidePermissionScope(entries, { + collection: target.collectionObject, + permissionScope: target.permissionScope, + timezone: QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }).timezone, + logger: this.options.logger, + }); } - // Returns null (after issuing the 404) when a configured record-level scope excludes the id — + // Returns null (after issuing the 404) when a configured record-level permission scope excludes the id — // same rule as the per-collection route: a scope can't be evaluated retroactively for a // now-deleted record, so a scoped caller cannot look up correlations for an out-of-scope id. - private async assertRecordReadable( - context: Context, - ): Promise<{ collection: string; recordId: string } | null> { + private async assertRecordReadable(context: Context): Promise { const query = context.request.query as Record; const body = (context.request.body ?? {}) as Record; const collectionName = (query.collection ?? body.collection)?.toString(); @@ -78,7 +135,13 @@ export default class AuditTrailCorrelationRoute extends BaseRoute { const collection = this.dataSource.getCollection(collectionName); await this.services.authorization.assertCanRead(context, collectionName); - const { visible } = await checkRecordVisibility(this.services, collection, recordId, context); + const permissionScope = await this.services.authorization.getScope(collection, context); + const { visible, goneEntirely } = await checkRecordVisibility( + collection, + recordId, + context, + permissionScope, + ); if (!visible) { context.throw(HttpCode.NotFound, 'Record does not exists'); @@ -86,7 +149,13 @@ export default class AuditTrailCorrelationRoute extends BaseRoute { return null; } - return { collection: collectionName, recordId }; + return { + collection: collectionName, + recordId, + collectionObject: collection, + permissionScope, + goneEntirely, + }; } // Body array (POST) takes precedence over the comma-separated query param (GET). diff --git a/packages/agent/src/routes/access/audit-trail.ts b/packages/agent/src/routes/access/audit-trail.ts index 383cd1718d..6742019fcf 100644 --- a/packages/agent/src/routes/access/audit-trail.ts +++ b/packages/agent/src/routes/access/audit-trail.ts @@ -13,7 +13,14 @@ import { import { DateTime } from 'luxon'; import { revertRecord } from '../../audit-trail'; -import checkRecordVisibility, { recordExists } from '../../audit-trail/scope'; +import checkRecordVisibility, { + recheckRecordVisibility, + recordExists, +} from '../../audit-trail/record-visibility'; +import withholdOutsidePermissionScope, { + permissionScopeAccepts, + snapshotFor, +} from '../../audit-trail/withhold'; import { HttpCode } from '../../types'; import IdUtils from '../../utils/id'; import QueryStringParser from '../../utils/query-string'; @@ -44,12 +51,12 @@ export default class AuditTrailRoute extends CollectionRoute { public async handleHistory(context: Context): Promise { await this.services.authorization.assertCanRead(context, this.collection.name); - const scope = await this.services.authorization.getScope(this.collection, context); + const permissionScope = await this.services.authorization.getScope(this.collection, context); const { visible, goneEntirely } = await checkRecordVisibility( - this.services, this.collection, context.params.id, context, + permissionScope, ); if (!visible) { @@ -87,13 +94,34 @@ export default class AuditTrailRoute extends CollectionRoute { isFirstFetch ? store.listDistinctUsers(filters) : undefined, ]); - // A genuinely deleted record bypasses the scope check above — there's nothing left to check + // The record can be deleted — or moved out of the caller's permission scope — between the check above and + // the audit read: the audit trail lives in its own database, often its own engine, so no single + // snapshot spans both. Ask again once the rows are in hand and let that answer decide, so a + // record that went away mid-request is treated as the request starting a moment later would + // have treated it. Only for a scoped caller: with no permission scope there is nothing to withhold. + const after = await recheckRecordVisibility( + this.collection, + context.params.id, + context, + permissionScope, + goneEntirely, + ); + + if (after && !after.visible) { + context.throw(HttpCode.NotFound, 'Record does not exists'); + + return; + } + + const gone = after ? after.goneEntirely : goneEntirely; + + // A genuinely deleted record bypasses the permission-scope check above — there's nothing left to check // existence against — but create/update/delete rows still carry captured column values from - // when the record existed. If those values themselves would have failed the caller's scope, + // when the record existed. If those values themselves would have failed the caller's permission scope, // withhold them while still surfacing that the row happened, by whom and when: that part // stays visible regardless. const data = - scope && goneEntirely ? this.withholdOutOfScopeValues(rawData, scope, context) : rawData; + permissionScope && gone ? this.withhold(rawData, permissionScope, context) : rawData; context.response.body = { data, @@ -101,38 +129,16 @@ export default class AuditTrailRoute extends CollectionRoute { }; } - private withholdOutOfScopeValues( + private withhold( entries: AuditRecord[], - scope: ConditionTree, + permissionScope: ConditionTree, context: Context, ): AuditRecord[] { - const { timezone } = QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }); - - return entries.map(entry => { - // `delete`'s previousValues and `create`'s newValues both capture every writable column - // (see instrument.ts's pickColumns), so the scope can be evaluated against them directly. - if (entry.operation === 'delete') { - if (scope.match(entry.previousValues, this.collection, timezone)) return entry; - - return { ...entry, previousValues: {} }; - } - - if (entry.operation === 'create') { - if (scope.match(entry.newValues, this.collection, timezone)) return entry; - - return { ...entry, newValues: {} }; - } - - // `update`'s previousValues/newValues are a partial diff (only the columns that changed) — - // a scope condition referencing a column this particular update didn't touch can't be - // evaluated reliably against it, so withhold unconditionally rather than risk a false - // negative. `action`/`action_failed` rows hold a submitted form and a result summary, not - // column values, so the scope doesn't apply to them at all. - if (entry.operation === 'update') { - return { ...entry, previousValues: {}, newValues: {} }; - } - - return entry; + return withholdOutsidePermissionScope(entries, { + collection: this.collection, + permissionScope, + timezone: QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }).timezone, + logger: this.options.logger, }); } @@ -142,14 +148,16 @@ export default class AuditTrailRoute extends CollectionRoute { const at = AuditTrailRoute.parseAt(context); const auditedColumns = AuditTrailRoute.auditedColumns(this.collection.schema); - const scope = await this.services.authorization.getScope(this.collection, context); - const current = await this.fetchCurrentRecord(context, auditedColumns, scope); - - // A missing current record is ambiguous (deleted vs. out-of-scope) once a scope applies; only - // deny when the id still exists outside that scope. If it's genuinely gone there's nothing - // left to evaluate the scope against, so the revert walk below proceeds — including, possibly, - // surfacing a delete entry's snapshot, which is the point once nothing exists to protect. - if (!current && scope) { + const permissionScope = await this.services.authorization.getScope(this.collection, context); + const current = await this.fetchCurrentRecord(context, auditedColumns, permissionScope); + + // A missing current record is ambiguous (deleted vs. out-of-scope) once a permission scope applies; only + // deny when the id still exists outside that scope. If it's genuinely gone the revert walk + // below proceeds — the reconstruction is then tested against the scope in its own right, since + // this route hands back nothing but those values. + let goneEntirely = false; + + if (!current && permissionScope) { const stillExists = await recordExists(this.collection, context.params.id, context, null); if (stillExists) { @@ -157,6 +165,8 @@ export default class AuditTrailRoute extends CollectionRoute { return; } + + goneEntirely = true; } const { store } = this.options.auditTrail; @@ -167,6 +177,25 @@ export default class AuditTrailRoute extends CollectionRoute { order: 'desc', }); + // Same re-read as the history route: the record can be deleted — or moved out of the caller's + // permission scope — while the audit read is in flight, and without asking again the gate below + // never runs for a record that was still there at the first check. + const after = await recheckRecordVisibility( + this.collection, + context.params.id, + context, + permissionScope, + goneEntirely, + ); + + if (after && !after.visible) { + context.throw(HttpCode.NotFound, 'Record did not exist at this timestamp'); + + return; + } + + const goneNow = after ? after.goneEntirely : goneEntirely; + // `startTimestamp` is an inclusive lower bound, so an entry timestamped exactly `at` comes // back too — but the record already reflects that entry's change at instant `at`, so it must // be kept rather than reverted (which would wrongly return the state just *before* it). @@ -191,7 +220,41 @@ export default class AuditTrailRoute extends CollectionRoute { const state = revertRecord(current, entries as Parameters[1]); - if (!state) context.throw(HttpCode.NotFound, 'Record did not exist at this timestamp'); + if (!state) { + context.throw(HttpCode.NotFound, 'Record did not exist at this timestamp'); + + return; + } + + // The history route withholds a gone record's captured values from a caller whose permission scope they + // fail; this route is nothing but those values, reassembled, so the same test applies to the + // reconstruction. Without it the withheld values are one request away for the same caller. + // A reconstruction the permission scope cannot answer — a scope on a column the capture never + // kept — withholds too: absent is not the same as passing, here as everywhere else. The packed + // id is merged in first for the same reason it is on a row: a read-only primary key never lands + // in the capture, so a scope on the id would blank the very record it names. + if ( + goneNow && + !permissionScopeAccepts( + // `false`: the reconstruction may sit on the far side of a primary-key move this route + // cannot see, so the requested id does not answer for a key the trail redacted. + snapshotFor( + state, + context.params.id, + { collection: this.collection, logger: this.options.logger }, + false, + ), + { + collection: this.collection, + permissionScope, + timezone: QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }).timezone, + }, + ) + ) { + context.response.body = { data: null }; + + return; + } context.response.body = { data: state }; } @@ -199,13 +262,13 @@ export default class AuditTrailRoute extends CollectionRoute { private async fetchCurrentRecord( context: Context, auditedColumns: string[], - scope: ConditionTree | null, + permissionScope: ConditionTree | null, ): Promise | null> { const id = IdUtils.unpackId(this.collection.schema, context.params.id); const filter = new PaginatedFilter({ conditionTree: ConditionTreeFactory.intersect( ConditionTreeFactory.matchIds(this.collection.schema, [id]), - scope, + permissionScope, ), }); diff --git a/packages/agent/test/audit-trail/instrument.test.ts b/packages/agent/test/audit-trail/instrument.test.ts index 8bc2be9a60..9c7d2128c5 100644 --- a/packages/agent/test/audit-trail/instrument.test.ts +++ b/packages/agent/test/audit-trail/instrument.test.ts @@ -1206,6 +1206,25 @@ describe('auditTrail plugin', () => { }); }); + // `'toString' in result` is true of every object, so a config naming an `Object.prototype` + // member used to write a placeholder for a column the row never had — corrupting the capture, + // and the write-side twin of the read-side check in `withhold.ts`. + it('does not invent a redacted column the record never had', async () => { + const sink = jest.fn(); + const accounts = fakeCollection('accounts'); + register([accounts], { sink, redact: { accounts: ['toString'] } }); + + await runCreate(accounts, { + caller: makeCaller(), + records: [{ id: 1, status: 'open', name: 'Acme', amount: 10 }], + }); + + const { newValues } = sink.mock.calls[0][0] as AuditRecord; + + expect(Object.prototype.hasOwnProperty.call(newValues, 'toString')).toBe(false); + expect(newValues).toEqual({ id: 1, status: 'open', name: 'Acme', amount: 10 }); + }); + it('still records that a redacted field changed, but masks both values', async () => { const sink = jest.fn(); const accounts = fakeCollection('accounts', [ diff --git a/packages/agent/test/audit-trail/scope.test.ts b/packages/agent/test/audit-trail/record-visibility.test.ts similarity index 74% rename from packages/agent/test/audit-trail/scope.test.ts rename to packages/agent/test/audit-trail/record-visibility.test.ts index 2358e22e13..3c12679f81 100644 --- a/packages/agent/test/audit-trail/scope.test.ts +++ b/packages/agent/test/audit-trail/record-visibility.test.ts @@ -1,6 +1,7 @@ +import { ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; -import checkRecordVisibility, { recordExists } from '../../src/audit-trail/scope'; +import checkRecordVisibility, { recordExists } from '../../src/audit-trail/record-visibility'; import * as factories from '../__factories__'; describe('audit-trail scope', () => { @@ -55,38 +56,28 @@ describe('audit-trail scope', () => { describe('checkRecordVisibility', () => { test('is visible without querying anything when no scope is configured', async () => { - const services = factories.forestAdminHttpDriverServices.build(); const collection = buildCollection(); const list = jest.spyOn(collection, 'list'); - await expect( - checkRecordVisibility(services, collection, '2', buildContext()), - ).resolves.toEqual({ visible: true, goneEntirely: false }); + await expect(checkRecordVisibility(collection, '2', buildContext(), null)).resolves.toEqual({ + visible: true, + goneEntirely: false, + }); expect(list).not.toHaveBeenCalled(); }); test('is visible when the id matches the scope', async () => { - const services = factories.forestAdminHttpDriverServices.build(); - (services.authorization.getScope as jest.Mock).mockResolvedValue({ - field: 'ownerId', - operator: 'Equal', - value: 1, - }); + const permissionScope = new ConditionTreeLeaf('ownerId', 'Equal', 1); const collection = buildCollection(); jest.spyOn(collection, 'list').mockResolvedValueOnce([{ id: 2 }]); await expect( - checkRecordVisibility(services, collection, '2', buildContext()), + checkRecordVisibility(collection, '2', buildContext(), permissionScope), ).resolves.toEqual({ visible: true, goneEntirely: false }); }); test('is not visible when the id still exists but fails the scope', async () => { - const services = factories.forestAdminHttpDriverServices.build(); - (services.authorization.getScope as jest.Mock).mockResolvedValue({ - field: 'ownerId', - operator: 'Equal', - value: 1, - }); + const permissionScope = new ConditionTreeLeaf('ownerId', 'Equal', 1); const collection = buildCollection(); jest .spyOn(collection, 'list') @@ -94,17 +85,12 @@ describe('audit-trail scope', () => { .mockResolvedValueOnce([{ id: 2 }]); // bare: still exists await expect( - checkRecordVisibility(services, collection, '2', buildContext()), + checkRecordVisibility(collection, '2', buildContext(), permissionScope), ).resolves.toEqual({ visible: false, goneEntirely: false }); }); test('is visible, and reports goneEntirely, when the id no longer exists at all, scope aside', async () => { - const services = factories.forestAdminHttpDriverServices.build(); - (services.authorization.getScope as jest.Mock).mockResolvedValue({ - field: 'ownerId', - operator: 'Equal', - value: 1, - }); + const permissionScope = new ConditionTreeLeaf('ownerId', 'Equal', 1); const collection = buildCollection(); const list = jest .spyOn(collection, 'list') @@ -112,7 +98,7 @@ describe('audit-trail scope', () => { .mockResolvedValueOnce([]); // bare: genuinely gone await expect( - checkRecordVisibility(services, collection, '2', buildContext()), + checkRecordVisibility(collection, '2', buildContext(), permissionScope), ).resolves.toEqual({ visible: true, goneEntirely: true }); expect(list).toHaveBeenCalledTimes(2); }); diff --git a/packages/agent/test/routes/access/audit-trail-correlation.test.ts b/packages/agent/test/routes/access/audit-trail-correlation.test.ts index 1f2ee2f269..32b5554e7a 100644 --- a/packages/agent/test/routes/access/audit-trail-correlation.test.ts +++ b/packages/agent/test/routes/access/audit-trail-correlation.test.ts @@ -1,3 +1,4 @@ +import { ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; import makeRoutes from '../../../src/routes'; @@ -181,26 +182,66 @@ describe('AuditTrailCorrelationRoute', () => { expect(store.listByCorrelation).not.toHaveBeenCalled(); }); - test('allows a deleted id through to the store, scope aside', async () => { - const history = [{ operation: 'delete', correlationKey: 'req-1' }]; - const { services, dataSource, options, store } = setup(history); - (services.authorization.getScope as jest.Mock).mockResolvedValue({ - field: 'ownerId', - operator: 'Equal', - value: 1, - }); - jest - .spyOn(dataSource.getCollection('books'), 'list') - .mockResolvedValueOnce([]) // scoped check: not found - .mockResolvedValueOnce([]); // bare check: genuinely gone, not just out of scope - const route = new AuditTrailCorrelationRoute(services, options, dataSource); - const context = contextWith({ timezone: 'Europe/Paris', collection: 'books', recordId: '2' }); + // Same rule as the per-record history route: a gone record's captured values are tested + // against the caller's scope here too, or they come back through a correlation lookup. + describe('a genuinely gone record, single lookup', () => { + const lookupUnder = async (scope: ConditionTreeLeaf) => { + const history = [ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: { title: 'Secret' }, + newValues: {}, + }, + ]; + const { services, dataSource, options, store } = setup(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue(scope); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([]) // scoped check: not found + .mockResolvedValueOnce([]); // bare check: genuinely gone + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = contextWith({ + timezone: 'Europe/Paris', + collection: 'books', + recordId: '2', + }); + + await route.handleHistory(context); + + return { context, store }; + }; - await route.handleHistory(context); + test('withholds the values the scope does not cover', async () => { + const { context, store } = await lookupUnder(new ConditionTreeLeaf('id', 'Equal', 1)); + + expect(context.throw).not.toHaveBeenCalled(); + expect(store.listByCorrelation).toHaveBeenCalled(); + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: {}, + newValues: {}, + }, + ]); + }); - expect(context.throw).not.toHaveBeenCalled(); - expect(store.listByCorrelation).toHaveBeenCalled(); - expect(context.response.body).toEqual({ data: history }); + test('keeps the values when the row is in scope', async () => { + const { context } = await lookupUnder(new ConditionTreeLeaf('id', 'Equal', 2)); + + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: { title: 'Secret' }, + newValues: {}, + }, + ]); + }); }); test('allows an id inside a restrictive scope through to the store', async () => { @@ -245,30 +286,67 @@ describe('AuditTrailCorrelationRoute', () => { expect(store.listByCorrelations).not.toHaveBeenCalled(); }); - test('allows a deleted id through the batch route, scope aside', async () => { - const history = [{ operation: 'delete', correlationKey: 'req-1' }]; - const { services, dataSource, options, store } = setup(history); - (services.authorization.getScope as jest.Mock).mockResolvedValue({ - field: 'ownerId', - operator: 'Equal', - value: 1, - }); - jest - .spyOn(dataSource.getCollection('books'), 'list') - .mockResolvedValueOnce([]) // scoped check: not found - .mockResolvedValueOnce([]); // bare check: genuinely gone, not just out of scope - const route = new AuditTrailCorrelationRoute(services, options, dataSource); - const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, - customProperties: { query: { timezone: 'Europe/Paris' } }, - requestBody: { collection: 'books', recordId: '2', correlationKeys: ['req-1'] }, - }); + // Same rule as the per-record history route: a gone record's captured values are tested + // against the caller's scope here too, or they come back through a correlation lookup. + describe('a genuinely gone record, batch route', () => { + const lookupUnder = async (scope: ConditionTreeLeaf) => { + const history = [ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: { title: 'Secret' }, + newValues: {}, + }, + ]; + const { services, dataSource, options, store } = setup(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue(scope); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([]) // scoped check: not found + .mockResolvedValueOnce([]); // bare check: genuinely gone + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = contextWith({ + timezone: 'Europe/Paris', + collection: 'books', + recordId: '2', + correlationKeys: 'req-1', + }); + + await route.handleBatch(context); + + return { context, store }; + }; - await route.handleBatch(context); + test('withholds the values the scope does not cover', async () => { + const { context, store } = await lookupUnder(new ConditionTreeLeaf('id', 'Equal', 1)); + + expect(context.throw).not.toHaveBeenCalled(); + expect(store.listByCorrelations).toHaveBeenCalled(); + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: {}, + newValues: {}, + }, + ]); + }); - expect(context.throw).not.toHaveBeenCalled(); - expect(store.listByCorrelations).toHaveBeenCalled(); - expect(context.response.body).toEqual({ data: history }); + test('keeps the values when the row is in scope', async () => { + const { context } = await lookupUnder(new ConditionTreeLeaf('id', 'Equal', 2)); + + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: { title: 'Secret' }, + newValues: {}, + }, + ]); + }); }); }); @@ -303,6 +381,99 @@ describe('AuditTrailCorrelationRoute', () => { expect(store.listByCorrelation).not.toHaveBeenCalled(); }); + // The record can go away while the audit read is in flight; the decision is taken again on the + // way out rather than reused from the check that authorized the request. + // The batch returns every row under the given keys, so one response can carry sides that answer + // the scope and sides that cannot. Each is decided on its own, not as a batch. + test('decides each row of a batch on its own values', async () => { + const history = [ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: { title: 'In scope, no id of its own' }, + newValues: {}, + }, + { + // The key moved to 2, so this row is filed under 2 while its previous side was still 7. + operation: 'update', + recordId: '2', + correlationKey: 'req-2', + previousValues: { id: 7, title: 'Captured while out of scope' }, + newValues: { id: 2, title: 'In scope' }, + }, + ]; + const { services, dataSource, options } = setup(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('id', 'Equal', 2), + ); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([] as never); + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = contextWith({ + timezone: 'Europe/Paris', + collection: 'books', + recordId: '2', + correlationKeys: 'req-1,req-2', + }); + + await route.handleBatch(context); + + expect((context.response.body as { data: unknown[] }).data).toEqual([ + // no id captured, so the packed id answers and it matches + { ...history[0] }, + // the previous side carried id 7 and answers for itself: withheld, new side kept + { ...history[1], previousValues: {} }, + ]); + }); + + describe('the record changes between the visibility check and the audit read', () => { + const raceWith = async (...listAnswers: unknown[][]) => { + const history = [ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: { title: 'Secret' }, + newValues: {}, + }, + ]; + const { services, dataSource, options } = setup(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('id', 'Equal', 1), + ); + const list = jest.spyOn(dataSource.getCollection('books'), 'list'); + listAnswers.forEach(answer => list.mockResolvedValueOnce(answer as never)); + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = contextWith({ timezone: 'Europe/Paris', collection: 'books', recordId: '2' }); + + await route.handleHistory(context); + + return context; + }; + + test('withholds the values when the record was deleted in between', async () => { + const context = await raceWith([{ id: 2 }], [], []); + + expect(context.throw).not.toHaveBeenCalled(); + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { + operation: 'delete', + recordId: '2', + correlationKey: 'req-1', + previousValues: {}, + newValues: {}, + }, + ]); + }); + + test('refuses when the record moved out of the caller scope in between', async () => { + const context = await raceWith([{ id: 2 }], [], [{ id: 2 }]); + + expect(context.throw).toHaveBeenCalledWith(404, 'Record does not exists'); + expect(context.response.body).toBeUndefined(); + }); + }); + describe('conditional mounting', () => { const buildDataSource = () => factories.dataSource.buildWithCollections([ diff --git a/packages/agent/test/routes/access/audit-trail.test.ts b/packages/agent/test/routes/access/audit-trail.test.ts index 67656f8385..da3d8f3886 100644 --- a/packages/agent/test/routes/access/audit-trail.test.ts +++ b/packages/agent/test/routes/access/audit-trail.test.ts @@ -1,6 +1,7 @@ import { ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; +import { REDACTED } from '../../../src/audit-trail'; import makeRoutes from '../../../src/routes'; import AuditTrailRoute from '../../../src/routes/access/audit-trail'; import * as factories from '../../__factories__'; @@ -716,7 +717,12 @@ describe('AuditTrailRoute', () => { test("allows a deleted id through to the store, but withholds an out-of-scope delete row's previousValues", async () => { const history = [ - { operation: 'delete', recordId: '2', previousValues: { ownerId: 2, title: 'Secret' } }, + { + operation: 'delete', + recordId: '2', + previousValues: { ownerId: 2, title: 'Secret' }, + newValues: {}, + }, ]; const { services, dataSource, options, store } = setup(history); (services.authorization.getScope as jest.Mock).mockResolvedValue( @@ -737,14 +743,19 @@ describe('AuditTrailRoute', () => { expect(context.throw).not.toHaveBeenCalled(); expect(store.listByRecord).toHaveBeenCalled(); expect(context.response.body).toEqual({ - data: [{ operation: 'delete', recordId: '2', previousValues: {} }], + data: [{ operation: 'delete', recordId: '2', previousValues: {}, newValues: {} }], meta: { count: 1, availableUsers: [] }, }); }); test("keeps a genuinely-deleted delete row's previousValues when they match the caller's scope", async () => { const history = [ - { operation: 'delete', recordId: '2', previousValues: { ownerId: 1, title: 'Visible' } }, + { + operation: 'delete', + recordId: '2', + previousValues: { ownerId: 1, title: 'Visible' }, + newValues: {}, + }, ]; const { services, dataSource, options, store } = setup(history); (services.authorization.getScope as jest.Mock).mockResolvedValue( @@ -770,45 +781,76 @@ describe('AuditTrailRoute', () => { }); }); - test('unconditionally withholds an update row for a genuinely gone, scoped record — its values are a partial diff, not a full record', async () => { - // A scope condition can reference a column this particular update didn't even touch, so the - // match can't be evaluated reliably against a partial diff — withhold regardless, rather - // than risk a false negative that would leak column values the caller was never allowed to - // see (this is exactly the gap the create/delete-only version of this fix left open). - const history = [ - { + // Each side of an update is tested against its own snapshot. A scope on a column this + // particular update never touched is simply unanswerable there, so it withholds by the same + // rule as everything else — no blanket case needed. + describe('an update row on a genuinely gone, scoped record', () => { + const updateUnderScope = async (previousValues, newValues) => { + const history = [{ operation: 'update', recordId: '2', previousValues, newValues }]; + const { services, dataSource, options } = setup(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('ownerId', 'Equal', 1), + ); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([]) // scoped check: not found + .mockResolvedValueOnce([]); // bare check: genuinely gone + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + }); + + await route.handleHistory(context); + + return (context.response.body as { data: unknown[] }).data[0]; + }; + + test('blanks only the side that falls outside the scope', async () => { + const row = await updateUnderScope({ ownerId: 2 }, { ownerId: 1 }); + + expect(row).toEqual({ operation: 'update', recordId: '2', - previousValues: { title: 'Old' }, - newValues: { title: 'New' }, - }, - ]; - const { services, dataSource, options, store } = setup(history); - (services.authorization.getScope as jest.Mock).mockResolvedValue( - new ConditionTreeLeaf('ownerId', 'Equal', 1), - ); - jest - .spyOn(dataSource.getCollection('books'), 'list') - .mockResolvedValueOnce([]) // scoped check: not found - .mockResolvedValueOnce([]); // bare check: genuinely gone, not just out of scope - const route = new AuditTrailRoute(services, options, dataSource, 'books'); - const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, - customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + previousValues: {}, + newValues: { ownerId: 1 }, + }); }); - await route.handleHistory(context); + test('keeps both sides when both are in scope', async () => { + const row = await updateUnderScope( + { ownerId: 1, title: 'Old' }, + { ownerId: 1, title: 'New' }, + ); - expect(store.listByRecord).toHaveBeenCalled(); - expect(context.response.body).toEqual({ - data: [{ operation: 'update', recordId: '2', previousValues: {}, newValues: {} }], - meta: { count: 1, availableUsers: [] }, + expect(row).toEqual({ + operation: 'update', + recordId: '2', + previousValues: { ownerId: 1, title: 'Old' }, + newValues: { ownerId: 1, title: 'New' }, + }); + }); + + test('withholds both sides when the diff never touched the scoped column', async () => { + const row = await updateUnderScope({ title: 'Old' }, { title: 'New' }); + + expect(row).toEqual({ + operation: 'update', + recordId: '2', + previousValues: {}, + newValues: {}, + }); }); }); test("withholds an out-of-scope create row's newValues for a genuinely gone, scoped record", async () => { const history = [ - { operation: 'create', recordId: '2', newValues: { ownerId: 2, title: 'Secret' } }, + { + operation: 'create', + recordId: '2', + previousValues: {}, + newValues: { ownerId: 2, title: 'Secret' }, + }, ]; const { services, dataSource, options } = setup(history); (services.authorization.getScope as jest.Mock).mockResolvedValue( @@ -827,14 +869,19 @@ describe('AuditTrailRoute', () => { await route.handleHistory(context); expect(context.response.body).toEqual({ - data: [{ operation: 'create', recordId: '2', newValues: {} }], + data: [{ operation: 'create', recordId: '2', previousValues: {}, newValues: {} }], meta: { count: 1, availableUsers: [] }, }); }); test("keeps a genuinely-gone create row's newValues when they match the caller's scope", async () => { const history = [ - { operation: 'create', recordId: '2', newValues: { ownerId: 1, title: 'Visible' } }, + { + operation: 'create', + recordId: '2', + previousValues: {}, + newValues: { ownerId: 1, title: 'Visible' }, + }, ]; const { services, dataSource, options } = setup(history); (services.authorization.getScope as jest.Mock).mockResolvedValue( @@ -905,6 +952,362 @@ describe('AuditTrailRoute', () => { expect(context.throw).not.toHaveBeenCalled(); expect(store.listByRecord).toHaveBeenCalled(); }); + + // The audit trail lives in its own database — often its own engine — so no single snapshot + // spans the visibility check and the audit read. The record can go away in between. + describe('the record changes between the visibility check and the audit read', () => { + const raceWith = async (...listAnswers: unknown[][]) => { + const history = [ + { + operation: 'delete', + recordId: '2', + previousValues: { ownerId: 2, title: 'Secret' }, + newValues: {}, + }, + ]; + const { services, dataSource, options } = setup(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('ownerId', 'Equal', 1), + ); + const list = jest.spyOn(dataSource.getCollection('books'), 'list'); + listAnswers.forEach(answer => list.mockResolvedValueOnce(answer as never)); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + }); + + await route.handleHistory(context); + + return { context, list, services }; + }; + + test('withholds the values when the record was deleted in between', async () => { + // present and in scope at the check, gone by the time the rows are in hand + const { context } = await raceWith([{ id: 2 }], [], []); + + expect(context.throw).not.toHaveBeenCalled(); + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { operation: 'delete', recordId: '2', previousValues: {}, newValues: {} }, + ]); + }); + + test('refuses when the record moved out of the caller scope in between', async () => { + // gone from the scoped read, but still there without the scope: not deleted, reassigned + const { context } = await raceWith([{ id: 2 }], [], [{ id: 2 }]); + + expect(context.throw).toHaveBeenCalledWith(404, 'Record does not exists'); + }); + + // One lookup for the whole request: the visibility answer and the withholding have to be + // taken against the same scope, or a cache turning over between them decides one with a + // scope and the other without. + test('reads the permission scope once and re-uses it', async () => { + const { services } = await raceWith([{ id: 2 }], [], []); + + expect(services.authorization.getScope).toHaveBeenCalledTimes(1); + }); + + test('does not ask again for a record that was already gone at the first check', async () => { + const { list } = await raceWith([], []); + + expect(list).toHaveBeenCalledTimes(2); + }); + }); + + describe('a snapshot that cannot answer the scope', () => { + // Mirrors what `instrument.ts` captures: writable columns only, so a read-only column is + // absent from the snapshot and a redacted one holds the placeholder rather than the value. + let lastLogger: jest.Mock; + + const setupUnanswerable = (history: unknown[]) => { + const services = factories.forestAdminHttpDriverServices.build(); + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + ownerId: factories.columnSchema.build({ + columnType: 'Number', + filterOperators: new Set(['Equal', 'NotEqual']), + }), + status: factories.columnSchema.build({ + columnType: 'String', + isReadOnly: true, + filterOperators: new Set(['Equal', 'NotEqual']), + }), + secret: factories.columnSchema.build({ + columnType: 'String', + filterOperators: new Set(['Equal', 'NotEqual']), + }), + // Named after an `Object.prototype` member on purpose: read-only, so it is never + // captured, and the answerability check must not find it on the prototype. + toString: factories.columnSchema.build({ + columnType: 'String', + isReadOnly: true, + filterOperators: new Set(['Equal', 'NotEqual']), + }), + }, + }), + }), + ]); + const store = { + listByRecord: jest.fn().mockResolvedValue(history), + countByRecord: jest.fn().mockResolvedValue(history.length), + listDistinctUsers: jest.fn().mockResolvedValue([]), + }; + const options = factories.forestAdminHttpDriverOptions.build({ + auditTrail: { connectionString: 'sqlite::memory:', store } as never, + }); + + return { services, dataSource, options }; + }; + + // The record is gone for good: empty in scope and empty without it. + const historyUnder = async (scope: ConditionTreeLeaf, history: unknown[]) => { + const { services, dataSource, options } = setupUnanswerable(history); + const logger = jest.fn(); + (options as { logger: unknown }).logger = logger; + lastLogger = logger; + (services.authorization.getScope as jest.Mock).mockResolvedValue(scope); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + }); + + await route.handleHistory(context); + + return (context.response.body as { data: unknown[] }).data; + }; + + test('withholds a delete row when the scope reads a column the snapshot never captured', async () => { + const data = await historyUnder(new ConditionTreeLeaf('status', 'NotEqual', 'private'), [ + { operation: 'delete', recordId: '2', previousValues: { ownerId: 1, secret: 'shh' } }, + ]); + + expect(data).toEqual([ + { operation: 'delete', recordId: '2', previousValues: {}, newValues: {} }, + ]); + }); + + test('withholds a create row when the scope reads a column the snapshot never captured', async () => { + const data = await historyUnder(new ConditionTreeLeaf('status', 'NotEqual', 'private'), [ + { operation: 'create', recordId: '2', newValues: { ownerId: 1, secret: 'shh' } }, + ]); + + expect(data).toEqual([ + { operation: 'create', recordId: '2', previousValues: {}, newValues: {} }, + ]); + }); + + test('withholds the values when the scoped field was stored redacted', async () => { + const data = await historyUnder(new ConditionTreeLeaf('secret', 'NotEqual', 'nope'), [ + { operation: 'delete', recordId: '2', previousValues: { ownerId: 1, secret: REDACTED } }, + ]); + + expect(data).toEqual([ + { operation: 'delete', recordId: '2', previousValues: {}, newValues: {} }, + ]); + }); + + test("keeps the values when the scope matches the row's own primary key", async () => { + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 2), [ + { operation: 'delete', recordId: '2', previousValues: { ownerId: 9 } }, + ]); + + expect(data).toEqual([ + { operation: 'delete', recordId: '2', previousValues: { ownerId: 9 }, newValues: {} }, + ]); + }); + + test('withholds the values when the scope names a different primary key', async () => { + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 3), [ + { operation: 'delete', recordId: '2', previousValues: { ownerId: 9 } }, + ]); + + expect(data).toEqual([ + { operation: 'delete', recordId: '2', previousValues: {}, newValues: {} }, + ]); + }); + + // A row is filed under ONE id, but an update carries TWO states. When the key itself moves, + // the row is filed under the id it moved to — so judging both sides by that id asks the + // previous side to answer for an id it never had. + // An action row's two columns hold a submitted form and a result summary, not column values, + // so no permission scope applies to them and they pass through whole. + test.each(['action', 'action_failed'])( + 'passes a %s row through untouched, values and all', + async operation => { + const row = { + operation, + recordId: '2', + actionName: 'Refund', + previousValues: { amount: 12 }, + newValues: { message: 'done' }, + }; + const data = await historyUnder(new ConditionTreeLeaf('ownerId', 'Equal', 1), [row]); + + expect(data).toEqual([row]); + }, + ); + + // The row is filed under the identity the record ended up with. When the key is redacted, the + // snapshot cannot say what it was, and the row's id speaks only for the side it was filed + // under — so the previous side of an update has nothing left to answer with and is withheld. + // Filling it would let the new id decide whether the old state was in scope. + test('withholds an update side whose redacted primary key the row id cannot answer for', async () => { + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 9), [ + { + operation: 'update', + recordId: '9', + previousValues: { id: REDACTED, secret: 'captured while it was out of scope' }, + newValues: { id: REDACTED, secret: 'public now' }, + }, + ]); + + expect(data).toEqual([ + { + operation: 'update', + recordId: '9', + previousValues: {}, + newValues: { id: REDACTED, secret: 'public now' }, + }, + ]); + }); + + test('says so when a row id cannot be unpacked, rather than withholding silently', async () => { + await historyUnder(new ConditionTreeLeaf('id', 'Equal', 2), [ + { operation: 'delete', recordId: '2|7', previousValues: { ownerId: 9 } }, + ]); + + expect(lastLogger).toHaveBeenCalledTimes(1); + expect(lastLogger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('cannot unpack record id "2|7"'), + ); + }); + + // `''` is a legal value for a string primary key: it packs and unpacks like any other, so it + // is an id the row has, not an id the row lacks. + test('answers a scope on a primary key whose value is the empty string', async () => { + const services = factories.forestAdminHttpDriverServices.build(); + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { + code: factories.columnSchema.build({ + columnType: 'String', + isPrimaryKey: true, + filterOperators: new Set(['Equal', 'In']), + }), + // Read-only, so the capture never keeps it and only the packed id can answer. + title: factories.columnSchema.build({ columnType: 'String', isReadOnly: true }), + }, + }), + }), + ]); + const history = [{ operation: 'delete', recordId: '', previousValues: { secret: 'kept' } }]; + const store = { + listByRecord: jest.fn().mockResolvedValue(history), + countByRecord: jest.fn().mockResolvedValue(1), + listDistinctUsers: jest.fn().mockResolvedValue([]), + }; + const options = factories.forestAdminHttpDriverOptions.build({ + auditTrail: { connectionString: 'sqlite::memory:', store } as never, + }); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('code', 'Equal', ''), + ); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '' } }, + }); + + await route.handleHistory(context); + + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { operation: 'delete', recordId: '', previousValues: { secret: 'kept' }, newValues: {} }, + ]); + }); + + test('tests each side of a key move against the id that side carried', async () => { + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 9), [ + { + operation: 'update', + recordId: '9', + previousValues: { id: 2, ownerId: 9 }, + newValues: { id: 9, ownerId: 4 }, + }, + ]); + + expect(data).toEqual([ + { + operation: 'update', + recordId: '9', + previousValues: {}, + newValues: { id: 9, ownerId: 4 }, + }, + ]); + }); + + test('withholds the values when the scope names an inherited property of the snapshot', async () => { + const data = await historyUnder(new ConditionTreeLeaf('toString', 'NotEqual', 'private'), [ + { operation: 'delete', recordId: '2', previousValues: { ownerId: 1, secret: 'shh' } }, + ]); + + expect(data).toEqual([ + { operation: 'delete', recordId: '2', previousValues: {}, newValues: {} }, + ]); + }); + + test('reads a redacted primary key back from the packed id rather than the snapshot', async () => { + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 2), [ + { + operation: 'delete', + recordId: '2', + previousValues: { id: REDACTED, ownerId: 9 }, + newValues: {}, + }, + ]); + + expect(data).toEqual([ + { + operation: 'delete', + recordId: '2', + previousValues: { id: REDACTED, ownerId: 9 }, + newValues: {}, + }, + ]); + }); + + test("withholds the values when the row's packed id no longer fits the schema", async () => { + // Two components against a single-column primary key: the collection was re-keyed since the + // row was written, so its id can no longer answer for the record it names. + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 2), [ + { operation: 'delete', recordId: '2|7', previousValues: { ownerId: 9 } }, + ]); + + expect(data).toEqual([ + { operation: 'delete', recordId: '2|7', previousValues: {}, newValues: {} }, + ]); + }); + + test('withholds the values when the row carries no record id to answer an id scope', async () => { + const data = await historyUnder(new ConditionTreeLeaf('id', 'Equal', 2), [ + { operation: 'create', recordId: null, newValues: { ownerId: 9 } }, + ]); + + expect(data).toEqual([ + { operation: 'create', recordId: null, previousValues: {}, newValues: {} }, + ]); + }); + }); }); describe('handleStateAt', () => { @@ -1308,24 +1711,182 @@ describe('AuditTrailRoute', () => { expect(context.response.body).toBeUndefined(); }); - test('exposes the delete snapshot, scope aside, once the record is genuinely gone', async () => { + // The history route withholds a gone record's captured values from a caller whose scope they + // fail; this route is those same values reassembled, so it has to answer the same way or the + // withheld values are one request away. + describe('a genuinely gone record, read by a scoped caller', () => { + const reconstructFor = async (scope: ConditionTreeLeaf) => { + const history = [ + { + operation: 'delete', + previousValues: { id: 2, status: 'closed', name: 'Acme' }, + newValues: {}, + }, + ]; + const { services, dataSource, route } = setupBooks(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue(scope); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([]) // scoped fetch: not found + .mockResolvedValueOnce([]); // bare check: genuinely gone, not just out of scope + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', at: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + return context; + }; + + test('withholds the reconstruction when it fails the scope', async () => { + const context = await reconstructFor(new ConditionTreeLeaf('status', 'Equal', 'mine')); + + expect(context.throw).not.toHaveBeenCalled(); + expect(context.response.body).toEqual({ data: null }); + }); + + test('serves the reconstruction when it matches the scope', async () => { + const context = await reconstructFor(new ConditionTreeLeaf('status', 'Equal', 'closed')); + + expect(context.throw).not.toHaveBeenCalled(); + expect(context.response.body).toEqual({ + data: { id: 2, status: 'closed', name: 'Acme' }, + }); + }); + + test('withholds the reconstruction when the scope reads a column the capture never kept', async () => { + const context = await reconstructFor(new ConditionTreeLeaf('displayName', 'Equal', 'Acme')); + + expect(context.response.body).toEqual({ data: null }); + }); + }); + + // A read-only primary key never lands in the capture, so the reconstruction of a gone record + // has no `id` of its own — the packed id from the request supplies it, exactly as a row's own + // `recordId` does on the history route. + // Same window the history route closes: in scope at the check, gone by the time the rows are in + // hand. Without the re-read the gate below never runs and the reconstruction goes out whole. + test('withholds a reconstruction for a record deleted while the audit read was in flight', async () => { + const history = [ + { operation: 'delete', previousValues: { status: 'closed', name: 'Acme' }, newValues: {} }, + ]; + const { services, dataSource, route } = setupBooks(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('status', 'Equal', 'mine'), + ); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([{ id: 2, status: 'closed', name: 'Acme' }]) // present and in scope + .mockResolvedValueOnce([]) // re-read, scoped: gone + .mockResolvedValueOnce([]); // re-read, bare: genuinely gone + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', at: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.response.body).toEqual({ data: null }); + }); + + test('refuses a reconstruction for a record moved out of scope while the audit read was in flight', async () => { + const { services, dataSource, route } = setupBooks([]); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('status', 'Equal', 'mine'), + ); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([{ id: 2, status: 'closed', name: 'Acme' }]) // present and in scope + .mockResolvedValueOnce([]) // re-read, scoped: no longer in scope + .mockResolvedValueOnce([{ id: 2 }]); // re-read, bare: still exists + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', at: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.throw).toHaveBeenCalledWith(404, 'Record did not exist at this timestamp'); + }); + + // The reconstruction of a gone record can sit on the far side of a primary-key move this route + // cannot see, so the requested id does not answer for a key the trail redacted. A read-only key + // is different — it cannot move — which is why the fixture's key has to be writable to reach + // this at all. + test('withholds a reconstruction whose writable primary key is redacted', async () => { const history = [ { operation: 'delete', - previousValues: { id: 2, status: 'closed', name: 'Acme' }, + previousValues: { id: REDACTED, status: 'closed', name: 'Acme' }, newValues: {}, }, ]; const { services, dataSource, route } = setupBooks(history); - (services.authorization.getScope as jest.Mock).mockResolvedValue({ - field: 'ownerId', - operator: 'Equal', - value: 1, + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('id', 'Equal', 2), + ); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValueOnce([]) // scoped fetch: not found + .mockResolvedValueOnce([]); // bare check: genuinely gone + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', at: '2026-06-18' }, + params: { id: '2' }, + }, }); + + await route.handleStateAt(context); + + expect(context.response.body).toEqual({ data: null }); + }); + + test('answers an id scope from the packed id the reconstruction is missing', async () => { + const history = [ + { operation: 'delete', previousValues: { status: 'closed', name: 'Acme' }, newValues: {} }, + ]; + const { services, dataSource, route } = setupBooks(history); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('id', 'Equal', 2), + ); jest .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValueOnce([]) // scoped fetch: not found - .mockResolvedValueOnce([]); // bare check: genuinely gone, not just out of scope + .mockResolvedValueOnce([]); // bare check: genuinely gone + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', at: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.response.body).toEqual({ data: { status: 'closed', name: 'Acme' } }); + }); + + test('does not test the reconstruction when the record still exists and is in scope', async () => { + const { services, dataSource, route } = setupBooks([]); + (services.authorization.getScope as jest.Mock).mockResolvedValue( + new ConditionTreeLeaf('status', 'Equal', 'open'), + ); + // The scoped read found it, so the caller may read the record — its earlier states come with + // it, the same rule the history route applies to a record that still exists. + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { @@ -1336,7 +1897,6 @@ describe('AuditTrailRoute', () => { await route.handleStateAt(context); - expect(context.throw).not.toHaveBeenCalled(); expect(context.response.body).toEqual({ data: { id: 2, status: 'closed', name: 'Acme' }, });