diff --git a/Directory.Packages.props b/Directory.Packages.props index 21464c0..d8f854e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,6 +64,14 @@ + + diff --git a/ScadaLink.slnx b/ScadaLink.slnx index 9b312e4..21b4f3a 100644 --- a/ScadaLink.slnx +++ b/ScadaLink.slnx @@ -1,5 +1,6 @@ + @@ -22,6 +23,7 @@ + diff --git a/alog.md b/alog.md index 9cc14d5..e68b91d 100644 --- a/alog.md +++ b/alog.md @@ -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 = 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= -2. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId= -3. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId= -4. Kind=CachedAttempt Status=Success HttpStatus=200 CorrelationId= -5. Kind=CachedTerminal Status=Delivered CorrelationId= +1. Kind=CachedSubmit Status=Submitted CorrelationId= +2. Kind=ApiCallCached Status=Forwarded CorrelationId= +3. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId= +4. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId= +5. Kind=ApiCallCached Status=Attempted HttpStatus=200 CorrelationId= +6. Kind=CachedResolve Status=Delivered CorrelationId= ``` 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= SourceSiteId="site-01" SourceInstance="Plant1.Boiler" -2. Kind=Attempt Status=TransientFailure ErrorMessage="SMTP 451 ..." CorrelationId= SourceSiteId=NULL (dispatch is central) -3. Kind=Attempt Status=Success CorrelationId= -4. Kind=Terminal Status=Delivered CorrelationId= +1. Kind=NotifySend Status=Submitted CorrelationId= SourceSiteId="site-01" SourceInstance="Plant1.Boiler" +2. Kind=NotifyDeliver Status=Attempted ErrorMessage="SMTP 451 ..." CorrelationId= SourceSiteId=NULL (dispatch is central) +3. Kind=NotifyDeliver Status=Attempted CorrelationId= +4. Kind=NotifyDeliver Status=Delivered CorrelationId= 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 = -- 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. diff --git a/docs/plans/2026-05-20-auditlog-m1-foundation.md b/docs/plans/2026-05-20-auditlog-m1-foundation.md new file mode 100644 index 0000000..459dfed --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m1-foundation.md @@ -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 1–11 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 + 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 1–3 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().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 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 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 + +**Files:** +- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` — add `public DbSet AuditLogs => Set();` 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` mapping to table `AuditLog`, columns per alog.md §4 (with max lengths), PK on `EventId`, enum columns stored as `varchar(32)` via `HasConversion().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/_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_` (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> 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`, applies filters, projects, paged by keyset on `(OccurredAtUtc desc, EventId desc)`. + - `SwitchOutPartitionAsync` builds a unique staging table name, runs `CREATE TABLE … ` with identical schema and ON `[PRIMARY]`, runs `ALTER TABLE AuditLog SWITCH PARTITION TO `, then `DROP TABLE `. 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();` 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()` (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>());`. +- 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 `` 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 `` 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 HeaderRedactList` (default: `[ "Authorization", "X-Api-Key", "Cookie", "Set-Cookie" ]`) + - `List GlobalBodyRedactors` (default: empty) + - `Dictionary PerTargetOverrides` (default empty) + - `int RetentionDays` (default 365; range [30, 3650]) +- Create: `src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs` — minimal: `int? CapBytes`, `List? AdditionalBodyRedactors`. +- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — `IValidateOptions` checking `DefaultCapBytes > 0`, `ErrorCapBytes >= DefaultCapBytes`, `RetentionDays` in `[30, 3650]`. +- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.AddAuditLog` to `services.AddOptions().Bind(config.GetSection("AuditLog")).ValidateOnStart(); services.AddSingleton, 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 1–3 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 M2–M8 sections of the roadmap with realities learned (step G), commit. +- Status paragraph (step H). +- Proceed to M2 (step I). diff --git a/docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json b/docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json new file mode 100644 index 0000000..de231f1 --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json @@ -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 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" +} diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index 13417b9..e3f6ed5 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -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` diff --git a/docs/requirements/Component-Host.md b/docs/requirements/Component-Host.md index 1196050..bbfaadc 100644 --- a/docs/requirements/Component-Host.md +++ b/docs/requirements/Component-Host.md @@ -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. diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs new file mode 100644 index 0000000..89cfe9b --- /dev/null +++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs @@ -0,0 +1,36 @@ +namespace ScadaLink.AuditLog.Configuration; + +/// +/// Configuration for Audit Log (#23). Bound from the AuditLog section of +/// appsettings.json. 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. +/// +public sealed class AuditLogOptions +{ + /// Default payload-summary cap in bytes (default 8 KiB). + public int DefaultCapBytes { get; set; } = 8192; + + /// Payload-summary cap on error rows in bytes (default 64 KiB). + public int ErrorCapBytes { get; set; } = 65536; + + /// HTTP headers redacted by default before persistence. + public List HeaderRedactList { get; set; } = new() + { + "Authorization", + "X-Api-Key", + "Cookie", + "Set-Cookie", + }; + + /// Body-content redactors applied globally (regex patterns). + public List GlobalBodyRedactors { get; set; } = new(); + + /// Per-target redaction overrides keyed by target identifier. + public Dictionary PerTargetOverrides { get; set; } = new(); + + /// Central retention window in days (default 365, range [30, 3650]). + public int RetentionDays { get; set; } = 365; +} diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs new file mode 100644 index 0000000..59785c3 --- /dev/null +++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Options; + +namespace ScadaLink.AuditLog.Configuration; + +/// +/// Validates on startup. The caps drive payload +/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily +/// large blobs into the central AuditLog table. +/// must be at least as large as +/// because the error cap is meant to capture more detail than the +/// happy-path summary, not less. is +/// bounded to [30, 3650] to keep purge windows sane: too short would +/// drop in-flight investigations, too long would defeat the partition-switch +/// purge's purpose. +/// +public sealed class AuditLogOptionsValidator : IValidateOptions +{ + /// Inclusive lower bound for . + public const int MinRetentionDays = 30; + + /// Inclusive upper bound for . + public const int MaxRetentionDays = 3650; + + /// + public ValidateOptionsResult Validate(string? name, AuditLogOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var failures = new List(); + + 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); + } +} diff --git a/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs new file mode 100644 index 0000000..739ee07 --- /dev/null +++ b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs @@ -0,0 +1,17 @@ +namespace ScadaLink.AuditLog.Configuration; + +/// +/// Per-target redaction override applied additively on top of +/// and the +/// / +/// caps. Targets are identified by the script-facing external-system / +/// database / notification-list / inbound-API-key name. +/// +public sealed class PerTargetRedactionOverride +{ + /// Optional payload cap override (bytes); null inherits the global cap. + public int? CapBytes { get; set; } + + /// Additional body redactor regex patterns (appended to the global list). + public List? AdditionalBodyRedactors { get; set; } +} diff --git a/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj new file mode 100644 index 0000000..4999344 --- /dev/null +++ b/src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + + diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..7e010e6 --- /dev/null +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Configuration; + +namespace ScadaLink.AuditLog; + +/// +/// Composition root for the Audit Log (#23) component. M1 registers +/// 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). +/// +public static class ServiceCollectionExtensions +{ + /// Configuration section bound to . + public const string ConfigSectionName = "AuditLog"; + + /// + /// Binds from the + /// section of + /// and registers so a misconfigured + /// AuditLog section is rejected with a key-naming message when the + /// options are first resolved (or at startup when consumers wire in + /// ValidateOnStart()). M2+ will register writers, telemetry actors, + /// and the central ingest pipeline here. IAuditLogRepository is + /// registered by + /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase, + /// so the caller (the Host on the central node) must also call that. + /// + public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + + services.AddOptions() + .Bind(config.GetSection(ConfigSectionName)) + .ValidateOnStart(); + services.AddSingleton, AuditLogOptionsValidator>(); + + return services; + } +} diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs new file mode 100644 index 0000000..f5148a3 --- /dev/null +++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs @@ -0,0 +1,73 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Entities.Audit; + +/// +/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null; +/// site rows leave IngestedAtUtc null until ingest. Append-only. +/// +public sealed record AuditEvent +{ + /// Idempotency key; uniquely identifies one audit lifecycle event. + public Guid EventId { get; init; } + + /// UTC timestamp when the audited action occurred at its source. + public DateTime OccurredAtUtc { get; init; } + + /// UTC timestamp when the row was ingested at central; null on the site hot-path. + public DateTime? IngestedAtUtc { get; init; } + + /// Trust-boundary channel the audited action crossed. + public AuditChannel Channel { get; init; } + + /// Specific event kind within the channel (see alog.md §4). + public AuditKind Kind { get; init; } + + /// Correlation id linking related audit rows (e.g. the cached-op lifecycle). + public Guid? CorrelationId { get; init; } + + /// Site id where the action originated; null for central-direct events. + public string? SourceSiteId { get; init; } + + /// Instance id where the action originated, when applicable. + public string? SourceInstanceId { get; init; } + + /// Script that initiated the action (script trust boundary), when applicable. + public string? SourceScript { get; init; } + + /// Authenticated actor for inbound paths (API key name, user, etc.). + public string? Actor { get; init; } + + /// Target of the action: external system name, db connection name, list name, or inbound method. + public string? Target { get; init; } + + /// Lifecycle status of this row. + public AuditStatus Status { get; init; } + + /// HTTP status code where applicable (outbound API + inbound API). + public int? HttpStatus { get; init; } + + /// Duration of the audited action in milliseconds, when measurable. + public int? DurationMs { get; init; } + + /// Human-readable error summary on failure rows. + public string? ErrorMessage { get; init; } + + /// Verbose error detail (stack/exception) on failure rows. + public string? ErrorDetail { get; init; } + + /// Truncated/redacted request summary; capped per AuditLogOptions. + public string? RequestSummary { get; init; } + + /// Truncated/redacted response summary; capped per AuditLogOptions. + public string? ResponseSummary { get; init; } + + /// True when Request/Response summaries were truncated to the payload cap. + public bool PayloadTruncated { get; init; } + + /// Free-form JSON extension column for channel-specific extras. + public string? Extra { get; init; } + + /// Site-local forwarding state; null on central rows. + public AuditForwardState? ForwardState { get; init; } +} diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs new file mode 100644 index 0000000..7b15962 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -0,0 +1,56 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.Commons.Interfaces.Repositories; + +/// +/// Append-only data access for the central AuditLog table (Audit Log #23). +/// +/// +/// +/// The append-only invariant is enforced both at the SQL level (the +/// scadalink_audit_writer 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 (). +/// +/// +/// Ingest is idempotent on EventId: is +/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed +/// the same writer without producing duplicates. +/// +/// +public interface IAuditLogRepository +{ + /// + /// Inserts if no row with the same + /// exists; otherwise silently leaves the + /// stored row untouched (first-write-wins). Bypasses the EF change tracker + /// so the row never enters a tracked state. + /// + Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default); + + /// + /// Returns up to rows matching + /// , ordered by (OccurredAtUtc DESC, EventId DESC). + /// Use keyset paging by passing the last returned row's + /// OccurredAtUtc + EventId back via + /// + + /// to fetch the next page. + /// + Task> QueryAsync( + AuditLogQueryFilter filter, + AuditLogPaging paging, + CancellationToken ct = default); + + /// + /// Switches out (purges) the monthly partition whose lower bound is + /// . The honest M1 implementation throws + /// : the UX_AuditLog_EventId unique + /// index is non-partition-aligned (lives on [PRIMARY], not on + /// ps_AuditLog_Month), so SQL Server rejects + /// ALTER TABLE … SWITCH PARTITION until the drop-and-rebuild dance + /// shipped by the M6 purge actor is in place. + /// + Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default); +} diff --git a/src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs b/src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs new file mode 100644 index 0000000..a95bc15 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs @@ -0,0 +1,17 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Interfaces.Services; + +/// +/// 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. +/// +public interface IAuditWriter +{ + /// + /// Persist an audit event. Best-effort: implementations must swallow/log internal failures + /// rather than propagating them to the calling boundary code. + /// + Task WriteAsync(AuditEvent evt, CancellationToken ct = default); +} diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs b/src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs new file mode 100644 index 0000000..d91f534 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs @@ -0,0 +1,16 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Interfaces.Services; + +/// +/// Central-only audit writer for the direct-write path (Notification Outbox dispatch, Inbound API). +/// Distinct from so DI binding can differ between site and central hosts. +/// +public interface ICentralAuditWriter +{ + /// + /// 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. + /// + Task WriteAsync(AuditEvent evt, CancellationToken ct = default); +} diff --git a/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs b/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs new file mode 100644 index 0000000..38d7468 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs @@ -0,0 +1,13 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Audit Log (#23) telemetry envelope sent from a site to central over gRPC. +/// At-least-once delivery; central is idempotent on . +/// See Component-AuditLog.md "Ingestion" for the handoff contract. +/// +public sealed record AuditTelemetryEnvelope( + Guid EnvelopeId, + string SourceSiteId, + IReadOnlyList Events); diff --git a/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs new file mode 100644 index 0000000..df70a30 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs @@ -0,0 +1,11 @@ +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Audit Log (#23) periodic reconciliation pull request: central asks a site for +/// audit events since the given UTC watermark, up to . +/// Acts as the fallback when streaming telemetry is lost. See Component-AuditLog.md "Ingestion". +/// +public sealed record PullAuditEventsRequest( + string SourceSiteId, + DateTime SinceUtc, + int BatchSize); diff --git a/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs new file mode 100644 index 0000000..c752304 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs @@ -0,0 +1,12 @@ +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.Commons.Messages.Integration; + +/// +/// Audit Log (#23) periodic reconciliation pull response: the next batch of site +/// audit events plus a flag signalling the caller +/// to advance the watermark and pull again. See Component-AuditLog.md "Ingestion". +/// +public sealed record PullAuditEventsResponse( + IReadOnlyList Events, + bool MoreAvailable); diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs new file mode 100644 index 0000000..7feeffd --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs @@ -0,0 +1,13 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Keyset paging cursor for . +/// The repository orders by (OccurredAtUtc DESC, EventId DESC); callers pass +/// the last row of the previous page back as + +/// to fetch the next page. Both must be non-null together, +/// or both null (first page). +/// +public sealed record AuditLogPaging( + int PageSize, + DateTime? AfterOccurredAtUtc = null, + Guid? AfterEventId = null); diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs new file mode 100644 index 0000000..4e2001a --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs @@ -0,0 +1,21 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Filter predicate for . +/// Any field left null means "do not constrain on that column". Time bounds +/// are half-open in the spec sense — is inclusive and +/// is inclusive of the upper bound; the repository SQL uses +/// >= / <= respectively. All filter fields are AND-combined. +/// +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); diff --git a/src/ScadaLink.Commons/Types/Enums/AuditChannel.cs b/src/ScadaLink.Commons/Types/Enums/AuditChannel.cs new file mode 100644 index 0000000..c3b26d3 --- /dev/null +++ b/src/ScadaLink.Commons/Types/Enums/AuditChannel.cs @@ -0,0 +1,13 @@ +namespace ScadaLink.Commons.Types.Enums; + +/// +/// 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. +/// +public enum AuditChannel +{ + ApiOutbound, + DbOutbound, + Notification, + ApiInbound +} diff --git a/src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs b/src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs new file mode 100644 index 0000000..dac799f --- /dev/null +++ b/src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs @@ -0,0 +1,14 @@ +namespace ScadaLink.Commons.Types.Enums; + +/// +/// Site-local Audit Log (#23) forwarding state, tracked only in the site SQLite hot-path. +/// Central rows leave this null. Pending = not yet sent; Forwarded = telemetry sent +/// and acked; Reconciled = confirmed present centrally via the periodic pull fallback. +/// The site retention purge MUST NOT drop a row whose state is still Pending. +/// +public enum AuditForwardState +{ + Pending, + Forwarded, + Reconciled +} diff --git a/src/ScadaLink.Commons/Types/Enums/AuditKind.cs b/src/ScadaLink.Commons/Types/Enums/AuditKind.cs new file mode 100644 index 0000000..faac09f --- /dev/null +++ b/src/ScadaLink.Commons/Types/Enums/AuditKind.cs @@ -0,0 +1,20 @@ +namespace ScadaLink.Commons.Types.Enums; + +/// +/// 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. +/// +public enum AuditKind +{ + ApiCall, + ApiCallCached, + DbWrite, + DbWriteCached, + NotifySend, + NotifyDeliver, + InboundRequest, + InboundAuthFailure, + CachedSubmit, + CachedResolve +} diff --git a/src/ScadaLink.Commons/Types/Enums/AuditStatus.cs b/src/ScadaLink.Commons/Types/Enums/AuditStatus.cs new file mode 100644 index 0000000..fdedb8d --- /dev/null +++ b/src/ScadaLink.Commons/Types/Enums/AuditStatus.cs @@ -0,0 +1,18 @@ +namespace ScadaLink.Commons.Types.Enums; + +/// +/// Lifecycle status of an Audit Log (#23) event row. +/// Cached operations produce multiple rows tracking Submitted → Forwarded → Attempted → Delivered/Parked/Discarded. +/// Skipped is used when an action was short-circuited (e.g. dry-run) but should still be audited. +/// +public enum AuditStatus +{ + Submitted, + Forwarded, + Attempted, + Delivered, + Failed, + Parked, + Discarded, + Skipped +} diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs new file mode 100644 index 0000000..9ad90e0 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.ConfigurationDatabase.Configurations; + +/// +/// Maps the record to the central AuditLog 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. +/// +public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.Kind) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.Status) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.ForwardState) + .HasConversion() + .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"); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs new file mode 100644 index 0000000..0d465d5 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs @@ -0,0 +1,1553 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260520142214_AddAuditLogTable")] + partial class AddAuditLogTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs new file mode 100644 index 0000000..9520efe --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// 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 pf_AuditLog_Month (RANGE RIGHT) with 24 + /// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC. + /// 2. Partition scheme ps_AuditLog_Month mapping every partition to + /// [PRIMARY] (dev/test parity; production may relocate via filegroups). + /// 3. AuditLog 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 + /// ps_AuditLog_Month(OccurredAtUtc). + /// 5. Two database roles: + /// - scadalink_audit_writer: 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. + /// - scadalink_audit_purger: 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. + /// + public partial class AddAuditLogTable : Migration + { + /// + 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;"); + } + + /// + 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;"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index e67197a..02b7745 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.ToTable("DataProtectionKeys"); }); + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("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("Id") diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs new file mode 100644 index 0000000..cf5682f --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -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; + +/// +/// EF Core implementation of . See the interface +/// for the append-only contract; this class only adds notes on the data-access +/// strategy used by each method. +/// +public class AuditLogRepository : IAuditLogRepository +{ + private readonly ScadaLinkDbContext _context; + + public AuditLogRepository(ScadaLinkDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Issues a single IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…) + /// via . + /// 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 varchar(32) via HasConversion<string>() in + /// ). + /// + 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()), 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); + } + + /// + /// Builds an AsNoTracking queryable over , applies + /// every non-null filter predicate, and pages by keyset on + /// (OccurredAtUtc DESC, EventId DESC). The keyset clause is expressed + /// directly (occurred < after || (occurred == after && eventId.CompareTo(afterId) < 0)) + /// — EF Core 10 translates against SQL Server's + /// uniqueidentifier sort order. + /// + public async Task> 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().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); + } + + /// + /// M1 honest contract: throws . The + /// UX_AuditLog_EventId unique index is non-aligned with + /// ps_AuditLog_Month (it lives on [PRIMARY] to keep + /// cheap), and SQL Server rejects + /// ALTER TABLE … SWITCH PARTITION when a non-aligned index is present. + /// The drop-and-rebuild dance that makes the switch legal ships with the M6 + /// purge actor. + /// + 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)."); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index c1db4a7..f25118f 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -84,6 +84,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext // Audit public DbSet AuditLogEntries => Set(); + public DbSet AuditLogs => Set(); // Data Protection Keys (for shared ASP.NET Data Protection across nodes) public DbSet DataProtectionKeys => Set(); diff --git a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs index 9eca319..b7ba6b4 100644 --- a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs new file mode 100644 index 0000000..a1057a1 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Configuration; + +namespace ScadaLink.AuditLog.Tests; + +/// +/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies +/// AddAuditLog registers against the +/// AuditLog configuration section. Bundle E ships only the scaffold; +/// the validator + full options surface land in Task 9. +/// +public class AddAuditLogTests +{ + [Fact] + public void AddAuditLog_RegistersAuditLogOptions() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var services = new ServiceCollection(); + services.AddAuditLog(config); + var provider = services.BuildServiceProvider(); + + var opts = provider.GetService>(); + + Assert.NotNull(opts); + Assert.NotNull(opts!.Value); + } + + [Fact] + public void AddAuditLog_NullServices_Throws() + { + var config = new ConfigurationBuilder().Build(); + + Assert.Throws( + () => ServiceCollectionExtensions.AddAuditLog(null!, config)); + } + + [Fact] + public void AddAuditLog_NullConfig_Throws() + { + var services = new ServiceCollection(); + + Assert.Throws( + () => services.AddAuditLog(null!)); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs new file mode 100644 index 0000000..4e68e41 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs @@ -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; + +/// +/// Task 9 (Bundle E): binding + validator +/// behavior. The validator enforces invariants used by M2+ writers +/// (per docs/plans/2026-05-20-auditlog-m1-foundation.md): +/// DefaultCapBytes > 0, ErrorCapBytes >= DefaultCapBytes, +/// RetentionDays in [30, 3650]. Header-redact defaults match the +/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie. +/// +public class AuditLogOptionsTests +{ + private static IOptions BuildOptions(Dictionary config) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build(); + var services = new ServiceCollection(); + services.AddAuditLog(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + [Fact] + public void ValidBinding_PopulatesAllScalarFields() + { + var opts = BuildOptions(new Dictionary + { + ["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()).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 + { + ["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 + { + ["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 + { + ["AuditLog:RetentionDays"] = "0", + }) + .Build(); + var services = new ServiceCollection(); + services.AddAuditLog(configuration); + var provider = services.BuildServiceProvider(); + var opts = provider.GetRequiredService>(); + + var ex = Assert.Throws(() => _ = opts.Value); + Assert.Contains("RetentionDays", ex.Message); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj new file mode 100644 index 0000000..9a866be --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs new file mode 100644 index 0000000..37ccb9f --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs @@ -0,0 +1,135 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Tests.Entities.Audit; + +/// +/// Verifies behaves as an init-only record: +/// every property reads back as constructed, and with expressions +/// produce a new instance with a single property changed. +/// +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); + } +} diff --git a/tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs b/tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs new file mode 100644 index 0000000..c40a5d2 --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.Commons.Tests.Interfaces.Services; + +/// +/// Reflection-level contract guards for the Audit Log (#23) writer interfaces. +/// Locks in method signature so DI bindings + adapter implementations stay aligned. +/// +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))); + } +} diff --git a/tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs new file mode 100644 index 0000000..1bf2f9b --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs @@ -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; + +/// +/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs. +/// At-least-once from sites; idempotent at central on . +/// +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 { 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(); + 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 { MakeEvent() } as IReadOnlyList; + 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 { MakeEvent(), MakeEvent() }; + var response = new PullAuditEventsResponse(events, MoreAvailable: true); + + Assert.True(response.MoreAvailable); + Assert.Equal(2, response.Events.Count); + + var collected = new List(); + 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(), MoreAvailable: false); + var updated = response with { MoreAvailable = true }; + + Assert.False(response.MoreAvailable); + Assert.True(updated.MoreAvailable); + } +} diff --git a/tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs b/tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs new file mode 100644 index 0000000..c9470a2 --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs @@ -0,0 +1,72 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Tests.Types.Enums; + +/// +/// 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. +/// +public class AuditEnumTests +{ + [Fact] + public void AuditChannel_HasExactlyExpectedMembers() + { + var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" }; + var actual = Enum.GetValues(typeof(AuditChannel)) + .Cast() + .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() + .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() + .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() + .Select(x => x.ToString()) + .ToArray(); + + Assert.Equal(expected.Length, actual.Length); + Assert.Equal(expected, actual); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs new file mode 100644 index 0000000..9427442 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Configurations; + +namespace ScadaLink.ConfigurationDatabase.Tests.Configurations; + +/// +/// Schema-level tests for (#23 M1 Bundle B). +/// Verifies that 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. +/// +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()); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs new file mode 100644 index 0000000..159690a --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs @@ -0,0 +1,241 @@ +using Microsoft.Data.SqlClient; +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// 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. +/// +/// +/// Tests use + Skip.IfNot(...) 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 +/// Assert.Skip/Assert.SkipUnless — those land in xunit v3 — so +/// SkippableFact is the canonical equivalent for this project. The fixture +/// applies the migration once at construction time. +/// +public class AddAuditLogTableMigrationTests : IClassFixture +{ + 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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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( + "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(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 ScalarAsync(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))!; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs new file mode 100644 index 0000000..e5906a0 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs @@ -0,0 +1,237 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// 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), +/// is set to false and describes why — tests pair +/// [SkippableFact] with Skip.IfNot(_fixture.Available, _fixture.SkipReason) +/// so the runner reports them as Skipped (not silently Passed). +/// +/// +/// xunit 2.9.x has no native Assert.Skip/Assert.SkipUnless (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 +/// 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. +/// +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; } + + /// + /// 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. + /// + public bool Available { get; } + + /// + /// Populated when is false; describes why the + /// fixture chose to skip (env var unset, connect failed, etc.). + /// + 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. + } + } + + /// + /// Applies the EF migrations to the per-fixture test database via a freshly + /// constructed 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. + /// + private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + await using var context = new ScadaLinkDbContext(options); + await context.Database.MigrateAsync(cancellationToken); + } + + /// + /// Convenience for opening a fresh to the test + /// database. Caller is responsible for disposal. + /// + 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. + } + } + + /// + /// Throws an when invoked on an + /// unavailable fixture; tests should branch on + /// before reaching this code path. + /// + 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; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs new file mode 100644 index 0000000..6bf8db4 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -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; + +/// +/// Bundle D (#23 M1) integration tests for . Uses +/// the same 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 +/// SourceSiteId guid suffix so they neither collide with one another nor +/// require cleanup. +/// +public class AuditLogRepositoryTests : IClassFixture +{ + 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() + .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() + .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( + () => 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() + .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, + }; +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj b/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj index cbc2ae4..40997e3 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj @@ -10,13 +10,29 @@ + + + + + diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs index c745a86..11baf14 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs @@ -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