- Status: done
- Date: 2026-05-23
- Specs touched:
LOGGING.md,NEXT.md
The brain had session-authenticated endpoints but no audit trail. This slice
lands the append-only audit_events table, the single write function, client
IP plumbing through the auth middleware, call sites at all v1 action points,
and a paginated read endpoint with member-vs-admin visibility.
New table in the existing brain.db migration (idempotent CREATE TABLE IF NOT EXISTS):
- Schema exactly as
LOGGING.md# Schema sketch:id AUTOINCREMENT,ts(epoch ms),actor_user_idnullable FKON DELETE SET NULL,actor_role,action,target_kind,target_id,source_ip,success,metadata. BEFORE UPDATEtrigger raises'audit_events is append-only'for all updates except the FK-cascadeSET NULLon actor_user_id (SQLite firesBEFORE UPDATEfor FK nullification; the trigger'sWHENclause passes through only that exact shape).BEFORE DELETEtrigger raises unconditionally.InsertAuditEventandListAuditEvents(AuditFilter)on*Store.ListAuditEventsreturns newest-first with anafter_idcursor and an optionalActorUserIDrestriction (member view: actor or target).
Layer-2 tests (7 new): insert + list, UPDATE blocked, DELETE blocked,
actor_user_id SET NULL on user delete (history preserved), system event with
null actor, member visibility filter, cursor pagination.
New package. Single concrete type Recorder; EventStore interface declared
here (consumer-side, per CLAUDE.md). Exported action constants for the full v1
vocabulary: setup.complete, login.success, login.failure, logout,
app.install, app.uninstall, app.custom.create.
Record(ctx, action, target, metadata, success) reads auth.Identity and
client IP from context; falls back to actor_role='system' when no identity is
present. INSERT failure is logged at Error level and swallowed — never
propagated.
WithClientIP / ClientIPFromContext — context helpers so the IP flows from
the middleware without leaking HTTP types into handlers.
Layer-2 tests (7 new): authenticated actor, system actor, target population, metadata serialisation, INSERT failure doesn't panic, login.failure success=false, IP context round-trip.
authMiddleware now calls audit.WithClientIP(r.Context(), clientIP(r)) before
any other context mutation — both public and authenticated paths get the IP so
login.failure (which has no identity) still records a source IP.
clientIP(r) takes X-Forwarded-For first hop (Caddy sets this in production),
strips port from RemoteAddr as fallback.
1 new unit test: TestClientIP covering RemoteAddr, single XFF, multi-hop XFF,
IPv6.
Server gains *audit.Recorder; NewServer signature updated (one new
parameter, same position pattern as authMgr). cmd/brain/main.go constructs
audit.New(st) and passes it through.
Audit records written at:
setuphandler —setup.complete(identity constructed from the freshly created user + issued session; system actor until session is issued, but the handler calls Record after issuing the session).loginhandler —login.success(identity manually attached to ctx) andlogin.failure(no identity, system actor, capturesusernamein metadata).logouthandler —logout(only when a valid identity is present).installAppjob —app.installwith manifest_id + slug in metadata.installCustomAppjob —app.custom.createwith name + slug.uninstallAppjob —app.uninstall.
Job goroutines capture the handler's ctx (which has the IP and identity) at
dispatch time, not the goroutine's context.Background().
Huma-registered endpoint (shows up in openapi.json). Query params: limit
(default 50, cap 200), after_id (cursor). Admin sees all rows; member sees
rows where actor_user_id = self OR (target_kind='user' AND
target_id=self.ID). Returns {events: [...]} newest-first.
2 new Layer-3 tests: admin sees all rows after setup+login, unauthenticated GET returns 401.
LOGGING.md—audit_eventstable, append-only triggers, and write path are now realised. v1 action vocabulary pinned in the spec and as exported consts.AUTH.md— login/logout/setup audit entries land per the spec's call-out thataudit_eventsrows should accompany these actions.
BEFORE UPDATEtrigger WHEN clause. The spec says "RAISE(ABORT) — defends against buggy migrations." SQLite fires the trigger for FK-cascade SET NULL updates too, so aWHENguard is required to let user deletes nullify the FK without tripping the trigger. This is correct behaviour (the intent is to prevent tampering, not to block the cascade); documented here so it isn't removed as "unnecessary complexity."- SSH / SMB / sudo ingestion deferred.
ssh.login.*,smb.login.*,sudo.invoke,su.invokeare not wired — requires thejournal_followhost-agent protocol and apamparsepackage perLOGGING.md# External auth ingestion. - Notifications fan-out not wired. Audit rows don't yet fan out to the
notificationstable (NOTIFICATIONS.md). - No hash-chain / sequence-number integrity. Append-only via triggers is
the v1 invariant; cryptographic chain is deferred per
NEXT.md. - No retention / prune. Audit log is forever-retained in v1; prune mechanics are deferred.
- No export-to-file. The dashboard "Activity" CSV/JSON export is a UI follow-up.
- Future call sites. Users, shares, and tier2 packages don't exist yet;
their audit entries are listed in
LOGGING.md# Write path as future work.
- Notifications fan-out — emit to
notificationstable on the allowlisted subset of audit actions (NOTIFICATIONS.md). - Multi-user CRUD — add member, change role, delete user. Each of these needs an audit record.
- SSH / SMB ingestion —
journal_follow+pamparse. - Activity UI — dashboard "Activity" view consuming
GET /api/v1/audit.