Files
scadaproj/components/audit/spec/EVENT-MODEL.md
T
2026-06-01 07:04:54 -04:00

7.6 KiB

Canonical event model (standardized)

Status: Standardized. The org-wide audit record + outcome enum every sister project maps onto. This is the reference companion to SPEC.md (mirroring auth's CANONICAL-ROLES.md / theme's DESIGN-TOKENS.md): the field-by-field canonical record, the AuditOutcome definition with which app states map onto each value, and the full per-project mapping table. The shared library defines exactly this record; each project projects its native record onto it at the seam.

The canonical record

namespace ZB.MOM.WW.Audit;

public sealed record AuditEvent
{
    // REQUIRED core — who / what / when / outcome
    public required Guid           EventId       { get; init; }  // idempotency key
    public required DateTimeOffset OccurredAtUtc { get; init; }  // normalized to UTC
    public required string         Actor         { get; init; }  // who — = ZB.MOM.WW.Auth principal at adoption
    public required string         Action        { get; init; }  // what — verb / event-type string
    public required AuditOutcome   Outcome       { get; init; }  // Success | Failure | Denied

    // OPTIONAL common
    public string? Category      { get; init; }  // subsystem / grouping bucket
    public string? Target        { get; init; }  // on-what (resource / method / connection)
    public string? SourceNode    { get; init; }  // emitting logical node / host
    public Guid?   CorrelationId { get; init; }  // join to originating request / workflow

    // EXTENSION — everything project-specific, as JSON
    public string? DetailsJson   { get; init; }
}

public enum AuditOutcome { Success, Failure, Denied }

Field-by-field

Field Req? Type Meaning Notes
EventId yes Guid Idempotency key Backs at-least-once transports: OtOpcUa's filtered-unique EventId index, ScadaBridge's first-write-wins. MxGateway has none today → generate at write time.
OccurredAtUtc yes DateTimeOffset When it happened, UTC MxGateway already uses DateTimeOffset. OtOpcUa / ScadaBridge store UTC-forced DateTime and widen at the mapping boundary.
Actor yes string Who acted SHOULD be the ZB.MOM.WW.Auth principal (SPEC.md §4). Kept a string (no Auth dependency). Keyless events use a "system" / "cli" fallback rather than empty.
Action yes string What was done (verb / event-type) Carries each app's domain verb: OtOpcUa EventType, MxGateway EventType, ScadaBridge {Channel}.{Kind}.
Outcome yes AuditOutcome Success / Failure / Denied New normalized field — no app stores it today; each derives it (see below).
Category no string? Coarse subsystem / grouping OtOpcUa Category ("Config"); MxGateway constant "ApiKey"; ScadaBridge Channel.
Target no string? The object acted on ScadaBridge Target (direct). OtOpcUa / MxGateway have no dedicated field → null or fold into DetailsJson.
SourceNode no string? Emitting logical node / host OtOpcUa SourceNode (a logical node name, not an OPC UA NodeId); ScadaBridge SourceNode; MxGateway RemoteAddress.
CorrelationId no Guid? Join to originating request / workflow OtOpcUa / ScadaBridge direct; MxGateway has none today (left null).
DetailsJson no string? Extension bag — all project-specific data Must be valid JSON where stored (OtOpcUa enforces this with a CHECK constraint). Absorbs each app's surplus columns.

AuditOutcome — definition and app-state mapping

Three values, deliberately minimal — enough to normalize denials and failures without importing any app's full taxonomy. Outcome is derived at each emit site (no app persists it today; OtOpcUa encodes it implicitly in EventType, MxGateway in the event-type literal, ScadaBridge in Status):

AuditOutcome Meaning OtOpcUa (EventType) MxGateway (event type) ScadaBridge (AuditStatus / AuditKind)
Success The action completed config-write verbs — DraftCreated, DraftEdited, Published, RolledBack, NodeApplied, CredentialAdded, ClusterCreated, NodeAdded, ExternalIdReleased, … key-lifecycle — init-db, create-key, list-keys, revoke-key, rotate-key + all dashboard-* Status = Delivered
Failure The action was attempted and failed (none today — a failed actor flush is dropped, not recorded as an event) (none emitted today) Status ∈ { Failed, Parked, Discarded }
Denied The action was rejected by authorization / policy OpcUaAccessDenied, CrossClusterNamespaceAttempt constraint-denied Kind = InboundAuthFailure

Notes:

  • OtOpcUa has no Failure source. Its vocabulary only distinguishes success-verbs from access-denials; an internal write failure is dropped (best-effort), not emitted as an event. So OtOpcUa produces only Success / Denied until/unless it adds failure events.
  • MxGateway emits only Success / Denied today (no failure events; authentication success/failure is surfaced as gRPC status, not persisted — see its current-state doc).
  • ScadaBridge in-flight states (Submitted / Forwarded / Attempted) are not terminal; when projecting to a single Outcome they collapse to the last-known terminal state. Skipped is not a user-facing outcome and is excluded from the canonical projection.

Per-project mapping table (canonical ← native record)

Consolidated from the three current-state docs. "Direct" = field exists with the same role; the right-hand notes flag the type bridges and synthesized fields.

Canonical field OtOpcUa AuditEvent (8 fields) MxGateway ApiKeyAuditRecord (6 fields) ScadaBridge AuditEvent (~25 fields)
EventId EventId — direct (idempotency key) generate new Guid (only AuditId rowid exists) EventId — direct
OccurredAtUtc OccurredAtUtc (DateTime UTC) → widen CreatedUtc (store-assigned DateTimeOffset) — direct OccurredAtUtc (DateTime UTC-forced) → widen
Actor Actor — direct KeyId (nullable → "system"/"cli" fallback) Actor (nullable on system rows)
Action Action (persisted as "{Category}:{Action}") EventType — direct {Channel}.{Kind} (e.g. ApiOutbound.ApiCall)
Outcome derive from EventType derive: constraint-deniedDenied, else Success derive from Status (+InboundAuthFailureDenied)
Category Category ("Config") constant "ApiKey" Channel
Target — none — (null or via DetailsJson) — none — (commandKind/target embedded in Details text) Target — direct
SourceNode SourceNode (logical node, NodeId.Value) RemoteAddress (dashboard path only) SourceNode — direct
CorrelationId CorrelationId (CorrelationId.Value) — direct — none — CorrelationId — direct
DetailsJson DetailsJson — direct (also ClusterId/GenerationId on the SP path) Details (plain string → store as-is or wrap) the ~15 rich/plumbing fields (ExecutionId, SourceSiteId, HttpStatus, DurationMs, ErrorMessage, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, …) serialize here

The canonical record is a lossy projection: it is sufficient for cross-project reporting, but each project keeps its native record as the storage shape — ScadaBridge especially, whose partitioned SQL schema, forwarding state, and reconciliation depend on the extra columns (SPEC.md §5).