Local Audit Logging (Core API Synchronous Write)
Overview
The Core API writes every successful mutation synchronously to the local audit_log table from the success path of its handler. Authenticated failures (401 with token, 403, every 5xx) are recorded by a wrapping middleware after the handler returns. The local table is the GDPR/HIPAA safety net — there is zero audit event loss because the audit insert is part of the same request the handler is about to return.
Asynchronous forwarding to the Telemetry service (P32 / Layer 11) is not yet implemented; this document describes only what ships today. See § Forward to Telemetry (Layer 11) for the planned shape.
Why Synchronous?
Compliance: Audit controls (HIPAA 164.312(b), GDPR Art. 30) mandate recording all activity in systems containing PHI. If the write is asynchronous and the application crashes before the event is persisted, an audit entry is lost — a compliance violation.
Strict success-path coupling: Handlers call audit.Record(ctx, ...) after the underlying repo write commits. If the audit insert fails, the handler returns a 500 — by convention, the platform must not commit a mutation it cannot prove happened. (See services/api/internal/core/domain/organization/handler.go:30 recordAuditOrFail.)
Performance trade-off: A single INSERT into audit_log adds ~1 ms to the request. The table has no INSERT policy at all — production writers run via AdminPool (owner role bypasses RLS) or via SECURITY DEFINER trigger function, so RLS evaluation never happens on the write path.
Pool choice: The recorder uses the admin pool, not the request's RLS-scoped connection. This matters for the failure-path middleware: when the request 401s, there are no session vars set, and an RLS-scoped connection would have no app.current_org_id / app.current_principal_id to write under.
Architecture
HTTP Request
│
▼
┌──────────────────────────────────────────────┐
│ audit.Middleware (server.go:53) │
│ • injects Recorder + request_id into ctx │
│ • wraps ResponseWriter to capture status │
└──────────────────────────────────────────────┘
│
▼ (downstream middleware sets actor + org via SetActor / SetCurrentOrg)
┌──────────────────────────────────────────────┐
│ Authenticate → OrganizationContext │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Handler │
│ ──► success path: audit.Record(ctx, Event) │
│ • returns err if insert failed │
│ • handler returns 500 on err │
└──────────────────────────────────────────────┘
│
▼ (back in audit.Middleware after handler returns)
shouldLogFailure(status, opts)?
│
├── 5xx → recorder.recordFailure(ctx)
├── 403 → recorder.recordFailure(ctx)
├── 401 with Bearer token → recorder.recordFailure(ctx)
└── 401 without Bearer (default) → drop (probe noise)Implementation
audit.Record — handler success path
Source: services/api/internal/core/audit/audit.go.
import "github.com/restartix/restartix-platform/services/api/internal/core/audit"
if err := audit.Record(r.Context(), audit.Event{
Action: audit.ActionUpdate,
EntityType: "organization",
EntityID: org.ID,
Before: priorState,
After: newState,
StatusCode: http.StatusOK,
OrgID: org.ID, // optional override; defaults to caller's current org
}); err != nil {
httputil.HandleError(w, err) // 500 — do not return success without an audit row
return
}Field semantics:
Action—CREATE,UPDATE,DELETE. Higher-layer verbs (IMPERSONATE,BREAK_GLASS_OPEN) live alongside but are not yet emitted.EntityType/EntityID— the resource the row describes.EntityIDmay beuuid.Nilfor bulk ops.Before/After— any JSON-serializable Go value. The recorder converts to JSON, computes the changed-fields diff forUPDATE(buildChangesindiff.go), and redacts sensitive keys viainternal/shared/redactbefore writing tochanges.Beforeis required for UPDATE/DELETE;Afterfor CREATE/UPDATE.StatusCode— defaults from action (201 CREATE / 204 DELETE / 200 otherwise).OrgID— overrides the caller's current org. Use this when a superadmin acts on a tenant: the row should attribute to the tenant, not the platform.ActorPrincipalID/ActorType— override the caller. Used by the auth middleware to attribute the very-first-loginhuman.createrow to the new principal before they have aSubject.ActorTypedefaults to'human'for the JIT-provisioning path; system/agent/service-account actors set it explicitly. The singleton system actor is the seededprincipalsrow with ID00000000-0000-0000-0000-000000000001(principal.SystemPrincipalID).ModelVersion/InputsHash/Confidence— AI provenance fields. Required whenActorType = 'agent'and the action involves a model call (drafting, classification, transcription); NULL otherwise.Confidenceis constrained to[0, 1]by a CHECK on the table.
audit.Middleware — failure-path rows
Source: services/api/internal/core/audit/middleware.go.
The middleware does three things:
- Injects the
Recorderand per-request info into the context so handlers can callaudit.Record(ctx, ...). Without this,audit.Recordreturnsaudit.ErrNoRecorder(programmer error — middleware not wired). - Wraps the
http.ResponseWriterso the middleware can read the final status code after the handler runs. - After the handler returns, calls
shouldLogFailure(status, opts):>=500— always record (INTERNAL_ERROR).403— always record (ACCESS_DENIED).401— record only when the request actually carried a Bearer token. A 401 without a token is a probe; recording it floods the table from unauthenticated polling. Override withMiddlewareOptions{LogUnauthenticated401: true}.
Failure rows store entity_type = "http_request" and entity_id = NULL. The changes column is empty for failures.
Wiring
// services/api/internal/core/server/server.go (line 53)
r.Use(audit.Middleware(s.recorder, audit.MiddlewareOptions{}))Place the audit middleware outside Authenticate and OrganizationContext so it sees the final status of unauthenticated/forbidden requests. The auth middleware bridges the gap by calling audit.SetActor(ctx, principalID, actorType) (middleware/auth.go:112) and audit.SetCurrentOrg(ctx, orgID) (middleware/organization.go:104) so failure rows attribute correctly even though the auth context is set deeper than the audit middleware.
Schema
The audit_log table, its INSERT policy, and the REVOKE-of-mutating-grants live in services/api/migrations/core/000001_init.up.sql. The SELECT policy (audit_select) is defined later in 000002_tenancy_rbac.up.sql because it depends on current_app_has_permission. Notable columns the recorder writes:
| Column | Type | Notes |
|---|---|---|
organization_id | UUID, nullable | NULL for pre-auth failures; non-null for tenant-scoped events |
actor_id | UUID, nullable, FK principals(id) | The acting principal; NULL when no actor could be attributed (e.g., 401 on a malformed token). For trigger-driven writes that need an actor, falls back to the singleton system principal 00000000-0000-0000-0000-000000000001 |
actor_type | text, NOT NULL | Denormalized actor kind ('human' | 'agent' | 'service_account' | 'system'); kept in sync with principals.principal_type so audit queries don't need to join |
action | text | CREATE / UPDATE / DELETE (success) or INTERNAL_ERROR / ACCESS_DENIED (failure) |
action_context | text | normal / break_glass / impersonation / gdpr_operation |
entity_type | text | Resource name; http_request on failure rows |
entity_id | UUID, nullable | Specific row affected; NULL for bulk ops or failure rows |
changes | JSONB | Field-level diff (UPDATE) or {after} / {before} (CREATE/DELETE), redacted |
model_version | text, nullable | AI provenance — model + version when the actor was an agent; NULL otherwise |
inputs_hash | bytea, nullable | AI provenance — SHA-256 of the inputs the model saw; NULL when not an AI action |
confidence | numeric(4,3), nullable, CHECK 0..1 | AI provenance — model-reported confidence; NULL when not an AI action |
ip_address | inet | Client IP from the proxy chain |
user_agent | text | |
request_path | text | |
request_method | text | |
status_code | int | |
request_id | UUID | Correlates with the request log line |
Append-Only Enforcement
Three independent layers, by design:
- RLS policies — only
audit_select(in000002_tenancy_rbac.up.sql, gated oncurrent_app_has_permission('audit_log', 'view_org')) exists. No INSERT / UPDATE / DELETE policy is defined; with RLS enabled, missing policies mean default-deny. The owner role still bypasses RLS for the legitimate writers (audit.Recordervia AdminPool, trigger-sideaudit_log_insertvia SECURITY DEFINER). - Privilege grant —
REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM restartix_appin000001_init.up.sql. The application role cannot mutate rows even with RLS off. - API surface — the
Recordertype exposesrecordSuccessandrecordFailure; there are no methods that modify or remove rows.
Verified by services/api/internal/test/rlstest/append_only_test.go:58, 95, 122.
Sensitive-Data Masking
buildChanges (in diff.go) routes through internal/shared/redact before serializing Before / After to changes. The masked patterns mirror the slog redaction set: password, secret, token, apikey, authorization, cookie, session. Sensitive values become the string "[REDACTED]".
Testing
Recorder + middleware are exercised by integration tests under services/api/internal/test/rlstest/:
append_only_test.go— RLS rejects UPDATE/DELETE; privilege grant rejects them too.- Per-domain handler tests assert the audit row is written for each mutation (e.g.
organization/handler_test.go).
Run with make test from services/api/.
Forward to Telemetry (Layer 11)
The asynchronous forward to the Telemetry service for ClickHouse + retention is owned by Layer 11 and is not yet implemented. When it lands, it will subscribe to the local insert and forward best-effort; the local row is the source of truth and remains the compliance-relevant artefact.
There is no internal/pkg/auditevt.Forwarder package today — the Layer 11 design is open and may not adopt that name.