Merge branch 'feature/audit-log-m1-foundation': Audit Log #23 M1 Foundation

M1 lands the schema + types every later milestone depends on. After M1 the
database is ready, the ScadaLink.AuditLog project is wired into the solution,
and dotnet test ScadaLink.slnx is green (2503 tests passed, 0 failed).

Shipped (15 commits):
- Commons audit types: AuditEvent record + Audit{Channel,Kind,Status,ForwardState}
  enums + IAuditWriter/ICentralAuditWriter interfaces + AuditTelemetryEnvelope +
  PullAuditEventsRequest/Response message DTOs.
- EF mapping: AuditEvent -> AuditLog table with composite PK (EventId, OccurredAtUtc),
  five named indexes (IX_AuditLog_*) + UX_AuditLog_EventId unique index for
  idempotency lookups. AuditLogEntry (config-audit) coexists, untouched.
- Migration AddAuditLogTable: monthly partition function pf_AuditLog_Month
  (24 boundaries Jan 2026-Dec 2027) + partition scheme ps_AuditLog_Month on PRIMARY,
  table aligned ON ps_AuditLog_Month(OccurredAtUtc); scadalink_audit_writer (INSERT/SELECT
  with explicit DENY UPDATE/DELETE) and scadalink_audit_purger (SELECT + ALTER on
  SCHEMA::dbo) DB roles created idempotently.
- IAuditLogRepository + EF impl: append-only surface (InsertIfNotExistsAsync,
  QueryAsync with keyset paging, SwitchOutPartitionAsync). M1 honest contract:
  SwitchOutPartitionAsync throws NotSupportedException pointing to M6 because
  UX_AuditLog_EventId is non-partition-aligned (SQL Server requires partition
  column in unique-index key); M6's purge actor will drop-and-rebuild around switches.
- New src/ScadaLink.AuditLog/ project: AuditLogOptions + validator (DefaultCapBytes
  8KB, ErrorCapBytes 64KB, RetentionDays 365 [30..3650], header redact defaults
  Authorization/X-Api-Key/Cookie/Set-Cookie).
