Skip to content

feat(agent-bff): audit reads and action executions - #1885

Open
nbouliol wants to merge 6 commits into
mainfrom
feature/prd-1150-bff-activity-logs
Open

nbouliol wants to merge 6 commits into
mainfrom
feature/prd-1150-bff-activity-logs

Conversation

@nbouliol

@nbouliol nbouliol commented Sep 7, 2026

Copy link
Copy Markdown
Member

agent-bff wrote no activity log at all — zero occurrences of activity / ActivityLog / activityLogsService in its source. A user who fetched data or triggered an action through the BFF left no audit trail, and the agent it proxies to writes none either (routes/access/audit-trail.ts only reads the trail). Only mcp-server and workflow-executor were writing logs.

Fixes PRD-1150

Depends on ForestAdmin/forestadmin-server#8483, which supplies the credential and the BFF source value. Implemented against that contract and mocked in tests, so this branch is reviewable now but must not ship before it.

Audited surface

Strict parity with mcp-server: three routes.

Route action
list search if the body carries one, else filter, else index
relations/:rel/list listRelatedData, parent id as the record, label naming the relation and its refinements
actions/:name/execute action, record ids, label naming the action

count, relations/:rel/count and actions/:name/form write nothing, deliberately. mcp-server has no standalone count tool — count is folded inside the list tool's single log — and does not audit get-action-form either. The accepted consequence is that a filtered count called on its own stays unaudited, which is an information oracle the MCP surface does not expose; auditing it would double the audit volume, since a table page-load fires list and count as two separate requests and every pagination click replays both.

How it is wired

New src/activity-log/: the service (its own instance, because ForestAdminClientOptions has no headers field and the source must travel as one), the creator holding the action→type map and the fail policy, the wrapper, the drainer, and a composition root so the route middlewares take a writer and never see the service or the token plumbing.

src/auth/forest-server-token-middleware.ts lands a lazy ctx.state.resolveForestServerToken, memoised per request. API-key mode returns the token that came with the resolve response; OAuth mode calls ensureFreshServerAccess. Lazy because /health, permissions, context, the OpenAPI document and the docs route audit nothing and must not pay a session lookup — and permissions is hit on every page load.

Fail policy, verbatim from mcp-server: a write whose pending log cannot be created is blocked (503 audit_unavailable, new, mirroring permissions_unavailable); a read proceeds with a warning; an authorization refusal propagates even for a read (403 audit_not_authorized, new). Both statuses were already declared on these routes and Retry-After was already wired for 503, so only the OpenAPI descriptions needed extending.

Points worth a reviewer's attention

Approval needed an explicit special case. mcp-server treats approvalRequested as a success, so its entry ends completed. The BFF throws actionRequiresApproval, which verbatim wrapping would record as failed — the same business event recorded differently depending on the channel, making action-failure statistics unusable. The log is marked completed before the rethrow.

activityLogs is a required option on both route middlewares rather than reached from ctx.state, so a wiring mistake cannot silently disable auditing. That is why the existing construction sites in the test suite now pass an explicit passthrough. The token resolver is what stays off their dependency lists.

Execute wraps the whole sequence, loadAction and setFields included, matching execute-action.ts. So an unknown action, or an agent down at form load, produces a failed entry for an attempt that never touched data. That is the intent — capture the attempt.

installShutdownHandlers replaces the previously installed pair instead of adding one. runCli is called many times in a single test file and would otherwise accumulate signal listeners; one process runs one BFF, so replacement is also the right production semantics.

An API-key write with no token answers 503, not 401. There is no session to have expired — the resolve response simply predates the server change.

Known follow-ups, not addressed here

  • The pending log is awaited before the operation, as mcp-server does, which adds a blocking round-trip to 100% of audited reads. Unmeasurable today: the Metrics port has increment and gauge but no duration.
  • The session store is in-memory, so behind more than one instance an OAuth read can land on an instance without the session. It then proceeds unaudited rather than 401-ing, which also avoids up to 15 minutes of 401s after each deploy while stateless access tokens outlive the process.
  • The action→type policy now exists in three places in the monorepo (mcp-server's map, workflow-executor's inline literals, and this). PRD-644 covers extracting a shared core.
  • Merge order: PRD-1076 moves this whole middleware chain from cli-core.ts into build-bff.ts and is already in review. Land it first and rebase this.

