Skip to content

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

Open
nbouliol wants to merge 1 commit into
mainfrom
feature/prd-1150-bff-activity-logs
Open

feat(agent-bff): audit reads and action executions#1885
nbouliol wants to merge 1 commit 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 agent-bff reads and action executions

  • Data-route list and relation-list requests now execute inside an activity-log wrapper that records search, filter, index, or listRelatedData actions.
  • Action-route execute requests are audited around action loading and execution; action form loads remain unaudited.
  • Adds createForestServerTokenMiddleware to resolve Forest server tokens for API-key and OAuth requests, providing credentials for audit writes.
  • BFFHttpServer.stop and installShutdownHandlers now await pending activity-log status updates via ActivityLogDrainer during SIGTERM/SIGINT.
  • Risk: Action execution blocks if the activity log cannot be created, returning a 403 auditNotAuthorized or 503 auditUnavailable error. Read requests proceed without a log when the audit store is unavailable.
📊 Macroscope summarized 8493042. 16 files reviewed, 2 issues evaluated, 0 issues filtered, 2 comments posted

🗂️ Filtered Issues

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>
@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

2 new issues

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

store: session.store,
serverClient: session.serverClient,
});
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High auth/forest-server-token-middleware.ts:45

When ensureFreshServerAccess fails with an OAuthRequestError (status 502) because the Forest server is unreachable, this catch-all converts it to sessionExpired (401). Valid OAuth users therefore receive a session-expired response and action executions are blocked until re-authentication instead of returning a retryable upstream error. Preserve and rethrow the OAuth request failure, mapping only genuine session-expiration errors to sessionExpired.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/auth/forest-server-token-middleware.ts around line 45:

When `ensureFreshServerAccess` fails with an `OAuthRequestError` (status 502) because the Forest server is unreachable, this catch-all converts it to `sessionExpired` (401). Valid OAuth users therefore receive a session-expired response and action executions are blocked until re-authentication instead of returning a retryable upstream error. Preserve and rethrow the OAuth request failure, mapping only genuine session-expiration errors to `sessionExpired`.

} 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 (15)

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/api-key-authenticator.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts96.2%443
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: C
packages/agent-bff/src/activity-log/activity-logs-creator.ts78.7%151-157, 183-198, 212
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%
Total94.0%
🤖 Increase coverage with AI coding...
In the `feature/prd-1150-bff-activity-logs` branch, add test coverage for this new code:

- `packages/agent-bff/src/activity-log/activity-logs-creator.ts` -- Lines 151-157, 183-198, and 212
- `packages/agent-bff/src/cli-core.ts` -- Line 443

🚦 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.

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.

1 participant