# Audit — current state: MxAccessGateway (`mxaccessgw`)

Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
Audit lives entirely in the **gateway** (.NET 10); the worker records nothing.
All paths relative to repo root; audit code under `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`. Verified 2026-06-01.

This is the **narrowest** of the three implementations: a single SQLite-backed append-only log scoped
to **API-key lifecycle and constraint denials**. There is no general-purpose audit abstraction, no
separate redaction seam, and no CorrelationId. Read-back exists but has no production consumer today.

## How it works today

The audit log is one seam, `IApiKeyAuditStore`
(`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs:6`), with exactly two
operations: `AppendAsync(ApiKeyAuditEntry, ...)` (`IApiKeyAuditStore.cs:14`) and
`ListRecentAsync(int count, ...)` (`IApiKeyAuditStore.cs:22`). Single implementation,
`SqliteApiKeyAuditStore` (`SqliteApiKeyAuditStore.cs:5`), registered as a singleton in
`AuthStoreServiceCollectionExtensions.cs:23` alongside the rest of the auth stores.

- **Append-side shape:** callers pass `ApiKeyAuditEntry(string? KeyId, string EventType, string? RemoteAddress, string? Details)`
  (`ApiKeyAuditEntry.cs:3`). The store sets the timestamp itself — `AppendAsync` writes
  `created_utc = DateTimeOffset.UtcNow.ToString("O")` (`SqliteApiKeyAuditStore.cs:20`), so the caller
  cannot supply the time and there is **no idempotency/event key** (the only identity is the DB
  `AUTOINCREMENT` rowid).
- **Read-side shape:** `ListRecentAsync` returns `ApiKeyAuditRecord(long AuditId, string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)`
  (`ApiKeyAuditRecord.cs:3`), ordered `audit_id DESC LIMIT $count` (`SqliteApiKeyAuditStore.cs:38-42`),
  returning `[]` for `count <= 0` (`SqliteApiKeyAuditStore.cs:29-32`).
- **Storage:** SQLite, the same gateway-owned auth DB (`AuthSqliteConnectionFactory`, WAL; default
  `C:\ProgramData\MxGateway\gateway-auth.db`). Table `api_key_audit` is created by
  `SqliteAuthStoreMigrator.cs:95-102` — `audit_id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NULL,
  event_type TEXT NOT NULL, remote_address TEXT NULL, created_utc TEXT NOT NULL, details TEXT NULL`,
  plus index `ix_api_key_audit_key_id_created_utc` (`SqliteAuthStoreMigrator.cs:107-108`). Table name
  constant `SqliteAuthSchema.ApiKeyAuditTable = "api_key_audit"` (`SqliteAuthSchema.cs:11`). The log is
  append-only: there is no update/delete/prune path.
- **Producers (three, all in the gateway):**
  - **Admin CLI** `ApiKeyAdminCliRunner` — its private `AppendAuditAsync` (`ApiKeyAdminCliRunner.cs:153`)
    always passes `RemoteAddress: null` (`ApiKeyAdminCliRunner.cs:163`). Event types:
    `"init-db"` (`:48`), `"create-key"` (`:74`), `"list-keys"` (`:83`),
    `"revoke-key"` with details `revoked`/`not-found-or-already-revoked` (`:102`),
    `"rotate-key"` with details `rotated`/`not-found` (`:121`).
  - **Dashboard** `DashboardApiKeyManagementService` — its `AppendAuditAsync` (`:197`) captures
    `RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString()` (`:207`).
    Event types: `"dashboard-create-key"` (`:62`), `"dashboard-revoke-key"` (`:103`, details
    `revoked`/`not-found-or-already-revoked`), `"dashboard-rotate-key"` (`:145`, details `rotated`/`not-found`),
    `"dashboard-delete-key"` (`:187`, details `deleted`/`not-found-or-active`).
  - **Constraint denials** `ConstraintEnforcer.RecordDenialAsync` (`ConstraintEnforcer.cs:117`) writes
    `EventType: "constraint-denied"`, `RemoteAddress: null`, and `Details:
    $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"` (`ConstraintEnforcer.cs:124-129`).
    This is the only "denial" event in the log.
- **No authn events.** The verifier (`ApiKeyVerifier`) and the gRPC authorization interceptor
  (`GatewayGrpcAuthorizationInterceptor`) do **not** write to the audit store — authentication
  success/failure and `Unauthenticated`/`PermissionDenied` outcomes are surfaced as gRPC statuses and
  (per policy) discriminated for logging, but are not persisted as audit rows. So in practice the log
  records **key lifecycle (CLI + dashboard) + constraint denials**, not per-request authn outcomes.
- **No separate redaction seam — scrubbing is structural, in the store/entry shape.** There is no
  redactor, scrubber, sanitizer, or masking helper. Safety comes from *what the entry type can carry*:
  `ApiKeyAuditEntry` has no field for a secret, and every caller passes only a `KeyId` (the public
  key identifier, never the secret), an event-type literal, and short hand-built `Details` strings —
  the secret/pepper never enters the audit path. This aligns with the repo policy that "API keys,
  passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs"
  (`CLAUDE.md:79`). Net: redaction is by construction, not a pluggable seam.