- Spec corrections (#23): alog.md + Component-AuditLog.md vocabulary reconciled
  to match M1 enums per user-authorized resolution of the cross-bundle review
  finding (CLAUDE.md cached-call lifecycle vocabulary supersedes alog.md's earlier
  Success/TransientFailure naming).

MSSQL integration tests gated by Xunit.SkippableFact + Connect Timeout=3 fast-fail;
when the infra/mssql container is up, all 8 migration tests + 8 repository tests
pass; when down, they Skip cleanly in ~3s.

Append-only invariant enforced at three layers:
  1. DB writer role: DENY UPDATE, DENY DELETE on dbo.AuditLog.
  2. Repo interface: no UpdateAsync, no row-DeleteAsync.
  3. Repo impl: raw IF NOT EXISTS INSERT only.

infra/* working-tree mods are pre-existing and untouched throughout M1.
This commit is contained in:
Joseph Doherty
2026-05-20 11:57:33 -04:00
45 changed files with 4568 additions and 74 deletions

View File

@@ -64,6 +64,14 @@
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.11.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<!--
Xunit.SkippableFact provides [SkippableFact] + Skip.IfNot/Skip.If for
xunit v2. The native Skip API (Assert.Skip / Assert.SkipUnless /
Assert.SkipWhen) only exists in xunit v3; xunit 2.9.x lacks it. Used by
Bundle C MSSQL integration tests in ScadaLink.ConfigurationDatabase.Tests
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
-->
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
<Project Path="src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
<Project Path="src/ScadaLink.Host/ScadaLink.Host.csproj" />
<Project Path="src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
@@ -22,6 +23,7 @@
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj" />
<Project Path="tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj" />
<Project Path="tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj" />
<Project Path="tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj" />

95
alog.md
View File

@@ -109,14 +109,14 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
| `Kind` | `varchar(32)` | Channel-specific event kind (see table below). |
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events (inbound API, central notification dispatch). |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
| `Status` | `varchar(32)` | Outcome of *this event*: `Success`, `TransientFailure`, `PermanentFailure`, `Enqueued`, `Retrying`, `Delivered`, `Parked`, `Discarded`. |
| `Status` | `varchar(32)` | Outcome of *this event*: `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. |
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
| `DurationMs` | `int` NULL | Call/attempt duration. |
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
@@ -135,14 +135,20 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
**`Kind` values by channel:**
**`Kind` values (flat — 10 discriminators across all channels):**
| Channel | Kinds |
| Kind | Fires when |
|---|---|
| `ApiOutbound` | `SyncCall`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
| `ApiInbound` | `Completed` (one row per request, written at request end with final status) |
| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
| `DbWriteCached` | A cached outbound-DB attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
| `NotifySend` | Script's `Notify.Send(...)` is enqueued on the site — first row in a notification's lifecycle (`Status=Submitted`). |
| `NotifyDeliver` | Central Notification Outbox dispatcher records a delivery attempt (`Attempted`) or terminal outcome (`Delivered`/`Parked`/`Discarded`). |
| `InboundRequest` | An inbound API request completes — one row per request, written at request end with final status. |
| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
### Site: `AuditLog` (SQLite)
@@ -215,13 +221,13 @@ This is the same self-healing pattern Site Call Audit uses for `SiteCalls`.
Events that originate at central never touch site SQLite:
- **Inbound API** — request completed at central; one `ApiInbound`/`Completed` row written via `ICentralAuditWriter` synchronously inside the request handler middleware before the HTTP response is flushed.
- **Notification Outbox dispatcher** — each delivery attempt writes a `Notification`/`Attempt` row; terminal status writes a `Notification`/`Terminal` row. (The site-originated `Notification`/`Enqueued` row arrives via §6.2.)
- **Inbound API** — request completed at central; one `ApiInbound`/`InboundRequest` row written via `ICentralAuditWriter` synchronously inside the request handler middleware before the HTTP response is flushed. Auth failures emit `ApiInbound`/`InboundAuthFailure` instead.
- **Notification Outbox dispatcher** — each delivery attempt writes a `Notification`/`NotifyDeliver` row with `Status=Attempted`; terminal status writes a `Notification`/`NotifyDeliver` row with `Status=Delivered`/`Parked`/`Discarded`. (The site-originated `Notification`/`NotifySend` row, `Status=Submitted`, arrives via §6.2.)
Central direct-writes use the same insert-if-not-exists semantics keyed on `EventId`, so a retried request handler can't produce duplicates.
### 6.5 Cached operations — site emits, central writes twice
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the source of truth for every audit row. The site writes each lifecycle event (`CachedEnqueued`, `CachedAttempt`, `CachedTerminal`) to its local SQLite `AuditLog` on the hot path (or on the retry tick for `CachedAttempt`), then forwards via the same telemetry channel described in §6.2. The telemetry message format gains the audit-row fields additively — one packet per lifecycle transition carries both the operational state update AND the audit row content.
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the source of truth for every audit row. The site writes each lifecycle event `CachedSubmit` (`Status=Submitted`), then `ApiCallCached`/`DbWriteCached` rows for the forward-ack (`Status=Forwarded`) and each retry (`Status=Attempted`), then a terminal `CachedResolve` row (`Status=Delivered`/`Failed`/`Parked`/`Discarded`) to its local SQLite `AuditLog` on the hot path (or on the retry tick for `Attempted` rows), then forwards via the same telemetry channel described in §6.2. The telemetry message format gains the audit-row fields additively — one packet per lifecycle transition carries both the operational state update AND the audit row content.
On receipt, central does two things in **one transaction**:
@@ -243,13 +249,13 @@ Worked examples — what each `Channel`/`Kind` row actually looks like. (Other c
```
EventId = <new guid>
Channel = ApiOutbound
Kind = SyncCall
Kind = ApiCall
CorrelationId = NULL -- one-shot, no operation to correlate
SourceSiteId = "site-01"
SourceInstance = "Plant1.Boiler"
SourceScript = "OnHourly"
Target = "Weather/GetForecast"
Status = Success
Status = Delivered
HttpStatus = 200
DurationMs = 142
RequestSummary = '{"city":"Dublin"}' -- truncated to cap
@@ -259,11 +265,12 @@ ResponseSummary= '{"tempC":11.4,...}' -- truncated to cap
**Cached call** (`ExternalSystem.CachedCall(...)`, hits a 500, retries, succeeds on attempt 3):
```
1. Kind=CachedEnqueued Status=Enqueued CorrelationId=<tracked-op-id>
2. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=<same>
3. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=<same>
4. Kind=CachedAttempt Status=Success HttpStatus=200 CorrelationId=<same>
5. Kind=CachedTerminal Status=Delivered CorrelationId=<same>
1. Kind=CachedSubmit Status=Submitted CorrelationId=<tracked-op-id>
2. Kind=ApiCallCached Status=Forwarded CorrelationId=<same>
3. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=<same>
4. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=<same>
5. Kind=ApiCallCached Status=Attempted HttpStatus=200 CorrelationId=<same>
6. Kind=CachedResolve Status=Delivered CorrelationId=<same>
```
The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
@@ -274,10 +281,10 @@ The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
```
Channel = DbOutbound
Kind = SyncWrite
Kind = DbWrite
Target = "PlantDB" -- connection name only, not server
CorrelationId = NULL
Status = Success
Status = Delivered
DurationMs = 9
RequestSummary = "INSERT INTO Readings(ts,val) VALUES (@p0,@p1)" -- SQL text
Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7}}' -- values captured by default
@@ -288,23 +295,25 @@ Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7
```
Channel = DbOutbound
Kind = SyncRead
Status = Success
Kind = DbWrite
Status = Delivered
DurationMs = 31
RequestSummary = "SELECT id, value FROM Readings WHERE ts > @p0"
Extra = '{"rowsReturned":42}'
ResponseSummary= NULL -- rows not captured by default; opt-in per connection
```
**Cached write** — same five-row lifecycle as the cached API example.
(Reads and writes share the `DbWrite` kind — the kind distinguishes the trust-boundary call shape, not the SQL verb. Distinguish by `RequestSummary` / `Extra.rowsAffected` vs `Extra.rowsReturned` when needed.)
**Cached write** — same multi-row lifecycle as the cached API example, using `Kind=DbWriteCached` for the `Forwarded` / `Attempted` rows in place of `ApiCallCached`.
### 7.3 `Notification` — outbound notifications
```
1. Kind=Enqueued Status=Enqueued CorrelationId=<NotificationId> SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
2. Kind=Attempt Status=TransientFailure ErrorMessage="SMTP 451 ..." CorrelationId=<same> SourceSiteId=NULL (dispatch is central)
3. Kind=Attempt Status=Success CorrelationId=<same>
4. Kind=Terminal Status=Delivered CorrelationId=<same>
1. Kind=NotifySend Status=Submitted CorrelationId=<NotificationId> SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
2. Kind=NotifyDeliver Status=Attempted ErrorMessage="SMTP 451 ..." CorrelationId=<same> SourceSiteId=NULL (dispatch is central)
3. Kind=NotifyDeliver Status=Attempted CorrelationId=<same>
4. Kind=NotifyDeliver Status=Delivered CorrelationId=<same>
Target = "OpsTeamEmail" -- notification list name
Extra = '{"resolvedTargets":["a@x.com","b@x.com"], "subject":"Boiler high temp"}'
RequestSummary = '...body, truncated...'
@@ -318,20 +327,20 @@ One row per request, written at request completion:
```
Channel = ApiInbound
Kind = Completed
Kind = InboundRequest
CorrelationId = <request-id> -- the request's correlation header (or generated)
SourceSiteId = NULL -- central-originated event
Actor = "AcmeSCADA" -- API key name (NOT the key itself)
Target = "RecordReading" -- inbound method name
Status = Success | PermanentFailure -- mapped from final HTTP outcome
HttpStatus = 200 | 400 | 401 | 500
Status = Delivered | Failed -- mapped from final HTTP outcome
HttpStatus = 200 | 400 | 500
DurationMs = 73
RequestSummary = '{"siteId":"...","value":12.4}' -- truncated; secrets/PII per redaction policy
ResponseSummary= '{"ok":true}' -- full body on 5xx
Extra = '{"remoteIp":"203.0.113.42","userAgent":"...","scriptInvoked":"RecordReading.Handle"}'
```
A bad API key → row with `Status=PermanentFailure`, `HttpStatus=401`, `Actor=NULL`, `Extra` carries `remoteIp` for abuse triage.
A bad API key → separate kind: `Kind=InboundAuthFailure`, `Status=Failed`, `HttpStatus=401`, `Actor=NULL`, `Extra` carries `remoteIp` for abuse triage.
---
@@ -339,7 +348,7 @@ A bad API key → row with `Status=PermanentFailure`, `HttpStatus=401`, `Actor=N
### 8.1 Truncation
- Default cap: **8 KB** for each of `RequestSummary` and `ResponseSummary`. Configurable globally; per-target overrides allowed (§8.4).
- On any non-`Success` row, the cap is raised to **64 KB** for that row — error context is precious.
- On any error row (`Status IN ('Failed', 'Parked', 'Discarded')`), the cap is raised to **64 KB** for that row — error context is precious.
- When a body is truncated, `PayloadTruncated = 1` and the captured prefix is preserved verbatim (UTF-8 byte-safe truncation, no mid-character cuts).
- Bodies exceeding the larger cap are still truncated; full bodies are never stored.
@@ -426,7 +435,7 @@ Lives under a new **Audit** nav group in Central UI (sibling to **Notifications*
- Target (text search — system+method, DB connection, list name).
- Actor (text search — inbound API key name).
- CorrelationId (paste a `TrackedOperationId` / `NotificationId` / request-id to see its full event sequence).
- "Errors only" toggle (`Status NOT IN (Success, Delivered, Enqueued)`).
- "Errors only" toggle (`Status IN ('Failed', 'Parked', 'Discarded')`).
**Results grid:**
- Columns (resizable, reorderable, persisted per user): `OccurredAtUtc`, `Site`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, `HttpStatus`, `ErrorMessage`.
@@ -450,7 +459,7 @@ Lives under a new **Audit** nav group in Central UI (sibling to **Notifications*
### 10.3 Health dashboard tiles
Three new tiles in an "Audit" KPI group:
- **Audit volume** — events/min global + per-site sparkline.
- **Audit error rate** — % non-`Success` rows, rolling 5 min.
- **Audit error rate** — % rows where `Status IN ('Failed', 'Parked', 'Discarded')`, rolling 5 min.
- **Audit backlog** — sum of `Pending` site rows; click → per-site breakdown.
### 10.4 Export
@@ -516,12 +525,12 @@ Rough back-of-envelope; load testing will confirm.
### 13.1 Per-site event rate (assumed nominal site)
| Channel/Kind | Typ events/min | Peak events/min |
|---|---:|---:|
| `ApiOutbound.SyncCall` | 10 | 100 |
| `ApiOutbound.Cached*` (~4 rows/op) | 4 | 20 |
| `DbOutbound.SyncWrite` | 30 | 300 |
| `DbOutbound.SyncRead` | 60 | 600 |
| `DbOutbound.Cached*` (~4 rows/op) | 4 | 20 |
| `Notification.Enqueued` (site-emit) | 1 | 10 |
| `ApiOutbound.ApiCall` | 10 | 100 |
| `ApiOutbound.ApiCallCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
| `DbOutbound.DbWrite` (writes) | 30 | 300 |
| `DbOutbound.DbWrite` (reads) | 60 | 600 |
| `DbOutbound.DbWriteCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
| `Notification.NotifySend` (site-emit) | 1 | 10 |
| **Per-site total** | **~110** | **~1,050** |
### 13.2 Central total (50-site deployment)
@@ -545,7 +554,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
### 13.6 Levers
- Reduce `DefaultCapBytes` per §8.1.
- Tighten per-channel retention per §12.1 (especially `DbOutbound.SyncRead`).
- Tighten per-channel retention per §12.1 (especially `DbOutbound.DbWrite` read traffic).
- Defer to v1.x: Parquet archival to object storage before purge (§15.2).
---
@@ -554,7 +563,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
### 14.1 New Audit Log KPIs
- **Volume** — events/min, global + per-site.
- **Error rate** — % non-`Success` rows, rolling 5 min.
- **Error rate** — % rows where `Status IN ('Failed', 'Parked', 'Discarded')`, rolling 5 min.
- **Backlog** — sum of `Pending` site rows.
- **Top inbound callers** — top-10 `Actor` by request count, last 1h.
- **Top outbound 5xx** — top-10 `Target` by 5xx-status count, last 1h.
@@ -590,6 +599,6 @@ A monthly job dumps the closing partition to Parquet on operator-configured obje
| 3 | Hash-chain tamper evidence (§11.4) | Deferred to v1.x. v1 enforces append-only via DB grants only. |
| 4 | Parquet archival to object storage (§15.2) | Deferred to v1.x. |
| 5 | Per-channel retention overrides (§12.1) | Deferred to v1.x. v1 uses a single global `RetentionDays`. |
| 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on non-`Success` rows. |
| 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on error rows (`Status IN ('Failed', 'Parked', 'Discarded')`). |
All earlier design decisions (purpose, topology, scope, payload depth, lifecycle granularity, retention default, site→central path, UI shape, cached-call audit emission, SQL parameter capture, never-fail-on-audit-failure) are also locked. See §1§15.

View File

@@ -0,0 +1,324 @@
# Audit Log #23 — M1 Foundation Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence per `feedback_subagent_cadence`).
**Goal:** Land the `AuditLog` table (monthly-partitioned) plus DB roles in MS SQL, and add the Commons types + EF repo + new `ScadaLink.AuditLog` project skeleton that every later milestone depends on. After M1 the database is ready, the new project is wired into the solution, and `dotnet build && dotnet test` are both green.
**Architecture:** New `AuditEvent` record + audit enums + writer interfaces in Commons. New EF entity configuration + EF Core migration creating `AuditLog` table aligned to `ps_AuditLog_Month` partition scheme on `OccurredAtUtc`, plus `scadalink_audit_writer` and `scadalink_audit_purger` SQL roles. New `IAuditLogRepository` with append-only surface (no Update, no row-delete). New `src/ScadaLink.AuditLog/` project skeleton + `AuditLogOptions`.
**Tech Stack:** .NET 10 / EF Core 10.0.7 / Microsoft.Data.SqlClient 6.0.2 / xUnit 2.9.3 / running `infra/mssql` container for integration tests.
**Brainstorm decisions (locked):**
- **MSSQL test harness:** integration tests hit the existing `infra/mssql` container (require `cd infra && docker compose up -d`).
- **AuditEvent shape:** one record with nullable `IngestedAtUtc` (set centrally) and nullable `ForwardState` (set site-locally).
- **Filegroup:** PRIMARY, hard-coded.
- **Indexes:** five named explicitly via `.HasDatabaseName("IX_AuditLog_…")`.
**Pre-existing reality:**
- `Entities/Audit/AuditLogEntry.cs` (config-audit, 9 cols) **coexists with** new `AuditEvent` — no rename, no removal.
- `IAuditService` (config-audit) is distinct from new `IAuditWriter` / `ICentralAuditWriter`.
- `tests/ScadaLink.IntegrationTests/` uses EF in-memory — NOT usable for partition/role tests.
- Roadmap M1-T10 (project skeleton) must run before M1-T9 (options class). **Swapped in this plan.**
---
## Bundles (cadence-aligned)
Tasks 111 from the roadmap are grouped into 6 bundles. Each bundle = one implementer dispatch + one combined spec+quality reviewer. The final cross-bundle reviewer runs over the whole branch.
- **Bundle A — Commons types** (roadmap T1+T2+T3+T4): enums, AuditEvent record + ForwardState enum, IAuditWriter / ICentralAuditWriter, telemetry message DTOs.
- **Bundle B — EF entity mapping** (T5): DbSet + IEntityTypeConfiguration<AuditEvent> + indexes.
- **Bundle C — Migration with partitioning + DB roles** (T6+T7 merged — one migration file).
- **Bundle D — Repository** (T8): IAuditLogRepository + EF implementation + DI registration.
- **Bundle E — AuditLog project skeleton + options** (T10 then T9): new `src/ScadaLink.AuditLog/` project + `AuditLogOptions`.
- **Bundle F — Docs paper trail** (T11): controller-direct edit; no subagent needed for a 13 line update.
---
## Bundle A — Commons types
### Task 1: Add audit enums to Commons
**Files:**
- Create: `src/ScadaLink.Commons/Types/Enums/AuditChannel.cs`
- Create: `src/ScadaLink.Commons/Types/Enums/AuditKind.cs`
- Create: `src/ScadaLink.Commons/Types/Enums/AuditStatus.cs`
- Create: `src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs`
- Create: `tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs`
**AuditChannel members** (4): `ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`.
**AuditKind members** (10, per alog.md §4): `ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`.
**AuditStatus members** (8, per alog.md §4): `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`.
**AuditForwardState members** (3): `Pending`, `Forwarded`, `Reconciled`.
**Steps:**
1. Failing tests assert each enum's exact member set via `Enum.GetValues(typeof(T)).Cast<T>().Select(x => x.ToString())` against a string-array literal.
2. Run: fail (enums don't exist).
3. Implement the four enums (no `[Flags]`).
4. Run: pass.
5. Commit: `feat(commons): add Audit{Channel,Kind,Status,ForwardState} enums for #23`.
### Task 2: Add AuditEvent record
**Files:**
- Create: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs``public sealed record AuditEvent` with the 20 central columns per alog.md §4, plus nullable `AuditForwardState? ForwardState` and nullable `DateTime? IngestedAtUtc`.
- Create: `tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs`.
Properties (in alog.md §4 order):
`Guid EventId`, `DateTime OccurredAtUtc`, `DateTime? IngestedAtUtc`, `AuditChannel Channel`, `AuditKind Kind`, `Guid? CorrelationId`, `string? SourceSiteId`, `string? SourceInstanceId`, `string? SourceScript`, `string? Actor`, `string? Target`, `AuditStatus Status`, `int? HttpStatus`, `int? DurationMs`, `string? ErrorMessage`, `string? ErrorDetail`, `string? RequestSummary`, `string? ResponseSummary`, `bool PayloadTruncated`, `string? Extra`, `AuditForwardState? ForwardState`.
**Steps:**
1. Failing test constructs an `AuditEvent`, asserts each property reads back as set, asserts `with` expression produces a new instance with one field changed.
2. Run: fail.
3. Implement record with all properties as `init`-only.
4. Run: pass.
5. Commit: `feat(commons): add AuditEvent record (#23)`.
### Task 3: Add IAuditWriter and ICentralAuditWriter
**Files:**
- Create: `src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs`
- Create: `src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs`
- Create: `tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs`
Both interfaces expose `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)`. XML doc comments name Audit Log #23 as the owner; `IAuditWriter` is the abstraction the boundary code calls, `ICentralAuditWriter` is the central-only flavor (used by direct-write paths in M2+).
**Steps:**
1. Failing reflection test: `typeof(IAuditWriter).GetMethod("WriteAsync")` returns a method whose parameters are `(AuditEvent, CancellationToken)` and return type is `Task`. Same for `ICentralAuditWriter`.
2. Run: fail.
3. Implement both interfaces with XML docs.
4. Run: pass.
5. Commit: `feat(commons): add IAuditWriter and ICentralAuditWriter (#23)`.
### Task 4: Add audit telemetry + pull message DTOs
**Files:**
- Create: `src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs``public sealed record AuditTelemetryEnvelope(Guid EnvelopeId, string SourceSiteId, IReadOnlyList<AuditEvent> Events)`.
- Create: `src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs``public sealed record PullAuditEventsRequest(string SourceSiteId, DateTime SinceUtc, int BatchSize)`.
- Create: `src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs``public sealed record PullAuditEventsResponse(IReadOnlyList<AuditEvent> Events, bool MoreAvailable)`.
- Create: `tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs`.
**Steps:**
1. Failing test constructs envelope with 3 events and asserts immutability and enumerability.
2. Failing test constructs `PullAuditEventsRequest` + `PullAuditEventsResponse` with `MoreAvailable=true`.
3. Run: fail.
4. Implement records.
5. Run: pass.
6. Commit: `feat(commons): add audit telemetry + pull message DTOs (#23)`.
**Bundle A acceptance:** Commons project compiles. Four enum tests, AuditEvent test, two interface contract tests, two telemetry-message tests all green. No existing tests regress.
---
## Bundle B — EF entity mapping
### Task 5: Extend ScadaLinkDbContext + add IEntityTypeConfiguration<AuditEvent>
**Files:**
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` — add `public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();` in the existing `// Audit` section, **directly after** the existing `AuditLogEntries` DbSet. Do not remove or modify `AuditLogEntries`.
- Create: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs``IEntityTypeConfiguration<AuditEvent>` mapping to table `AuditLog`, columns per alog.md §4 (with max lengths), PK on `EventId`, enum columns stored as `varchar(32)` via `HasConversion<string>().HasMaxLength(32)`. **No partition function declared here** — that goes in the migration's raw SQL.
Five indexes with explicit names:
- `IX_AuditLog_OccurredAtUtc` on (`OccurredAtUtc` desc)
- `IX_AuditLog_Site_Occurred` on (`SourceSiteId`, `OccurredAtUtc` desc)
- `IX_AuditLog_CorrelationId` on (`CorrelationId`) where `CorrelationId IS NOT NULL`
- `IX_AuditLog_Channel_Status_Occurred` on (`Channel`, `Status`, `OccurredAtUtc` desc)
- `IX_AuditLog_Target_Occurred` on (`Target`, `OccurredAtUtc` desc) where `Target IS NOT NULL`
- Modify: `OnModelCreating` — apply via `modelBuilder.ApplyConfiguration(new AuditLogEntityTypeConfiguration())`.
- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs` — use a `ModelBuilder` directly (no DbContext required) and assert:
- mapped table name is `AuditLog`,
- PK is `EventId`,
- exactly 21 properties are mapped (20 + ForwardState; IngestedAtUtc is one of the 20 per spec; but ForwardState is the +1),
- the five indexes exist with the documented names.
**Steps:**
1. Failing test: model asserts on table name + PK + property count.
2. Implement config + apply in `OnModelCreating`; add the DbSet.
3. Failing test: model asserts five named indexes.
4. Add `HasIndex(...).HasDatabaseName(...)` for each.
5. Run: pass.
6. Commit: `feat(configdb): map AuditEvent to AuditLog table with PK and five named indexes (#23)`.
**Bundle B acceptance:** ConfigurationDatabase project compiles. Mapping test passes. No existing ConfigurationDatabase.Tests regress.
---
## Bundle C — Migration with partitioning + DB roles
### Task 6+7 (merged): Create migration with partition function/scheme/table + DB roles
**Files:**
- Generate: `src/ScadaLink.ConfigurationDatabase/Migrations/<yyyyMMddHHmmss>_AddAuditLogTable.cs` via:
```
dotnet ef migrations add AddAuditLogTable --project src/ScadaLink.ConfigurationDatabase \
--startup-project src/ScadaLink.Host --output-dir Migrations
```
- Customize the migration's `Up()`:
1. Raw SQL: create partition function `pf_AuditLog_Month` (RANGE RIGHT FOR VALUES with month-boundaries from `2026-01-01` through `2027-12-01` UTC), and partition scheme `ps_AuditLog_Month` ALL TO ([PRIMARY]).
2. Drop EF's auto-generated `CREATE TABLE` and replace with raw SQL that creates `AuditLog` ON `ps_AuditLog_Month(OccurredAtUtc)`. (Or: let EF generate the table, then `ALTER TABLE … ADD CONSTRAINT … PK … ON ps_AuditLog_Month(OccurredAtUtc)` — whichever EF 10 supports cleanly.)
3. Create the five named indexes via `migrationBuilder.CreateIndex(...)`, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)` where appropriate.
4. Raw SQL roles, idempotent (`IF NOT EXISTS … CREATE ROLE`):
- `scadalink_audit_writer`: GRANT INSERT ON AuditLog; GRANT SELECT ON AuditLog. (No UPDATE, no DELETE.)
- `scadalink_audit_purger`: GRANT ALTER ON SCHEMA::dbo; GRANT SELECT ON AuditLog. (Enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION.)
- `Down()` drops indexes, table, scheme, function, then both roles.
- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADALINK_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
Integration test assertions:
- `sys.partition_functions` contains `pf_AuditLog_Month`.
- `sys.partition_schemes` contains `ps_AuditLog_Month`.
- `INFORMATION_SCHEMA.TABLES` contains `AuditLog` aligned to the partition scheme.
- `sys.indexes` contains the five expected named indexes.
- `sys.database_principals` contains both roles.
- Smoke test: log in as a user mapped to `scadalink_audit_writer`, attempt `UPDATE AuditLog …`, expect `SqlException` with permission error.
**Steps:**
1. Generate the migration; let EF auto-fill the body.
2. Failing integration test: assert partition function exists.
3. Edit migration to add the partition function + scheme + table alignment.
4. Re-run: pass.
5. Failing integration test: assert five indexes exist.
6. Add named indexes to migration.
7. Failing integration test: assert both roles exist with documented grants.
8. Add roles to migration.
9. Failing integration test: smoke `UPDATE AuditLog` as writer expects permission error.
10. Verify role grants exclude UPDATE.
11. Run: pass.
12. Commit: `feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23)`.
**Notes for the implementer:**
- Use `Microsoft.Data.SqlClient` directly in the test fixture (not EF) to issue raw SQL for grant assertions.
- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADALINK_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
- Test database name: `ScadaLinkAuditMigrationTest_<guid>` (created per fixture, dropped on dispose).
**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADALINK_MSSQL_TEST_CONN` is set; skip cleanly when unset.
---
## Bundle D — Repository
### Task 8: IAuditLogRepository + EF implementation + DI
**Files:**
- Create: `src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs` — three methods:
- `Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);`
- `Task<IReadOnlyList<AuditEvent>> QueryAsync(AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default);`
- `Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);`
Plus two small DTOs in the same file (or co-located `Types/Audit/`):
- `AuditLogQueryFilter` record: nullable `AuditChannel?`, `AuditKind?`, `AuditStatus?`, `string? SourceSiteId`, `string? Target`, `string? Actor`, `Guid? CorrelationId`, `DateTime? FromUtc`, `DateTime? ToUtc`.
- `AuditLogPaging` record: `int PageSize`, `Guid? AfterEventId`, `DateTime? AfterOccurredAtUtc` (keyset).
- Create: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implements all three methods:
- `InsertIfNotExistsAsync` uses raw SQL `IF NOT EXISTS (SELECT 1 FROM AuditLog WHERE EventId = @id) INSERT INTO AuditLog …` via `DbContext.Database.ExecuteSqlInterpolatedAsync` (bypasses change tracker).
- `QueryAsync` builds an `IQueryable<AuditEvent>`, applies filters, projects, paged by keyset on `(OccurredAtUtc desc, EventId desc)`.
- `SwitchOutPartitionAsync` builds a unique staging table name, runs `CREATE TABLE … <staging>` with identical schema and ON `[PRIMARY]`, runs `ALTER TABLE AuditLog SWITCH PARTITION <n> TO <staging>`, then `DROP TABLE <staging>`. All inside a single transaction. Computes partition number from `monthBoundary` via `$partition.pf_AuditLog_Month(@boundary)`.
- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` — add `services.AddScoped<IAuditLogRepository, AuditLogRepository>();` after `INotificationOutboxRepository` line.
- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — uses the same MSSQL fixture from Bundle C (skipped when env var unset) since `InsertIfNotExistsAsync` uses raw SQL that won't run on EF in-memory.
Tests:
- Insert for fresh `EventId` writes one row.
- Calling `InsertIfNotExistsAsync` again with the same `EventId` is a no-op (no exception, row count unchanged).
- `QueryAsync` returns rows in `(OccurredAtUtc desc, EventId desc)` order honoring all filter predicates.
- `QueryAsync` with non-null `AfterEventId`/`AfterOccurredAtUtc` keysets correctly to the next page.
- `SwitchOutPartitionAsync` for an old boundary removes the rows belonging to that partition from the live table.
**Steps:**
1. Failing test: insert + duplicate insert.
2. Implement using raw SQL.
3. Failing test: query order + filters.
4. Implement.
5. Failing test: keyset paging.
6. Implement.
7. Failing test: switch-out partition.
8. Implement.
9. Run all: pass.
10. Commit: `feat(configdb): IAuditLogRepository + EF implementation, append-only with partition-switch purge (#23)`.
**Bundle D acceptance:** Repository tests green. DI smoke test from existing ConfigurationDatabase.Tests still passes.
---
## Bundle E — AuditLog project skeleton + options
### Task 10 (first): Scaffold `src/ScadaLink.AuditLog/` project
**Files:**
- Create: `src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj` — TargetFramework `net10.0` (matches solution), references `ScadaLink.Commons` + `ScadaLink.ConfigurationDatabase`.
- Create: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs` — `public static class ServiceCollectionExtensions { public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) { … } }` registering `AuditLogOptions` (from Task 9) and forwarding to `services.AddScoped<IAuditLogRepository, AuditLogRepository>()` (already registered by ConfigurationDatabase, so this is a no-op but documents the dependency).
- Create: `tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj` with one smoke test.
- Create: `tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs` — smoke test: `services.AddAuditLog(config); var p = services.BuildServiceProvider(); Assert.NotNull(p.GetService<IOptions<AuditLogOptions>>());`.
- Modify: `ScadaLink.slnx` — add both projects.
**Steps:**
1. `dotnet new classlib -n ScadaLink.AuditLog -o src/ScadaLink.AuditLog --framework net10.0` (then delete the default `Class1.cs`).
2. `dotnet new xunit -n ScadaLink.AuditLog.Tests -o tests/ScadaLink.AuditLog.Tests --framework net10.0`.
3. Add `<ProjectReference>` to Commons + ConfigurationDatabase in the src csproj; add reference to ScadaLink.AuditLog in the test csproj.
4. Add both projects to `ScadaLink.slnx` (inside the existing `/src/` and `/tests/` folders).
5. Add `<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />` to the src csproj (already in `Directory.Packages.props`).
6. Create stub `ServiceCollectionExtensions.AddAuditLog` (just registers options; writer impl comes in M2).
7. Commit: `feat(auditlog): scaffold ScadaLink.AuditLog project (#23)`.
### Task 9: AuditLogOptions
**Files:**
- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs` — class with:
- `int DefaultCapBytes` (default 8192)
- `int ErrorCapBytes` (default 65536)
- `List<string> HeaderRedactList` (default: `[ "Authorization", "X-Api-Key", "Cookie", "Set-Cookie" ]`)
- `List<string> GlobalBodyRedactors` (default: empty)
- `Dictionary<string, PerTargetRedactionOverride> PerTargetOverrides` (default empty)
- `int RetentionDays` (default 365; range [30, 3650])
- Create: `src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs` — minimal: `int? CapBytes`, `List<string>? AdditionalBodyRedactors`.
- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — `IValidateOptions<AuditLogOptions>` checking `DefaultCapBytes > 0`, `ErrorCapBytes >= DefaultCapBytes`, `RetentionDays` in `[30, 3650]`.
- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.AddAuditLog` to `services.AddOptions<AuditLogOptions>().Bind(config.GetSection("AuditLog")).ValidateOnStart(); services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();`.
- Add: `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs`:
- Bind valid section → values present.
- Bind invalid `RetentionDays = 0` → validator rejects.
- Bind invalid `ErrorCapBytes < DefaultCapBytes` → validator rejects.
**Steps:**
1. Failing test: valid bind round-trip.
2. Implement options class.
3. Failing test: invalid `RetentionDays`.
4. Implement validator.
5. Failing test: invalid `ErrorCapBytes`.
6. Validator covers it.
7. Run: pass.
8. Commit: `feat(auditlog): add AuditLogOptions + validator (#23)`.
**Bundle E acceptance:** New `src/ScadaLink.AuditLog/` project builds. Solution still builds. Smoke + options tests green. `ScadaLink.slnx` includes both new entries.
---
## Bundle F — Docs paper trail (controller-direct)
### Task 11: Register AuditLog project in Component-Host.md and confirm README
**Files:**
- Modify: `docs/requirements/Component-Host.md` — list `ScadaLink.AuditLog` in the central role's registration set.
- Modify: `README.md` — confirm row #23 link reflects the new project (no functional change unless missing).
This is a 13 line edit. Per the cadence memory, controller does it directly without a subagent.
**Commit:** `docs(audit): register ScadaLink.AuditLog project in Host role (#23)`.
---
## Final cross-bundle review
After all bundles ship:
- Dispatch a final code-reviewer subagent over the whole M1 branch.
- Acceptance gate (from goal prompt step E):
- `dotnet test ScadaLink.slnx` green (full solution).
- All M1 roadmap acceptance criteria met; each cited by name to the proving test.
- If green, merge to main `--no-ff` with summary message (step F).
- Update M2M8 sections of the roadmap with realities learned (step G), commit.
- Status paragraph (step H).
- Proceed to M2 (step I).

View File

@@ -0,0 +1,24 @@
{
"planPath": "docs/plans/2026-05-20-auditlog-m1-foundation.md",
"tasks": [
{"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "pending"},
{"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "pending", "blockedBy": ["A1"]},
{"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "pending", "blockedBy": ["A2"]},
{"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "pending", "blockedBy": ["A2"]},
{"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "pending", "blockedBy": ["A1", "A2", "A3", "A4"]},
{"id": "B5", "subject": "Bundle B T5: ScadaLinkDbContext.AuditLogs + IEntityTypeConfiguration<AuditEvent> with five named indexes", "status": "pending", "blockedBy": ["A-rev"]},
{"id": "B-rev", "subject": "Bundle B review", "status": "pending", "blockedBy": ["B5"]},
{"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "pending", "blockedBy": ["B-rev"]},
{"id": "C-rev", "subject": "Bundle C review", "status": "pending", "blockedBy": ["C67"]},
{"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "pending", "blockedBy": ["C-rev"]},
{"id": "D-rev", "subject": "Bundle D review", "status": "pending", "blockedBy": ["D8"]},
{"id": "E10", "subject": "Bundle E T10: Scaffold src/ScadaLink.AuditLog/ project + slnx entries", "status": "pending", "blockedBy": ["D-rev"]},
{"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "pending", "blockedBy": ["E10"]},
{"id": "E-rev", "subject": "Bundle E review", "status": "pending", "blockedBy": ["E10", "E9"]},
{"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ScadaLink.AuditLog in Component-Host.md + README confirm", "status": "pending", "blockedBy": ["E-rev"]},
{"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "pending", "blockedBy": ["F11"]},
{"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "pending", "blockedBy": ["FINAL-rev"]},
{"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "pending", "blockedBy": ["MERGE"]}
],
"lastUpdated": "2026-05-20T00:00:00Z"
}

View File

@@ -81,14 +81,14 @@ row per lifecycle event across all channels.
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
| `Kind` | `varchar(32)` | Channel-specific event kind (see below). |
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
| `Status` | `varchar(32)` | Outcome of *this event*`Success`, `TransientFailure`, `PermanentFailure`, `Enqueued`, `Retrying`, `Delivered`, `Parked`, `Discarded`. |
| `Status` | `varchar(32)` | Outcome of *this event*`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. |
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
| `DurationMs` | `int` NULL | Call / attempt duration. |
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
@@ -107,17 +107,24 @@ row per lifecycle event across all channels.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
**`Kind` values by channel:**
**`Kind` values (flat — 10 discriminators across all channels):**
| Channel | Kinds |
| Kind | Fires when |
|---|---|
| `ApiOutbound` | `SyncCall`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
| `ApiInbound` | `Completed` — one row per request, written at request end with final status |
| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
| `DbWriteCached` | A cached outbound-DB attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
| `NotifySend` | Script's `Notify.Send(...)` is enqueued on the site — first row in a notification's lifecycle (`Status=Submitted`). |
| `NotifyDeliver` | Central Notification Outbox dispatcher records a delivery attempt (`Attempted`) or terminal outcome (`Delivered`/`Parked`/`Discarded`). |
| `InboundRequest` | An inbound API request completes — one row per request, written at request end with final status. |
| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
Inbound API is intentionally collapsed to a single `Completed` row per request
rather than a multi-event lifecycle.
Inbound API is intentionally collapsed to a single `InboundRequest` (or
`InboundAuthFailure` for auth rejections) row per request rather than a
multi-event lifecycle.
## The Site-Local `AuditLog` (SQLite)
@@ -178,18 +185,24 @@ pattern as Site Call Audit's reconciliation of `SiteCalls`.
### Central direct-write (central-originated events)
Events originating at central never touch site SQLite. Inbound API writes one
`ApiInbound.Completed` row via `ICentralAuditWriter` synchronously inside the
request-handler middleware, before the HTTP response is flushed. The
Notification Outbox dispatcher writes `Notification.Attempt` per delivery
attempt and `Notification.Terminal` on terminal status. Central direct-writes
use the same insert-if-not-exists semantics keyed on `EventId`.
`ApiInbound.InboundRequest` row via `ICentralAuditWriter` synchronously inside
the request-handler middleware, before the HTTP response is flushed; auth-layer
rejections emit `ApiInbound.InboundAuthFailure` (`Status=Failed`, HTTP 401)
instead. The Notification Outbox dispatcher writes
`Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and
`Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on
terminal status. Central direct-writes use the same insert-if-not-exists
semantics keyed on `EventId`.
## Cached Operations — Combined Telemetry
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the
source of truth for every audit row. The site writes each lifecycle event
(`CachedEnqueued`, `CachedAttempt`, `CachedTerminal`) to its local SQLite
`AuditLog` on the hot path (or on the retry tick for `CachedAttempt`), then
source of truth for every audit row. The site writes each lifecycle event
`CachedSubmit` (`Status=Submitted`), then `ApiCallCached`/`DbWriteCached` rows
for the forward-ack (`Status=Forwarded`) and each retry (`Status=Attempted`),
then a terminal `CachedResolve` row
(`Status=Delivered`/`Failed`/`Parked`/`Discarded`) — to its local SQLite
`AuditLog` on the hot path (or on the retry tick for `Attempted` rows), then
forwards via the same telemetry channel. The telemetry message format gains the
audit-row fields additively — one packet per lifecycle transition carries both
the operational state update AND the audit row content.
@@ -207,7 +220,7 @@ operational `SiteCalls` shape for the dispatcher and UI.
## Payload Capture Policy
- **Default cap** — 8 KB for each of `RequestSummary` and `ResponseSummary`;
raised to 64 KB on any non-`Success` row.
raised to 64 KB on any error row (`Status IN ('Failed', 'Parked', 'Discarded')`).
- **Truncation** — UTF-8 byte-safe; `PayloadTruncated = 1` when applied. Full
bodies are never stored.
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, and
@@ -292,7 +305,7 @@ MS SQL for direct-write events). Unredacted secrets never persist.
Point-in-time, computed from the central `AuditLog` table; global and per-site.
- **Audit volume** — events/min landing in the central `AuditLog`; global plus per-site sparkline.
- **Audit error rate** — % of central `AuditLog` rows with `Status` NOT IN (`Success`, `Delivered`, `Enqueued`) over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, transient failures, parked deliveries) — NOT audit-writer health, which surfaces separately via `CentralAuditWriteFailures` and `AuditRedactionFailure`.
- **Audit error rate** — % of central `AuditLog` rows with `Status IN ('Failed', 'Parked', 'Discarded')` over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, permanent failures, parked deliveries) — NOT audit-writer health, which surfaces separately via `CentralAuditWriteFailures` and `AuditRedactionFailure`.
- **Audit backlog** — sum of `Pending` site rows across sites; click drills into a per-site breakdown.
[Notification Outbox](Component-NotificationOutbox.md) and
@@ -350,19 +363,22 @@ global value in v1; per-channel overrides are deferred to v1.x.
## Interactions
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md)** —
emits `ApiOutbound.SyncCall` rows on every sync `Call()`. For `CachedCall`,
emits `ApiOutbound.ApiCall` rows on every sync `Call()`. For `CachedCall`,
emits the combined cached telemetry packet (audit row + operational update)
per Cached Operations — Combined Telemetry.
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md) — Database layer** — the database access modes inside ESG emit `DbOutbound.SyncWrite` and `DbOutbound.SyncRead` on script-initiated `Connection()` calls; `Database.CachedWrite` emits the cached-write lifecycle rows via the combined-telemetry packet (same path as `ApiOutbound.Cached*`). Site Runtime is the API surface that exposes the `Database.*` calls to scripts; the audit emission itself lives in ESG.
per Cached Operations — Combined Telemetry, using kinds
`CachedSubmit` / `ApiCallCached` / `CachedResolve`.
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md) — Database layer** — the database access modes inside ESG emit `DbOutbound.DbWrite` rows on script-initiated `Connection()` calls (writes and reads share the kind; distinguish via `Extra.rowsAffected` vs `Extra.rowsReturned`); `Database.CachedWrite` emits the cached-write lifecycle rows via the combined-telemetry packet using kinds `CachedSubmit` / `DbWriteCached` / `CachedResolve` (same shape as `ApiOutbound`). Site Runtime is the API surface that exposes the `Database.*` calls to scripts; the audit emission itself lives in ESG.
- **[Inbound API (#14)](Component-InboundAPI.md)** — emits one
`ApiInbound.Completed` row per request from request-handler middleware,
written directly to central via `ICentralAuditWriter` before the response is
flushed.
`ApiInbound.InboundRequest` row per successful request from request-handler
middleware, written directly to central via `ICentralAuditWriter` before the
response is flushed. Auth-layer rejections emit
`ApiInbound.InboundAuthFailure` instead (`Status=Failed`, HTTP 401).
- **[Notification Outbox (#21)](Component-NotificationOutbox.md)** — the
site-emitted `Notification.Enqueued` row flows via audit telemetry; the
central dispatcher writes `Notification.Attempt` (per delivery attempt) and
`Notification.Terminal` (on terminal status) directly via
`ICentralAuditWriter`. The operational `Notifications` table is unchanged.
site-emitted `Notification.NotifySend` row (`Status=Submitted`) flows via
audit telemetry; the central dispatcher writes `Notification.NotifyDeliver`
rows directly via `ICentralAuditWriter``Status=Attempted` per delivery
attempt, `Status=Delivered`/`Parked`/`Discarded` on terminal status. The
operational `Notifications` table is unchanged.
- **[Site Call Audit (#22)](Component-SiteCallAudit.md)** — shares the
cached-call telemetry packet. Central ingest of that packet performs both the
`AuditLog` insert and the `SiteCalls` upsert in one transaction. `SiteCalls`

View File

@@ -178,6 +178,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
| Communication | Yes | Yes | Yes | Yes | No |
| HealthMonitoring | Yes | Yes | Yes | Yes | No |
| ExternalSystemGateway | Yes | Yes | Yes | Yes | No |
| AuditLog | Yes | Yes | Yes | Yes | No |
| NotificationService | Yes | No | Yes | Yes | No |
| NotificationOutbox | Yes | No | Yes | Yes | No |
| SiteCallAudit | Yes | No | Yes | Yes | No |
@@ -197,7 +198,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
## Dependencies
- **All 18 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable).
- **All 19 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable). Audit Log (#23) ships its central+site code in `ScadaLink.AuditLog`; the Host calls `AddAuditLog()` on both roles, M2+ will add `AddAuditLogActors()`.
- **Akka.Hosting**: For `AddAkka()` and the hosting configuration builder.
- **Akka.Remote.Hosting, Akka.Cluster.Hosting**: For Akka subsystem configuration. (No Akka.Persistence plugin — see the Persistence note under REQ-HOST-6.)
- **Serilog.AspNetCore**: For structured logging integration.

View File

@@ -0,0 +1,36 @@
namespace ScadaLink.AuditLog.Configuration;
/// <summary>
/// Configuration for Audit Log (#23). Bound from the <c>AuditLog</c> section of
/// <c>appsettings.json</c>. Defaults reflect the design (alog.md §6, §10): an
/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
/// retention window with monthly partition-switch purge. The default
/// header-redact list covers HTTP auth headers; per-target overrides extend
/// (never replace) the global redactor set.
/// </summary>
public sealed class AuditLogOptions
{
/// <summary>Default payload-summary cap in bytes (default 8 KiB).</summary>
public int DefaultCapBytes { get; set; } = 8192;
/// <summary>Payload-summary cap on error rows in bytes (default 64 KiB).</summary>
public int ErrorCapBytes { get; set; } = 65536;
/// <summary>HTTP headers redacted by default before persistence.</summary>
public List<string> HeaderRedactList { get; set; } = new()
{
"Authorization",
"X-Api-Key",
"Cookie",
"Set-Cookie",
};
/// <summary>Body-content redactors applied globally (regex patterns).</summary>
public List<string> GlobalBodyRedactors { get; set; } = new();
/// <summary>Per-target redaction overrides keyed by target identifier.</summary>
public Dictionary<string, PerTargetRedactionOverride> PerTargetOverrides { get; set; } = new();
/// <summary>Central retention window in days (default 365, range [30, 3650]).</summary>
public int RetentionDays { get; set; } = 365;
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Options;
namespace ScadaLink.AuditLog.Configuration;
/// <summary>
/// Validates <see cref="AuditLogOptions"/> on startup. The caps drive payload
/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily
/// large blobs into the central <c>AuditLog</c> table. <see cref="AuditLogOptions.ErrorCapBytes"/>
/// must be at least as large as <see cref="AuditLogOptions.DefaultCapBytes"/>
/// because the error cap is meant to capture <em>more</em> detail than the
/// happy-path summary, not less. <see cref="AuditLogOptions.RetentionDays"/> is
/// bounded to <c>[30, 3650]</c> to keep purge windows sane: too short would
/// drop in-flight investigations, too long would defeat the partition-switch
/// purge's purpose.
/// </summary>
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
{
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
public const int MinRetentionDays = 30;
/// <summary>Inclusive upper bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
public const int MaxRetentionDays = 3650;
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var failures = new List<string>();
if (options.DefaultCapBytes <= 0)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
"must be > 0; it drives payload-summary truncation in audit writers.");
}
if (options.ErrorCapBytes < options.DefaultCapBytes)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
"the error-row cap is intended to capture more detail than the happy-path summary.");
}
if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
}
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
}
}

View File

@@ -0,0 +1,17 @@
namespace ScadaLink.AuditLog.Configuration;
/// <summary>
/// Per-target redaction override applied additively on top of
/// <see cref="AuditLogOptions.GlobalBodyRedactors"/> and the
/// <see cref="AuditLogOptions.DefaultCapBytes"/> / <see cref="AuditLogOptions.ErrorCapBytes"/>
/// caps. Targets are identified by the script-facing external-system /
/// database / notification-list / inbound-API-key name.
/// </summary>
public sealed class PerTargetRedactionOverride
{
/// <summary>Optional payload cap override (bytes); null inherits the global cap.</summary>
public int? CapBytes { get; set; }
/// <summary>Additional body redactor regex patterns (appended to the global list).</summary>
public List<string>? AdditionalBodyRedactors { get; set; }
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<!-- Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call Audit (#22).
IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project
reference is documented here so M2 writers + telemetry actors can depend on it. -->
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.AuditLog.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
namespace ScadaLink.AuditLog;
/// <summary>
/// Composition root for the Audit Log (#23) component. M1 registers
/// <see cref="AuditLogOptions"/> and its validator; later milestones extend
/// this method to wire up writers, telemetry actors, and the central ingest
/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
/// Site Call Audit (#22).
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>Configuration section bound to <see cref="AuditLogOptions"/>.</summary>
public const string ConfigSectionName = "AuditLog";
/// <summary>
/// Binds <see cref="AuditLogOptions"/> from the
/// <see cref="ConfigSectionName"/> section of <paramref name="config"/>
/// and registers <see cref="AuditLogOptionsValidator"/> so a misconfigured
/// <c>AuditLog</c> section is rejected with a key-naming message when the
/// options are first resolved (or at startup when consumers wire in
/// <c>ValidateOnStart()</c>). M2+ will register writers, telemetry actors,
/// and the central ingest pipeline here. <c>IAuditLogRepository</c> is
/// registered by
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so the caller (the Host on the central node) must also call that.
/// </summary>
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
services.AddOptions<AuditLogOptions>()
.Bind(config.GetSection(ConfigSectionName))
.ValidateOnStart();
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
return services;
}
}

View File

@@ -0,0 +1,73 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Entities.Audit;
/// <summary>
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
/// site rows leave IngestedAtUtc null until ingest. Append-only.
/// </summary>
public sealed record AuditEvent
{
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
public DateTime OccurredAtUtc { get; init; }
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
public DateTime? IngestedAtUtc { get; init; }
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}

View File

@@ -0,0 +1,56 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.Commons.Interfaces.Repositories;
/// <summary>
/// Append-only data access for the central <c>AuditLog</c> table (Audit Log #23).
/// </summary>
/// <remarks>
/// <para>
/// The append-only invariant is enforced both at the SQL level (the
/// <c>scadalink_audit_writer</c> role has only INSERT + SELECT — UPDATE and DELETE
/// are not granted) and at the API level: this interface deliberately exposes no
/// Update and no single-row Delete. Bulk purge is performed exclusively via
/// monthly partition switch-out (<see cref="SwitchOutPartitionAsync"/>).
/// </para>
/// <para>
/// Ingest is idempotent on <c>EventId</c>: <see cref="InsertIfNotExistsAsync"/> is
/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed
/// the same writer without producing duplicates.
/// </para>
/// </remarks>
public interface IAuditLogRepository
{
/// <summary>
/// Inserts <paramref name="evt"/> if no row with the same
/// <see cref="AuditEvent.EventId"/> exists; otherwise silently leaves the
/// stored row untouched (first-write-wins). Bypasses the EF change tracker
/// so the row never enters a tracked state.
/// </summary>
Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
/// <summary>
/// Returns up to <see cref="AuditLogPaging.PageSize"/> rows matching
/// <paramref name="filter"/>, ordered by <c>(OccurredAtUtc DESC, EventId DESC)</c>.
/// Use keyset paging by passing the last returned row's
/// <c>OccurredAtUtc</c> + <c>EventId</c> back via
/// <see cref="AuditLogPaging.AfterOccurredAtUtc"/> +
/// <see cref="AuditLogPaging.AfterEventId"/> to fetch the next page.
/// </summary>
Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging paging,
CancellationToken ct = default);
/// <summary>
/// Switches out (purges) the monthly partition whose lower bound is
/// <paramref name="monthBoundary"/>. The honest M1 implementation throws
/// <see cref="NotSupportedException"/>: the <c>UX_AuditLog_EventId</c> unique
/// index is non-partition-aligned (lives on <c>[PRIMARY]</c>, not on
/// <c>ps_AuditLog_Month</c>), so SQL Server rejects
/// <c>ALTER TABLE … SWITCH PARTITION</c> until the drop-and-rebuild dance
/// shipped by the M6 purge actor is in place.
/// </summary>
Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
}

View File

@@ -0,0 +1,17 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Interfaces.Services;
/// <summary>
/// Boundary-side abstraction for emitting Audit Log (#23) events.
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
/// Failures must NEVER abort the user-facing action.
/// </summary>
public interface IAuditWriter
{
/// <summary>
/// Persist an audit event. Best-effort: implementations must swallow/log internal failures
/// rather than propagating them to the calling boundary code.
/// </summary>
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}

View File

@@ -0,0 +1,16 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Interfaces.Services;
/// <summary>
/// Central-only audit writer for the direct-write path (Notification Outbox dispatch, Inbound API).
/// Distinct from <see cref="IAuditWriter"/> so DI binding can differ between site and central hosts.
/// </summary>
public interface ICentralAuditWriter
{
/// <summary>
/// Persist an audit event into the central AuditLog table directly (bypassing site telemetry).
/// Best-effort: implementations must swallow/log internal failures rather than propagating them.
/// </summary>
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Messages.Integration;
/// <summary>
/// Audit Log (#23) telemetry envelope sent from a site to central over gRPC.
/// At-least-once delivery; central is idempotent on <see cref="AuditEvent.EventId"/>.
/// See Component-AuditLog.md "Ingestion" for the handoff contract.
/// </summary>
public sealed record AuditTelemetryEnvelope(
Guid EnvelopeId,
string SourceSiteId,
IReadOnlyList<AuditEvent> Events);

View File

@@ -0,0 +1,11 @@
namespace ScadaLink.Commons.Messages.Integration;
/// <summary>
/// Audit Log (#23) periodic reconciliation pull request: central asks a site for
/// audit events since the given UTC watermark, up to <paramref name="BatchSize"/>.
/// Acts as the fallback when streaming telemetry is lost. See Component-AuditLog.md "Ingestion".
/// </summary>
public sealed record PullAuditEventsRequest(
string SourceSiteId,
DateTime SinceUtc,
int BatchSize);

View File

@@ -0,0 +1,12 @@
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.Commons.Messages.Integration;
/// <summary>
/// Audit Log (#23) periodic reconciliation pull response: the next batch of site
/// audit events plus a <paramref name="MoreAvailable"/> flag signalling the caller
/// to advance the watermark and pull again. See Component-AuditLog.md "Ingestion".
/// </summary>
public sealed record PullAuditEventsResponse(
IReadOnlyList<AuditEvent> Events,
bool MoreAvailable);

View File

@@ -0,0 +1,13 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Keyset paging cursor for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// The repository orders by <c>(OccurredAtUtc DESC, EventId DESC)</c>; callers pass
/// the last row of the previous page back as <see cref="AfterOccurredAtUtc"/> +
/// <see cref="AfterEventId"/> to fetch the next page. Both must be non-null together,
/// or both null (first page).
/// </summary>
public sealed record AuditLogPaging(
int PageSize,
DateTime? AfterOccurredAtUtc = null,
Guid? AfterEventId = null);

View File

@@ -0,0 +1,21 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined.
/// </summary>
public sealed record AuditLogQueryFilter(
AuditChannel? Channel = null,
AuditKind? Kind = null,
AuditStatus? Status = null,
string? SourceSiteId = null,
string? Target = null,
string? Actor = null,
Guid? CorrelationId = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);

View File

@@ -0,0 +1,13 @@
namespace ScadaLink.Commons.Types.Enums;
/// <summary>
/// Top-level Audit Log (#23) channel — the trust boundary the audited action crosses.
/// One of: outbound API call, outbound DB write, notification send/deliver, or inbound API request.
/// </summary>
public enum AuditChannel
{
ApiOutbound,
DbOutbound,
Notification,
ApiInbound
}

View File

@@ -0,0 +1,14 @@
namespace ScadaLink.Commons.Types.Enums;
/// <summary>
/// Site-local Audit Log (#23) forwarding state, tracked only in the site SQLite hot-path.
/// Central rows leave this null. <c>Pending</c> = not yet sent; <c>Forwarded</c> = telemetry sent
/// and acked; <c>Reconciled</c> = confirmed present centrally via the periodic pull fallback.
/// The site retention purge MUST NOT drop a row whose state is still <c>Pending</c>.
/// </summary>
public enum AuditForwardState
{
Pending,
Forwarded,
Reconciled
}

View File

@@ -0,0 +1,20 @@
namespace ScadaLink.Commons.Types.Enums;
/// <summary>
/// Specific Audit Log (#23) event kind within a channel — what action produced the row.
/// Cached variants emit multiple rows per operation (submit → forward → attempt → resolve).
/// See alog.md §4 for the full taxonomy.
/// </summary>
public enum AuditKind
{
ApiCall,
ApiCallCached,
DbWrite,
DbWriteCached,
NotifySend,
NotifyDeliver,
InboundRequest,
InboundAuthFailure,
CachedSubmit,
CachedResolve
}

View File

@@ -0,0 +1,18 @@
namespace ScadaLink.Commons.Types.Enums;
/// <summary>
/// Lifecycle status of an Audit Log (#23) event row.
/// Cached operations produce multiple rows tracking <c>Submitted → Forwarded → Attempted → Delivered/Parked/Discarded</c>.
/// <c>Skipped</c> is used when an action was short-circuited (e.g. dry-run) but should still be audited.
/// </summary>
public enum AuditStatus
{
Submitted,
Forwarded,
Attempted,
Delivered,
Failed,
Parked,
Discarded,
Skipped
}

View File

@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
/// described in alog.md §4. Column lengths/types and the five named indexes are
/// fixed by that specification — keep this in sync with the doc.
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
{
public void Configure(EntityTypeBuilder<AuditEvent> builder)
{
builder.ToTable("AuditLog");
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
// separate unique index is declared on EventId alone.
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
builder.HasIndex(e => e.EventId)
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
// Enum-as-string columns: bounded varchar(32) ASCII.
builder.Property(e => e.Channel)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Kind)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Status)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.ForwardState)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false);
// Ascii identifier columns — never carry user-supplied unicode.
builder.Property(e => e.SourceSiteId)
.HasMaxLength(64)
.IsUnicode(false);
builder.Property(e => e.SourceInstanceId)
.HasMaxLength(128)
.IsUnicode(false);
builder.Property(e => e.SourceScript)
.HasMaxLength(128)
.IsUnicode(false);
builder.Property(e => e.Actor)
.HasMaxLength(128)
.IsUnicode(false);
builder.Property(e => e.Target)
.HasMaxLength(256)
.IsUnicode(false);
// Bounded unicode message column.
builder.Property(e => e.ErrorMessage)
.HasMaxLength(1024);
// ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
// Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
builder.HasIndex(e => e.OccurredAtUtc)
.IsDescending(true)
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Site_Occurred");
builder.HasIndex(e => e.CorrelationId)
.HasFilter("[CorrelationId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_CorrelationId");
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
builder.HasIndex(e => new { e.Target, e.OccurredAtUtc })
.IsDescending(false, true)
.HasFilter("[Target] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Target_Occurred");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Bundle C (#23 M1): creates the centralized AuditLog table with monthly
/// partitioning and the two access-control roles documented in alog.md §4.
///
/// Structure:
/// 1. Partition function <c>pf_AuditLog_Month</c> (RANGE RIGHT) with 24
/// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC.
/// 2. Partition scheme <c>ps_AuditLog_Month</c> mapping every partition to
/// [PRIMARY] (dev/test parity; production may relocate via filegroups).
/// 3. <c>AuditLog</c> table created via raw SQL so it is created directly
/// on the partition scheme. The clustered PK is composite
/// {EventId, OccurredAtUtc} — required because partition-aligned PKs
/// must include the partition column.
/// 4. Five reconciliation/query indexes from alog.md §4, plus the
/// UX_AuditLog_EventId unique index that preserves single-column
/// EventId uniqueness for InsertIfNotExistsAsync (M1-T8). All
/// non-clustered indexes are partition-aligned on
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c>.
/// 5. Two database roles:
/// - <c>scadalink_audit_writer</c>: INSERT + SELECT on AuditLog, with
/// explicit DENY on UPDATE and DELETE so additive role membership
/// (e.g. later db_datawriter) cannot accidentally re-enable mutation.
/// - <c>scadalink_audit_purger</c>: SELECT on AuditLog and ALTER on
/// SCHEMA::dbo so the purger can run ALTER PARTITION FUNCTION SWITCH
/// and SWITCH PARTITION when sliding the retention window.
/// </summary>
public partial class AddAuditLogTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Partition function (monthly boundaries Jan 2026 Dec 2027 UTC).
// RANGE RIGHT — the boundary value belongs to the right-hand partition,
// matching the convention used by SQL Server partition-switch tooling.
migrationBuilder.Sql(@"
CREATE PARTITION FUNCTION pf_AuditLog_Month (datetime2(7))
AS RANGE RIGHT FOR VALUES (
'2026-01-01T00:00:00', '2026-02-01T00:00:00', '2026-03-01T00:00:00', '2026-04-01T00:00:00',
'2026-05-01T00:00:00', '2026-06-01T00:00:00', '2026-07-01T00:00:00', '2026-08-01T00:00:00',
'2026-09-01T00:00:00', '2026-10-01T00:00:00', '2026-11-01T00:00:00', '2026-12-01T00:00:00',
'2027-01-01T00:00:00', '2027-02-01T00:00:00', '2027-03-01T00:00:00', '2027-04-01T00:00:00',
'2027-05-01T00:00:00', '2027-06-01T00:00:00', '2027-07-01T00:00:00', '2027-08-01T00:00:00',
'2027-09-01T00:00:00', '2027-10-01T00:00:00', '2027-11-01T00:00:00', '2027-12-01T00:00:00'
);");
// 2) Partition scheme mapping every partition to [PRIMARY].
migrationBuilder.Sql(@"
CREATE PARTITION SCHEME ps_AuditLog_Month
AS PARTITION pf_AuditLog_Month ALL TO ([PRIMARY]);");
// 3) Create the table directly on the partition scheme. Column shapes
// are copied from AuditLogEntityTypeConfiguration so the live schema
// matches the EF model exactly. The clustered PK is composite to
// satisfy SQL Server's rule that partition-aligned clustered indexes
// must include the partition column.
migrationBuilder.Sql(@"
CREATE TABLE dbo.AuditLog (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
IngestedAtUtc datetime2(7) NULL,
Channel varchar(32) NOT NULL,
Kind varchar(32) NOT NULL,
CorrelationId uniqueidentifier NULL,
SourceSiteId varchar(64) NULL,
SourceInstanceId varchar(128) NULL,
SourceScript varchar(128) NULL,
Actor varchar(128) NULL,
Target varchar(256) NULL,
Status varchar(32) NOT NULL,
HttpStatus int NULL,
DurationMs int NULL,
ErrorMessage nvarchar(1024) NULL,
ErrorDetail nvarchar(max) NULL,
RequestSummary nvarchar(max) NULL,
ResponseSummary nvarchar(max) NULL,
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc)
) ON ps_AuditLog_Month(OccurredAtUtc);");
// 4) Reconciliation/query indexes from alog.md §4. All non-clustered
// indexes are partition-aligned on ps_AuditLog_Month(OccurredAtUtc)
// so partition-switch operations only touch a single partition. The
// filtered indexes carry their NOT NULL predicates as documented.
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
ON dbo.AuditLog (OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
ON dbo.AuditLog (CorrelationId)
WHERE CorrelationId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
ON dbo.AuditLog (Channel ASC, Status ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);");
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
WHERE Target IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
// The EventId uniqueness index supports InsertIfNotExistsAsync
// (M1-T8). It is INTENTIONALLY non-aligned (placed on [PRIMARY]
// rather than ps_AuditLog_Month).
//
// SQL Server's rule for unique partition-aligned indexes is that the
// partition column must be a SUBSET of the index key. Including
// OccurredAtUtc in the key would change the uniqueness semantics
// from "EventId is globally unique" to "(EventId, OccurredAtUtc)
// is unique", which is the same guarantee the composite PK already
// provides — it would not give us single-column EventId uniqueness.
//
// Trade-off: a non-aligned index disables ALTER TABLE … SWITCH
// PARTITION on AuditLog. The M1 purge story (M2/M3) uses an
// explicit rebuild path that drops and re-creates this index
// around the switch, so the aligned-indexes pattern is preserved
// for partition switching at purge time.
migrationBuilder.Sql(@"
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
ON dbo.AuditLog (EventId)
ON [PRIMARY];");
// 5) DB roles. Both definitions are idempotent so the migration is
// safe to re-apply against a database that already has the role.
// The DENY UPDATE / DENY DELETE on the writer role is deliberate —
// a future db_datawriter membership cannot quietly re-enable
// mutation because DENY outranks GRANT.
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NULL
EXEC sp_executesql N'CREATE ROLE scadalink_audit_writer';
GRANT INSERT ON dbo.AuditLog TO scadalink_audit_writer;
GRANT SELECT ON dbo.AuditLog TO scadalink_audit_writer;
DENY UPDATE ON dbo.AuditLog TO scadalink_audit_writer;
DENY DELETE ON dbo.AuditLog TO scadalink_audit_writer;");
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NULL
EXEC sp_executesql N'CREATE ROLE scadalink_audit_purger';
GRANT SELECT ON dbo.AuditLog TO scadalink_audit_purger;
GRANT ALTER ON SCHEMA::dbo TO scadalink_audit_purger;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Drop in reverse dependency order so each statement's prerequisites
// still exist when it runs. Each DROP is guarded so a partial Up()
// (or a re-applied Down()) cannot fail on missing objects.
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NOT NULL
EXEC sp_executesql N'DROP ROLE scadalink_audit_purger';
IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NOT NULL
EXEC sp_executesql N'DROP ROLE scadalink_audit_writer';");
// Indexes are dropped implicitly when the table goes away, but
// dropping them explicitly first keeps the Down() statement self-
// describing and mirrors the Up() shape.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Target_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Channel_Status_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_CorrelationId' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Site_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog;
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_OccurredAtUtc' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;");
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.AuditLog', 'U') IS NOT NULL
DROP TABLE dbo.AuditLog;");
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month')
DROP PARTITION SCHEME ps_AuditLog_Month;
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month')
DROP PARTITION FUNCTION pf_AuditLog_Month;");
}
}
}

View File

@@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b =>
{
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("OccurredAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Actor")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("DurationMs")
.HasColumnType("int");
b.Property<string>("ErrorDetail")
.HasColumnType("nvarchar(max)");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Extra")
.HasColumnType("nvarchar(max)");
b.Property<string>("ForwardState")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<int?>("HttpStatus")
.HasColumnType("int");
b.Property<DateTime?>("IngestedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<bool>("PayloadTruncated")
.HasColumnType("bit");
b.Property<string>("RequestSummary")
.HasColumnType("nvarchar(max)");
b.Property<string>("ResponseSummary")
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceInstanceId")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceScript")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceSiteId")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<string>("Target")
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("varchar(256)");
b.HasKey("EventId", "OccurredAtUtc");
b.HasIndex("CorrelationId")
.HasDatabaseName("IX_AuditLog_CorrelationId")
.HasFilter("[CorrelationId] IS NOT NULL");
b.HasIndex("EventId")
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
b.HasIndex("SourceSiteId", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Site_Occurred");
b.HasIndex("Target", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Target_Occurred")
.HasFilter("[Target] IS NOT NULL");
b.HasIndex("Channel", "Status", "OccurredAtUtc")
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
b.ToTable("AuditLog", (string)null);
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
{
b.Property<int>("Id")

View File

@@ -0,0 +1,163 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
/// for the append-only contract; this class only adds notes on the data-access
/// strategy used by each method.
/// </summary>
public class AuditLogRepository : IAuditLogRepository
{
private readonly ScadaLinkDbContext _context;
public AuditLogRepository(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <summary>
/// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
/// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
/// Bypasses the EF change tracker so the row never enters a tracked state and
/// the enum-as-string conversion is done explicitly in C# (the columns are
/// declared <c>varchar(32)</c> via <c>HasConversion&lt;string&gt;()</c> in
/// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
/// </summary>
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
{
throw new ArgumentNullException(nameof(evt));
}
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
var channel = evt.Channel.ToString();
var kind = evt.Kind.ToString();
var status = evt.Status.ToString();
var forwardState = evt.ForwardState?.ToString();
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
ct);
}
/// <summary>
/// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
/// every non-null filter predicate, and pages by keyset on
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
/// directly (<c>occurred &lt; after || (occurred == after &amp;&amp; eventId.CompareTo(afterId) &lt; 0)</c>)
/// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
/// <c>uniqueidentifier</c> sort order.
/// </summary>
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
{
if (filter is null)
{
throw new ArgumentNullException(nameof(filter));
}
if (paging is null)
{
throw new ArgumentNullException(nameof(paging));
}
var query = _context.Set<AuditEvent>().AsNoTracking();
if (filter.Channel is { } channel)
{
query = query.Where(e => e.Channel == channel);
}
if (filter.Kind is { } kind)
{
query = query.Where(e => e.Kind == kind);
}
if (filter.Status is { } status)
{
query = query.Where(e => e.Status == status);
}
if (!string.IsNullOrEmpty(filter.SourceSiteId))
{
var siteId = filter.SourceSiteId;
query = query.Where(e => e.SourceSiteId == siteId);
}
if (!string.IsNullOrEmpty(filter.Target))
{
var target = filter.Target;
query = query.Where(e => e.Target == target);
}
if (!string.IsNullOrEmpty(filter.Actor))
{
var actor = filter.Actor;
query = query.Where(e => e.Actor == actor);
}
if (filter.CorrelationId is { } correlationId)
{
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
}
if (filter.ToUtc is { } toUtc)
{
query = query.Where(e => e.OccurredAtUtc <= toUtc);
}
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
{
query = query.Where(e =>
e.OccurredAtUtc < afterOccurred
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
return await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
}
/// <summary>
/// M1 honest contract: throws <see cref="NotSupportedException"/>. The
/// <c>UX_AuditLog_EventId</c> unique index is non-aligned with
/// <c>ps_AuditLog_Month</c> (it lives on <c>[PRIMARY]</c> to keep
/// <see cref="InsertIfNotExistsAsync"/> cheap), and SQL Server rejects
/// <c>ALTER TABLE … SWITCH PARTITION</c> when a non-aligned index is present.
/// The drop-and-rebuild dance that makes the switch legal ships with the M6
/// purge actor.
/// </summary>
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
throw new NotSupportedException(
"AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
"unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
}
}

View File

@@ -84,6 +84,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Audit
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

View File

@@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
namespace ScadaLink.AuditLog.Tests;
/// <summary>
/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies
/// <c>AddAuditLog</c> registers <see cref="AuditLogOptions"/> against the
/// <c>AuditLog</c> configuration section. Bundle E ships only the scaffold;
/// the validator + full options surface land in Task 9.
/// </summary>
public class AddAuditLogTests
{
[Fact]
public void AddAuditLog_RegistersAuditLogOptions()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection();
services.AddAuditLog(config);
var provider = services.BuildServiceProvider();
var opts = provider.GetService<IOptions<AuditLogOptions>>();
Assert.NotNull(opts);
Assert.NotNull(opts!.Value);
}
[Fact]
public void AddAuditLog_NullServices_Throws()
{
var config = new ConfigurationBuilder().Build();
Assert.Throws<ArgumentNullException>(
() => ServiceCollectionExtensions.AddAuditLog(null!, config));
}
[Fact]
public void AddAuditLog_NullConfig_Throws()
{
var services = new ServiceCollection();
Assert.Throws<ArgumentNullException>(
() => services.AddAuditLog(null!));
}
}

View File

@@ -0,0 +1,167 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
namespace ScadaLink.AuditLog.Tests.Configuration;
/// <summary>
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
/// behavior. The validator enforces invariants used by M2+ writers
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
/// <c>DefaultCapBytes &gt; 0</c>, <c>ErrorCapBytes &gt;= DefaultCapBytes</c>,
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie.
/// </summary>
public class AuditLogOptionsTests
{
private static IOptions<AuditLogOptions> BuildOptions(Dictionary<string, string?> config)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(config)
.Build();
var services = new ServiceCollection();
services.AddAuditLog(configuration);
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
}
[Fact]
public void ValidBinding_PopulatesAllScalarFields()
{
var opts = BuildOptions(new Dictionary<string, string?>
{
["AuditLog:DefaultCapBytes"] = "4096",
["AuditLog:ErrorCapBytes"] = "32768",
["AuditLog:RetentionDays"] = "180",
}).Value;
Assert.Equal(4096, opts.DefaultCapBytes);
Assert.Equal(32768, opts.ErrorCapBytes);
Assert.Equal(180, opts.RetentionDays);
}
[Fact]
public void DefaultsAreReasonable_WhenSectionEmpty()
{
var opts = BuildOptions(new Dictionary<string, string?>()).Value;
Assert.Equal(8192, opts.DefaultCapBytes);
Assert.Equal(65536, opts.ErrorCapBytes);
Assert.Equal(365, opts.RetentionDays);
Assert.Contains("Authorization", opts.HeaderRedactList);
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
Assert.Contains("Cookie", opts.HeaderRedactList);
Assert.Contains("Set-Cookie", opts.HeaderRedactList);
Assert.Empty(opts.GlobalBodyRedactors);
Assert.Empty(opts.PerTargetOverrides);
}
[Fact]
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
{
// Microsoft.Extensions.Configuration's collection binder appends to a
// defaulted list (it does not replace it), so config-supplied entries
// augment the built-in redact list rather than overriding it. The
// built-in entries are the safety-net defaults documented on
// AuditLogOptions; supplying additional headers is the supported
// extension point.
var opts = BuildOptions(new Dictionary<string, string?>
{
["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth",
["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id",
}).Value;
Assert.Contains("X-Custom-Auth", opts.HeaderRedactList);
Assert.Contains("X-Tenant-Id", opts.HeaderRedactList);
Assert.Contains("Authorization", opts.HeaderRedactList);
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
}
[Fact]
public void PerTargetOverrides_BindsFromConfig()
{
var opts = BuildOptions(new Dictionary<string, string?>
{
["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384",
["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}",
}).Value;
Assert.True(opts.PerTargetOverrides.ContainsKey("CRM"));
var crm = opts.PerTargetOverrides["CRM"];
Assert.Equal(16384, crm.CapBytes);
Assert.NotNull(crm.AdditionalBodyRedactors);
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
}
[Fact]
public void InvalidDefaultCapBytes_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { DefaultCapBytes = 0 });
Assert.True(result.Failed);
Assert.Contains("DefaultCapBytes", result.FailureMessage);
}
[Fact]
public void InvalidErrorCapBytes_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 });
Assert.True(result.Failed);
Assert.Contains("ErrorCapBytes", result.FailureMessage);
}
[Fact]
public void RetentionDaysBelowMinimum_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { RetentionDays = 0 });
Assert.True(result.Failed);
Assert.Contains("RetentionDays", result.FailureMessage);
}
[Fact]
public void RetentionDaysAboveMaximum_FailsValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions { RetentionDays = 3651 });
Assert.True(result.Failed);
Assert.Contains("RetentionDays", result.FailureMessage);
}
[Fact]
public void DefaultOptions_PassValidation()
{
var result = new AuditLogOptionsValidator().Validate(
Options.DefaultName,
new AuditLogOptions());
Assert.True(result.Succeeded, result.FailureMessage);
}
[Fact]
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AuditLog:RetentionDays"] = "0",
})
.Build();
var services = new ServiceCollection();
services.AddAuditLog(configuration);
var provider = services.BuildServiceProvider();
var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>();
var ex = Assert.Throws<OptionsValidationException>(() => _ = opts.Value);
Assert.Contains("RetentionDays", ex.Message);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,135 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Tests.Entities.Audit;
/// <summary>
/// Verifies <see cref="AuditEvent"/> behaves as an init-only record:
/// every property reads back as constructed, and <c>with</c> expressions
/// produce a new instance with a single property changed.
/// </summary>
public class AuditEventTests
{
[Fact]
public void Construction_AllPropertiesReadBack()
{
var eventId = Guid.NewGuid();
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
var corrId = Guid.NewGuid();
var evt = new AuditEvent
{
EventId = eventId,
OccurredAtUtc = occurredAt,
IngestedAtUtc = ingestedAt,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = corrId,
SourceSiteId = "site-01",
SourceInstanceId = "inst-7",
SourceScript = "OnAlarm",
Actor = "system",
Target = "WeatherAPI",
Status = AuditStatus.Delivered,
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = "GET /forecast",
ResponseSummary = "{\"temp\":21}",
PayloadTruncated = false,
Extra = "{}",
ForwardState = AuditForwardState.Forwarded
};
Assert.Equal(eventId, evt.EventId);
Assert.Equal(occurredAt, evt.OccurredAtUtc);
Assert.Equal(ingestedAt, evt.IngestedAtUtc);
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(corrId, evt.CorrelationId);
Assert.Equal("site-01", evt.SourceSiteId);
Assert.Equal("inst-7", evt.SourceInstanceId);
Assert.Equal("OnAlarm", evt.SourceScript);
Assert.Equal("system", evt.Actor);
Assert.Equal("WeatherAPI", evt.Target);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(200, evt.HttpStatus);
Assert.Equal(42, evt.DurationMs);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
Assert.Equal("GET /forecast", evt.RequestSummary);
Assert.Equal("{\"temp\":21}", evt.ResponseSummary);
Assert.False(evt.PayloadTruncated);
Assert.Equal("{}", evt.Extra);
Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState);
}
[Fact]
public void NullableProperties_AcceptNull()
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
IngestedAtUtc = null,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
CorrelationId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = null,
Status = AuditStatus.Submitted,
HttpStatus = null,
DurationMs = null,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null
};
Assert.Null(evt.IngestedAtUtc);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);
Assert.Null(evt.Actor);
Assert.Null(evt.Target);
Assert.Null(evt.HttpStatus);
Assert.Null(evt.DurationMs);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
Assert.Null(evt.RequestSummary);
Assert.Null(evt.ResponseSummary);
Assert.Null(evt.Extra);
Assert.Null(evt.ForwardState);
}
[Fact]
public void With_ProducesNewInstance_WithSingleFieldChanged()
{
var original = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Submitted,
PayloadTruncated = false
};
var updated = original with { Status = AuditStatus.Delivered };
Assert.NotSame(original, updated);
Assert.Equal(AuditStatus.Submitted, original.Status);
Assert.Equal(AuditStatus.Delivered, updated.Status);
Assert.Equal(original.EventId, updated.EventId);
Assert.NotEqual(original, updated);
}
}

View File

@@ -0,0 +1,36 @@
using System.Reflection;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.Commons.Tests.Interfaces.Services;
/// <summary>
/// Reflection-level contract guards for the Audit Log (#23) writer interfaces.
/// Locks in method signature so DI bindings + adapter implementations stay aligned.
/// </summary>
public class AuditWriterContractTests
{
[Theory]
[InlineData(typeof(IAuditWriter))]
[InlineData(typeof(ICentralAuditWriter))]
public void WriteAsync_HasExpectedSignature(Type writerType)
{
var method = writerType.GetMethod("WriteAsync", BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
Assert.Equal(typeof(Task), method!.ReturnType);
var parameters = method.GetParameters();
Assert.Equal(2, parameters.Length);
Assert.Equal(typeof(AuditEvent), parameters[0].ParameterType);
Assert.Equal(typeof(CancellationToken), parameters[1].ParameterType);
Assert.True(parameters[1].HasDefaultValue);
}
[Fact]
public void IAuditWriter_AndICentralAuditWriter_AreDistinctTypes()
{
Assert.NotEqual(typeof(IAuditWriter), typeof(ICentralAuditWriter));
Assert.False(typeof(IAuditWriter).IsAssignableFrom(typeof(ICentralAuditWriter)));
Assert.False(typeof(ICentralAuditWriter).IsAssignableFrom(typeof(IAuditWriter)));
}
}

View File

@@ -0,0 +1,105 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Tests.Messages.Integration;
/// <summary>
/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs.
/// At-least-once from sites; idempotent at central on <see cref="AuditEvent.EventId"/>.
/// </summary>
public class AuditTelemetryMessagesTests
{
private static AuditEvent MakeEvent(Guid? id = null) => new()
{
EventId = id ?? Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false
};
[Fact]
public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable()
{
var envelopeId = Guid.NewGuid();
var events = new List<AuditEvent> { MakeEvent(), MakeEvent(), MakeEvent() };
var envelope = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
Assert.Equal(envelopeId, envelope.EnvelopeId);
Assert.Equal("site-01", envelope.SourceSiteId);
Assert.Equal(3, envelope.Events.Count);
// Enumerable round-trip
var collected = new List<AuditEvent>();
foreach (var e in envelope.Events)
{
collected.Add(e);
}
Assert.Equal(3, collected.Count);
}
[Fact]
public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList()
{
// The record's value equality compares the IReadOnlyList reference; two envelopes
// built with the same list instance + same fields must be equal, but using a
// different list instance (even with equal content) must NOT be equal.
var events = new List<AuditEvent> { MakeEvent() } as IReadOnlyList<AuditEvent>;
var envelopeId = Guid.NewGuid();
var a = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
var b = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
var withDifferentSite = a with { SourceSiteId = "site-02" };
Assert.NotEqual(a, withDifferentSite);
Assert.Equal("site-02", withDifferentSite.SourceSiteId);
Assert.Equal("site-01", a.SourceSiteId);
}
[Fact]
public void PullAuditEventsRequest_ConstructsAndIsImmutable()
{
var since = new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc);
var request = new PullAuditEventsRequest("site-01", since, 100);
Assert.Equal("site-01", request.SourceSiteId);
Assert.Equal(since, request.SinceUtc);
Assert.Equal(100, request.BatchSize);
var bigger = request with { BatchSize = 500 };
Assert.Equal(100, request.BatchSize);
Assert.Equal(500, bigger.BatchSize);
}
[Fact]
public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable()
{
var events = new List<AuditEvent> { MakeEvent(), MakeEvent() };
var response = new PullAuditEventsResponse(events, MoreAvailable: true);
Assert.True(response.MoreAvailable);
Assert.Equal(2, response.Events.Count);
var collected = new List<AuditEvent>();
foreach (var e in response.Events)
{
collected.Add(e);
}
Assert.Equal(2, collected.Count);
}
[Fact]
public void PullAuditEventsResponse_WithExpression_ChangesSingleField()
{
var response = new PullAuditEventsResponse(new List<AuditEvent>(), MoreAvailable: false);
var updated = response with { MoreAvailable = true };
Assert.False(response.MoreAvailable);
Assert.True(updated.MoreAvailable);
}
}

View File

@@ -0,0 +1,72 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Tests.Types.Enums;
/// <summary>
/// Asserts the exact member sets of the Audit Log (#23) enums.
/// Lock-in tests; any addition/removal/rename is a deliberate design change
/// that must come with a corresponding update to alog.md §4.
/// </summary>
public class AuditEnumTests
{
[Fact]
public void AuditChannel_HasExactlyExpectedMembers()
{
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" };
var actual = Enum.GetValues(typeof(AuditChannel))
.Cast<AuditChannel>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(expected.Length, actual.Length);
Assert.Equal(expected, actual);
}
[Fact]
public void AuditKind_HasExactlyTenExpectedMembers()
{
var expected = new[]
{
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached",
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
"CachedSubmit", "CachedResolve",
};
var actual = Enum.GetValues(typeof(AuditKind))
.Cast<AuditKind>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(10, actual.Length);
Assert.Equal(expected, actual);
}
[Fact]
public void AuditStatus_HasExactlyEightExpectedMembers()
{
var expected = new[]
{
"Submitted", "Forwarded", "Attempted", "Delivered",
"Failed", "Parked", "Discarded", "Skipped",
};
var actual = Enum.GetValues(typeof(AuditStatus))
.Cast<AuditStatus>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(8, actual.Length);
Assert.Equal(expected, actual);
}
[Fact]
public void AuditForwardState_HasExactlyExpectedMembers()
{
var expected = new[] { "Pending", "Forwarded", "Reconciled" };
var actual = Enum.GetValues(typeof(AuditForwardState))
.Cast<AuditForwardState>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(expected.Length, actual.Length);
Assert.Equal(expected, actual);
}
}

View File

@@ -0,0 +1,140 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Configurations;
namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
/// Verifies that <see cref="AuditEvent"/> maps to the AuditLog table with the
/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4.
/// Inspects EF model metadata via the existing in-memory SQLite test context — no
/// database round-trips required.
/// </summary>
public class AuditLogEntityTypeConfigurationTests : IDisposable
{
private readonly ScadaLinkDbContext _context;
public AuditLogEntityTypeConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
[Fact]
public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey()
{
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
// AuditLog table — the clustered key must include the partition column
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
Assert.Equal("AuditLog", entity!.GetTableName());
var pk = entity.FindPrimaryKey();
Assert.NotNull(pk);
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames);
}
[Fact]
public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups()
{
// EventId remains globally unique (the idempotency key for
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
// is independent of the composite PK.
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
var eventIdIndex = entity!.GetIndexes()
.SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId");
Assert.NotNull(eventIdIndex);
Assert.True(eventIdIndex!.IsUnique);
var indexedProperty = Assert.Single(eventIdIndex.Properties);
Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name);
}
[Fact]
public void Configure_HasExpectedPropertyCount()
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
var properties = entity!.GetProperties()
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditEvent record exposes 21 init-only properties (alog.md §4).
Assert.Equal(21, properties.Count);
}
[Fact]
public void Configure_ExpectedIndexes_WithCorrectNames()
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
var indexNames = entity!.GetIndexes()
.Select(i => i.GetDatabaseName())
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C).
var expected = new[]
{
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
"UX_AuditLog_EventId",
};
Assert.Equal(expected, indexNames);
}
[Theory]
[InlineData(nameof(AuditEvent.Channel))]
[InlineData(nameof(AuditEvent.Kind))]
[InlineData(nameof(AuditEvent.Status))]
[InlineData(nameof(AuditEvent.ForwardState))]
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
var property = entity!.FindProperty(propertyName);
Assert.NotNull(property);
// Enums are converted to strings (varchar(32) IsUnicode=false on SQL Server).
Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType);
Assert.Equal(32, property.GetMaxLength());
Assert.False(property.IsUnicode() ?? true);
}
[Fact]
public void Configure_FilteredIndexes_HaveExpectedFilters()
{
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
Assert.NotNull(entity);
var correlationIdx = entity!.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_CorrelationId");
Assert.Equal("[CorrelationId] IS NOT NULL", correlationIdx.GetFilter());
var targetIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
}
}

View File

@@ -0,0 +1,241 @@
using Microsoft.Data.SqlClient;
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Bundle C (#23 M1) integration tests: applies the EF migrations to a
/// freshly-created MSSQL test database on the running infra/mssql container
/// and asserts that the AddAuditLogTable migration produced the expected
/// partition function, partition scheme, partition-aligned table, named
/// indexes, and DB roles.
/// </summary>
/// <remarks>
/// Tests use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> from
/// the Xunit.SkippableFact package so the runner reports them as Skipped (not
/// Passed) when MSSQL is unreachable. xunit 2.9.x does not ship a native
/// <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> — those land in xunit v3 — so
/// SkippableFact is the canonical equivalent for this project. The fixture
/// applies the migration once at construction time.
/// </remarks>
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_CreatesAuditLogTable()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var exists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_NAME = 'AuditLog' AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, exists);
}
[SkippableFact]
public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var functionExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month';");
Assert.Equal(1, functionExists);
// Specification (alog.md §4 / Bundle C plan): 24 monthly boundaries
// covering 2026-01-01 through 2027-12-01 UTC.
var boundaryCount = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.partition_range_values rv " +
"INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id " +
"WHERE pf.name = 'pf_AuditLog_Month';");
Assert.True(boundaryCount >= 24,
$"Expected at least 24 monthly boundaries on pf_AuditLog_Month; got {boundaryCount}.");
}
[SkippableFact]
public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var schemeExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';");
Assert.Equal(1, schemeExists);
}
[SkippableFact]
public async Task AppliesMigration_TableIsPartitionAligned()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// The clustered (PK) index on AuditLog must live on the ps_AuditLog_Month
// partition scheme; sys.indexes.data_space_id points at the scheme.
var schemeName = await ScalarAsync<string?>(
"SELECT ps.name FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
"INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " +
"WHERE o.name = 'AuditLog' AND i.index_id = 1;");
Assert.Equal("ps_AuditLog_Month", schemeName);
}
[SkippableFact]
public async Task AppliesMigration_CreatesFiveNamedIndexes()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var expected = new[]
{
"IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_Target_Occurred",
};
foreach (var indexName in expected)
{
var count = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.indexes i " +
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
$"WHERE o.name = 'AuditLog' AND i.name = '{indexName}';");
Assert.True(count == 1, $"Expected index '{indexName}' to exist on AuditLog; found {count}.");
}
}
[SkippableFact]
public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var roleExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_principals " +
"WHERE name = 'scadalink_audit_writer' AND type = 'R';");
Assert.Equal(1, roleExists);
// GRANT INSERT + GRANT SELECT must be present (G state = grant).
var insertGranted = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_permissions p " +
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
" AND p.permission_name = 'INSERT' AND p.state IN ('G','W');");
Assert.Equal(1, insertGranted);
var selectGranted = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_permissions p " +
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
" AND p.permission_name = 'SELECT' AND p.state IN ('G','W');");
Assert.Equal(1, selectGranted);
// UPDATE / DELETE must NOT be granted — and DENY (state = 'D') is even
// stronger. Treat presence of GRANT (state 'G' or 'W') as the failure.
var updateGranted = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_permissions p " +
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
" AND p.permission_name = 'UPDATE' AND p.state IN ('G','W');");
Assert.Equal(0, updateGranted);
var deleteGranted = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_permissions p " +
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
" AND p.permission_name = 'DELETE' AND p.state IN ('G','W');");
Assert.Equal(0, deleteGranted);
}
[SkippableFact]
public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var roleExists = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_principals " +
"WHERE name = 'scadalink_audit_purger' AND type = 'R';");
Assert.Equal(1, roleExists);
// SELECT on AuditLog.
var selectGranted = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_permissions p " +
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
"WHERE pr.name = 'scadalink_audit_purger' AND o.name = 'AuditLog' " +
" AND p.permission_name = 'SELECT' AND p.state IN ('G','W');");
Assert.Equal(1, selectGranted);
// ALTER on SCHEMA::dbo (class 3 = SCHEMA).
var alterSchema = await ScalarAsync<int>(
"SELECT COUNT(*) FROM sys.database_permissions p " +
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
"INNER JOIN sys.schemas s ON p.major_id = s.schema_id " +
"WHERE pr.name = 'scadalink_audit_purger' AND s.name = 'dbo' " +
" AND p.class = 3 AND p.permission_name = 'ALTER' AND p.state IN ('G','W');");
Assert.Equal(1, alterSchema);
}
[SkippableFact]
public async Task AuditWriterRole_CannotUpdateAuditLog()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Set up a dedicated user mapped to scadalink_audit_writer, then EXECUTE AS
// and attempt UPDATE — DENY UPDATE on the role must reject the statement.
// Use a guid-suffixed user name so reruns in the same fixture don't collide.
var testUser = $"audit_writer_smoke_{Guid.NewGuid():N}".Substring(0, 32);
await using (var setup = new SqlConnection(_fixture.ConnectionString))
{
await setup.OpenAsync();
await using var setupCmd = setup.CreateCommand();
setupCmd.CommandText =
$"CREATE USER [{testUser}] WITHOUT LOGIN; " +
$"ALTER ROLE scadalink_audit_writer ADD MEMBER [{testUser}];";
await setupCmd.ExecuteNonQueryAsync();
}
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
{
await using var conn = new SqlConnection(_fixture.ConnectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
// WHERE 1=0 guarantees no rows are touched even if the permission check
// somehow passes — the test asserts the engine rejects the statement
// at permission-check time, not via a side effect on data.
cmd.CommandText =
$"EXECUTE AS USER = '{testUser}'; " +
$"UPDATE dbo.AuditLog SET Status = 'X' WHERE 1 = 0; " +
$"REVERT;";
await cmd.ExecuteNonQueryAsync();
});
// SQL Server permission-denied errors carry number 229 (e.g. "The UPDATE
// permission was denied"). Assert the message mentions permission rather
// than pinning to the exact code, in case the engine version drifts.
Assert.Contains("permission", ex.Message, StringComparison.OrdinalIgnoreCase);
}
// --- helpers ------------------------------------------------------------
private async Task<T> ScalarAsync<T>(string sql)
{
await using var conn = _fixture.OpenConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var result = await cmd.ExecuteScalarAsync();
if (result is null || result is DBNull)
{
return default!;
}
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
}
}

View File

@@ -0,0 +1,237 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1).
///
/// Creates a fresh, uniquely-named test database on the running infra/mssql
/// container, applies the EF migrations against it, and drops it on dispose.
/// When MSSQL is not reachable (CI without the container), <see cref="Available"/>
/// is set to false and <see cref="SkipReason"/> describes why — tests pair
/// <c>[SkippableFact]</c> with <c>Skip.IfNot(_fixture.Available, _fixture.SkipReason)</c>
/// so the runner reports them as Skipped (not silently Passed).
/// </summary>
/// <remarks>
/// xunit 2.9.x has no native <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> (those
/// are v3); the project uses the Xunit.SkippableFact package as the canonical
/// equivalent. The fixture attempts connect + create-db + migrate once at
/// construct time. The Connect Timeout=3 in <see cref="DefaultAdminConnectionString"/>
/// makes the fixture fail fast in a no-container environment (under ~5s total)
/// instead of hanging 30s on SqlClient's default. Only connect-failure exceptions
/// (SqlException, plus the InvalidOperationException SqlClient raises from
/// OpenAsync) flip Available to false — every other exception bubbles up so a
/// real bug is not silently swallowed.
/// </remarks>
public sealed class MsSqlMigrationFixture : IDisposable
{
// Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed
// production secret — this is a local dev container connection string.
// Connect Timeout=3 makes the fixture fail fast (~3s) in a no-container
// environment rather than hanging on SqlClient's default 30s connect timeout.
private const string DefaultAdminConnectionString =
"Server=localhost,1433;User Id=sa;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3";
private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN";
public string DatabaseName { get; }
public string ConnectionString { get; }
/// <summary>
/// True when the MSSQL container was reachable at fixture construction
/// time AND the per-fixture test database was successfully created. When
/// false, the integration tests using this fixture must early-return.
/// </summary>
public bool Available { get; }
/// <summary>
/// Populated when <see cref="Available"/> is false; describes why the
/// fixture chose to skip (env var unset, connect failed, etc.).
/// </summary>
public string SkipReason { get; }
private readonly string _adminConnectionString;
public MsSqlMigrationFixture()
{
// Short, lowercase guid suffix keeps the database identifier under SQL Server's
// 128-char limit and safe for raw concatenation (no quoting required).
DatabaseName = $"ScadaLinkAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38);
// Env var lets CI / power users override the admin endpoint; absent
// defaults to the local docker dev container's sa connection.
var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar);
_adminConnectionString = string.IsNullOrWhiteSpace(fromEnv)
? DefaultAdminConnectionString
: fromEnv;
// Step 1: open the admin connection. This is the only step that may
// legitimately fail when MSSQL is absent; SqlException + the rare
// InvalidOperationException from OpenAsync are the connect-failure
// surfaces we tolerate. Everything else (CREATE DATABASE, MigrateAsync)
// is treated as a hard fixture failure once we *have* a connection.
try
{
using var connection = new SqlConnection(_adminConnectionString);
try
{
connection.Open();
}
catch (SqlException ex)
{
ConnectionString = string.Empty;
Available = false;
SkipReason = $"MSSQL unavailable (connect failed: SqlException {ex.Number}: {ex.Message})";
return;
}
catch (InvalidOperationException ex)
{
ConnectionString = string.Empty;
Available = false;
SkipReason = $"MSSQL unavailable (OpenAsync threw: {ex.Message})";
return;
}
using (var createCmd = connection.CreateCommand())
{
createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];";
createCmd.ExecuteNonQuery();
}
ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName);
// Apply the EF migrations once at fixture construction so each test
// can read from a fully-migrated database without per-test setup.
// Failures here are real bugs — let them bubble.
ApplyMigrationsCore(ConnectionString, CancellationToken.None).GetAwaiter().GetResult();
Available = true;
SkipReason = string.Empty;
}
catch
{
// Best-effort cleanup if we created the database but failed before
// setting Available — otherwise Dispose() would skip the drop.
TryDropOrphanDatabase();
throw;
}
}
private void TryDropOrphanDatabase()
{
if (string.IsNullOrEmpty(ConnectionString))
{
return;
}
try
{
SqlConnection.ClearAllPools();
using var connection = new SqlConnection(_adminConnectionString);
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText =
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
$"BEGIN " +
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
$" DROP DATABASE [{DatabaseName}]; " +
$"END";
cmd.ExecuteNonQuery();
}
catch
{
// Best-effort — orphan databases carry a random guid suffix.
}
}
/// <summary>
/// Applies the EF migrations to the per-fixture test database via a freshly
/// constructed <see cref="ScadaLinkDbContext"/> pointed at it. Uses the
/// schema-only single-argument constructor — the AuditLog migration does
/// not write secret-bearing columns at apply time. Called once from the
/// constructor; tests do not invoke this directly.
/// </summary>
private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(connectionString)
.Options;
await using var context = new ScadaLinkDbContext(options);
await context.Database.MigrateAsync(cancellationToken);
}
/// <summary>
/// Convenience for opening a fresh <see cref="SqlConnection"/> to the test
/// database. Caller is responsible for disposal.
/// </summary>
public SqlConnection OpenConnection()
{
ThrowIfUnavailable();
var connection = new SqlConnection(ConnectionString);
connection.Open();
return connection;
}
public void Dispose()
{
if (!Available)
{
return;
}
// Best-effort drop — never let a teardown failure pollute later runs.
// SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections
// so the DROP DATABASE doesn't fail with "database is in use".
try
{
// Connection-pool cleanup is necessary because EF's MigrateAsync leaves
// pooled connections behind; SqlConnection.ClearAllPools() forces them
// closed so the SINGLE_USER + DROP sequence below can complete.
SqlConnection.ClearAllPools();
using var connection = new SqlConnection(_adminConnectionString);
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText =
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
$"BEGIN " +
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
$" DROP DATABASE [{DatabaseName}]; " +
$"END";
cmd.ExecuteNonQuery();
}
catch
{
// Swallow — the database name carries a random guid suffix so a
// stranded test database does not collide with future runs.
}
}
/// <summary>
/// Throws an <see cref="InvalidOperationException"/> when invoked on an
/// unavailable fixture; tests should branch on <see cref="Available"/>
/// before reaching this code path.
/// </summary>
private void ThrowIfUnavailable()
{
if (!Available)
{
throw new InvalidOperationException(
$"MsSqlMigrationFixture is not Available: {SkipReason}");
}
}
private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName)
{
var builder = new SqlConnectionStringBuilder(adminConnectionString)
{
InitialCatalog = databaseName,
};
return builder.ConnectionString;
}
}

View File

@@ -0,0 +1,267 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// Bundle D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
/// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so
/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
/// real partitioned schema. Tests scope all queries by a per-test
/// <c>SourceSiteId</c> guid suffix so they neither collide with one another nor
/// require cleanup.
/// </summary>
public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AuditLogRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
await repo.InsertIfNotExistsAsync(evt);
// Re-read in a fresh context so we exercise the persisted row, not the
// (already-bypassed) change tracker.
await using var readContext = CreateContext();
var loaded = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Single(loaded);
Assert.Equal(evt.EventId, loaded[0].EventId);
}
[SkippableFact]
public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first");
await repo.InsertIfNotExistsAsync(first);
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
var second = first with { ErrorMessage = "second-should-be-ignored" };
await repo.InsertIfNotExistsAsync(second);
await using var readContext = CreateContext();
var loaded = await readContext.Set<AuditEvent>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.Single(loaded);
Assert.Equal("first", loaded[0].ErrorMessage);
}
[SkippableFact]
public async Task QueryAsync_ReturnsRowsInOccurredDescOrder()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10)));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(3, rows.Count);
Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc);
Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
}
[SkippableFact]
public async Task QueryAsync_FilterByChannel()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
}
[SkippableFact]
public async Task QueryAsync_FilterBySourceSiteId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var otherSiteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1)));
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
}
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30)));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteId: siteId,
FromUtc: t0.AddMinutes(10),
ToUtc: t0.AddHours(1)),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
}
[SkippableFact]
public async Task QueryAsync_Keyset_NextPageStartsAfterCursor()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc);
// Five rows at one-minute intervals. Page-size 2 → page 1 returns minutes 4,3.
// Cursor (minutes 3) → page 2 returns minutes 2,1. Cursor (minutes 1) → page 3 returns minute 0.
for (var i = 0; i < 5; i++)
{
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i)));
}
var page1 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc);
Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc);
var cursor = page1[^1];
var page2 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc,
AfterEventId: cursor.EventId));
Assert.Equal(2, page2.Count);
Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc);
Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc);
var cursor2 = page2[^1];
var page3 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
AfterEventId: cursor2.EventId));
Assert.Single(page3);
Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
}
[SkippableFact]
public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// The partition-switch path is intentionally blocked in M1 because
// UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships
// with the M6 purge actor.
var ex = await Assert.ThrowsAsync<NotSupportedException>(
() => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)));
Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase);
}
// --- helpers ------------------------------------------------------------
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
private static string NewSiteId() =>
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private static AuditEvent NewEvent(
string siteId,
DateTime occurredAtUtc,
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? errorMessage = null) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc,
Channel = channel,
Kind = kind,
Status = status,
SourceSiteId = siteId,
ErrorMessage = errorMessage,
};
}

View File

@@ -10,13 +10,29 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<!--
Bundle C migration integration tests need Microsoft.Data.SqlClient. EF
SqlServer 10.0.7 pulls in SqlClient >= 6.1.1, but the central package
version is pinned at 6.0.2 (the version the production
ExternalSystemGateway uses). Override the version locally for the test
project only; production assemblies are unaffected.
-->
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!--
SkippableFact lets the Bundle C MSSQL integration tests report as Skipped
(not Passed) when the dev MSSQL container is not running. xunit 2.9.x does
not ship Assert.Skip / SkipUnless — those are v3-only — so we use the
canonical community wrapper instead.
-->
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>

View File

@@ -18,6 +18,7 @@ public class ServiceCollectionExtensionsTests
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
}
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly