ADR-0023: Audit actor resolution before authentication exists
- Status: superseded by ADR-0041
- Date: 2026-09-04
- Deciders: Mahdi Amirabdollahi
Context
Non-negotiable #3: "Every mutating call writes an audit_event in the same transaction as the command, before the broker call, updated with the outcome." ADR-0011 records the mechanism: AuditService persists a PENDING row before the broker call and updates the same managed entity to SUCCESS / FAILURE after, in one transaction.
Phase 3 is the first phase where an operator does something worth attributing — moving, deleting, purging messages. But authentication is Phase 8. Today config/SecurityConfig is permitAll() and AuditService writes a hard-coded username = "system" for the register / rotate-credentials flows.
The audit_event table (changeset 004-audit.sql) already has the columns for a real actor: username, source_ip inet, request_id, user_id (FK to app_user, nullable). Three of them — source_ip, request_id, user_id — are not mapped on AuditEventEntity; ddl-auto=validate tolerates the extra DB columns.
The question is what to record as the actor now, so that the Phase 3 audit trail is honest and Phase 8 fills the same columns with real users and no migration.
Decision
We will resolve and record a best-effort actor now, from the request, and fill the columns that already exist.
security/ActorResolver(a@Component) returnsActor(username, sourceIp, requestId, userId):username— the authenticated principal's name fromSecurityContextHolderwhen one is present; otherwise the literal"anonymous". Not null, ever.sourceIp—HttpServletRequest.getRemoteAddr().requestId— theX-Request-Idrequest header when present, else a freshUUID. Lets an operator or a proxy correlate an audit row to a request.userId— null until Phase 8 (noapp_userrows exist yet).
ActorResolver.system()returns the fixed system actor for scheduler-originated writes. The scheduler does not serve an HTTP request.AuditService.begin(...)takes anActorand stores all four values. Existing callers (ClusterService.register/rotateCredentials) passactorResolver.resolve(); theSYSTEM_USERconstant becomesActorResolver.system().AuditEventEntitygains@Columnmappings forrequest_id,source_ip(inet↔String), anduser_id. No changeset — the columns are already released.- No authorization. The resolver records who is acting; it does not decide whether they may. Permission checks are Phase 8. A mutating endpoint in Phase 3 is reachable by anyone who can reach the API, exactly as every other endpoint is today (
SecurityConfigwarns at startup if not bound to loopback).
Consequences
- The Phase 3 audit trail is honest: it says
anonymouswhen it does not know who acted, records the source IP and a correlatable request id, and never pretends a human wassystem. - Phase 8 wires real authentication into
SecurityContextHolderand populatesapp_user;ActorResolverstarts returning real names anduser_idvalues into the same columns. No schema change, noAuditServicesignature change. source_ipis only as trustworthy asgetRemoteAddr()— behind a reverse proxy it is the proxy's address unless the proxy is configured to forward and Studio to trustX-Forwarded-For. That configuration is out of scope here; the column records what the servlet container reports.- One more component in the request path, but a trivial one.
Alternatives considered
- Leave
usernamenull until Phase 8. Rejected — every Phase 3 audit row would be indistinguishable between "no auth configured" and "we lost track of who acted".anonymousplus a source IP and request id is a real record. - Pull Phase 8's local users + HTTP Basic forward into Phase 3. Rejected — it drags an entire phase (user store, password hashing, login, session) into a message-operations phase for one column's sake. ADR-0011 and the roadmap keep auth in Phase 8.
- Record only a request id, no username field. Rejected — the
usernamecolumn exists and the audit-log screen filters on it; a literalanonymousis a usable filter value and a truthful one.