- **Read-back has no production consumer.** `ListRecentAsync` is called only by tests
  (`SqliteAuthStoreTests`, `ApiKeyAdminCliRunnerTests`). The dashboard `ApiKeysPage.razor` mentions the
  audit log only in a delete-confirmation string (`ApiKeysPage.razor:321`) — it does **not** render it.
  There is no UI or RPC that surfaces audit history today.

## Mapping to the canonical record

Target: `ZB.MOM.WW.Audit`'s `AuditEvent { Guid EventId; DateTimeOffset OccurredAtUtc; string Actor;
string Action; AuditOutcome Outcome; string? Category; string? Target; string? SourceNode;
Guid? CorrelationId; string? DetailsJson; }` with `AuditOutcome ∈ { Success, Failure, Denied }`.

| `AuditEvent` field | Source today | Mapping note |
|---|---|---|
| `EventId` (Guid, required) | — none — | **Must be generated** at write time. `ApiKeyAuditRecord` has only the autoincrement `AuditId` (`ApiKeyAuditRecord.cs:4`); no idempotency key exists. |
| `OccurredAtUtc` (required) | `CreatedUtc` (`ApiKeyAuditRecord.cs:8`), set as `DateTimeOffset.UtcNow` in the store (`SqliteApiKeyAuditStore.cs:20`) | Direct. Note: time is store-assigned today, not caller-supplied. |
| `Actor` (required) | `KeyId` (`ApiKeyAuditRecord.cs:5`) | Nullable today (`init-db`/`list-keys` pass `null`); the canonical `Actor` is required, so a fallback (e.g. `"system"`/`"cli"`) is needed for keyless events. |
| `Action` (required) | `EventType` (`ApiKeyAuditRecord.cs:6`) | Direct. CLI vocab: `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`; dashboard vocab: `dashboard-create-key`, `dashboard-revoke-key`, `dashboard-rotate-key`, `dashboard-delete-key`; plus `constraint-denied`. |
| `Outcome` (required) | derived | `constraint-denied` → `Denied`; everything else → `Success` (no `Failure` events are emitted today). |
| `Category` | — none — | Constant `"ApiKey"`. |
| `Target` | — none as a field — | No structured target. (`ConstraintEnforcer` does embed `commandKind`/`target` inside `Details` text, but there is no dedicated column.) |
| `SourceNode` | `RemoteAddress` (`ApiKeyAuditRecord.cs:7`) | Direct; populated only on the dashboard path (`DashboardApiKeyManagementService.cs:207`), `null` on CLI/constraint paths. |
| `CorrelationId` | — none — | Not captured today. |
| `DetailsJson` | `Details` (`ApiKeyAuditRecord.cs:9`) | Today this is a **plain string**, not JSON; either store as-is in `DetailsJson` or wrap as a small JSON object. |

---

## Adoption plan → `ZB.MOM.WW.Audit`

**Effort: LOW.** The seam is tiny (one interface, two methods, one record pair) and the data already
maps cleanly onto `AuditEvent`. Concretely:

1. **Adapter, not rewrite.** Map `IApiKeyAuditStore` → the shared `IAuditWriter`, and
   `ApiKeyAuditEntry`/`ApiKeyAuditRecord` → `AuditEvent`, using the table above: generate a new
   `EventId` Guid per write; `KeyId → Actor` (with a `"system"` fallback for null); `EventType → Action`;
   `CreatedUtc → OccurredAtUtc`; `RemoteAddress → SourceNode`; `constraint-denied → Outcome.Denied`,
   else `Success`; constant `Category = "ApiKey"`; `Details → DetailsJson`. The three producers
   (`ApiKeyAdminCliRunner`, `DashboardApiKeyManagementService`, `ConstraintEnforcer`) keep their call
   sites — only the injected type changes.
2. **Redaction stays by-construction.** No separate redactor needs porting; just preserve the rule that
   callers never put secrets in `DetailsJson` (mirrors `CLAUDE.md:79`). The shared writer can keep its
   own redaction policy as a defence-in-depth layer.
3. **Read-back is free to drop or defer.** `ListRecentAsync` has no production consumer, so the adapter
   need not implement a shared query API on day one — only the test/CLI read paths exercise it.
4. **No new dimensions required.** `CorrelationId` and a structured `Target` are absent today and are
   *not* in scope to add as part of adoption (descriptive parity only); the canonical record simply
   leaves them `null`.

**Coordination risk — sequence against the health/observability work.** A parallel session is actively
editing **this same repo** (`mxaccessgw`) for the MEL → Serilog logging migration
(`ZB.MOM.WW.Health` + `ZB.MOM.WW.Telemetry` normalization). Because audit adoption here also touches the
gateway's `Security/Authentication/` wiring (DI registration in `AuthStoreServiceCollectionExtensions.cs`,
and the three producer call sites), the two efforts can collide on the same files and on logging-pipeline
DI. **Do not start MxGateway audit adoption until the Serilog migration in this repo has landed (or is
explicitly fenced off)**, and confirm with the orchestrator that the logging session is not mid-flight in
`Security/` before opening a PR. The audit and logging seams are conceptually independent (audit = durable
SQLite record of who-did-what; logging = operational telemetry), but they share the gateway's startup/DI
surface, so they must be merged in a defined order rather than in parallel.