Verification

tsc --noEmit clean; yarn workspace @forestadmin/agent-bff test → 91 suites, 1698 tests passing; targeted eslint clean.

🤖 Generated with Claude Code

Note

Add activity-log auditing for data reads and action executions in agent-bff

  • Introduces ActivityLogWriter, ActivityLogDrainer, and withActivityLog to create pending activity logs before audited operations and mark them completed or failed after
  • Wires activity logging into createDataRoutesMiddleware (list, relation-list) and createActionRoutesMiddleware (execute only; form loads are not audited); both now require an ActivityLogWriter dependency
  • Adds createForestServerTokenMiddleware for lazy Forest server token resolution: API-key requests use the stored token, OAuth requests resolve via session lifecycle; both paths memoize a single shared resolution promise
  • Adds API-key identity invalidation (invalidateApiKeyIdentity, ResolveCache.invalidate, ApiKeyAuthenticator.invalidate) triggered on 401 from the activity-log service, so stale cached tokens are discarded
  • Adds auditUnavailable (503, with 5s retry-after) and auditNotAuthorized (403) error factories, and updates the OpenAPI error descriptions
  • Reworks BFFHttpServer.stop to close idle connections immediately, force-destroy remaining connections after a 10s deadline (configurable via shutdownTimeoutMs), then drain activity-log work; Agent.stop and EmbeddedBff.stop now await this drain
  • Behavioral Change: createDataRoutesMiddleware and createActionRoutesMiddleware now require an ActivityLogWriter — all in-tree callers are updated but out-of-tree consumers must supply one; read operations fail-open (warn and proceed) when activity-log creation fails, while write/action operations fail-closed (reject with auditUnavailable)

Changes since #1885 opened

  • Modified ActivityLogDrainer to track operations with human-readable descriptions and support deadline-bounded draining [3a9a68b]
  • Enhanced activity log creation error handling to distinguish unretryable endpoint absence and improve credential resolution failure reporting [3a9a68b]
  • Integrated deadline-aware activity log draining throughout the BFF server shutdown process [3a9a68b]
  • Implemented per-key invalidation rate-limiting in createResolveCache() to prevent rapid repeated invalidations within the positive TTL window [3a9a68b]
  • Added bounded eviction for invalidation windows in the API key resolve cache [63e1941]
  • Modified retry timer in activity log status update to not prevent process shutdown [63e1941]

Macroscope summarized c5930cc.

@linear-code

linear-code Bot commented Sep 7, 2026

Copy link
Copy Markdown

PRD-1150

@qltysh

qltysh Bot commented Sep 7, 2026

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): createPendingActivityLog 2
qlty Structure Function with high complexity (count = 16): createPendingActivityLog 1

Comment thread packages/agent-bff/src/auth/forest-server-token-middleware.ts Outdated
} 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium activity-log/activity-logs-creator.ts:183

When updateActivityLogStatus encounters a transient transport or 5xx failure, the activity log remains permanently pending even though the audited operation has finished, corrupting audit status and action-failure statistics. updateStatus retries only NotFoundError, so these recoverable failures reach the fire-and-forget catcher immediately; add bounded retries for transient status-update failures while preserving non-retryable errors.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/activity-log/activity-logs-creator.ts around line 183:

When `updateActivityLogStatus` encounters a transient transport or 5xx failure, the activity log remains permanently `pending` even though the audited operation has finished, corrupting audit status and action-failure statistics. `updateStatus` retries only `NotFoundError`, so these recoverable failures reach the fire-and-forget catcher immediately; add bounded retries for transient status-update failures while preserving non-retryable errors.

@qltysh

qltysh Bot commented Sep 7, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (20)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/api-key/api-key-middleware.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/auth/auth-mode.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/api-key/resolve-cache.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/embedded-bff.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/api-key/api-key-authenticator.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/agent.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/data/agent-query.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/build-bff.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-http-server.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/api-key/api-key-client.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/action/action-routes-middleware.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/data/data-routes-middleware.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-local-errors.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/activity-log/activity-logs-service.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/activity-log/activity-logs-creator.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/activity-log/with-activity-log.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/auth/forest-server-token-middleware.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/activity-log/activity-log-drainer.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/activity-log/activity-log-writer.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

