---
id: w4/m10/t001
title: Audit hook at the one interception point (core base + authz middleware)
worker: worker4
status: done
estimate: 30m
depends_on: []
---

## Objective

Add audit recording where every verb already passes exactly once: `Base.Authorize`/`AuthorizeOn` in the feature-package kernel (`lego/backend/internal/core/base.go`, which all feature `Service`s embed) — **not** the raw OpenFGA `Checker` (`lego/backend/internal/authz/authz.go`), which also serves read/list filtering and would over-record. Each authenticated **write** verb and each authz **denial** produces one `AuditEvent{caller, verb, resource, outcome, at}` — with no way for a future verb to forget to log.

## Context

- Caller identity is already on the request context (`core.IdentityFrom`, `lego/backend/internal/core/identity.go` — OAuth2 `client_id` or Kratos identity id; the `api` package only attaches it via `core.WithIdentity` in its auth middleware; `docs/ADR006-bex-api.md` §Auth).
- _2026-07-11:_ there is **no post-write kernel hook** — the only universal choke point is `Base.Authorize`/`AuthorizeOn` at verb start (every verb calls it exactly once; `core/base.go`), and the REST/GraphQL/MCP adapters call verbs directly, so "success events fire after the verb's write succeeds" cannot be captured at one point as originally written. Decide the semantics up front: either (a) authorize-time events with `outcome=allowed/denied` — one true interception point, accepting that a post-authorize k8s failure still records `allowed` — or (b) a tiny per-verb emit helper, with t004's `TestAuthzGuardsEveryVerb`-based inventory test (`lego/backend/internal/api/server_test.go`) as the "no verb forgets" enforcement instead of the kernel. Record the choice in this task on execution.
- Recording must be non-blocking for the verb path (best-effort async or short-timeout write); an audit-store outage must not take down writes — log-and-continue, never fail the verb.
- Read verbs are out of scope by default (volume); the event shape should carry `outcome` so denials of reads can be added later without a schema change.

## Steps

1. Define `AuditEvent` + an `AuditSink` seam in `internal/core`; no-op sink when the store is absent.
2. Emit from `Base.Authorize`/`AuthorizeOn` (denials always; successes per the chosen semantics — authorize-time `allowed`, or the per-verb helper), tagging verb + resource id.
3. Ensure event payloads never carry request bodies or values (env-var writes log key names at most).

## Files

- `lego/backend/internal/core/base.go` (the `Base.Authorize`/`AuthorizeOn` seam — the denial hook lives here)
- `lego/backend/internal/authz/authz.go` (the `core.Checker` over OpenFGA — reference only; do not emit from it)
- `lego/backend/internal/core/` (new audit types file)

## Acceptance criteria

- [x] Every existing write verb emits exactly one event on success; a 403 emits one denial event; unit-verifiable with a fake sink.
- [x] A sink error never fails the verb.
- [x] No values/secrets in any event payload.

## Out of scope

- Persistence (t002); read surface (t003); auditing read verbs.

## Resolution (2026-07-11)

Went with **option (a), authorize-time events**, refined one step further: `Base.Authorize`/`AuthorizeOn` both funnel through a shared `authorizeAndAudit`, which records only for the **write-tier relations** (`can_operate`/`can_create`/`can_manage_keys`/`can_manage`) — success and denial alike — so read-heavy list/get verbs never reach the sink at all (the "volume" concern from the Context section, resolved without a second gate). The `verb` name is **not** threaded through call sites by hand (would touch ~80 of them); it's resolved from the call stack (`callerVerb`/`runtime.Caller`, `internal/core/audit.go`) — the immediate caller of `Authorize`/`AuthorizeOn` is, by the CLAUDE.md invariant "every verb starts with `s.Authorize`", the verb itself. `Base.emit` bounds the sink call to `auditRecordTimeout` (2s) via `context.WithTimeout` — satisfies "non-blocking ... short-timeout write" without the complexity/test-nondeterminism of a fully async goroutine. A sink error is logged and swallowed, never returned. `TestAuditCoversEveryWriteVerbExactlyOnce` (`internal/api/server_test.go`) is the "no verb forgets" enforcement per t004, reusing `TestAuthzGuardsEveryVerb`'s exact service inventory so the two can't drift.
