From 84930422ef5f119aa39991b00b9f75715c69a76a Mon Sep 17 00:00:00 2001 From: Nicolas Bouliol Date: Mon, 7 Sep 2026 16:01:53 +0200 Subject: [PATCH] feat(agent-bff): audit reads and action executions The BFF wrote no activity log at all, so a user fetching data or triggering an action through it left no audit trail. Wrap list, relation list and action execute with the mcp-server pattern: a pending log awaited before the operation, a fire-and-forget status transition after it, blocking a write whose log cannot be created and proceeding on a read. A lazy resolver lands the Forest server bearer for both auth modes in one place, and the drain reachable through stop() keeps a status transition from dying with the process. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/action/action-routes-middleware.ts | 72 +++- .../src/activity-log/activity-log-drainer.ts | 20 ++ .../src/activity-log/activity-log-writer.ts | 43 +++ .../src/activity-log/activity-logs-creator.ts | 216 ++++++++++++ .../src/activity-log/activity-logs-service.ts | 31 ++ .../src/activity-log/with-activity-log.ts | 58 ++++ .../src/api-key/api-key-authenticator.ts | 8 +- .../agent-bff/src/api-key/api-key-client.ts | 14 +- .../src/api-key/api-key-middleware.ts | 1 + packages/agent-bff/src/auth/auth-mode.ts | 12 +- .../auth/forest-server-token-middleware.ts | 92 +++++ packages/agent-bff/src/cli-core.ts | 86 ++++- .../src/data/data-routes-middleware.ts | 54 ++- .../agent-bff/src/http/bff-http-server.ts | 10 + .../agent-bff/src/http/bff-local-errors.ts | 19 +- .../agent-bff/src/openapi/openapi-document.ts | 4 +- .../action/action-routes-activity-log.test.ts | 275 +++++++++++++++ .../action/action-routes-middleware.test.ts | 3 + .../activity-log/activity-log-drainer.test.ts | 45 +++ .../activity-logs-service.test.ts | 19 + .../forest-server-token-middleware.test.ts | 141 ++++++++ packages/agent-bff/test/cli-shutdown.test.ts | 55 +++ .../data/data-routes-activity-log.test.ts | 326 ++++++++++++++++++ .../test/data/data-routes-middleware.test.ts | 3 + .../test/data/fixtures/live-agent-harness.ts | 10 +- .../agent-bff/test/helpers/action-routes.ts | 3 + .../agent-bff/test/helpers/activity-log.ts | 92 +++++ .../test/http/bff-http-server.test.ts | 46 ++- .../test/http/bff-local-errors.test.ts | 11 + .../openapi/openapi-generated-client.test.ts | 3 + 30 files changed, 1730 insertions(+), 42 deletions(-) create mode 100644 packages/agent-bff/src/activity-log/activity-log-drainer.ts create mode 100644 packages/agent-bff/src/activity-log/activity-log-writer.ts create mode 100644 packages/agent-bff/src/activity-log/activity-logs-creator.ts create mode 100644 packages/agent-bff/src/activity-log/activity-logs-service.ts create mode 100644 packages/agent-bff/src/activity-log/with-activity-log.ts create mode 100644 packages/agent-bff/src/auth/forest-server-token-middleware.ts create mode 100644 packages/agent-bff/test/action/action-routes-activity-log.test.ts create mode 100644 packages/agent-bff/test/activity-log/activity-log-drainer.test.ts create mode 100644 packages/agent-bff/test/activity-log/activity-logs-service.test.ts create mode 100644 packages/agent-bff/test/auth/forest-server-token-middleware.test.ts create mode 100644 packages/agent-bff/test/cli-shutdown.test.ts create mode 100644 packages/agent-bff/test/data/data-routes-activity-log.test.ts create mode 100644 packages/agent-bff/test/helpers/activity-log.ts diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 3f02a1a192..babc39a0d0 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -4,6 +4,7 @@ import type { AgentActionClient, AgentActionClientOptions, } from './agent-action-client'; +import type { ActivityLogWriter } from '../activity-log/activity-log-writer'; import type { Logger } from '../ports/logger-port'; import type ReadModelStore from '../read-model/read-model-store'; import type { Context, Middleware } from 'koa'; @@ -27,7 +28,9 @@ import { requireAgentToken, resolveReadModel, } from '../http/agent-route-helpers'; +import { BffHttpError } from '../http/bff-http-error'; import { + ACTION_REQUIRES_APPROVAL_TYPE, actionError, actionRequiresApproval, invalidRequest, @@ -36,6 +39,18 @@ import { const ACTION_ROUTE = /^\/agent\/v1\/([^/]+)\/actions\/([^/]+)\/(form|execute)$/; +const EXECUTE_VERB = 'execute'; + +/** + * An approval request is a business outcome, not a failure: the action was routed for review. The + * BFF answers it with a 403, but recording the entry as `failed` would make the same event count + * differently here and in mcp-server, which records it as a success — and action-failure statistics + * would mix refusals with runs that never happened. + */ +function isApprovalRequest(error: unknown): boolean { + return error instanceof BffHttpError && error.type === ACTION_REQUIRES_APPROVAL_TYPE; +} + interface ActionRequestBody { recordIds?: unknown; values?: unknown; @@ -94,6 +109,7 @@ export interface ActionRoutesMiddlewareOptions { agentUrl: string; timeoutMs?: number; logger: Logger; + activityLogs: ActivityLogWriter; createClient?: (options: AgentActionClientOptions) => AgentActionClient; } @@ -182,6 +198,7 @@ export default function createActionRoutesMiddleware({ agentUrl, timeoutMs, logger, + activityLogs, createClient = defaultCreateAgentActionClient, }: ActionRoutesMiddlewareOptions): Middleware { return async function actionRoutesMiddleware(ctx, next) { @@ -220,23 +237,44 @@ export default function createActionRoutesMiddleware({ timeoutMs, }); - const action = await callAgent( - () => - client.loadAction({ - collection, - actionName, - recordIds, - timezone: ctx.state.timezone as string, - }), - logger, - ); - - const handlerArgs = { ctx, action, values, logger }; - - if (verb === 'execute') { - await handleExecute(handlerArgs); - } else { - await handleForm(handlerArgs); + const loadAction = () => + callAgent( + () => + client.loadAction({ + collection, + actionName, + recordIds, + timezone: ctx.state.timezone as string, + }), + logger, + ); + + // The form is not audited, mirroring mcp-server, whose get-action-form tool writes no log + // either: the record-touching event the trail records is the execution. + if (verb !== EXECUTE_VERB) { + const action = await loadAction(); + + await handleForm({ ctx, action, values, logger }); + + return; } + + // The whole sequence is audited, loadAction and setFields included, so the intent is recorded + // even when the attempt never reaches the agent's execute. + await activityLogs.record({ + ctx, + action: 'action', + context: { + collectionName: collection, + recordIds, + label: `triggered the action "${actionName}"`, + }, + isCompletedDespite: isApprovalRequest, + operation: async () => { + const action = await loadAction(); + + await handleExecute({ ctx, action, values, logger }); + }, + }); }; } diff --git a/packages/agent-bff/src/activity-log/activity-log-drainer.ts b/packages/agent-bff/src/activity-log/activity-log-drainer.ts new file mode 100644 index 0000000000..e890406b84 --- /dev/null +++ b/packages/agent-bff/src/activity-log/activity-log-drainer.ts @@ -0,0 +1,20 @@ +/** + * Holds the status transitions that are fired without `await`. Nothing else keeps them alive: + * `server.close()` waits for connections, and a transition sent after the response is attached to + * none — without this, every deploy would leave entries stuck in `pending`. + */ +export default class ActivityLogDrainer { + private readonly inFlight = new Set>(); + + track(operation: () => Promise): Promise { + const promise = operation(); + this.inFlight.add(promise); + promise.finally(() => this.inFlight.delete(promise)).catch(() => {}); + + return promise; + } + + async drain(): Promise { + await Promise.allSettled([...this.inFlight]); + } +} diff --git a/packages/agent-bff/src/activity-log/activity-log-writer.ts b/packages/agent-bff/src/activity-log/activity-log-writer.ts new file mode 100644 index 0000000000..4dd62e2214 --- /dev/null +++ b/packages/agent-bff/src/activity-log/activity-log-writer.ts @@ -0,0 +1,43 @@ +import type { ActivityLogContext, BffActivityLogAction } from './activity-logs-creator'; +import type { ActivityLogsWriter } from './activity-logs-service'; +import type { Logger } from '../ports/logger-port'; +import type { Context } from 'koa'; + +import ActivityLogDrainer from './activity-log-drainer'; +import withActivityLog from './with-activity-log'; + +export interface RecordActivityLogOptions { + ctx: Context; + action: BffActivityLogAction; + context?: ActivityLogContext; + operation: () => Promise; + isCompletedDespite?: (error: unknown) => boolean; +} + +export interface ActivityLogWriter { + record(options: RecordActivityLogOptions): Promise; + /** Waits for the status transitions no connection holds. Called when the server stops. */ + drain(): Promise; +} + +export interface ActivityLogWriterOptions { + service: ActivityLogsWriter; + logger: Logger; +} + +export default function createActivityLogWriter({ + service, + logger, +}: ActivityLogWriterOptions): ActivityLogWriter { + const drainer = new ActivityLogDrainer(); + + return { + record(options: RecordActivityLogOptions): Promise { + return withActivityLog({ ...options, service, drainer, logger }); + }, + + drain(): Promise { + return drainer.drain(); + }, + }; +} diff --git a/packages/agent-bff/src/activity-log/activity-logs-creator.ts b/packages/agent-bff/src/activity-log/activity-logs-creator.ts new file mode 100644 index 0000000000..2fdb2d5162 --- /dev/null +++ b/packages/agent-bff/src/activity-log/activity-logs-creator.ts @@ -0,0 +1,216 @@ +import type ActivityLogDrainer from './activity-log-drainer'; +import type { ActivityLogsWriter } from './activity-logs-service'; +import type { Logger } from '../ports/logger-port'; +import type { + ActivityLogAction, + ActivityLogResponse, + ActivityLogType, +} from '@forestadmin/forestadmin-client'; +import type { Context } from 'koa'; + +import { HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; + +import { + resolveForestServerToken, + resolveRenderingId, +} from '../auth/forest-server-token-middleware'; +import { sessionExpired } from '../http/bff-http-error'; +import { + AUDIT_RETRY_AFTER_SECONDS, + auditNotAuthorized, + auditUnavailable, +} from '../http/bff-local-errors'; + +/** The actions the BFF writes: its data routes read, and its action route writes. */ +export type BffActivityLogAction = Extract< + ActivityLogAction, + 'index' | 'search' | 'filter' | 'listRelatedData' | 'action' +>; + +/** + * Fail policy for the audit trail, keyed by action type: a write whose activity log cannot be + * created is blocked (no unaudited side effect), while a read proceeds with a warning (an audit + * store outage must not take down the read surface). + * + * One case is arbitrated by the cause instead of the action type: an authorization refusal + * (401/403) propagates for reads too — the read itself is not authorized either. + */ +const ACTION_TO_TYPE: Record = { + index: 'read', + search: 'read', + filter: 'read', + listRelatedData: 'read', + action: 'write', +}; + +const MAX_STATUS_ATTEMPTS = 5; +const STATUS_RETRY_DELAY_MS = 500; + +const NO_RENDERING_MESSAGE = 'This request carries no usable rendering'; + +export interface ActivityLogContext { + collectionName?: string; + recordId?: string | number; + recordIds?: string[] | number[]; + label?: string; +} + +/** + * The token that created the log is kept for the status transition: the transition is fired after + * the response, when the session it came from may already be unreachable. + */ +export interface PendingActivityLog { + activityLog: ActivityLogResponse; + forestServerToken: string; +} + +export interface CreatePendingActivityLogOptions { + ctx: Context; + service: ActivityLogsWriter; + action: BffActivityLogAction; + context?: ActivityLogContext; + logger: Logger; +} + +interface AuditCredentials { + forestServerToken: string; + renderingId: string; +} + +function describeCause(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +function isAuthorizationRefusal(error: unknown): boolean { + return error instanceof HttpError && (error.status === 401 || error.status === 403); +} + +async function resolveCredentials(ctx: Context): Promise { + const renderingId = resolveRenderingId(ctx); + + if (renderingId === undefined) throw sessionExpired(NO_RENDERING_MESSAGE); + + return { + forestServerToken: await resolveForestServerToken(ctx), + renderingId: String(renderingId), + }; +} + +export default async function createPendingActivityLog({ + ctx, + service, + action, + context, + logger, +}: CreatePendingActivityLogOptions): Promise { + const type = ACTION_TO_TYPE[action]; + + let credentials: AuditCredentials; + + try { + credentials = await resolveCredentials(ctx); + } catch (error) { + logger('Error', `Activity log for '${action}' has no credentials to be created with`, { + cause: describeCause(error), + }); + + if (type === 'write') throw error; + + return null; + } + + const { forestServerToken, renderingId } = credentials; + + let activityLog: ActivityLogResponse; + + try { + activityLog = await service.createMcpActivityLog({ + forestServerToken, + renderingId, + action, + type, + collectionName: context?.collectionName, + recordId: context?.recordId, + recordIds: context?.recordIds, + label: context?.label, + }); + } catch (error) { + logger('Error', `Activity log for '${action}' could not be created`, { + cause: describeCause(error), + }); + + if (isAuthorizationRefusal(error)) throw auditNotAuthorized(); + if (type === 'write') throw auditUnavailable(AUDIT_RETRY_AFTER_SECONDS); + + return null; + } + + if (activityLog?.id === null || activityLog?.id === undefined) { + if (type === 'write') throw auditUnavailable(AUDIT_RETRY_AFTER_SECONDS); + + logger( + 'Error', + `Activity log for '${action}' could not be created: the server answered with no activity ` + + 'log id, so the audit store dropped the write', + ); + + return null; + } + + return { activityLog, forestServerToken }; +} + +export interface MarkActivityLogOptions { + service: ActivityLogsWriter; + drainer: ActivityLogDrainer; + pending: PendingActivityLog; + status: 'completed' | 'failed'; + logger: Logger; +} + +async function updateStatus(options: MarkActivityLogOptions, attempt = 1): Promise { + const { service, pending, status, logger } = options; + + try { + await service.updateActivityLogStatus({ + forestServerToken: pending.forestServerToken, + activityLog: pending.activityLog, + status, + }); + } catch (error) { + // The document may not exist yet when the transition lands, and only then is a retry worth + // anything: a network failure loses the transition permanently. + if (error instanceof NotFoundError && attempt < MAX_STATUS_ATTEMPTS) { + logger('Debug', `Activity log not found, retrying its status transition`, { + attempt, + attempts: MAX_STATUS_ATTEMPTS, + }); + + await new Promise(resolve => { + setTimeout(resolve, STATUS_RETRY_DELAY_MS); + }); + + await updateStatus(options, attempt + 1); + + return; + } + + throw error; + } +} + +/** + * Fire-and-forget on purpose: the caller's response must not wait for the audit store. The drainer + * holds the promise so a shutdown can wait for it instead. + */ +export function markActivityLog(options: MarkActivityLogOptions): void { + const { drainer, status, logger } = options; + + drainer + .track(() => updateStatus(options)) + .catch(error => { + logger('Error', `Failed to mark the activity log as '${status}'`, { + cause: describeCause(error), + }); + }); +} diff --git a/packages/agent-bff/src/activity-log/activity-logs-service.ts b/packages/agent-bff/src/activity-log/activity-logs-service.ts new file mode 100644 index 0000000000..a9feaabde1 --- /dev/null +++ b/packages/agent-bff/src/activity-log/activity-logs-service.ts @@ -0,0 +1,31 @@ +import type { + ActivityLogResponse, + CreateActivityLogParams, + UpdateActivityLogStatusParams, +} from '@forestadmin/forestadmin-client'; + +import { ActivityLogsService, ForestHttpApi } from '@forestadmin/forestadmin-client'; + +export const APPLICATION_SOURCE_HEADER = 'Forest-Application-Source'; +export const BFF_APPLICATION_SOURCE = 'BFF'; + +/** + * The slice of `ActivityLogsService` the BFF uses. Named so a fake can stand in for the two calls + * without carrying the rest of the Forest client. + */ +export interface ActivityLogsWriter { + createMcpActivityLog(params: CreateActivityLogParams): Promise; + updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise; +} + +/** + * Its own instance rather than the client `oauth/forest-server-client.ts` already holds: + * `ForestAdminClientOptions` carries no `headers`, so that one cannot tell the server which channel + * wrote the log. + */ +export default function createBffActivityLogsService(forestServerUrl: string): ActivityLogsWriter { + return new ActivityLogsService(new ForestHttpApi(), { + forestServerUrl, + headers: { [APPLICATION_SOURCE_HEADER]: BFF_APPLICATION_SOURCE }, + }); +} diff --git a/packages/agent-bff/src/activity-log/with-activity-log.ts b/packages/agent-bff/src/activity-log/with-activity-log.ts new file mode 100644 index 0000000000..22af758f29 --- /dev/null +++ b/packages/agent-bff/src/activity-log/with-activity-log.ts @@ -0,0 +1,58 @@ +import type ActivityLogDrainer from './activity-log-drainer'; +import type { ActivityLogContext, BffActivityLogAction } from './activity-logs-creator'; +import type { ActivityLogsWriter } from './activity-logs-service'; +import type { Logger } from '../ports/logger-port'; +import type { Context } from 'koa'; + +import createPendingActivityLog, { markActivityLog } from './activity-logs-creator'; + +const COMPLETED = 'completed'; +const FAILED = 'failed'; + +export interface WithActivityLogOptions { + ctx: Context; + service: ActivityLogsWriter; + drainer: ActivityLogDrainer; + action: BffActivityLogAction; + context?: ActivityLogContext; + logger: Logger; + operation: () => Promise; + /** + * Errors the log records as `completed` rather than `failed` — the operation reached a business + * outcome the BFF answers with an error status. + */ + isCompletedDespite?: (error: unknown) => boolean; +} + +/** + * Runs an operation under an activity log: the pending log is awaited before the operation starts, + * so nothing runs unaudited, and the status transition is fired without `await` afterwards. + */ +export default async function withActivityLog(options: WithActivityLogOptions): Promise { + const { ctx, service, drainer, action, context, logger, operation, isCompletedDespite } = options; + + const pending = await createPendingActivityLog({ ctx, service, action, context, logger }); + + if (!pending) { + logger( + 'Warn', + `Activity log for '${action}' was not created; proceeding without an audit trail for this ` + + 'read operation', + ); + } + + try { + const result = await operation(); + + if (pending) markActivityLog({ service, drainer, pending, status: COMPLETED, logger }); + + return result; + } catch (error) { + if (pending) { + const status = isCompletedDespite?.(error) ? COMPLETED : FAILED; + markActivityLog({ service, drainer, pending, status, logger }); + } + + throw error; + } +} diff --git a/packages/agent-bff/src/api-key/api-key-authenticator.ts b/packages/agent-bff/src/api-key/api-key-authenticator.ts index a6c540de02..a15255dda3 100644 --- a/packages/agent-bff/src/api-key/api-key-authenticator.ts +++ b/packages/agent-bff/src/api-key/api-key-authenticator.ts @@ -25,6 +25,8 @@ export interface ApiKeyAuthenticatorOptions { export interface AuthenticatedApiKey { agentToken: string; identity: ResolvedApiKeyIdentity; + /** The Forest server token the resolve response carried, cached with the identity. */ + forestServerToken?: string; } export interface ApiKeyAuthenticator { @@ -54,7 +56,11 @@ export default function createApiKeyAuthenticator({ authSecret, }: ApiKeyAuthenticatorOptions): ApiKeyAuthenticator { function mint(identity: ResolvedApiKeyIdentity): AuthenticatedApiKey { - return { agentToken: issueAgentToken({ identity, authSecret }), identity }; + return { + agentToken: issueAgentToken({ identity, authSecret }), + identity, + forestServerToken: identity.saasAccessToken, + }; } return { diff --git a/packages/agent-bff/src/api-key/api-key-client.ts b/packages/agent-bff/src/api-key/api-key-client.ts index f6ef887c51..d58cc59d11 100644 --- a/packages/agent-bff/src/api-key/api-key-client.ts +++ b/packages/agent-bff/src/api-key/api-key-client.ts @@ -17,6 +17,12 @@ export interface ResolvedApiKeyIdentity { user: ApiKeyIdentityUser; renderingId: number; allowedOrigins: string[]; + /** + * Short-lived, user-scoped Forest server token, used to write the activity log. Optional so a + * Forest server that does not send one yet still resolves keys: the audit trail then degrades on + * its own terms (a read proceeds unaudited, a write is blocked) instead of taking auth down. + */ + saasAccessToken?: string; } export interface ApiKeyClientOptions { @@ -90,12 +96,18 @@ export default class ApiKeyClient { private static isResolvedIdentity(body: unknown): body is ResolvedApiKeyIdentity { if (typeof body !== 'object' || body === null) return false; - const candidate = body as { user?: unknown; renderingId?: unknown; allowedOrigins?: unknown }; + const candidate = body as { + user?: unknown; + renderingId?: unknown; + allowedOrigins?: unknown; + saasAccessToken?: unknown; + }; return ( typeof candidate.renderingId === 'number' && Array.isArray(candidate.allowedOrigins) && candidate.allowedOrigins.every(entry => typeof entry === 'string') && + (candidate.saasAccessToken === undefined || typeof candidate.saasAccessToken === 'string') && ApiKeyClient.isIdentityUser(candidate.user) ); } diff --git a/packages/agent-bff/src/api-key/api-key-middleware.ts b/packages/agent-bff/src/api-key/api-key-middleware.ts index fdc60da445..84231b0b8d 100644 --- a/packages/agent-bff/src/api-key/api-key-middleware.ts +++ b/packages/agent-bff/src/api-key/api-key-middleware.ts @@ -50,6 +50,7 @@ export default function createApiKeyMiddleware({ ctx.state.agentToken = authenticated.agentToken; ctx.state.apiKeyIdentity = authenticated.identity; + ctx.state.forestServerToken = authenticated.forestServerToken; ctx.set('Cache-Control', 'no-store'); logger('Info', 'Resolved BFF API key', { keyHash: fingerprintApiKey(rawKey), diff --git a/packages/agent-bff/src/auth/auth-mode.ts b/packages/agent-bff/src/auth/auth-mode.ts index 5752427310..a118763eb2 100644 --- a/packages/agent-bff/src/auth/auth-mode.ts +++ b/packages/agent-bff/src/auth/auth-mode.ts @@ -7,12 +7,20 @@ export type AuthMode = 'oauth' | 'api-key'; const BEARER_PATTERN = /^Bearer[ \t]+(.+)$/i; const POSITIVE_INTEGER = /^[1-9]\d*$/; +export function readRenderingId(principal: BffAccessTokenPayload): number | undefined { + if (!POSITIVE_INTEGER.test(String(principal.rendering_id))) return undefined; + + return Number(principal.rendering_id); +} + export function requireRenderingId(principal: BffAccessTokenPayload): number { - if (!POSITIVE_INTEGER.test(String(principal.rendering_id))) { + const renderingId = readRenderingId(principal); + + if (renderingId === undefined) { throw unauthorized('The session carries no usable rendering'); } - return Number(principal.rendering_id); + return renderingId; } export function extractBearerToken(authorization: string | undefined): string | undefined { diff --git a/packages/agent-bff/src/auth/forest-server-token-middleware.ts b/packages/agent-bff/src/auth/forest-server-token-middleware.ts new file mode 100644 index 0000000000..455383500a --- /dev/null +++ b/packages/agent-bff/src/auth/forest-server-token-middleware.ts @@ -0,0 +1,92 @@ +import type { ResolvedApiKeyIdentity } from '../api-key/api-key-client'; +import type { BffAccessTokenPayload } from '../oauth/bff-token'; +import type ForestServerClient from '../oauth/forest-server-client'; +import type { SessionStore } from '../oauth/session-store'; +import type { Context, Middleware } from 'koa'; + +import { readRenderingId } from './auth-mode'; +import { sessionExpired } from '../http/bff-http-error'; +import { AUDIT_RETRY_AFTER_SECONDS, auditUnavailable } from '../http/bff-local-errors'; +import ensureFreshServerAccess from '../oauth/session-lifecycle'; + +export type ForestServerTokenResolver = () => Promise; + +export interface OAuthSessionAccess { + store: SessionStore; + serverClient: ForestServerClient; +} + +export interface ForestServerTokenMiddlewareOptions { + session?: OAuthSessionAccess; +} + +const NO_SESSION_MESSAGE = 'The session behind this request could not be resolved'; +const NO_RESOLVER_MESSAGE = 'This request carries no Forest server credentials'; + +async function resolveToken(ctx: Context, session?: OAuthSessionAccess): Promise { + if (ctx.state.authMode === 'api-key') { + const token = ctx.state.forestServerToken as string | undefined; + + if (!token) throw auditUnavailable(AUDIT_RETRY_AFTER_SECONDS); + + return token; + } + + const principal = ctx.state.principal as BffAccessTokenPayload | undefined; + + if (!principal || !session) throw sessionExpired(NO_SESSION_MESSAGE); + + try { + return await ensureFreshServerAccess({ + sid: principal.sid, + store: session.store, + serverClient: session.serverClient, + }); + } catch { + throw sessionExpired(NO_SESSION_MESSAGE); + } +} + +/** + * Lands a lazy resolver of the Forest server bearer on the context, for both auth modes. Lazy on + * purpose: the routes that audit nothing — /health, the permissions, context, OpenAPI and docs + * routes — must not pay a session lookup, and the permissions one is hit on every page load. + * + * Keeping both modes here is what lets the data and action routes read one function off the context + * instead of taking the session store and the Forest server client as dependencies. + */ +export default function createForestServerTokenMiddleware({ + session, +}: ForestServerTokenMiddlewareOptions): Middleware { + return async function forestServerTokenMiddleware(ctx, next) { + let pending: Promise | undefined; + + const resolver: ForestServerTokenResolver = () => { + pending ??= resolveToken(ctx, session); + + return pending; + }; + + ctx.state.resolveForestServerToken = resolver; + + await next(); + }; +} + +export function resolveForestServerToken(ctx: Context): Promise { + const resolver = ctx.state.resolveForestServerToken as ForestServerTokenResolver | undefined; + + if (!resolver) throw sessionExpired(NO_RESOLVER_MESSAGE); + + return resolver(); +} + +export function resolveRenderingId(ctx: Context): number | undefined { + if (ctx.state.authMode === 'api-key') { + return (ctx.state.apiKeyIdentity as ResolvedApiKeyIdentity | undefined)?.renderingId; + } + + const principal = ctx.state.principal as BffAccessTokenPayload | undefined; + + return principal ? readRenderingId(principal) : undefined; +} diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 50031bbb16..5b7d94fc87 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -1,3 +1,4 @@ +import type { ActivityLogWriter } from './activity-log/activity-log-writer'; import type { BFFConfig } from './config/env-config'; import type { SessionStore } from './oauth/session-store'; import type { UnfoldSource } from './openapi/unfolded-document'; @@ -9,6 +10,8 @@ import type { Middleware } from 'koa'; import { bodyParser } from '@koa/bodyparser'; import createActionRoutesMiddleware from './action/action-routes-middleware'; +import createActivityLogWriter from './activity-log/activity-log-writer'; +import createBffActivityLogsService from './activity-log/activity-logs-service'; import createConsoleLogger from './adapters/console-logger'; import createAgentStubMiddleware from './agent/agent-stub'; import AiProxyClient from './ai/ai-proxy-client'; @@ -18,6 +21,7 @@ import ApiKeyClient from './api-key/api-key-client'; import createApiKeyMiddleware from './api-key/api-key-middleware'; import createResolveCache from './api-key/resolve-cache'; import createAuthModeMiddleware from './auth/auth-mode-middleware'; +import createForestServerTokenMiddleware from './auth/forest-server-token-middleware'; import { parseConfig } from './config/env-config'; import createContextRoutesMiddleware from './context/context-routes-middleware'; import createCorsMiddleware from './cors/cors-middleware'; @@ -284,19 +288,26 @@ export function resolveUnfoldSource(config: BFFConfig, logger: Logger): UnfoldSo return toUnfoldSource(resolveReadModelBundle(config, logger, UNMEASURED), config, logger); } +// The routes that write an activity log come with the writer holding their pending transitions, so +// the server can drain it when it stops. +interface AgentRouteEdge { + middlewares: Middleware[]; + activityLogs?: ActivityLogWriter; +} + // The data middleware falls through to the action middleware on a non-data path. function buildAgentRouteMiddlewares( bundle: ReadModelBundle | undefined, config: BFFConfig, logger: Logger, -): Middleware[] { +): AgentRouteEdge { if (!bundle) { logger( 'Warn', 'Data, action and permissions endpoints disabled: FOREST_SERVER_URL, FOREST_ENV_SECRET or FOREST_AUTH_SECRET is missing', ); - return [createAgentStubMiddleware()]; + return { middlewares: [createAgentStubMiddleware()] }; } const { store, apiKeyConfig } = bundle; @@ -315,14 +326,22 @@ function buildAgentRouteMiddlewares( if (!agentUrl) { logger('Warn', 'Data and action endpoints disabled: AGENT_URL is missing'); - return [permissionsMiddleware, createAgentStubMiddleware()]; + return { middlewares: [permissionsMiddleware, createAgentStubMiddleware()] }; } - return [ - permissionsMiddleware, - createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), - createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), - ]; + const activityLogs = createActivityLogWriter({ + service: createBffActivityLogsService(apiKeyConfig.forestServerUrl), + logger, + }); + + return { + middlewares: [ + permissionsMiddleware, + createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger, activityLogs }), + createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger, activityLogs }), + ], + activityLogs, + }; } function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger): Middleware[] { @@ -355,14 +374,14 @@ function buildAgentMiddlewares( logger: Logger, oauth: OAuthEdge, aiMiddlewares: Middleware[], -): Middleware[] { +): AgentRouteEdge { const { forestAuthSecret, defaultTimezone } = config; const { environmentId } = oauth; if (!forestAuthSecret) { logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); - return []; + return { middlewares: [] }; } const apiKeyStep = buildApiKeyMiddleware(config, logger) ?? createApiKeyUnavailableGuard(logger); @@ -371,9 +390,13 @@ function buildAgentMiddlewares( const bundle = resolveReadModelBundle(config, logger); const source = toUnfoldSource(bundle, config, logger); + const routeEdge = buildAgentRouteMiddlewares(bundle, config, logger); + const chain: Middleware[] = [ createAuthModeMiddleware({ authSecret: forestAuthSecret }), apiKeyStep, + // After both auth middlewares: the resolver it lands reads what they put on the context. + createForestServerTokenMiddleware({ session: oauth.session }), createRateLimitMiddleware({ maxRequests: config.rateLimitMaxRequests, windowMs: config.rateLimitWindowMs, @@ -389,10 +412,44 @@ function buildAgentMiddlewares( ...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []), ...aiMiddlewares, createTimezoneMiddleware({ defaultTimezone }), - ...buildAgentRouteMiddlewares(bundle, config, logger), + ...routeEdge.middlewares, ]; - return chain.map(agentScoped); + return { middlewares: chain.map(agentScoped), activityLogs: routeEdge.activityLogs }; +} + +const SHUTDOWN_SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + +let installedShutdownHandlers: { signal: NodeJS.Signals; handler: () => void }[] = []; + +/** + * Routes a termination signal to `stop()`, which drains the activity-log transitions no connection + * holds. Registered here rather than in the server: an embedded deployment does not own the process + * signals, so the drain has to be reachable through `stop()` instead. + * + * A process runs one BFF, so a second call replaces the handlers instead of adding a pair: the + * signal must reach the server that is listening, and nothing else. + */ +export function installShutdownHandlers(server: BFFHttpServer, logger: Logger): void { + for (const { signal, handler } of installedShutdownHandlers) { + process.removeListener(signal, handler); + } + + installedShutdownHandlers = SHUTDOWN_SIGNALS.map(signal => { + const handler = () => { + logger('Info', 'Stopping the Forest BFF', { signal }); + + server.stop().catch(error => { + logger('Error', 'The Forest BFF did not stop cleanly', { + cause: extractErrorMessage(error), + }); + }); + }; + + process.on(signal, handler); + + return { signal, handler }; + }); } export default async function runCli( @@ -409,7 +466,8 @@ export default async function runCli( const oauth = await buildOAuthMiddlewares(config, logger); const aiMiddlewares = buildAiMiddlewares(config, oauth, logger); - const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares); + const agentEdge = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares); + const agentMiddlewares = agentEdge.middlewares; const hasAgentEdge = agentMiddlewares.length > 0; const agentErrorMiddleware = hasAgentEdge ? [agentScoped(createErrorMiddleware({ logger }))] : []; const agentJsonOnlyGuard = hasAgentEdge ? [agentScoped(createJsonOnlyGuard())] : []; @@ -435,9 +493,11 @@ export default async function runCli( config, logger, middlewares, + drainActivityLogs: agentEdge.activityLogs && (() => agentEdge.activityLogs.drain()), }); await server.start(); + installShutdownHandlers(server, logger); return server; } diff --git a/packages/agent-bff/src/data/data-routes-middleware.ts b/packages/agent-bff/src/data/data-routes-middleware.ts index be4915cca9..81385a859e 100644 --- a/packages/agent-bff/src/data/data-routes-middleware.ts +++ b/packages/agent-bff/src/data/data-routes-middleware.ts @@ -5,6 +5,8 @@ import type { RelationCountRequestBody, RelationListRequestBody, } from './agent-query'; +import type { ActivityLogWriter } from '../activity-log/activity-log-writer'; +import type { BffActivityLogAction } from '../activity-log/activity-logs-creator'; import type { Logger } from '../ports/logger-port'; import type { CapabilitiesResult } from '../read-model/capabilities-cache'; import type ReadModel from '../read-model/read-model'; @@ -46,6 +48,7 @@ export interface DataRoutesMiddlewareOptions { agentUrl: string; timeoutMs?: number; logger: Logger; + activityLogs: ActivityLogWriter; createClient?: (options: AgentDataClientOptions) => AgentDataClient; } @@ -58,6 +61,7 @@ interface RequestHandlerDeps { token: string; timezone: string; logger: Logger; + activityLogs: ActivityLogWriter; } type ListHandlerDeps = RequestHandlerDeps & { primaryKeys: PrimaryKeyField[] }; @@ -139,7 +143,14 @@ async function resolveOwnCapabilities( return result; } -async function handleList(ctx: Context, body: ListRequestBody, deps: ListHandlerDeps) { +function selectListAction(body: ListRequestBody): BffActivityLogAction { + if (body.search) return 'search'; + if (body.filter) return 'filter'; + + return 'index'; +} + +async function listRecords(ctx: Context, body: ListRequestBody, deps: ListHandlerDeps) { assertNoRelationFieldPaths(collectListFieldPaths(body)); const validationInput = toValidationInput(body); @@ -162,6 +173,15 @@ async function handleList(ctx: Context, body: ListRequestBody, deps: ListHandler ctx.body = mapListResponse(deps.collection, records, primaryKeys); } +async function handleList(ctx: Context, body: ListRequestBody, deps: ListHandlerDeps) { + await deps.activityLogs.record({ + ctx, + action: selectListAction(body), + context: { collectionName: deps.collection }, + operation: () => listRecords(ctx, body, deps), + }); +} + async function handleCount(ctx: Context, body: CountRequestBody, deps: RequestHandlerDeps) { assertNoRelationFieldPaths(collectCountFieldPaths(body)); @@ -217,7 +237,18 @@ async function resolveExposedRelationCapabilities( return result; } -async function handleRelationList( +function relationListLabel(relation: string, body: RelationListRequestBody): string { + const refinements: string[] = []; + + if (body.search) refinements.push('search'); + if (body.filter) refinements.push('filter'); + + const suffix = refinements.length > 0 ? ` with ${refinements.join(' and ')}` : ''; + + return `list relation "${relation}"${suffix}`; +} + +async function listRelatedRecords( ctx: Context, body: RelationListRequestBody, deps: RelationListHandlerDeps, @@ -244,6 +275,23 @@ async function handleRelationList( ctx.body = mapListResponse(deps.foreignCollection, records, primaryKeys); } +async function handleRelationList( + ctx: Context, + body: RelationListRequestBody, + deps: RelationListHandlerDeps, +) { + await deps.activityLogs.record({ + ctx, + action: 'listRelatedData', + context: { + collectionName: deps.collection, + recordId: body.parentId, + label: relationListLabel(deps.relation, body), + }, + operation: () => listRelatedRecords(ctx, body, deps), + }); +} + async function handleRelationCount( ctx: Context, body: RelationCountRequestBody, @@ -309,6 +357,7 @@ export default function createDataRoutesMiddleware({ agentUrl, timeoutMs, logger, + activityLogs, createClient = defaultCreateAgentDataClient, }: DataRoutesMiddlewareOptions): Middleware { return async function dataRoutesMiddleware(ctx, next) { @@ -343,6 +392,7 @@ export default function createDataRoutesMiddleware({ token, timezone: ctx.state.timezone as string, logger, + activityLogs, }; const rawBody = ctx.request.body ?? {}; diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index 7f6a086121..26b2224359 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -14,6 +14,11 @@ export interface BFFHttpServerOptions { config: BFFConfig; logger?: Logger; middlewares?: Middleware[]; + /** + * Waits for the work no connection holds: the activity-log status transitions are fired without + * `await`, so `close()` does not cover them and a shutdown would leave entries `pending`. + */ + drainActivityLogs?: () => Promise; } export default class BFFHttpServer { @@ -88,6 +93,11 @@ export default class BFFHttpServer { } async stop(): Promise { + await this.closeConnections(); + await this.options.drainActivityLogs?.(); + } + + private async closeConnections(): Promise { return new Promise((resolve, reject) => { if (!this.server) { resolve(); diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index 5189065f3c..3746d22263 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -101,9 +101,26 @@ export function tooManyRequests( }); } +export const ACTION_REQUIRES_APPROVAL_TYPE = 'action_requires_approval'; + export function actionRequiresApproval( message = 'This action requires an approval before it can run', details?: unknown, ): BffHttpError { - return new BffHttpError(403, 'action_requires_approval', message, { details }); + return new BffHttpError(403, ACTION_REQUIRES_APPROVAL_TYPE, message, { details }); +} + +export const AUDIT_RETRY_AFTER_SECONDS = 5; + +export function auditUnavailable( + retryAfter: number, + message = 'The activity log could not be written, so the operation was not performed', +): BffHttpError { + return new BffHttpError(503, 'audit_unavailable', message, { retryAfter }); +} + +export function auditNotAuthorized( + message = 'Not authorized to write the activity log for this request', +): BffHttpError { + return new BffHttpError(403, 'audit_not_authorized', message); } diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 0fe022d54c..88ffbf0f27 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -39,7 +39,7 @@ const SECURITY = [{ [SESSION_SCHEME]: [] }, { [API_KEY_SCHEME]: [] }]; const ERROR_STATUSES: Record = { 400: 'Malformed body, a malformed URL-encoded path segment, an invalid filter operator, a filter nested too deep, ambiguous credentials, an unsupported page, a missing or invalid timezone, an unknown submitted action field, a required action field left empty or a malformed file value at execute, or a rejected action form (type action_error)', 401: 'Missing, invalid, or expired credentials', - 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, or the agent refused the collection, relation, or action', + 403: 'The action needs approval before it runs (the body carries the approving roles), the Forest identity behind the API key is not allowed, the origin is not allowed for this key, the Forest server refused to write the activity log the request needs (type audit_not_authorized), or the agent refused the collection, relation, or action', 404: 'Unknown collection, relation, or action', 413: `The request body exceeds the BFF limit of ${BODY_LIMIT}`, 415: 'The request Content-Type is neither application/json nor an application/*+json type, including form-urlencoded, and is rejected with 415 instead of being silently dropped; a request carrying a body with no Content-Type at all is rejected the same way; or the declared character set cannot be decoded', @@ -48,7 +48,7 @@ const ERROR_STATUSES: Record = { 500: 'The agent payload could not be mapped to the BFF contract, or the BFF hit an unexpected error', 501: 'The BFF is running without an agent configured, so the proxy is not implemented', 502: 'The agent refused the connection, its host could not be resolved, or the transport failed another way (a connection reset mid-flight, a socket hang up, a TLS failure) — it failed outright rather than running out of time', - 503: 'The agent schema is unavailable, the agent returned a 5xx, the API key could not be resolved, or the Forest permissions could not be fetched and no fresh cache was left (type permissions_unavailable)', + 503: 'The agent schema is unavailable, the agent returned a 5xx, the API key could not be resolved, the activity log an action execution must be recorded in could not be written, so the action was not run (type audit_unavailable), or the Forest permissions could not be fetched and no fresh cache was left (type permissions_unavailable)', 504: 'The agent did not answer before the BFF timeout (BFF_AGENT_TIMEOUT_MS, 10s by default). The deadline is armed when the request starts, so at the default it also covers a host that accepts nothing and never resets the connection — raise the timeout past the OS connect timeout and that case reverts to 502', }; diff --git a/packages/agent-bff/test/action/action-routes-activity-log.test.ts b/packages/agent-bff/test/action/action-routes-activity-log.test.ts new file mode 100644 index 0000000000..902c8d2bf4 --- /dev/null +++ b/packages/agent-bff/test/action/action-routes-activity-log.test.ts @@ -0,0 +1,275 @@ +import type { AgentActionClient } from '../../src/action/agent-action-client'; +import type { ActivityLogWriter } from '../../src/activity-log/activity-log-writer'; +import type { Logger } from '../../src/ports/logger-port'; +import type { Middleware } from 'koa'; + +import { ActionRequiresApprovalError } from '@forestadmin/agent-client'; +import { HttpError } from '@forestadmin/forestadmin-client'; +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; +import request from 'supertest'; + +import createActionRoutesMiddleware from '../../src/action/action-routes-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import { TIMEZONE, clientOf, makeAction, readModel, storeOf } from '../helpers/action-routes'; +import { + ACTIVITY_LOG_ID, + ACTIVITY_LOG_INDEX, + API_KEY_SERVER_TOKEN, + RENDERING_ID, + activityLogsOf, + apiKeyCredentials, + fakeActivityLogsService, + forestServerTokenStep, + oauthCredentials, + sessionAccessToken, +} from '../helpers/activity-log'; + +const noopLogger: Logger = () => undefined; + +function buildApp({ + service, + client, + credentials = apiKeyCredentials(), + saasAccessToken, +}: { + service: ReturnType; + client: AgentActionClient; + credentials?: Middleware; + saasAccessToken?: string; +}): { app: Koa; activityLogs: ActivityLogWriter } { + const activityLogs = activityLogsOf(service, noopLogger); + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: noopLogger })); + app.use(bodyParser()); + app.use(credentials); + app.use(forestServerTokenStep(saasAccessToken)); + app.use(async (ctx, next) => { + ctx.state.timezone = TIMEZONE; + ctx.state.agentToken = 'agent-jwt'; + await next(); + }); + app.use( + createActionRoutesMiddleware({ + store: storeOf(readModel), + agentUrl: 'https://agent.example.com', + logger: noopLogger, + activityLogs, + createClient: () => client, + }), + ); + + return { app, activityLogs }; +} + +function executingAction() { + return makeAction({ execute: jest.fn(async () => ({ success: 'Done' })) }); +} + +describe('action routes activity log', () => { + describe('when executing an action', () => { + it('should record the action, its records and its label', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: clientOf(executingAction()) }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42', '43'] }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).toHaveBeenCalledWith({ + forestServerToken: API_KEY_SERVER_TOKEN, + renderingId: String(RENDERING_ID), + action: 'action', + type: 'write', + collectionName: 'users', + recordId: undefined, + recordIds: ['42', '43'], + label: 'triggered the action "approve"', + }); + }); + + it('should mark the log completed once the action ran', async () => { + const service = fakeActivityLogsService(); + const { app, activityLogs } = buildApp({ service, client: clientOf(executingAction()) }); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + await activityLogs.drain(); + + expect(service.updateActivityLogStatus).toHaveBeenCalledWith({ + forestServerToken: API_KEY_SERVER_TOKEN, + activityLog: { id: ACTIVITY_LOG_ID, attributes: { index: ACTIVITY_LOG_INDEX } }, + status: 'completed', + }); + }); + + it('should mark the log failed when the action throws', async () => { + const service = fakeActivityLogsService(); + const form = makeAction({ + execute: jest.fn(async () => { + throw new Error('the agent is down'); + }), + }); + const { app, activityLogs } = buildApp({ service, client: clientOf(form) }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + await activityLogs.drain(); + + expect(response.status).toBe(502); + expect(service.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }), + ); + }); + + it('should mark the log failed when the action cannot even be loaded', async () => { + const service = fakeActivityLogsService(); + const loadAction = jest.fn(async () => { + throw new Error('the agent is down'); + }); + const { app, activityLogs } = buildApp({ + service, + client: clientOf(executingAction(), loadAction as jest.Mock), + }); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + await activityLogs.drain(); + + expect(service.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }), + ); + }); + + it('should mark the log completed when the action was routed for approval', async () => { + const service = fakeActivityLogsService(); + const form = makeAction({ + execute: jest.fn(async () => { + throw new ActionRequiresApprovalError('Needs approval', [7]); + }), + }); + const { app, activityLogs } = buildApp({ service, client: clientOf(form) }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + await activityLogs.drain(); + + expect(response.status).toBe(403); + expect(response.body.error.type).toBe('action_requires_approval'); + expect(service.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }), + ); + }); + + it('should refuse with audit_unavailable and never reach the agent when the log cannot be created', async () => { + const service = fakeActivityLogsService({ + createMcpActivityLog: jest.fn(async () => { + throw new Error('the audit store is down'); + }), + }); + const loadAction = jest.fn(async () => executingAction()); + const { app } = buildApp({ + service, + client: clientOf(executingAction(), loadAction as jest.Mock), + }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(503); + expect(response.body.error.type).toBe('audit_unavailable'); + expect(response.headers['retry-after']).toBe('5'); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('should refuse with audit_unavailable when the audit endpoint returns no log id', async () => { + const service = fakeActivityLogsService({ + createMcpActivityLog: jest.fn(async () => ({ attributes: { index: ACTIVITY_LOG_INDEX } })), + }); + const { app } = buildApp({ service, client: clientOf(executingAction()) }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(503); + expect(response.body.error.type).toBe('audit_unavailable'); + }); + + it('should refuse with audit_not_authorized when the audit endpoint rejects the identity', async () => { + const service = fakeActivityLogsService({ + createMcpActivityLog: jest.fn(async () => { + throw new HttpError('Forbidden', 403); + }), + }); + const { app } = buildApp({ service, client: clientOf(executingAction()) }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(403); + expect(response.body.error.type).toBe('audit_not_authorized'); + }); + + it('should refuse with session_expired when the oauth session cannot be resolved', async () => { + const service = fakeActivityLogsService(); + const loadAction = jest.fn(async () => executingAction()); + const { app } = buildApp({ + service, + client: clientOf(executingAction(), loadAction as jest.Mock), + credentials: oauthCredentials(), + saasAccessToken: undefined, + }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('session_expired'); + expect(loadAction).not.toHaveBeenCalled(); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); + + it('should use the session token when the caller carries an oauth session', async () => { + const service = fakeActivityLogsService(); + const saasAccessToken = sessionAccessToken(); + const { app } = buildApp({ + service, + client: clientOf(executingAction()), + credentials: oauthCredentials(), + saasAccessToken, + }); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ forestServerToken: saasAccessToken }), + ); + }); + }); + + describe('when loading an action form', () => { + it('should write no log', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: clientOf(makeAction()) }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/agent-bff/test/action/action-routes-middleware.test.ts b/packages/agent-bff/test/action/action-routes-middleware.test.ts index 628791f6fa..4b6da603af 100644 --- a/packages/agent-bff/test/action/action-routes-middleware.test.ts +++ b/packages/agent-bff/test/action/action-routes-middleware.test.ts @@ -25,6 +25,7 @@ import { readModel, storeOf, } from '../helpers/action-routes'; +import { passthroughActivityLogs } from '../helpers/activity-log'; describe('action routes middleware', () => { it('forwards the configured agent timeout to the action client', async () => { @@ -46,6 +47,7 @@ describe('action routes middleware', () => { agentUrl: 'https://agent.example.com', timeoutMs: 2500, logger: noopLogger, + activityLogs: passthroughActivityLogs(), createClient, }), ); @@ -75,6 +77,7 @@ describe('action routes middleware', () => { store: storeOf(readModel), agentUrl: 'https://agent.example.com', logger: noopLogger, + activityLogs: passthroughActivityLogs(), createClient, }), ); diff --git a/packages/agent-bff/test/activity-log/activity-log-drainer.test.ts b/packages/agent-bff/test/activity-log/activity-log-drainer.test.ts new file mode 100644 index 0000000000..1e351e249f --- /dev/null +++ b/packages/agent-bff/test/activity-log/activity-log-drainer.test.ts @@ -0,0 +1,45 @@ +import ActivityLogDrainer from '../../src/activity-log/activity-log-drainer'; + +describe('activity log drainer', () => { + it('should wait for a tracked transition to settle', async () => { + const drainer = new ActivityLogDrainer(); + let settled = false; + + drainer.track( + () => + new Promise(resolve => { + setTimeout(() => { + settled = true; + resolve(); + }, 10); + }), + ); + + await drainer.drain(); + + expect(settled).toBe(true); + }); + + it('should wait for a rejected transition without rethrowing it', async () => { + const drainer = new ActivityLogDrainer(); + + const tracked = drainer.track(async () => { + throw new Error('the audit store is down'); + }); + tracked.catch(() => undefined); + + await expect(drainer.drain()).resolves.toBeUndefined(); + }); + + it('should resolve immediately when nothing is in flight', async () => { + const drainer = new ActivityLogDrainer(); + + await expect(drainer.drain()).resolves.toBeUndefined(); + }); + + it('should return the tracked result to its caller', async () => { + const drainer = new ActivityLogDrainer(); + + await expect(drainer.track(async () => 'done')).resolves.toBe('done'); + }); +}); diff --git a/packages/agent-bff/test/activity-log/activity-logs-service.test.ts b/packages/agent-bff/test/activity-log/activity-logs-service.test.ts new file mode 100644 index 0000000000..c8b761ee53 --- /dev/null +++ b/packages/agent-bff/test/activity-log/activity-logs-service.test.ts @@ -0,0 +1,19 @@ +import { ActivityLogsService, ForestHttpApi } from '@forestadmin/forestadmin-client'; + +import createBffActivityLogsService from '../../src/activity-log/activity-logs-service'; + +jest.mock('@forestadmin/forestadmin-client', () => ({ + ...jest.requireActual('@forestadmin/forestadmin-client'), + ActivityLogsService: jest.fn(), +})); + +describe('BFF activity logs service', () => { + it('should build the service with the BFF application source header', () => { + createBffActivityLogsService('https://api.forestadmin.com'); + + expect(ActivityLogsService).toHaveBeenCalledWith(expect.any(ForestHttpApi), { + forestServerUrl: 'https://api.forestadmin.com', + headers: { 'Forest-Application-Source': 'BFF' }, + }); + }); +}); diff --git a/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts b/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts new file mode 100644 index 0000000000..e677caa7a4 --- /dev/null +++ b/packages/agent-bff/test/auth/forest-server-token-middleware.test.ts @@ -0,0 +1,141 @@ +import type { SessionStore } from '../../src/oauth/session-store'; +import type { Context } from 'koa'; + +import createForestServerTokenMiddleware, { + resolveForestServerToken, +} from '../../src/auth/forest-server-token-middleware'; +import { + API_KEY_SERVER_TOKEN, + RENDERING_ID, + SESSION_ID, + sessionAccessToken, + unusedServerClient, +} from '../helpers/activity-log'; + +function contextOf(state: Record): Context { + return { state } as unknown as Context; +} + +function storeOf(saasAccessToken: string | undefined, get = jest.fn()) { + const store = { + get: get.mockImplementation((sid: string) => + sid === SESSION_ID && saasAccessToken !== undefined ? { saasAccessToken } : undefined, + ), + } as unknown as SessionStore; + + return { store, get }; +} + +async function landResolver(ctx: Context, store?: SessionStore): Promise<() => Promise> { + const middleware = createForestServerTokenMiddleware({ + session: store ? { store, serverClient: unusedServerClient } : undefined, + }); + + await middleware(ctx, async () => undefined); + + return () => resolveForestServerToken(ctx); +} + +describe('forest server token middleware', () => { + describe('in api-key mode', () => { + it('should resolve the token the key resolution carried', async () => { + const ctx = contextOf({ + authMode: 'api-key', + apiKeyIdentity: { renderingId: RENDERING_ID }, + forestServerToken: API_KEY_SERVER_TOKEN, + }); + + const resolve = await landResolver(ctx); + + await expect(resolve()).resolves.toBe(API_KEY_SERVER_TOKEN); + }); + + it('should refuse with audit_unavailable when the resolution carried no token', async () => { + const ctx = contextOf({ authMode: 'api-key', apiKeyIdentity: { renderingId: RENDERING_ID } }); + + const resolve = await landResolver(ctx); + + await expect(resolve()).rejects.toMatchObject({ + status: 503, + type: 'audit_unavailable', + retryAfter: 5, + }); + }); + }); + + describe('in oauth mode', () => { + it('should resolve the token held by the session', async () => { + const saasAccessToken = sessionAccessToken(); + const { store } = storeOf(saasAccessToken); + const ctx = contextOf({ + authMode: 'oauth', + principal: { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }, + }); + + const resolve = await landResolver(ctx, store); + + await expect(resolve()).resolves.toBe(saasAccessToken); + }); + + it('should refuse with session_expired when the session is gone', async () => { + const { store } = storeOf(undefined); + const ctx = contextOf({ + authMode: 'oauth', + principal: { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }, + }); + + const resolve = await landResolver(ctx, store); + + await expect(resolve()).rejects.toMatchObject({ + status: 401, + type: 'session_expired', + }); + }); + + it('should refuse with session_expired when the deployment carries no session store', async () => { + const ctx = contextOf({ + authMode: 'oauth', + principal: { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }, + }); + + const resolve = await landResolver(ctx); + + await expect(resolve()).rejects.toMatchObject({ + status: 401, + type: 'session_expired', + }); + }); + + it('should look the session up only once for repeated resolutions', async () => { + const { store, get } = storeOf(sessionAccessToken()); + const ctx = contextOf({ + authMode: 'oauth', + principal: { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }, + }); + + const resolve = await landResolver(ctx, store); + await resolve(); + await resolve(); + + expect(get).toHaveBeenCalledTimes(1); + }); + }); + + it('should not look the session up when nothing resolves the token', async () => { + const { store, get } = storeOf(sessionAccessToken()); + const ctx = contextOf({ + authMode: 'oauth', + principal: { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }, + }); + + await landResolver(ctx, store); + + expect(get).not.toHaveBeenCalled(); + }); + + it('should refuse with session_expired when no resolver was landed on the context', () => { + expect(() => resolveForestServerToken(contextOf({ authMode: 'oauth' }))).toThrow( + expect.objectContaining({ status: 401, type: 'session_expired' }), + ); + }); +}); diff --git a/packages/agent-bff/test/cli-shutdown.test.ts b/packages/agent-bff/test/cli-shutdown.test.ts new file mode 100644 index 0000000000..2b0d7e0185 --- /dev/null +++ b/packages/agent-bff/test/cli-shutdown.test.ts @@ -0,0 +1,55 @@ +import type BFFHttpServer from '../src/http/bff-http-server'; +import type { Logger } from '../src/ports/logger-port'; + +import { installShutdownHandlers } from '../src/cli-core'; + +const noopLogger: Logger = () => undefined; + +const SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + +function serverStub(stop = jest.fn(async () => undefined)) { + return { server: { stop } as unknown as BFFHttpServer, stop }; +} + +function installedHandlers(): { signal: NodeJS.Signals; handler: () => void }[] { + return SIGNALS.map(signal => ({ + signal, + handler: process.listeners(signal).at(-1) as () => void, + })); +} + +describe('shutdown handlers', () => { + let installed: { signal: NodeJS.Signals; handler: () => void }[] = []; + + afterEach(() => { + for (const { signal, handler } of installed) process.removeListener(signal, handler); + installed = []; + }); + + it.each(SIGNALS)('should stop the server on %s', signal => { + const { server, stop } = serverStub(); + + installShutdownHandlers(server, noopLogger); + installed = installedHandlers(); + installed.find(entry => entry.signal === signal)?.handler(); + + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('should replace the handlers of a previous server instead of adding a pair', () => { + const first = serverStub(); + const second = serverStub(); + + installShutdownHandlers(first.server, noopLogger); + const before = process.listenerCount('SIGTERM'); + installShutdownHandlers(second.server, noopLogger); + installed = installedHandlers(); + + expect(process.listenerCount('SIGTERM')).toBe(before); + + installed[0].handler(); + + expect(first.stop).not.toHaveBeenCalled(); + expect(second.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/agent-bff/test/data/data-routes-activity-log.test.ts b/packages/agent-bff/test/data/data-routes-activity-log.test.ts new file mode 100644 index 0000000000..76777744b6 --- /dev/null +++ b/packages/agent-bff/test/data/data-routes-activity-log.test.ts @@ -0,0 +1,326 @@ +import type { ActivityLogWriter } from '../../src/activity-log/activity-log-writer'; +import type { AgentDataClient } from '../../src/data/agent-data-client'; +import type { Logger } from '../../src/ports/logger-port'; +import type ReadModelStore from '../../src/read-model/read-model-store'; +import type { Middleware } from 'koa'; + +import { HttpError } from '@forestadmin/forestadmin-client'; +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; +import request from 'supertest'; + +import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import ReadModel from '../../src/read-model/read-model'; +import { + ACTIVITY_LOG_ID, + ACTIVITY_LOG_INDEX, + API_KEY_SERVER_TOKEN, + RENDERING_ID, + activityLogsOf, + apiKeyCredentials, + fakeActivityLogsService, + forestServerTokenStep, + oauthCredentials, + sessionAccessToken, +} from '../helpers/activity-log'; +import { collection, column, relation } from '../read-model/fixtures'; + +const AGENT_URL = 'https://agent.example.com'; +const TIMEZONE = 'Europe/Paris'; +const OPERATORS = ['present', 'blank', 'equal', 'not_equal', 'in', 'like']; +const EMAIL_FILTER = { field: 'email', operator: 'Equal', value: 'joe@example.com' }; +const TITLE_FILTER = { field: 'title', operator: 'Equal', value: 'hello' }; + +const noopLogger: Logger = () => undefined; + +const readModel = new ReadModel([ + collection('users', [column('id'), column('email'), relation('posts', 'HasMany', 'posts.id')]), + collection('posts', [column('id'), column('title')]), +]); + +function storeOf(): ReadModelStore { + return { + getReadModel: async () => readModel, + getCapabilities: async () => ({ + capabilities: { + fields: ['id', 'email', 'title'].map(name => ({ + name, + type: 'String', + operators: OPERATORS, + })), + }, + readModel, + }), + } as unknown as ReadModelStore; +} + +function buildApp({ + service, + client, + credentials = apiKeyCredentials(), + saasAccessToken, + logger = noopLogger, +}: { + service: ReturnType; + client: Partial; + credentials?: Middleware; + saasAccessToken?: string; + logger?: Logger; +}): { app: Koa; activityLogs: ActivityLogWriter } { + const activityLogs = activityLogsOf(service, logger); + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: noopLogger })); + app.use(bodyParser()); + app.use(credentials); + app.use(forestServerTokenStep(saasAccessToken)); + app.use(async (ctx, next) => { + ctx.state.timezone = TIMEZONE; + ctx.state.agentToken = 'agent-jwt'; + await next(); + }); + app.use( + createDataRoutesMiddleware({ + store: storeOf(), + agentUrl: AGENT_URL, + logger, + activityLogs, + createClient: () => client as AgentDataClient, + }), + ); + + return { app, activityLogs }; +} + +describe('data routes activity log', () => { + describe('when listing records', () => { + it('should record a search when the body carries a search and a filter', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { list: async () => [] } }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send({ search: 'joe', filter: EMAIL_FILTER }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).toHaveBeenCalledWith({ + forestServerToken: API_KEY_SERVER_TOKEN, + renderingId: String(RENDERING_ID), + action: 'search', + type: 'read', + collectionName: 'users', + recordId: undefined, + recordIds: undefined, + label: undefined, + }); + }); + + it('should record a filter when the body carries a filter and no search', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { list: async () => [] } }); + + const response = await request(app.callback()) + .post('/agent/v1/users/list') + .send({ filter: EMAIL_FILTER }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ action: 'filter', type: 'read', collectionName: 'users' }), + ); + }); + + it('should record an index when the body carries neither a search nor a filter', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { list: async () => [] } }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ action: 'index', type: 'read' }), + ); + }); + + it('should mark the log completed once the records are served', async () => { + const service = fakeActivityLogsService(); + const { app, activityLogs } = buildApp({ service, client: { list: async () => [] } }); + + await request(app.callback()).post('/agent/v1/users/list').send({}); + await activityLogs.drain(); + + expect(service.updateActivityLogStatus).toHaveBeenCalledWith({ + forestServerToken: API_KEY_SERVER_TOKEN, + activityLog: { id: ACTIVITY_LOG_ID, attributes: { index: ACTIVITY_LOG_INDEX } }, + status: 'completed', + }); + }); + + it('should mark the log failed when the agent refuses the list', async () => { + const service = fakeActivityLogsService(); + const { app, activityLogs } = buildApp({ + service, + client: { + list: async () => { + throw new Error('agent is down'); + }, + }, + }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + await activityLogs.drain(); + + expect(response.status).toBe(502); + expect(service.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }), + ); + }); + + it('should serve the records and warn when the log cannot be created', async () => { + const service = fakeActivityLogsService({ + createMcpActivityLog: jest.fn(async () => { + throw new Error('the audit store is down'); + }), + }); + const logger = jest.fn(); + const list = jest.fn(async () => []); + const { app } = buildApp({ service, client: { list }, logger }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(200); + expect(list).toHaveBeenCalledTimes(1); + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining("Activity log for 'index' was not created"), + ); + }); + + it('should refuse the list when the audit endpoint rejects the identity', async () => { + const service = fakeActivityLogsService({ + createMcpActivityLog: jest.fn(async () => { + throw new HttpError('Forbidden', 403); + }), + }); + const list = jest.fn(async () => []); + const { app } = buildApp({ service, client: { list } }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(403); + expect(response.body.error.type).toBe('audit_not_authorized'); + expect(list).not.toHaveBeenCalled(); + }); + + it('should serve the records unaudited when the oauth session cannot be resolved', async () => { + const service = fakeActivityLogsService(); + const list = jest.fn(async () => []); + const { app } = buildApp({ + service, + client: { list }, + credentials: oauthCredentials(), + saasAccessToken: undefined, + }); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(200); + expect(list).toHaveBeenCalledTimes(1); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); + + it('should use the session token when the caller carries an oauth session', async () => { + const service = fakeActivityLogsService(); + const saasAccessToken = sessionAccessToken(); + const { app } = buildApp({ + service, + client: { list: async () => [] }, + credentials: oauthCredentials(), + saasAccessToken, + }); + + await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ forestServerToken: saasAccessToken }), + ); + }); + }); + + describe('when listing a relation', () => { + it('should record the parent record and label the refinements it was given', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { listRelation: async () => [] } }); + + const response = await request(app.callback()) + .post('/agent/v1/users/relations/posts/list') + .send({ parentId: 'users-1', search: 'hello', filter: TITLE_FILTER }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'listRelatedData', + type: 'read', + collectionName: 'users', + recordId: 'users-1', + label: 'list relation "posts" with search and filter', + }), + ); + }); + + it('should label a relation list carrying only a search', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { listRelation: async () => [] } }); + + await request(app.callback()) + .post('/agent/v1/users/relations/posts/list') + .send({ parentId: 'users-1', search: 'hello' }); + + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ label: 'list relation "posts" with search' }), + ); + }); + + it('should label a plain relation list without a refinement suffix', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { listRelation: async () => [] } }); + + await request(app.callback()) + .post('/agent/v1/users/relations/posts/list') + .send({ parentId: 'users-1' }); + + expect(service.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ label: 'list relation "posts"' }), + ); + }); + }); + + describe('when counting records', () => { + it('should write no log for a count', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ service, client: { countRaw: async () => ({ count: 3 }) } }); + + const response = await request(app.callback()) + .post('/agent/v1/users/count') + .send({ filter: EMAIL_FILTER }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); + + it('should write no log for a relation count', async () => { + const service = fakeActivityLogsService(); + const { app } = buildApp({ + service, + client: { countRelationRaw: async () => ({ count: 1 }) }, + }); + + const response = await request(app.callback()) + .post('/agent/v1/users/relations/posts/count') + .send({ parentId: 'users-1' }); + + expect(response.status).toBe(200); + expect(service.createMcpActivityLog).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/agent-bff/test/data/data-routes-middleware.test.ts b/packages/agent-bff/test/data/data-routes-middleware.test.ts index 5a35588180..09808d6941 100644 --- a/packages/agent-bff/test/data/data-routes-middleware.test.ts +++ b/packages/agent-bff/test/data/data-routes-middleware.test.ts @@ -12,6 +12,7 @@ import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; import createErrorMiddleware from '../../src/http/error-middleware'; import SchemaUnavailableError from '../../src/read-model/errors'; import ReadModel from '../../src/read-model/read-model'; +import { passthroughActivityLogs } from '../helpers/activity-log'; import { collection, column, polymorphic, relation } from '../read-model/fixtures'; const TIMEZONE = 'Europe/Paris'; @@ -80,6 +81,7 @@ function buildApp( store, agentUrl: AGENT_URL, logger, + activityLogs: passthroughActivityLogs(), createClient, }), ); @@ -163,6 +165,7 @@ describe('data routes middleware', () => { agentUrl: AGENT_URL, timeoutMs: 2500, logger: noopLogger, + activityLogs: passthroughActivityLogs(), createClient, }), ); diff --git a/packages/agent-bff/test/data/fixtures/live-agent-harness.ts b/packages/agent-bff/test/data/fixtures/live-agent-harness.ts index 509b3eac3b..167f1e175f 100644 --- a/packages/agent-bff/test/data/fixtures/live-agent-harness.ts +++ b/packages/agent-bff/test/data/fixtures/live-agent-harness.ts @@ -13,6 +13,7 @@ import createErrorMiddleware from '../../../src/http/error-middleware'; import CapabilitiesCache from '../../../src/read-model/capabilities-cache'; import ReadModelStore from '../../../src/read-model/read-model-store'; import SchemaCache from '../../../src/read-model/schema-cache'; +import { passthroughActivityLogs } from '../../helpers/activity-log'; const TIMEZONE = 'Europe/Paris'; export const AUTH_SECRET = 'b0bdf0a639c16bae8851dd24ee3d79ef0a352e957c5b86cb'; @@ -80,7 +81,14 @@ export function buildApp(agentUrl: string, schemaPath: string): Koa { ctx.state.agentToken = token; await next(); }); - app.use(createDataRoutesMiddleware({ store, agentUrl, logger: noopLogger })); + app.use( + createDataRoutesMiddleware({ + store, + agentUrl, + logger: noopLogger, + activityLogs: passthroughActivityLogs(), + }), + ); return app; } diff --git a/packages/agent-bff/test/helpers/action-routes.ts b/packages/agent-bff/test/helpers/action-routes.ts index 55adf0fbb6..cadd5ec39c 100644 --- a/packages/agent-bff/test/helpers/action-routes.ts +++ b/packages/agent-bff/test/helpers/action-routes.ts @@ -5,6 +5,7 @@ import type ReadModelStore from '../../src/read-model/read-model-store'; import { bodyParser } from '@koa/bodyparser'; import Koa from 'koa'; +import { passthroughActivityLogs } from './activity-log'; import createActionRoutesMiddleware from '../../src/action/action-routes-middleware'; import createErrorMiddleware from '../../src/http/error-middleware'; import ReadModel from '../../src/read-model/read-model'; @@ -124,6 +125,7 @@ export function buildApp( store, agentUrl: 'https://agent.example.com', logger, + activityLogs: passthroughActivityLogs(), createClient: () => client, }), ); @@ -150,6 +152,7 @@ export function buildAppWithTerminal(client: AgentActionClient) { store: storeOf(readModel), agentUrl: 'https://agent.example.com', logger: noopLogger, + activityLogs: passthroughActivityLogs(), createClient: () => client, }), ); diff --git a/packages/agent-bff/test/helpers/activity-log.ts b/packages/agent-bff/test/helpers/activity-log.ts new file mode 100644 index 0000000000..1b66ca4153 --- /dev/null +++ b/packages/agent-bff/test/helpers/activity-log.ts @@ -0,0 +1,92 @@ +import type { + ActivityLogWriter, + RecordActivityLogOptions, +} from '../../src/activity-log/activity-log-writer'; +import type { ActivityLogsWriter } from '../../src/activity-log/activity-logs-service'; +import type ForestServerClient from '../../src/oauth/forest-server-client'; +import type { SessionStore } from '../../src/oauth/session-store'; +import type { Logger } from '../../src/ports/logger-port'; +import type { Middleware } from 'koa'; + +import jsonwebtoken from 'jsonwebtoken'; + +import createActivityLogWriter from '../../src/activity-log/activity-log-writer'; +import createForestServerTokenMiddleware from '../../src/auth/forest-server-token-middleware'; + +export const ACTIVITY_LOG_ID = 'log-1'; +export const ACTIVITY_LOG_INDEX = 'activity-logs-2024'; +export const API_KEY_SERVER_TOKEN = 'api-key-server-token'; +export const RENDERING_ID = 42; +export const SESSION_ID = 'sid-1'; + +export interface FakeActivityLogsService extends ActivityLogsWriter { + createMcpActivityLog: jest.Mock; + updateActivityLogStatus: jest.Mock; +} + +export function fakeActivityLogsService( + overrides: Partial = {}, +): FakeActivityLogsService { + return { + createMcpActivityLog: jest.fn(async () => ({ + id: ACTIVITY_LOG_ID, + attributes: { index: ACTIVITY_LOG_INDEX }, + })), + updateActivityLogStatus: jest.fn(async () => undefined), + ...overrides, + } as FakeActivityLogsService; +} + +export function activityLogsOf(service: ActivityLogsWriter, logger: Logger): ActivityLogWriter { + return createActivityLogWriter({ service, logger }); +} + +export function passthroughActivityLogs(): ActivityLogWriter { + return { + record(options: RecordActivityLogOptions): Promise { + return options.operation(); + }, + + drain(): Promise { + return Promise.resolve(); + }, + }; +} + +export function sessionAccessToken(): string { + return jsonwebtoken.sign({ scope: 'forest' }, 'session-secret', { expiresIn: '15m' }); +} + +export function sessionStoreOf(saasAccessToken: string | undefined): SessionStore { + return { + get: (sid: string) => + sid === SESSION_ID && saasAccessToken !== undefined ? { saasAccessToken } : undefined, + } as unknown as SessionStore; +} + +export const unusedServerClient = {} as ForestServerClient; + +export function apiKeyCredentials( + forestServerToken: string | undefined = API_KEY_SERVER_TOKEN, +): Middleware { + return async function stubApiKeyCredentials(ctx, next) { + ctx.state.authMode = 'api-key'; + ctx.state.apiKeyIdentity = { renderingId: RENDERING_ID }; + ctx.state.forestServerToken = forestServerToken; + await next(); + }; +} + +export function oauthCredentials(): Middleware { + return async function stubOAuthCredentials(ctx, next) { + ctx.state.authMode = 'oauth'; + ctx.state.principal = { sid: SESSION_ID, rendering_id: String(RENDERING_ID) }; + await next(); + }; +} + +export function forestServerTokenStep(saasAccessToken?: string): Middleware { + return createForestServerTokenMiddleware({ + session: { store: sessionStoreOf(saasAccessToken), serverClient: unusedServerClient }, + }); +} diff --git a/packages/agent-bff/test/http/bff-http-server.test.ts b/packages/agent-bff/test/http/bff-http-server.test.ts index ef4073d4aa..ee47046297 100644 --- a/packages/agent-bff/test/http/bff-http-server.test.ts +++ b/packages/agent-bff/test/http/bff-http-server.test.ts @@ -19,10 +19,16 @@ const VALID_ENV = { const noopLogger = () => undefined; -function createServer(env: NodeJS.ProcessEnv, port = 0) { +function createServer(env: NodeJS.ProcessEnv, port = 0, drainActivityLogs?: () => Promise) { const config = parseConfig(env); - return new BFFHttpServer({ port, version: VERSION, config, logger: noopLogger }); + return new BFFHttpServer({ + port, + version: VERSION, + config, + logger: noopLogger, + drainActivityLogs, + }); } function listenOnEphemeralPort(server: Server): Promise { @@ -208,6 +214,42 @@ describe('BFFHttpServer', () => { }); }); + describe('when stopping a server that writes activity logs', () => { + it('should drain the pending status transitions after closing the connections', async () => { + const events: string[] = []; + const server = createServer({ ...VALID_ENV }, 0, async () => { + events.push('drain'); + }); + await server.start(); + (server as unknown as { server: Server }).server.on('close', () => events.push('close')); + + await server.stop(); + + expect(events).toEqual(['close', 'drain']); + }); + + it('should not drain when the connections could not be closed', async () => { + const drain = jest.fn(async () => undefined); + const server = createServer({ ...VALID_ENV }, 0, drain); + await server.start(); + + const closeError = new Error('close failed'); + const internal = (server as unknown as { server: Server }).server; + jest.spyOn(internal, 'close').mockImplementation(((cb: (err?: Error) => void) => { + cb(closeError); + + return internal; + }) as Server['close']); + + await expect(server.stop()).rejects.toBe(closeError); + + expect(drain).not.toHaveBeenCalled(); + + jest.restoreAllMocks(); + await closeServer(internal); + }); + }); + describe('when the underlying server fails to close', () => { it('should reject with the close error', async () => { const server = createServer({ ...VALID_ENV }); diff --git a/packages/agent-bff/test/http/bff-local-errors.test.ts b/packages/agent-bff/test/http/bff-local-errors.test.ts index 45a549c596..d47b3b8004 100644 --- a/packages/agent-bff/test/http/bff-local-errors.test.ts +++ b/packages/agent-bff/test/http/bff-local-errors.test.ts @@ -1,5 +1,7 @@ import { actionNotAllowed, + auditNotAuthorized, + auditUnavailable, collectionNotAllowed, invalidRequest, mappingError, @@ -25,10 +27,19 @@ describe('bff local errors', () => { [schemaUnavailable, 'schema_unavailable', 503], [unsupportedActionResult, 'unsupported_action_result', 501], [openapiDisabled, 'openapi_disabled', 404], + [auditNotAuthorized, 'audit_not_authorized', 403], ])('%p builds a %s error with status %d', (factory, type, status) => { expect(factory()).toMatchObject({ type, status }); }); + it('carries the retry delay on auditUnavailable', () => { + expect(auditUnavailable(5)).toMatchObject({ + type: 'audit_unavailable', + status: 503, + retryAfter: 5, + }); + }); + it('carries details on invalidRequest', () => { expect(invalidRequest('bad', { field: 'x' })).toMatchObject({ type: 'invalid_request', diff --git a/packages/agent-bff/test/openapi/openapi-generated-client.test.ts b/packages/agent-bff/test/openapi/openapi-generated-client.test.ts index 8de8b65b79..2703120002 100644 --- a/packages/agent-bff/test/openapi/openapi-generated-client.test.ts +++ b/packages/agent-bff/test/openapi/openapi-generated-client.test.ts @@ -17,6 +17,7 @@ import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; import createErrorMiddleware from '../../src/http/error-middleware'; import ReadModel from '../../src/read-model/read-model'; import createTimezoneMiddleware, { TIMEZONE_HEADER } from '../../src/timezone/timezone-middleware'; +import { passthroughActivityLogs } from '../helpers/activity-log'; import { action, collection, column, relation } from '../read-model/fixtures'; const MARK_AS_PAID = 'Mark as paid'; @@ -188,6 +189,7 @@ function buildApp(): Koa { store, agentUrl: ENV.AGENT_URL, logger: noopLogger, + activityLogs: passthroughActivityLogs(), createClient: () => dataClient, }), ); @@ -196,6 +198,7 @@ function buildApp(): Koa { store, agentUrl: ENV.AGENT_URL, logger: noopLogger, + activityLogs: passthroughActivityLogs(), createClient: () => actionClient, }), );