nbouliol and others added 2 commits September 10, 2026 11:44
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) <noreply@anthropic.com>
EmbeddedBff.stop() dropped the callback and returned, so the activity-log status transitions the BFF fires without await died with the process and left their entries pending.

The standalone deployment reaches the drain through its own stop(); the embedded one had no path to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nbouliol
nbouliol force-pushed the feature/prd-1150-bff-activity-logs branch from 8493042 to 0df7cec Compare September 10, 2026 09:50
Comment thread packages/agent-bff/src/data/data-routes-middleware.ts Outdated
this.bff = null;
this.stopped = true;

await bff?.drainActivityLogs?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/embedded-bff.ts:170

stop() can return while an already-dispatched audited request is still running, leaving its activity log permanently pending. The request registers its status transition only when operation() completes, but drainActivityLogs() snapshots before that registration; wait for in-flight requests to quiesce before draining, or make the drainer wait for later registrations.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent/src/embedded-bff.ts around line 170:

`stop()` can return while an already-dispatched audited request is still running, leaving its activity log permanently `pending`. The request registers its status transition only when `operation()` completes, but `drainActivityLogs()` snapshots before that registration; wait for in-flight requests to quiesce before draining, or make the drainer wait for later registrations.

nbouliol and others added 2 commits September 10, 2026 12:06
… the search the agent runs

The token resolver turned every ensureFreshServerAccess failure into session_expired, so a Forest server blip logged OAuth users out instead of failing the audit write alone. Only a 401 means re-authenticating helps; anything else is now audit_unavailable, which is what the write path already knows how to report.

A whitespace-only search was recorded as a search although buildListAgentQuery drops it, so a filter ran while the trail claimed a search. Both now ask the same predicate.

The drainer settled one snapshot, but a transition is registered only once its request finishes, so an embedded stop() could return before work it should have waited for. It tracks the requests too, and loops until nothing is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An expired api-key token answered 401, which the refusal check read as a rejection and turned into 403, taking the read surface down for the whole resolve-cache window. Only a 403 is a refusal now, and a 401 drops the cached identity so the next request re-resolves.

A missing saasAccessToken no longer advertises a retry that cannot succeed, the audit failure logs name the rendering and the entry, the write path logs before it throws, and the token resolver reports the cause it was hiding behind a mapped error.

A blank filter stops counting as a filtered read, the pending-log guard checks the index the transition needs, and stop() is bounded so one busy connection cannot hold the drain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Tonours Tonours left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec (PRD-1150): conforms. The three audited routes, the action selection, the approval special case, the two new errors and the lazy two-mode resolver all match the ticket, and count / relation count / form stay unaudited as it asks.

Comment thread packages/agent-bff/src/http/bff-http-server.ts Outdated
Comment thread packages/agent-bff/src/activity-log/activity-logs-creator.ts Outdated
Comment thread packages/agent-bff/src/activity-log/activity-logs-creator.ts Outdated
Comment thread packages/agent-bff/src/activity-log/activity-logs-creator.ts
Comment thread packages/agent-bff/src/activity-log/activity-logs-creator.ts
Comment thread packages/agent-bff/src/cli-core.ts
stop() spends one deadline across the connection close and the drain, so a slow audit store can no longer hold the process past it, and what was still in flight is named when it expires.

A Forest server that mints no audit credential is reported once at Warn instead of an Error and a Warn, an absent audit route answers without a retry hint, an empty id or index fails the guard like a missing one, and an audit 401 invalidates a key at most once per cache window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/api-key/resolve-cache.ts
Comment thread packages/agent-bff/src/activity-log/activity-logs-creator.ts

@Tonours Tonours left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All six findings from the previous round are verified closed on this head, each with its regression pinned by a new case: the shared shutdown deadline, the single Warn for a deployment that mints no audit credential, the absent audit route answering without a retry hint, the empty id or index failing the guard, and the throttled key invalidation. The untested failed-shutdown branch now has its own case.

One unrelated job is red, LLM Integration Tests (ai-proxy), on a package this branch does not touch. Linting & Testing (agent-bff) and the BFF integration tests pass.

…timer

The per-key invalidation window grew without bound while the entries it guards are capped, and the status retry kept the event loop alive past the shutdown grace the drain deadline enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants