diff --git a/packages/agent/src/audit-trail/README.md b/packages/agent/src/audit-trail/README.md index 2684d8b535..3f030e732d 100644 --- a/packages/agent/src/audit-trail/README.md +++ b/packages/agent/src/audit-trail/README.md @@ -190,6 +190,14 @@ 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. +**Detail values are admin-only.** Whatever the collection permissions say, `previousValues` and +`newValues` only reach a caller whose permission level is `admin` — exactly that level, not a +privileged set. Every other caller gets each row with both objects empty: what happened, by whom and +when stays readable, the values do not. The `/state` route is nothing but reconstructed values, so it +answers **HTTP 403** instead. The capabilities payload advertises this as +`restrictsAuditTrailValuesToAdmins`, so a client can tell an agent that enforces the rule from an +older one that merely hides the values in its UI. + 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 diff --git a/packages/agent/src/audit-trail/admin-gate.ts b/packages/agent/src/audit-trail/admin-gate.ts new file mode 100644 index 0000000000..25e3a7cfda --- /dev/null +++ b/packages/agent/src/audit-trail/admin-gate.ts @@ -0,0 +1,29 @@ +import type { AuditRecord } from './types'; +import type { Context } from 'koa'; + +import { ForbiddenError } from '@forestadmin/datasource-toolkit'; + +// `admin` exactly, not a privileged set (PRD-1259). The level comes from the Forest-signed JWT that +// `context.state.user` holds, so it needs no round-trip and cannot be forged by the caller. +function isAdmin(context: Context): boolean { + return context.state.user?.permissionLevel === 'admin'; +} + +// The row itself always stays: operation, author and timestamp are what makes the timeline readable, +// and are not the values a non-admin isn't entitled to. Composes with the scope withholding — a row +// can fail both tests. +export function withholdValuesFromNonAdmin( + entries: AuditRecord[], + context: Context, +): AuditRecord[] { + if (isAdmin(context)) return entries; + + return entries.map(entry => ({ ...entry, previousValues: {}, newValues: {} })); +} + +// For a route that carries nothing but values: refusing it outright says more than an empty payload. +export function assertCanReadAuditValues(context: Context): void { + if (!isAdmin(context)) { + throw new ForbiddenError('Only an admin can read audit-trail values'); + } +} diff --git a/packages/agent/src/routes/access/audit-trail-correlation.ts b/packages/agent/src/routes/access/audit-trail-correlation.ts index f4b3b2781f..965bf8fb25 100644 --- a/packages/agent/src/routes/access/audit-trail-correlation.ts +++ b/packages/agent/src/routes/access/audit-trail-correlation.ts @@ -6,6 +6,7 @@ import type { Context } from 'koa'; import { ValidationError } from '@forestadmin/datasource-toolkit'; +import { withholdValuesFromNonAdmin } from '../../audit-trail/admin-gate'; import checkRecordVisibility from '../../audit-trail/scope'; import { HttpCode, RouteType } from '../../types'; import BaseRoute from '../base-route'; @@ -43,7 +44,7 @@ export default class AuditTrailCorrelationRoute extends BaseRoute { correlationKey: context.params.correlationKey, }); - context.response.body = { data: history }; + context.response.body = { data: withholdValuesFromNonAdmin(history, context) }; } public async handleBatch(context: Context): Promise { @@ -58,7 +59,7 @@ export default class AuditTrailCorrelationRoute extends BaseRoute { ? await store.listByCorrelations({ collection, recordId, correlationKeys }) : []; - context.response.body = { data: history }; + context.response.body = { data: withholdValuesFromNonAdmin(history, context) }; } // Returns null (after issuing the 404) when a configured record-level scope excludes the id — diff --git a/packages/agent/src/routes/access/audit-trail.ts b/packages/agent/src/routes/access/audit-trail.ts index 383cd1718d..51c56ba6d2 100644 --- a/packages/agent/src/routes/access/audit-trail.ts +++ b/packages/agent/src/routes/access/audit-trail.ts @@ -13,6 +13,7 @@ import { import { DateTime } from 'luxon'; import { revertRecord } from '../../audit-trail'; +import { assertCanReadAuditValues, withholdValuesFromNonAdmin } from '../../audit-trail/admin-gate'; import checkRecordVisibility, { recordExists } from '../../audit-trail/scope'; import { HttpCode } from '../../types'; import IdUtils from '../../utils/id'; @@ -92,8 +93,9 @@ export default class AuditTrailRoute extends CollectionRoute { // when the record existed. If those values themselves would have failed the caller's scope, // withhold them while still surfacing that the row happened, by whom and when: that part // stays visible regardless. - const data = + const scoped = scope && goneEntirely ? this.withholdOutOfScopeValues(rawData, scope, context) : rawData; + const data = withholdValuesFromNonAdmin(scoped, context); context.response.body = { data, @@ -139,6 +141,8 @@ export default class AuditTrailRoute extends CollectionRoute { // Only audited columns are returned; read-only/computed fields are not captured in the log. public async handleStateAt(context: Context): Promise { await this.services.authorization.assertCanRead(context, this.collection.name); + // Nothing but reconstructed values, so there is no row left to hand back once they are withheld. + assertCanReadAuditValues(context); const at = AuditTrailRoute.parseAt(context); const auditedColumns = AuditTrailRoute.auditedColumns(this.collection.schema); diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts index 6c51f72e60..580d2ca652 100644 --- a/packages/agent/src/routes/capabilities.ts +++ b/packages/agent/src/routes/capabilities.ts @@ -46,6 +46,10 @@ export default class Capabilities extends BaseRoute { // `x-forest-correlation-id` is emitted on every response regardless — the frontend must // gate the History tab on this flag rather than inferring the feature from that header. canUseAuditTrail: this.options.auditTrail !== null, + // Constant, not config-driven: it states that this agent version enforces the admin-only + // rule on audit detail values itself, so the front can stop treating its own hidden + // expander as the guarantee. An older agent simply omits the flag. + restrictsAuditTrailValuesToAdmins: true, checksRelationReadPermissions: !this.options.skipRelationReadPermissions, }, collections: 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..62a5444314 100644 --- a/packages/agent/test/routes/access/audit-trail-correlation.test.ts +++ b/packages/agent/test/routes/access/audit-trail-correlation.test.ts @@ -30,7 +30,7 @@ describe('AuditTrailCorrelationRoute', () => { const contextWith = (query: Record, correlationKey = 'req-1') => createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query, params: { correlationKey } }, }); @@ -85,7 +85,7 @@ describe('AuditTrailCorrelationRoute', () => { const { services, dataSource, options, store } = setup(history); const route = new AuditTrailCorrelationRoute(services, options, dataSource); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' } }, requestBody: { collection: 'books', recordId: '2', correlationKeys: ['a', 'b'] }, }); @@ -234,7 +234,7 @@ describe('AuditTrailCorrelationRoute', () => { .mockResolvedValueOnce([{ id: 2 }]); // bare check: still exists const route = new AuditTrailCorrelationRoute(services, options, dataSource); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' } }, requestBody: { collection: 'books', recordId: '2', correlationKeys: ['a'] }, }); @@ -259,7 +259,7 @@ describe('AuditTrailCorrelationRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' } }, requestBody: { collection: 'books', recordId: '2', correlationKeys: ['req-1'] }, }); @@ -342,4 +342,70 @@ describe('AuditTrailCorrelationRoute', () => { expect(routes.filter(route => route instanceof AuditTrailCorrelationRoute)).toHaveLength(0); }); }); + + describe('admin permission level', () => { + const nonAdminContext = (query: Record, correlationKey = 'req-1') => + createMockContext({ + state: { user: { email: 'jane.doe@domain.com', permissionLevel: 'editor' } }, + customProperties: { query, params: { correlationKey } }, + }); + + const history = [ + { + operation: 'update', + recordId: '2', + correlationKey: 'req-1', + userId: 7, + previousValues: { title: 'Old' }, + newValues: { title: 'New' }, + }, + ]; + + test('blanks the values of a single-correlation lookup for a non-admin caller', async () => { + const { services, dataSource, options } = setup(history); + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = nonAdminContext({ collection: 'books', recordId: '2' }); + + await route.handleHistory(context); + + expect(context.response.body).toEqual({ + data: [ + { + operation: 'update', + recordId: '2', + correlationKey: 'req-1', + userId: 7, + previousValues: {}, + newValues: {}, + }, + ], + }); + }); + + test('blanks the values of the batch lookup for a non-admin caller', async () => { + const { services, dataSource, options } = setup(history); + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = nonAdminContext({ + collection: 'books', + recordId: '2', + correlationKeys: 'req-1', + }); + + await route.handleBatch(context); + + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { ...history[0], previousValues: {}, newValues: {} }, + ]); + }); + + test('keeps the values for an admin caller', async () => { + const { services, dataSource, options } = setup(history); + const route = new AuditTrailCorrelationRoute(services, options, dataSource); + const context = contextWith({ collection: 'books', recordId: '2' }); + + await route.handleHistory(context); + + expect(context.response.body).toEqual({ data: history }); + }); + }); }); diff --git a/packages/agent/test/routes/access/audit-trail.test.ts b/packages/agent/test/routes/access/audit-trail.test.ts index 67656f8385..cbdbaedd77 100644 --- a/packages/agent/test/routes/access/audit-trail.test.ts +++ b/packages/agent/test/routes/access/audit-trail.test.ts @@ -1,4 +1,4 @@ -import { ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; +import { ConditionTreeLeaf, ForbiddenError } from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; import makeRoutes from '../../../src/routes'; @@ -57,7 +57,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(history); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -80,7 +80,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', 'page[size]': '10', 'page[number]': '3' }, params: { id: '2' }, @@ -102,7 +102,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -117,7 +117,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', 'page[size]': '50', 'page[number]': '3' }, params: { id: '2' }, @@ -135,7 +135,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', 'page[size]': '500', 'page[number]': '1' }, params: { id: '2' }, @@ -152,7 +152,7 @@ describe('AuditTrailRoute', () => { store.countByRecord.mockResolvedValue(137); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', userIds: '7', 'page[size]': '20', 'page[number]': '2' }, params: { id: '2' }, @@ -178,7 +178,7 @@ describe('AuditTrailRoute', () => { ]); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', userIds: '1' }, params: { id: '2' }, @@ -201,7 +201,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', 'page[number]': '2' }, params: { id: '2' }, @@ -220,7 +220,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', 'page[number]': '1' }, params: { id: '2' }, @@ -238,7 +238,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', userIds: '12,45' }, params: { id: '2' }, @@ -254,7 +254,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', userIds: '12,abc,45' }, params: { id: '2' }, @@ -270,7 +270,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', userIds: 'abc,def' }, params: { id: '2' }, @@ -288,7 +288,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', startDate: '2026-06-17', endDate: '2026-06-17' }, params: { id: '2' }, @@ -309,7 +309,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', @@ -336,7 +336,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', fields: 'title,author' }, params: { id: '2' }, @@ -357,7 +357,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', fields: ' title , , author ' }, params: { id: '2' }, @@ -375,7 +375,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', fields: '' }, params: { id: '2' }, @@ -393,7 +393,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', fields: ' , , ' }, params: { id: '2' }, @@ -411,7 +411,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', search: ' Lyon ' }, params: { id: '2' }, @@ -428,7 +428,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', search: ' ' }, params: { id: '2' }, @@ -446,7 +446,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', @@ -475,7 +475,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -494,7 +494,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -507,7 +507,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', sort: '-timestamp' }, params: { id: '2' }, @@ -523,7 +523,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', sort: 'timestamp' }, params: { id: '2' }, @@ -539,7 +539,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', startDate: '17-06-2026' }, params: { id: '2' }, @@ -556,7 +556,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', @@ -581,7 +581,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', startDate: '2026-06-18 11:48' }, params: { id: '2' }, @@ -599,7 +599,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', @@ -624,7 +624,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'America/New_York', @@ -649,7 +649,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options, store } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', startDate: '2026-06-18T11' }, params: { id: '2' }, @@ -666,7 +666,7 @@ describe('AuditTrailRoute', () => { const { services, dataSource, options } = setup(); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -681,7 +681,7 @@ describe('AuditTrailRoute', () => { const list = jest.spyOn(dataSource.getCollection('books'), 'list'); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -704,7 +704,7 @@ describe('AuditTrailRoute', () => { .mockResolvedValueOnce([{ id: 2 }]); // bare check: still exists const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -728,7 +728,7 @@ describe('AuditTrailRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -756,7 +756,7 @@ describe('AuditTrailRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -793,7 +793,7 @@ describe('AuditTrailRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -820,7 +820,7 @@ describe('AuditTrailRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -846,7 +846,7 @@ describe('AuditTrailRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -867,7 +867,7 @@ describe('AuditTrailRoute', () => { .mockResolvedValue([{ id: 2 }]); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -896,7 +896,7 @@ describe('AuditTrailRoute', () => { jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([{ id: 2 }]); const route = new AuditTrailRoute(services, options, dataSource, 'books'); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -907,6 +907,110 @@ describe('AuditTrailRoute', () => { }); }); + describe('admin permission level', () => { + const nonAdminContext = () => + createMockContext({ + state: { user: { email: 'jane.doe@domain.com', permissionLevel: 'editor' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + }); + + test('blanks the values of every operation for a non-admin caller', async () => { + const history = [ + { operation: 'create', recordId: '2', previousValues: {}, newValues: { title: 'New' } }, + { + operation: 'update', + recordId: '2', + previousValues: { title: 'Old' }, + newValues: { title: 'New' }, + }, + { operation: 'delete', recordId: '2', previousValues: { title: 'Old' }, newValues: {} }, + { operation: 'action', recordId: '2', previousValues: { form: 1 }, newValues: { ok: 1 } }, + { + operation: 'action_failed', + recordId: '2', + previousValues: { form: 1 }, + newValues: { error: 'boom' }, + }, + ]; + const { services, dataSource, options } = setup(history); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = nonAdminContext(); + + await route.handleHistory(context); + + expect((context.response.body as { data: unknown[] }).data).toEqual( + history.map(entry => ({ ...entry, previousValues: {}, newValues: {} })), + ); + }); + + test('keeps operation, author and timestamp visible to a non-admin caller', async () => { + const history = [ + { + operation: 'update', + recordId: '2', + timestamp: '2026-01-02T10:00:00.000Z', + userId: 7, + userEmail: 'author@domain.com', + previousValues: { title: 'Old' }, + newValues: { title: 'New' }, + }, + ]; + const { services, dataSource, options } = setup(history); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = nonAdminContext(); + + await route.handleHistory(context); + + expect(context.response.body).toEqual({ + data: [ + { + operation: 'update', + recordId: '2', + timestamp: '2026-01-02T10:00:00.000Z', + userId: 7, + userEmail: 'author@domain.com', + previousValues: {}, + newValues: {}, + }, + ], + meta: { count: 1, availableUsers: [] }, + }); + }); + + test('keeps the values for an admin caller', async () => { + const history = [ + { + operation: 'update', + recordId: '2', + previousValues: { title: 'Old' }, + newValues: { title: 'New' }, + }, + ]; + const { services, dataSource, options } = setup(history); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + }); + + await route.handleHistory(context); + + expect((context.response.body as { data: unknown[] }).data).toEqual(history); + }); + + test('refuses the state route to a non-admin caller', async () => { + const { services, dataSource, options, store } = setup(); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + const context = nonAdminContext(); + context.request.query = { timezone: 'Europe/Paris', at: '2026-01-02T10:00:00.000Z' }; + + await expect(route.handleStateAt(context)).rejects.toThrow( + new ForbiddenError('Only an admin can read audit-trail values'), + ); + expect(store.listByRecord).not.toHaveBeenCalled(); + }); + }); + describe('handleStateAt', () => { const setupBooks = (history: unknown[] = []) => { const services = factories.forestAdminHttpDriverServices.build(); @@ -941,7 +1045,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', at: '2026-06-18T12:00' }, params: { id: '2' }, @@ -962,7 +1066,7 @@ describe('AuditTrailRoute', () => { const { dataSource, route } = setupBooks(); jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); @@ -973,7 +1077,7 @@ describe('AuditTrailRoute', () => { const { dataSource, route } = setupBooks(); jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', at: '17-06-2026' }, params: { id: '2' }, @@ -991,7 +1095,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { at: '2026-06-18T14:26:06.545Z' }, params: { id: '2' }, @@ -1014,7 +1118,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', at: '2026-06-18T14:26:06.545Z' }, params: { id: '2' }, @@ -1037,7 +1141,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', at: '2026-06-18' }, params: { id: '2' }, @@ -1055,7 +1159,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'Europe/Paris', at: '2026-06-18' }, params: { id: '2' }, @@ -1077,7 +1181,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18T12:00' }, params: { id: '2' }, @@ -1108,7 +1212,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18T11:00:00.000Z' }, params: { id: '2' }, @@ -1140,7 +1244,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18T11:00:00.000Z' }, params: { id: '2' }, @@ -1168,7 +1272,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { at: '2026-06-18T12:00:00.000Z' }, params: { id: '2' }, @@ -1200,7 +1304,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'archived', name: 'Acme Inc.' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, @@ -1218,7 +1322,7 @@ describe('AuditTrailRoute', () => { const { dataSource, route } = setupBooks([]); jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, @@ -1239,7 +1343,7 @@ describe('AuditTrailRoute', () => { .spyOn(dataSource.getCollection('books'), 'list') .mockResolvedValue([{ id: 2, status: 'open', name: 'Acme' }]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, @@ -1262,7 +1366,7 @@ describe('AuditTrailRoute', () => { const { dataSource, route } = setupBooks(history); jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, @@ -1295,7 +1399,7 @@ describe('AuditTrailRoute', () => { .mockResolvedValueOnce([]) // scoped fetch: not found .mockResolvedValueOnce([{ id: 2 }]); // bare check: still exists const context = createMockContext({ - state: { user: { email: 'john.doe@domain.com' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, @@ -1327,7 +1431,7 @@ describe('AuditTrailRoute', () => { .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' } }, + state: { user: { email: 'john.doe@domain.com', permissionLevel: 'admin' } }, customProperties: { query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts index bf3fc8f643..639dec6a52 100644 --- a/packages/agent/test/routes/capabilities.test.ts +++ b/packages/agent/test/routes/capabilities.test.ts @@ -81,6 +81,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsAuditTrailValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [], @@ -107,6 +108,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsAuditTrailValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [], @@ -131,6 +133,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsAuditTrailValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [], @@ -205,6 +208,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsAuditTrailValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [