9.8 KiB
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 —AppendAsyncwritescreated_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 DBAUTOINCREMENTrowid). - Read-side shape:
ListRecentAsyncreturnsApiKeyAuditRecord(long AuditId, string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)(ApiKeyAuditRecord.cs:3), orderedaudit_id DESC LIMIT $count(SqliteApiKeyAuditStore.cs:38-42), returning[]forcount <= 0(SqliteApiKeyAuditStore.cs:29-32). - Storage: SQLite, the same gateway-owned auth DB (
AuthSqliteConnectionFactory, WAL; defaultC:\ProgramData\MxGateway\gateway-auth.db). Tableapi_key_auditis created bySqliteAuthStoreMigrator.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 indexix_api_key_audit_key_id_created_utc(SqliteAuthStoreMigrator.cs:107-108). Table name constantSqliteAuthSchema.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 privateAppendAuditAsync(ApiKeyAdminCliRunner.cs:153) always passesRemoteAddress: null(ApiKeyAdminCliRunner.cs:163). Event types:"init-db"(:48),"create-key"(:74),"list-keys"(:83),"revoke-key"with detailsrevoked/not-found-or-already-revoked(:102),"rotate-key"with detailsrotated/not-found(:121). - Dashboard
DashboardApiKeyManagementService— itsAppendAuditAsync(:197) capturesRemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString()(:207). Event types:"dashboard-create-key"(:62),"dashboard-revoke-key"(:103, detailsrevoked/not-found-or-already-revoked),"dashboard-rotate-key"(:145, detailsrotated/not-found),"dashboard-delete-key"(:187, detailsdeleted/not-found-or-active). - Constraint denials
ConstraintEnforcer.RecordDenialAsync(ConstraintEnforcer.cs:117) writesEventType: "constraint-denied",RemoteAddress: null, andDetails: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"(ConstraintEnforcer.cs:124-129). This is the only "denial" event in the log.
- Admin CLI
- No authn events. The verifier (
ApiKeyVerifier) and the gRPC authorization interceptor (GatewayGrpcAuthorizationInterceptor) do not write to the audit store — authentication success/failure andUnauthenticated/PermissionDeniedoutcomes 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:
ApiKeyAuditEntryhas no field for a secret, and every caller passes only aKeyId(the public key identifier, never the secret), an event-type literal, and short hand-builtDetailsstrings — the secret/pepper never enters the audit path. This aligns with the repo policy that "API keys, passwords,WriteSecuredpayloads, andAuthenticateUsercredentials must never reach logs" (CLAUDE.md:79). Net: redaction is by construction, not a pluggable seam. - Read-back has no production consumer.
ListRecentAsyncis called only by tests (SqliteAuthStoreTests,ApiKeyAdminCliRunnerTests). The dashboardApiKeysPage.razormentions 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:
- Adapter, not rewrite. Map
IApiKeyAuditStore→ the sharedIAuditWriter, andApiKeyAuditEntry/ApiKeyAuditRecord→AuditEvent, using the table above: generate a newEventIdGuid per write;KeyId → Actor(with a"system"fallback for null);EventType → Action;CreatedUtc → OccurredAtUtc;RemoteAddress → SourceNode;constraint-denied → Outcome.Denied, elseSuccess; constantCategory = "ApiKey";Details → DetailsJson. The three producers (ApiKeyAdminCliRunner,DashboardApiKeyManagementService,ConstraintEnforcer) keep their call sites — only the injected type changes. - Redaction stays by-construction. No separate redactor needs porting; just preserve the rule that
callers never put secrets in
DetailsJson(mirrorsCLAUDE.md:79). The shared writer can keep its own redaction policy as a defence-in-depth layer. - Read-back is free to drop or defer.
ListRecentAsynchas no production consumer, so the adapter need not implement a shared query API on day one — only the test/CLI read paths exercise it. - No new dimensions required.
CorrelationIdand a structuredTargetare absent today and are not in scope to add as part of adoption (descriptive parity only); the canonical record simply leaves themnull.
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.