Files
scadaproj/components/audit/current-state/mxaccessgw/CURRENT-STATE.md
T

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 — 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-102audit_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-deniedDenied; 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/ApiKeyAuditRecordAuditEvent, 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.