Skip to content

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.

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:

  • ActionCREATE, UPDATE, DELETE. Higher-layer verbs (IMPERSONATE, BREAK_GLASS_OPEN) live alongside but are not yet emitted.
  • EntityType / EntityID — the resource the row describes. EntityID may be uuid.Nil for bulk ops.
  • Before / After — any JSON-serializable Go value. The recorder converts to JSON, computes the changed-fields diff for UPDATE (buildChanges in diff.go), and redacts sensitive keys via internal/shared/redact before writing to changes. Before is required for UPDATE/DELETE; After for 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-login human.create row to the new principal before they have a Subject. ActorType defaults to 'human' for the JIT-provisioning path; system/agent/service-account actors set it explicitly. The singleton system actor is the seeded principals row with ID 00000000-0000-0000-0000-000000000001 (principal.SystemPrincipalID).
  • ModelVersion / InputsHash / Confidence — AI provenance fields. Required when ActorType = 'agent' and the action involves a model call (drafting, classification, transcription); NULL otherwise. Confidence is 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:

  1. Injects the Recorder and per-request info into the context so handlers can call audit.Record(ctx, ...). Without this, audit.Record returns audit.ErrNoRecorder (programmer error — middleware not wired).
  2. Wraps the http.ResponseWriter so the middleware can read the final status code after the handler runs.
  3. 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 with MiddlewareOptions{LogUnauthenticated401: true}.

Failure rows store entity_type = "http_request" and entity_id = NULL. The changes column is empty for failures.

Wiring

go
// 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:

ColumnTypeNotes
organization_idUUID, nullableNULL for pre-auth failures; non-null for tenant-scoped events
actor_idUUID, 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_typetext, NOT NULLDenormalized actor kind ('human' | 'agent' | 'service_account' | 'system'); kept in sync with principals.principal_type so audit queries don't need to join
actiontextCREATE / UPDATE / DELETE (success) or INTERNAL_ERROR / ACCESS_DENIED (failure)
action_contexttextnormal / break_glass / impersonation / gdpr_operation
entity_typetextResource name; http_request on failure rows
entity_idUUID, nullableSpecific row affected; NULL for bulk ops or failure rows
changesJSONBField-level diff (UPDATE) or {after} / {before} (CREATE/DELETE), redacted
model_versiontext, nullableAI provenance — model + version when the actor was an agent; NULL otherwise
inputs_hashbytea, nullableAI provenance — SHA-256 of the inputs the model saw; NULL when not an AI action
confidencenumeric(4,3), nullable, CHECK 0..1AI provenance — model-reported confidence; NULL when not an AI action
ip_addressinetClient IP from the proxy chain
user_agenttext
request_pathtext
request_methodtext
status_codeint
request_idUUIDCorrelates with the request log line

Append-Only Enforcement

Three independent layers, by design:

  1. RLS policies — only audit_select (in 000002_tenancy_rbac.up.sql, gated on current_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.Recorder via AdminPool, trigger-side audit_log_insert via SECURITY DEFINER).
  2. Privilege grantREVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM restartix_app in 000001_init.up.sql. The application role cannot mutate rows even with RLS off.
  3. API surface — the Recorder type exposes recordSuccess and recordFailure; 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.