59 Commits

Author SHA1 Message Date
Joseph Doherty 58352a67cb fix(centralui): include AntiforgeryToken in LoginCard (match OtOpcUa + kit contract) 2026-06-03 03:39:47 -04:00
Joseph Doherty b9516e6721 feat(centralui): LoginCard sign-in
Replace hand-rolled Bootstrap card with the shared <LoginCard> from ZB.MOM.WW.Theme.
Update ComponentRenderingTests assertions to match LoginCard's rendered structure
(h1.login-title, div.panel.notice.login-error, "Sign in" button text).
2026-06-03 03:34:12 -04:00
Joseph Doherty 957203ec7b feat(centralui): MainLayout/NavMenu delegate to ZB.MOM.WW.Theme ThemeShell + kit nav 2026-06-03 03:31:10 -04:00
Joseph Doherty 6fb545d75b refactor(centralui): drop vendored theme.css/fonts/nav-state.js; keep app-only CSS in site.css 2026-06-03 03:25:04 -04:00
Joseph Doherty 6d75bdb372 feat(host): use ZB.MOM.WW.Theme ThemeHead + ThemeScripts 2026-06-03 03:23:03 -04:00
Joseph Doherty e1589497f1 build(centralui): reference ZB.MOM.WW.Theme 0.2.0 2026-06-03 03:21:44 -04:00
Joseph Doherty b3de8408fa feat(audit): ScadaBridge IAuditActorAccessor + wire audit Actor from Auth principal at authenticated emit sites (Phase 3) 2026-06-02 15:33:01 -04:00
Joseph Doherty bc0e5bfd37 docs(audit): ScadaBridge C7 review — correct 'six persisted' computed-col wording (5 persisted + IngestedAtUtc non-persisted) + stale perf iteration comment 2026-06-02 15:08:49 -04:00
Joseph Doherty 635461c0fd chore(audit): ScadaBridge C7 — perf re-baseline + CollapseAuditLogToCanonical projection test + index-test fix + dead-cref cleanup (Task 2.5)
Perf re-baseline (HotPathLatencyTests): empirical p95 on Apple M-series Release
build: 4KB DetailsJson slow path ≈14 µs, small-DetailsJson no-redactors ≈2 µs,
true no-op fast path ≈0 µs. Thresholds updated: 200 µs / 30 µs / 5 µs (≈15×
headroom for contested CI runners). Old thresholds (50 µs / 10 µs) were set for
the pre-C3 typed-field path; canonical JSON parse+rewrite is empirically faster.
Adds a third test (Filter_Apply_NoDetailsJson_FastPath) that asserts same-instance
return on the DetailsJson-null + within-cap fast path. Env-var overrides retained.

CollapseAuditLogToCanonicalMigrationTests (new): three MSSQL-gated [SkippableFact]
tests verifying Action/Category/Outcome projection, NULL Actor, DetailsJson codec
round-trip, and all six persisted computed columns (Kind/Status/SourceSiteId/
ExecutionId/ParentExecutionId) for ApiOutbound, InboundAuthFailure, and Failed-
status rows.

AddAuditLogTableMigrationTests: rename CreatesFiveNamedIndexes →
CreatesNineNamedIndexes; expand coverage from 5 original indexes to all 9 named
non-clustered indexes present after CollapseAuditLogToCanonical (adds
IX_AuditLog_Execution, IX_AuditLog_ParentExecution, IX_AuditLog_Node_Occurred,
UX_AuditLog_EventId).

Dead-cref cleanup: zero references to the deleted IAuditPayloadFilter /
DefaultAuditPayloadFilter / SafeDefaultAuditPayloadFilter types remain in any
.cs file (source or test). 26 occurrences across 13 files replaced with correct
references to IAuditRedactor / ScadaBridgeAuditRedactor / SafeDefaultAuditRedactor
or reworded as plain prose.

Residual sweep: no unused transitional code found beyond the acknowledged
"C3 transitional shim" comments on IngestedAtUtc stamping (active code, not dead).
2026-06-02 14:59:23 -04:00
Joseph Doherty 68a6bd1720 feat(audit)!: ScadaBridge C5 — collapse central dbo.AuditLog to 10 canonical cols + persisted computed cols; CollapseAuditLogToCanonical migration; repo writes canonical directly (Task 2.5) 2026-06-02 14:06:46 -04:00
Joseph Doherty 1737d15f04 fix(audit): ScadaBridge C4 review — enable PRAGMA foreign_keys + MarkForwarded state guard (no Reconciled demotion) + test (Task 2.5) 2026-06-02 13:23:36 -04:00
Joseph Doherty 946d3e2aef feat(audit): ScadaBridge C4 — site SQLite two-table (audit_event canonical + audit_forward_state sidecar), forwarding on sidecar, IsCachedKind drain split (Task 2.5) 2026-06-02 13:11:20 -04:00
Joseph Doherty c27b2c3d5f fix(audit): ScadaBridge C3 review — safe enum-parse (fallback) in SqliteAuditWriter.MapRow + AuditEventDtoMapper.FromDto (Task 2.5) 2026-06-02 12:55:07 -04:00
Joseph Doherty db707bb0de feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5) 2026-06-02 12:37:50 -04:00
Joseph Doherty 5aaf9e2923 fix(audit): ScadaBridge C2 review — over-redact scrubs all sensitive free-text fields + outer-catch never-leak test + marker alignment
I1 (security): OverRedact() in ScadaBridgeAuditRedactor now suppresses ErrorDetail,
ErrorMessage, and Extra (in addition to RequestSummary/ResponseSummary) to the
over-redacted marker in BOTH code paths (Deserialize+with path and the fallback
new-AuditDetails path). SafeDefaultAuditRedactor catch block aligned to match.

M3 (test): OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted forces
the outer try/catch → OverRedact path via a ThrowingMonitor that throws from
CurrentValue (the first statement in the try block). Asserts (a) Apply does not
throw, and (b) all five sensitive free-text fields are suppressed to the
over-redacted marker with PayloadTruncated=true.

M1 (consistency): SafeDefaultAuditRedactor now uses AuditRedactionPrimitives
constants (RedactedMarker for line-format header values, OverRedactedEventMarker
for the catch block), eliminating the divergent [REDACTED]/[redacted by ...]
strings. AuditRedactionPrimitives gains OverRedactedEventMarker = RedactorErrorMarker.
SafeDefaultAuditRedactorTests updated from [REDACTED] → <redacted>.

M2 (comment): Added one-line note in TruncateField explaining why the char-count
(result.Length != value.Length) truncation check is sufficient given TruncateUtf8
only ever shortens.
2026-06-02 11:12:18 -04:00
Joseph Doherty adfb4d385c feat(audit): ScadaBridge C2 — ScadaBridgeAuditRedactor/SafeDefaultAuditRedactor : IAuditRedactor on canonical record (Task 2.5) 2026-06-02 11:00:36 -04:00
Joseph Doherty 3d77dc003c feat(audit): ScadaBridge C1 — AuditDetails codec (deterministic) + AuditOutcome projection + canonical field builders + ZB.MOM.WW.Audit ref (Task 2.5)
Additive foundation only — no existing type/interface/emitter changed.
Commons now references ZB.MOM.WW.Audit 0.1.0 (Gitea feed, central PM pin).
Adds four pure new types in Commons/Types/Audit/:
  AuditDetails (sealed record, 17 domain fields, declaration-order = JSON key order)
  AuditDetailsCodec (static; single cached JsonSerializerOptions: camelCase, no-indent,
    WhenWritingNull, UnsafeRelaxedJsonEscaping — byte-deterministic across calls)
  AuditOutcomeProjector (static; InboundAuthFailure→Denied first, then Delivered→Success,
    Failed/Parked/Discarded→Failure, all others→Success)
  AuditFieldBuilders (static; BuildAction="{channel}.{kind}", BuildCategory=channel.ToString())
56 new tests in Commons.Tests/Types/Audit/ covering codec round-trip, byte-determinism
(hand-pinned expected JSON string), null/empty sentinel, full projection table,
InboundAuthFailure-Denied precedence, and Action/Category builders. All pass.
2026-06-02 10:42:51 -04:00
Joseph Doherty 4118452e72 docs(auth): ScadaBridge Task 1.7 review — correct stale role-name prose in NavMenu comments (Admin/Design/Deployment/Audit→canonical) 2026-06-02 08:13:38 -04:00
Joseph Doherty b104760b3a feat(auth)!: ScadaBridge canonical roles + SoD collapse (Audit→Administrator, AuditReadOnly→Viewer) + config-DB migration (Task 1.7)
Standardize role string VALUES on the canonical vocabulary
(Administrator/Designer/Deployer/Viewer; Operator/Engineer unused here):
  Admin        -> Administrator
  Design       -> Designer
  Deployment   -> Deployer
  Audit        -> Administrator   (COLLAPSE; accepted privilege escalation)
  AuditReadOnly-> Viewer          (COLLAPSE; keeps audit-read, no export)

SoD: OperationalAuditRoles = { Administrator, Viewer },
     AuditExportRoles      = { Administrator }
so Viewer reads the audit log + nav but cannot bulk-export, while
Administrator does both + holds the full admin surface (the documented,
accepted auditor/admin SoD collapse).

Atomic move across every enforcement site:
- Roles constants; AuthorizationPolicies (RequireClaim values + SoD arrays +
  honest XML-doc); RoleMapper Deployer check.
- ManagementActor.GetRequiredRole switch + the hard-coded site-scope
  admin-bypass (now Roles.Administrator at all 6 sites). Site-scoping logic
  is otherwise unchanged.
- DebugStreamHub Administrator/Deployer gates (Deployer kept case-sensitive).
- CentralUI BrowseService/BindingTester Designer guards; LdapMappingForm
  dropdown now offers canonical values (incl. Viewer).
- Config-DB seed (LdapGroupMappings Id 1-4) + EF migration CanonicalizeRoles:
  Id-keyed UpdateData for seed rows + idempotent raw catch-all UPDATEs for
  operator-added rows. Down is lossy on the collapse (documented in-file).
  No pending model changes.

Tests reworked to the collapsed model across Security/CentralUI/
ManagementService/ConfigurationDatabase/Integration suites, incl. explicit
Viewer-reads-not-exports and former-Audit-now-Administrator-escalation cases.

CHANGELOG: BREAKING security note documenting the canonicalization + SoD
collapse.
2026-06-02 08:00:47 -04:00
Joseph Doherty 6ae605160c chore(auth): ScadaBridge unify dev LDAP base DN to dc=zb,dc=local (Task 1.6)
Replace dc=scadabridge,dc=local with dc=zb,dc=local in all dev/test LDAP
references — app config, docker test-cluster node configs (docker/ and
docker-env2/), GLAuth fixture, dev tooling, Host.Tests fixtures,
IntegrationTests factory, and operational test_infra docs. OU structure
(ou=SCADA-Admins,ou=users,etc.) preserved throughout. Email domains
(@scadabridge.local), hostnames, and container names are untouched.
Historical plan docs (2026-05-24-second-environment.md,
2026-05-31-folder-repo-rename-scadabridge-design.md) excluded as
point-in-time records. No synthetic dc=example,dc=com placeholders touched.
2026-06-02 06:54:14 -04:00
Joseph Doherty c185a567f5 fix(auth): ScadaBridge Task 1.5 review — use JwtTokenService.RoleClaimType constant in CentralUI tests (canonical spelling) 2026-06-02 06:29:16 -04:00
Joseph Doherty a0938f708b feat(auth): ScadaBridge full canonical claims (ZbClaimTypes role/scope) + ZbCookieDefaults, keep cookie name (Task 1.5) 2026-06-02 06:23:15 -04:00
Joseph Doherty afa55981d5 feat(auth)!: ScadaBridge retire SQL Server ApiKey entity + ApprovedApiKeyIds + legacy hashing; EF migration RetireInboundApiKeyStore; re-issue runbook + CHANGELOG (re-arch C5/E) — BREAKING: X-API-Key -> Bearer sbk_, keys re-issued 2026-06-02 05:39:59 -04:00
Joseph Doherty b13d7b3d28 fix(auth): C4 review polish — document backward-compat JSON tolerance, shared BundleJsonOptions, PreviewAsync legacy-bundle test, doc fix (review I-2/I-3/M-1/M-2; I-1 intentionally skipped) 2026-06-02 05:15:50 -04:00
Joseph Doherty 731cfd3bfc feat(auth): ScadaBridge TransportExport excludes inbound API keys (re-arch C4; methods-only, import ignores legacy key sections); keys re-issued per environment 2026-06-02 05:06:40 -04:00
Joseph Doherty d1191fddf9 fix(auth): C3 review — surface seam not-found (no silent success), partial-reconcile-failure guidance, create validation order, concurrent-edit reconciler test 2026-06-02 04:46:32 -04:00
Joseph Doherty 107e524914 feat(auth): ScadaBridge CentralUI pages onto IInboundApiKeyAdmin seam (re-arch C3; string keyId, method-scopes replace ApprovedApiKeyIds, token-once display, approved-keys<->scopes inversion) 2026-06-02 04:36:50 -04:00
Joseph Doherty 8219b8ee18 fix(auth): C2 review — not-found throws (no spurious audit) on update/delete/set-methods, reject empty methods (unusable-key/stealth-disable), richer set-methods response, token advisory to stderr 2026-06-02 04:21:28 -04:00
Joseph Doherty 6518e93424 feat(auth): ScadaBridge ManagementActor + CLI + Commons messages onto IInboundApiKeyAdmin seam (re-arch C2; int->string keyId, +Methods, +SetApiKeyMethods) 2026-06-02 04:11:44 -04:00
Joseph Doherty 7f7ea3f3c9 fix(auth): C1 review polish — guard name at seam, document seam contract (throws/O(n)), explicit cookie test (review #1/#2/#3/#5/#8) 2026-06-02 04:01:43 -04:00
Joseph Doherty 55099b19f6 fix(auth): move AddZbLdapAuth to Host composition root so component-lib AddSecurity() drops IConfiguration param (satisfy OptionsTests arch rule; fix pre-existing ac34dac red); behaviour-preserving 2026-06-02 03:50:16 -04:00
Joseph Doherty 7e25efa790 test(host): supply Central test ApiKeyPepper so StartupValidator preflight passes (fix pre-existing 1fcc4f5 red); lock pepper-required behavior
Commit 1fcc4f5 added a Central-only Require for ScadaBridge:InboundApi:ApiKeyPepper
(>=16 chars) to StartupValidator. That Require fires in Program.cs before WebApplicationFactory
can apply any WithWebHostBuilder config overlays, so it must be satisfied via environment
variables (which ARE in the pre-host AddEnvironmentVariables() pass).

Fix (test-only, no src/ changes):
- CentralDbTestEnvironment: add ScadaBridge__InboundApi__ApiKeyPepper env var (TestPepper
  constant, 23 chars) alongside the existing db connection string; restore on Dispose.
  Fixes HealthCheckTests, MetricsEndpointTests, and HostStartupTests.CentralRole_StartsWithoutError
  which all use CentralDbTestEnvironment.
- CentralActorPathTests.InitializeAsync: set the pepper env var before WebApplicationFactory
  is constructed (the class uses IAsyncLifetime directly, not CentralDbTestEnvironment).
- CentralCompositionRootTests ctor + Dispose: same env-var pattern; those tests already had
  the pepper in AddInMemoryCollection (DI-layer only, too late for pre-host validation).
- CentralAuditWiringTests ctor + Dispose: same env-var pattern for the same reason.
- StartupValidatorTests.ValidCentralConfig(): add pepper so the unit tests that call
  StartupValidator.Validate() directly with a Central config stop failing.
- Add guard tests: Central_MissingApiKeyPepper_FailsValidation,
  Central_ShortApiKeyPepper_FailsValidation, Site_ApiKeyPepper_NotRequired — these lock
  the production behavior introduced by 1fcc4f5.
2026-06-02 03:40:56 -04:00
Joseph Doherty d09def2be0 feat(auth): ScadaBridge re-pin Auth 0.1.3 + add IInboundApiKeyAdmin seam over library admin facade (re-arch C1, additive) 2026-06-02 03:32:25 -04:00
Joseph Doherty 1fcc4f5c2b fix(auth): ScadaBridge inbound auth review fixes — scope-before-DB, pinned 403 body, pepper fail-fast, log category 2026-06-02 02:50:10 -04:00
Joseph Doherty a94558c289 feat(auth): ScadaBridge inbound API — adopt ZB.MOM.WW.Auth.ApiKeys verifier + Bearer + scope=method (re-arch A+B); additive, old path retired later 2026-06-02 02:40:18 -04:00
Joseph Doherty 4db8c373af fix(auth): ScadaBridge 1.2 review fixes — secret-test repoint, checklist, Scope guard, 0.1.1 pin 2026-06-02 01:23:52 -04:00
Joseph Doherty ac34dac479 feat(auth): cut ScadaBridge over to ZB.MOM.WW.Auth.Ldap; nest+rename Ldap config; roles+sitescope via IGroupRoleMapper (Task 1.2/1.4) 2026-06-02 01:04:34 -04:00
Joseph Doherty 9230afa25f feat(auth): add IGroupRoleMapper<string> seam (Task 1.1) 2026-06-02 00:30:42 -04:00
Joseph Doherty aaad38958e build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed
and pins all 4 Auth packages + Audit at 0.1.0. PackageReferences added
during Phase 1/2 adoption.
2026-06-02 00:17:40 -04:00
Joseph Doherty 145d2668e2 fix: wire ValidateOnStart for ScadaBridge HealthMonitoring + Cluster options (fail-fast at startup) 2026-06-01 23:07:46 -04:00
Joseph Doherty 9668a4e84a refactor: ScadaBridge module options registration -> AddValidatedOptions; clarify De Morgan predicates 2026-06-01 22:49:41 -04:00
Joseph Doherty 6dbbc7ad04 refactor: ScadaBridge StartupValidator -> ConfigPreflight (byte-compatible) 2026-06-01 19:04:13 -04:00
Joseph Doherty aac59c9fae refactor: ScadaBridge validators onto OptionsValidatorBase (messages unchanged) 2026-06-01 18:56:04 -04:00
Joseph Doherty 9bca6aae61 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:29 -04:00
Joseph Doherty 7d16f8f275 Merge feat/telemetry-followons: telemetry follow-ons for ScadaBridge
Site-node HTTP/1.1 /metrics listener (NodeOptions.MetricsPort=8084, avoids the
site RemotingPort collision; StartupValidator enforces distinctness). First
application instruments: ScadaBridgeTelemetry meter + deployments.applied,
store_and_forward.queue.depth, inbound_api.requests, site.connection.up.
Config-driven OTLP exporter opt-in (default Prometheus).
2026-06-01 17:17:39 -04:00
Joseph Doherty ccf43312e8 feat(scadabridge): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 17:14:35 -04:00
Joseph Doherty a5f8651b0f feat(scadabridge): track scadabridge.site.connection.up over site-stream lifetime (balanced open/close) 2026-06-01 17:11:39 -04:00
Joseph Doherty 15a626390b fix(scadabridge): queue-depth seed uses Add (no lost concurrent enqueue) + clarify registration/discard comments 2026-06-01 17:07:03 -04:00
Joseph Doherty 782fb73015 feat(scadabridge): emit scadabridge.inbound_api.requests (by method) at inbound API entry 2026-06-01 17:03:10 -04:00
Joseph Doherty 547b685a42 feat(scadabridge): wire scadabridge.store_and_forward.queue.depth gauge to buffered count 2026-06-01 16:58:09 -04:00
Joseph Doherty 877f2e200b feat(scadabridge): emit scadabridge.deployments.applied on deployment success 2026-06-01 16:52:09 -04:00
Joseph Doherty c41cb41c7b fix(scadabridge): default MetricsPort to 8084 (avoid site RemotingPort collision) + validate port distinctness 2026-06-01 16:46:59 -04:00
Joseph Doherty fe25ac3e51 feat(scadabridge): add ScadaBridgeTelemetry meter + 4 instruments; register with OTel 2026-06-01 16:41:52 -04:00
Joseph Doherty bbc9f09268 feat(scadabridge): add HTTP/1.1 metrics listener on site nodes (NodeOptions.MetricsPort=8082) 2026-06-01 16:36:59 -04:00
Joseph Doherty 43f5886024 Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across ScadaBridge
AddZbTelemetry (shared OTel Resource + standard instrumentation + /metrics) wired
into both Central and Site composition roots; kept LoggerConfigurationFactory
(min-level governance) and added the shared TraceContextEnricher for trace<->log
correlation. Behaviour-preserving (no AddZbSerilog; factory retained).
2026-06-01 16:05:49 -04:00
Joseph Doherty f743ffaad2 feat(scadabridge): add shared TraceContextEnricher to log pipeline (trace correlation) 2026-06-01 15:40:42 -04:00
Joseph Doherty b3070c0bda feat(scadabridge): wire AddZbTelemetry + /metrics in both composition roots 2026-06-01 15:36:55 -04:00
Joseph Doherty 20a31835cf build(scadabridge): reference ZB.MOM.WW.Telemetry packages from Gitea feed 2026-06-01 15:30:00 -04:00
Joseph Doherty 59dca0d5fd Merge feat/adopt-zb-health: adopt ZB.MOM.WW.Health shared probes (/healthz, canonical writer, ActorSystem DI bridge) 2026-06-01 14:07:00 -04:00
345 changed files with 18045 additions and 9587 deletions
+99
View File
@@ -0,0 +1,99 @@
# Changelog
All notable changes to ScadaBridge are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed — BREAKING: canonical role names + audit separation-of-duties collapse (Task 1.7)
Role string VALUES are standardized onto the canonical vocabulary
(`Administrator`/`Designer`/`Deployer`/`Viewer`; `Operator`/`Engineer` are unused
by ScadaBridge). The legacy ScadaBridge role names were renamed and two were
**collapsed**:
| Legacy role | Canonical role | Notes |
|-----------------|-----------------|-------|
| `Admin` | `Administrator` | rename |
| `Design` | `Designer` | rename |
| `Deployment` | `Deployer` | rename |
| `Audit` | `Administrator` | **COLLAPSE** |
| `AuditReadOnly` | `Viewer` | **COLLAPSE** |
- **SECURITY — privilege escalation (accepted).** The former `Audit` role
collapses into `Administrator`. This is a real escalation: a former audit-only
user now holds the **entire admin surface** (create/update/delete sites, manage
LDAP group→role mappings and API keys, preview/import transport bundles), not
just audit read+export. This loss of auditor/admin separation-of-duties is a
deliberate, accepted trade-off of the canonicalization.
- **SECURITY — half-SoD preserved.** The former `AuditReadOnly` role collapses
into `Viewer`, which **keeps audit READ** (Audit Log page, Configuration Audit
Log page, audit nav group) but **cannot bulk-export**. The audit policy sets are
now `OperationalAuditRoles = { Administrator, Viewer }` and
`AuditExportRoles = { Administrator }`, so a `Viewer` reads the audit log but the
Export-CSV button / `/api/audit/export` endpoint correctly refuses it.
- **Enforcement.** Every enforcement site moved together: the role-claim values,
the authorization policies (`RequireAdmin`/`RequireDesign`/`RequireDeployment`
policy *names* are unchanged; only the role *values* inside them changed), the
`ManagementActor.GetRequiredRole` switch, the hard-coded site-scope admin-bypass
(`Roles.Administrator` everywhere), the `DebugStreamHub` Administrator/Deployer
gates, and the CentralUI `BrowseService`/`BindingTester` Designer guards.
**Site-scoping logic is otherwise unchanged** — only the admin-bypass *value*
moved from `"Admin"` to `Roles.Administrator`.
- **Config-DB migration `CanonicalizeRoles`.** Updates the four seeded
`LdapGroupMappings` rows (Id 1-4) to the canonical role values and adds raw
idempotent catch-all `UPDATE`s for operator-added rows
(`Admin`/`Audit``Administrator`, `Design``Designer`, `Deployment``Deployer`,
`AuditReadOnly``Viewer`). The Down migration is **lossy** for the collapse: it
best-effort maps `Administrator``Admin` and `Viewer``AuditReadOnly` but cannot
recover the original `Audit`/`Admin` or `Viewer`/`AuditReadOnly` distinction.
- **Operator action.** Any LDAP group→role mappings created with the legacy role
strings are migrated automatically by `CanonicalizeRoles`. New mappings created
via the CentralUI LDAP-mappings form now offer the canonical role values
(including a `Viewer` option for audit-read-only delegation).
### Changed — BREAKING: inbound API authentication
Inbound API authentication has migrated off the SQL Server `X-API-Key` scheme and
onto the shared `ZB.MOM.WW.Auth.ApiKeys` library.
- **Credential format.** The inbound `POST /api/{methodName}` endpoint now
authenticates an `Authorization: Bearer sbk_<keyId>_<secret>` token instead of the
raw `X-API-Key: <key>` header. The secret is verified with a peppered, constant-time
HMAC compare inside the shared library verifier.
- **Storage.** Inbound API keys now live in the shared `ZB.MOM.WW.Auth.ApiKeys` SQLite
store, not the SQL Server configuration database. The deterministic-HMAC `ApiKey`
table is gone.
- **Authorization model.** A key's allowed methods are now its per-key **scopes**
(scope string == method name, ordinal/case-sensitive). The previous
`ApiMethod.ApprovedApiKeyIds` CSV that linked methods to key IDs has been removed.
- **Peppering.** Keys are peppered per environment via
`ScadaBridge:InboundApi:ApiKeyPepper` (≥ 16 characters, **different per environment**,
kept secret). The same configuration key now backs the library verifier's pepper
secret.
> **BREAKING — all existing inbound API keys are INVALIDATED and must be re-issued.**
> Old `X-API-Key` credentials and their stored HMAC hashes are not migrated and are
> not recoverable; the `ApiKeys` table is dropped. Operators must re-issue every
> inbound key as an `sbk_…` token and update every API client. See the runbook:
> [`docs/operations/inbound-api-key-reissue.md`](docs/operations/inbound-api-key-reissue.md).
### Removed
- The SQL Server `ApiKey` entity (`ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey`),
its EF Core mapping, and its `IInboundApiRepository` key methods
(`GetApiKeyByIdAsync`, `GetAllApiKeysAsync`, `GetApiKeyByValueAsync`, `AddApiKeyAsync`,
`UpdateApiKeyAsync`, `DeleteApiKeyAsync`, `GetApprovedKeysForMethodAsync`).
- The `ApiMethod.ApprovedApiKeyIds` property, its EF mapping, and the CSV
parse/serialize helpers.
- The legacy hashing code: `ApiKeyHasher` / `IApiKeyHasher` and the in-repo inbound
`ApiKeyValidator` (superseded by the shared `IApiKeyVerifier`), plus their DI
registrations and tests.
### Migrations
- `RetireInboundApiKeyStore` — drops the `ApiKeys` table and the
`ApiMethods.ApprovedApiKeyIds` column. `Down` recreates both, but **dropped keys are
not recoverable**: rolling the migration back does not restore credentials. Rollback
means reverting the deployment, then re-issuing keys.
+9
View File
@@ -75,8 +75,17 @@
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
</ItemGroup>
</Project>
@@ -22,13 +22,15 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
},
"Security": {
"LdapServer": "scadabridge-ldap",
"LdapPort": 3893,
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "password",
"Ldap": {
"Server": "scadabridge-ldap",
"Port": 3893,
"Transport": "None",
"AllowInsecure": true,
"SearchBase": "dc=zb,dc=local",
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30,
@@ -22,13 +22,15 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
},
"Security": {
"LdapServer": "scadabridge-ldap",
"LdapPort": 3893,
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "password",
"Ldap": {
"Server": "scadabridge-ldap",
"Port": 3893,
"Transport": "None",
"AllowInsecure": true,
"SearchBase": "dc=zb,dc=local",
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30,
@@ -22,13 +22,15 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
},
"Security": {
"LdapServer": "scadabridge-ldap",
"LdapPort": 3893,
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "password",
"Ldap": {
"Server": "scadabridge-ldap",
"Port": 3893,
"Transport": "None",
"AllowInsecure": true,
"SearchBase": "dc=zb,dc=local",
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30,
@@ -22,13 +22,15 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
},
"Security": {
"LdapServer": "scadabridge-ldap",
"LdapPort": 3893,
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "password",
"Ldap": {
"Server": "scadabridge-ldap",
"Port": 3893,
"Transport": "None",
"AllowInsecure": true,
"SearchBase": "dc=zb,dc=local",
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30,
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-a-a",
"SiteId": "site-a",
"RemotingPort": 8082,
"GrpcPort": 8083
"GrpcPort": 8083,
"MetricsPort": 8084
},
"Cluster": {
"SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-a-b",
"SiteId": "site-a",
"RemotingPort": 8082,
"GrpcPort": 8083
"GrpcPort": 8083,
"MetricsPort": 8084
},
"Cluster": {
"SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-b-a",
"SiteId": "site-b",
"RemotingPort": 8082,
"GrpcPort": 8083
"GrpcPort": 8083,
"MetricsPort": 8084
},
"Cluster": {
"SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-b-b",
"SiteId": "site-b",
"RemotingPort": 8082,
"GrpcPort": 8083
"GrpcPort": 8083,
"MetricsPort": 8084
},
"Cluster": {
"SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-c-a",
"SiteId": "site-c",
"RemotingPort": 8082,
"GrpcPort": 8083
"GrpcPort": 8083,
"MetricsPort": 8084
},
"Cluster": {
"SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-c-b",
"SiteId": "site-c",
"RemotingPort": 8082,
"GrpcPort": 8083
"GrpcPort": 8083,
"MetricsPort": 8084
},
"Cluster": {
"SeedNodes": [
+4 -3
View File
@@ -18,9 +18,10 @@
- [ ] EF Core migrations have been applied (SQL script reviewed and executed)
- [ ] `ScadaBridge:Security:JwtSigningKey` is at least 32 characters, randomly generated
- [ ] **Both central nodes use the same JwtSigningKey** (required for JWT failover)
- [ ] `ScadaBridge:Security:LdapServer` points to the production LDAP/AD server
- [ ] `ScadaBridge:Security:LdapUseTls` is `true` (LDAPS required in production)
- [ ] `ScadaBridge:Security:AllowInsecureLdap` is `false`
- [ ] `ScadaBridge:Security:Ldap:Server` points to the production LDAP/AD server
- [ ] `ScadaBridge:Security:Ldap:Transport` is `Ldaps` (LDAPS required in production)
- [ ] `ScadaBridge:Security:Ldap:AllowInsecure` is `false`
- [ ] LDAP service-account password supplied via env var `ScadaBridge__Security__Ldap__ServiceAccountPassword` (renamed from `ScadaBridge__Security__LdapServiceAccountPassword` in the Task 1.4 nested-config cutover)
- [ ] LDAP search base DN is correct for the organization
- [ ] LDAP group-to-role mappings are configured
- [ ] Load balancer is configured in front of central UI (sticky sessions not required)
+175
View File
@@ -0,0 +1,175 @@
# Inbound API Key Re-issue Runbook
**Status:** BREAKING change — action required on every environment that uses the
inbound API (`POST /api/{methodName}`).
**Date:** 2026-06-02
**Migration:** `RetireInboundApiKeyStore`
This runbook covers the migration of inbound API authentication from the legacy SQL
Server `X-API-Key` scheme to the shared `ZB.MOM.WW.Auth.ApiKeys` store. After this
change **all existing inbound API keys are invalidated** and every API client must be
re-issued a new credential.
---
## 1. What changed and why
| | Before | After |
|---|---|---|
| Header | `X-API-Key: <key>` | `Authorization: Bearer sbk_<keyId>_<secret>` |
| Verification | Deterministic HMAC hash, looked up in SQL Server | Peppered, constant-time HMAC compare in the shared `ZB.MOM.WW.Auth.ApiKeys` verifier |
| Storage | SQL Server `ApiKeys` table (config DB) | `ZB.MOM.WW.Auth.ApiKeys` SQLite store |
| Authorization | `ApiMethod.ApprovedApiKeyIds` CSV linking methods to key IDs | Per-key **scopes**, where each scope string is an allowed method name (ordinal, case-sensitive) |
**Why:** the inbound credential path now reuses the shared auth library that the rest
of the `ZB.MOM.WW.*` family uses, with a single, tested, peppered verifier and a
proper one-time-token issuance model. The deterministic SQL Server hash table and its
method-link CSV are retired. The legacy `ApiKeyHasher` / `IApiKeyHasher` and the
in-repo `ApiKeyValidator` are gone — inbound auth runs through `IApiKeyVerifier`.
> The old `X-API-Key` credentials are **not migrated**. There is no automated
> conversion: the stored hashes are not reversible, and the new tokens have a
> different shape (`sbk_<keyId>_<secret>`). Every key must be re-issued.
---
## 2. Required configuration (per environment)
Set these under the ScadaBridge configuration for each environment (appsettings,
environment variables, or your secret store):
| Key | Value | Notes |
|---|---|---|
| `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` | Filesystem path to the SQLite key store | Defaults to `<content-root>/data/inbound-api-keys.sqlite` if unset. Choose a durable, backed-up path on a writable volume. |
| `ScadaBridge:InboundApi:ApiKeyPepper` | A strong, random string, **≥ 16 characters** | **DIFFERENT per environment.** Keep it secret (secret store, not source control). This is the HMAC pepper that binds every stored key to this deployment; it is also the verifier's pepper secret. |
Notes:
- The pepper must be present and at least 16 characters or the host fails fast at
startup (`AddZbApiKeyAuth`).
- Changing the pepper after keys are issued invalidates all keys in that environment
(they would no longer verify). Set it once, per environment, and keep it stable.
- The token prefix is `sbk` and migrations run on startup by default
(`ScadaBridge:InboundApi:ApiKeyStore:RunMigrationsOnStartup = true`); these are
wired by the Host and normally need no operator change.
---
## 3. Database migration step
Apply the EF Core migration `RetireInboundApiKeyStore` to the SQL Server
configuration database. It:
- drops the `ApiKeys` table, and
- drops the `ApprovedApiKeyIds` column from `ApiMethods`.
If migrations are applied automatically on deploy (the default for the central node),
this happens as part of the rollout. To apply manually:
```bash
dotnet ef database update RetireInboundApiKeyStore \
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
```
> Applying this migration **permanently drops** the old key data. Take a database
> backup first if you need a record of the prior `ApiKeys` rows for audit purposes
> (the hashes are not usable credentials, but the names/enabled flags may be of
> record-keeping value).
The new inbound keys live in the **SQLite** store (section 2), not in SQL Server.
---
## 4. Operator re-issue procedure
Re-issue one key per client. Each key is created with the exact method names it is
allowed to call (its scopes).
### Option A — Admin UI
1. Navigate to **`/admin/api-keys`** in the central UI.
2. **Create** a new key: enter a display name and select the allowed method(s).
3. The one-time token `sbk_<keyId>_<secret>` is shown **exactly once** — copy it now.
It cannot be retrieved later.
4. Distribute the token securely to the owning client.
### Option B — CLI
```bash
scadabridge --url <central-url> security api-key create \
--name <client-name> \
--methods <method1,method2>
```
- `--methods` is a comma-separated list of allowed method names — these become the
key's scopes. A method name must match the registered `ApiMethod.Name` **exactly**
(case-sensitive).
- The command prints `API key created. KeyId: <id>` and then the one-time token on
stdout (the "save this now — it will not be shown again" advisory goes to stderr, so
piping stdout captures only the token).
Capture the `sbk_…` token at issue time; it is the only moment the secret is available.
To later change which methods a key may call:
```bash
scadabridge --url <central-url> security api-key set-methods --key-id <id> --methods <m1,m2>
```
---
## 5. Client change
Each API client must replace its header:
- **Remove:** `X-API-Key: <old-key>`
- **Add:** `Authorization: Bearer sbk_<keyId>_<secret>`
Example:
```http
POST /api/CreateOrder HTTP/1.1
Host: scadabridge.example.com
Authorization: Bearer sbk_7f3a...._9c1e....
Content-Type: application/json
```
The token is the full `sbk_<keyId>_<secret>` string exactly as issued — do not split
or transform it.
---
## 6. Verification
1. **Authn (valid key):** call an allowed method with the new Bearer token → `200`
(or the method's normal result).
2. **Authn (no/old credential):** call with no `Authorization` header, or with the old
`X-API-Key` header only → `401` with `{"error":"Invalid or missing API key"}`.
3. **Authz (out of scope):** call a method the key is **not** scoped for → `403` with
`{"error":"API key not approved for this method"}`. A non-existent method name
returns the identical `403` body (enumeration-safe — by design).
4. **Audit:** a successful call records the verified key's display name as the audit
actor; an auth failure records `Actor=null`. Confirm via the audit log.
5. Confirm no client is still sending `X-API-Key` (those requests now fail `401`).
---
## 7. Rollback
The migration `Down` recreates the `ApiKeys` table and the `ApprovedApiKeyIds` column,
**but the dropped key rows are not restored**`Down` only rebuilds empty structures.
Rolling the migration back does **not** recover any credential.
Therefore "rollback" means **reverting the deployment** to the prior build (which still
speaks `X-API-Key`), not reverting the keys:
1. Redeploy the previous ScadaBridge build.
2. If you took a SQL Server backup before section 3, restore the `ApiKeys` table from
it so the old keys verify again.
3. Without that backup, the old keys are gone and must be re-created under the legacy
scheme as well.
Because rollback is costly and lossy, prefer rolling **forward**: complete the re-issue
in section 4 and fix any straggler clients rather than reverting.
in section 4 and fix any straggler clients rather than reverting.
+9 -7
View File
@@ -246,13 +246,15 @@ These are clones of `docker/central-node-a/appsettings.Central.json` and `docker
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
},
"Security": {
"LdapServer": "scadabridge-ldap",
"LdapPort": 3893,
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "password",
"Ldap": {
"Server": "scadabridge-ldap",
"Port": 3893,
"Transport": "None",
"AllowInsecure": true,
"SearchBase": "dc=scadabridge,dc=local",
"ServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30,
+1 -1
View File
@@ -67,7 +67,7 @@ For use in `appsettings.Development.json`:
"Ldap": {
"Server": "localhost",
"Port": 3893,
"BaseDN": "dc=scadabridge,dc=local",
"BaseDN": "dc=zb,dc=local",
"UseSsl": false
},
"OpcUa": {
+12 -12
View File
@@ -12,7 +12,7 @@ The test LDAP server uses [GLAuth](https://glauth.github.io/), a lightweight LDA
## Base DN
```
dc=scadabridge,dc=local
dc=zb,dc=local
```
## Test Users
@@ -41,20 +41,20 @@ All users have the password `password`.
Users bind with their full DN, which includes the primary group as an OU:
```
cn=<username>,ou=<PrimaryGroupName>,ou=users,dc=scadabridge,dc=local
cn=<username>,ou=<PrimaryGroupName>,ou=users,dc=zb,dc=local
```
For example: `cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local`
For example: `cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local`
The full DNs for all test users:
| Username | Full DN |
|----------|---------|
| `admin` | `cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local` |
| `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=scadabridge,dc=local` |
| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=scadabridge,dc=local` |
| `site-deployer` | `cn=site-deployer,ou=SCADA-Deploy-SiteA,ou=users,dc=scadabridge,dc=local` |
| `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local` |
| `admin` | `cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local` |
| `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=zb,dc=local` |
| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=zb,dc=local` |
| `site-deployer` | `cn=site-deployer,ou=SCADA-Deploy-SiteA,ou=users,dc=zb,dc=local` |
| `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=zb,dc=local` |
## Verification
@@ -68,9 +68,9 @@ docker ps --filter name=scadabridge-ldap
```bash
ldapsearch -H ldap://localhost:3893 \
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local" \
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local" \
-w password \
-b "dc=scadabridge,dc=local" \
-b "dc=zb,dc=local" \
"(objectClass=*)"
```
@@ -78,9 +78,9 @@ ldapsearch -H ldap://localhost:3893 \
```bash
ldapsearch -H ldap://localhost:3893 \
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local" \
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local" \
-w password \
-b "dc=scadabridge,dc=local" \
-b "dc=zb,dc=local" \
"(cn=multi-role)"
```
+1 -1
View File
@@ -7,7 +7,7 @@
[backend]
datastore = "config"
baseDN = "dc=scadabridge,dc=local"
baseDN = "dc=zb,dc=local"
# ── Groups ──────────────────────────────────────────────────────────
+3 -3
View File
@@ -9,10 +9,10 @@ from ldap3 import Server, Connection, NONE, SUBTREE, SIMPLE
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 3893
DEFAULT_BASE_DN = "dc=scadabridge,dc=local"
DEFAULT_BASE_DN = "dc=zb,dc=local"
# GLAuth places users under ou=<PrimaryGroupName>,ou=users,dc=...
# The admin user (primarygroup SCADA-Admins) needs search capabilities in config.
DEFAULT_BIND_DN = "cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local"
DEFAULT_BIND_DN = "cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local"
DEFAULT_BIND_PASSWORD = "password"
@@ -48,7 +48,7 @@ def cmd_check(args):
def cmd_bind(args):
"""Test user authentication via bind.
GLAuth DN format: cn=<user>,ou=<PrimaryGroup>,ou=users,dc=scadabridge,dc=local
GLAuth DN format: cn=<user>,ou=<PrimaryGroup>,ou=users,dc=zb,dc=local
Since we don't know the user's primary group upfront, we search for the user first
to discover the full DN, then rebind with that DN.
"""
+7
View File
@@ -18,6 +18,13 @@
<package pattern="ZB.MOM.WW.MxGateway.*" />
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
<package pattern="ZB.MOM.WW.Telemetry" />
<package pattern="ZB.MOM.WW.Telemetry.*" />
<package pattern="ZB.MOM.WW.Configuration" />
<package pattern="ZB.MOM.WW.Auth" />
<package pattern="ZB.MOM.WW.Auth.*" />
<package pattern="ZB.MOM.WW.Audit" />
<package pattern="ZB.MOM.WW.Theme" />
</packageSource>
</packageSourceMapping>
<!--
@@ -1,10 +1,11 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -13,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// Central-side singleton (per Bundle E wiring) that ingests batches of
/// <see cref="AuditEvent"/> rows pushed from sites via the
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
/// <see cref="AuditEvent.IngestedAtUtc"/> and inserted idempotently via
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
/// silently swallowed (first-write-wins per Bundle A's hardening).
/// </summary>
@@ -116,10 +117,10 @@ public class AuditLogIngestActor : ReceiveActor
// Resolve the repository for the whole batch — one DbContext per
// message, mirroring NotificationOutboxActor. The injected-repository
// mode (Bundle D tests) skips the scope entirely.
// Bundle C (M5-T6): the IAuditPayloadFilter is also resolved from the
// Bundle C (M5-T6): the IAuditRedactor is also resolved from the
// per-message scope when one is available so the row is truncated +
// redacted before InsertIfNotExistsAsync. The single-repository test
// ctor has no service provider — it falls through with no filter,
// ctor has no service provider — it falls through with no redactor,
// which preserves the small-payload assumptions baked into the
// existing D2 fixtures.
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
@@ -127,19 +128,19 @@ public class AuditLogIngestActor : ReceiveActor
// without blocking on sync Dispose() of pending connection cleanup.
if (_injectedRepository is not null)
{
await IngestWithRepositoryAsync(_injectedRepository, filter: null, failureCounter: null, cmd, nowUtc, accepted)
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
else
{
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted)
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
.ConfigureAwait(false);
}
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
private async Task IngestWithRepositoryAsync(
IAuditLogRepository repository,
IAuditPayloadFilter? filter,
IAuditRedactor? redactor,
ICentralAuditWriteFailureCounter? failureCounter,
IngestAuditEventsCommand cmd,
DateTime nowUtc,
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
// repository hardening already swallows duplicate-key races,
// so the same id arriving twice (site retry, reconciliation)
// is a silent no-op.
// Filter BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. Filter
// Redact BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. The redactor
// is contract-bound to never throw. AuditLog-008: a null
// filter (test composition root, no IAuditPayloadFilter
// redactor (test composition root, no IAuditRedactor
// registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filtered = safeFilter.Apply(evt);
var ingested = filtered with { IngestedAtUtc = nowUtc };
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on
// the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filtered = safeRedactor.Apply(evt);
var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId);
}
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Bundle C (M5-T6): resolve the filter for the whole batch from
// the scope; null = pass-through for test composition roots that
// skip the filter registration. The filter is contract-bound to
// Bundle C (M5-T6): resolve the redactor for the whole batch from
// the scope; null = SafeDefault for test composition roots that
// skip the redactor registration. The redactor is contract-bound to
// never throw, so we can apply it inside the per-entry try
// without risking an unbounded blast radius.
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): same best-effort central health counter as
// the OnIngestAsync path — null on test composition roots that
// skip the registration.
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
// matching timestamps (debugging convenience, not a
// correctness invariant).
var ingestedAt = DateTime.UtcNow;
// Filter the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are filterable; SiteCalls
// Redact the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are redactable; SiteCalls
// carries operational state only (status, retry count) and
// is left untouched. AuditLog-008: null filter falls back
// is left untouched. AuditLog-008: null redactor falls back
// to SafeDefault so header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
var filteredAudit = safeFilter.Apply(entry.Audit);
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
// C3 transitional shim: IngestedAtUtc is a DetailsJson field
// on the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filteredAudit = safeRedactor.Apply(entry.Audit);
var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
await auditRepo.InsertIfNotExistsAsync(auditStamped)
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M6 Bundle E (T9) — bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL
/// parameter redactor stage throws and the filter has to over-redact the
/// offending field) into <see cref="AuditCentralHealthSnapshot"/> so the
/// failure surfaces on the central health surface as
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// a header / body / SQL parameter redactor stage throws and the redactor has
/// to over-redact the offending field) into <see cref="AuditCentralHealthSnapshot"/>
/// so the failure surfaces on the central health surface as
/// <c>AuditCentralHealthSnapshot.AuditRedactionFailure</c>.
/// </summary>
/// <remarks>
@@ -1,9 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
private readonly IServiceProvider _services;
private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter;
private readonly IAuditRedactor _redactor;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
/// </summary>
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
/// <param name="redactor">Optional canonical redactor for truncation and redaction; defaults to the always-safe default.</param>
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
public CentralAuditWriter(
IServiceProvider services,
ILogger<CentralAuditWriter> logger,
IAuditPayloadFilter? filter = null,
IAuditRedactor? redactor = null,
ICentralAuditWriteFailureCounter? failureCounter = null,
INodeIdentityProvider? nodeIdentity = null)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead.
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
// SafeDefaultAuditRedactor applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the
// real filter still scrubs Authorization / X-Api-Key / Cookie /
// real redactor still scrubs Authorization / X-Api-Key / Cookie /
// Set-Cookie before persistence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity;
}
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try
{
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws". AuditLog-008: _filter is now
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
// redactor contract is "never throws". AuditLog-008: _redactor is
// now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the
// real filter.
var filtered = _filter.Apply(evt);
// real redactor.
var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins
// (supports any future direct-write callsite that already has its
@@ -124,7 +126,9 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
await using var scope = _services.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the
// canonical record, so stamp it via the projection helper.
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
}
catch (Exception ex)
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
// misbehaving custom counter does, swallowing here keeps the
// best-effort contract intact.
}
// Log the input event's identifying fields. These three (EventId,
// Kind, Status) are immutable across the filter+stamp chain — the
// `with` clones above touch only SourceNode and IngestedAtUtc — so
// referencing `evt` here is intentional and equivalent to the
// stamped record for diagnostics. If you add a field here that the
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
// post-stamp record name instead, not `evt`.
// Log the input event's identifying fields. EventId + Action are
// immutable across the redact+stamp chain — the `with` clones above
// touch only SourceNode and DetailsJson — so referencing `evt` here
// is intentional and equivalent to the stamped record for
// diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
// canonical Outcome carries the coarse status (fine-grained Status
// lives in DetailsJson).
_logger.LogWarning(
ex,
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
evt.EventId, evt.Kind, evt.Status);
"CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
evt.EventId, evt.Action, evt.Outcome);
}
}
}
@@ -2,8 +2,8 @@ using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -258,7 +258,9 @@ public class SiteAuditReconciliationActor : ReceiveActor
// concurrent push, or a retry of this very pull) collapse to
// a no-op courtesy of M2 Bundle A's race-fix on
// InsertIfNotExistsAsync.
var ingested = evt with { IngestedAtUtc = nowUtc };
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
// stamp it via the projection helper.
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
_failedInsertAttempts.Remove(evt.EventId);
advanceForThisRow = true;
@@ -299,9 +301,11 @@ public class SiteAuditReconciliationActor : ReceiveActor
}
}
if (advanceForThisRow && evt.OccurredAtUtc > maxOccurred)
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
if (advanceForThisRow && occurredUtc > maxOccurred)
{
maxOccurred = evt.OccurredAtUtc;
maxOccurred = occurredUtc;
}
}
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
/// drop in-flight investigations, too long would defeat the partition-switch
/// purge's purpose.
/// </summary>
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOptions>
{
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
public const int MinRetentionDays = 30;
@@ -28,43 +28,29 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
public const int MaxInboundMaxBytes = 16_777_216;
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
protected override void Validate(ValidationBuilder builder, AuditLogOptions options)
{
ArgumentNullException.ThrowIfNull(options);
builder.RequireThat(options.DefaultCapBytes > 0,
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
"must be > 0; it drives payload-summary truncation in audit writers.");
var failures = new List<string>();
builder.RequireThat(options.ErrorCapBytes >= options.DefaultCapBytes,
$"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.DefaultCapBytes <= 0)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
"must be > 0; it drives payload-summary truncation in audit writers.");
}
// Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
// guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
builder.RequireThat(
!(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
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.");
}
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
// Valid when InboundMaxBytes is within [Min, Max] inclusive. The De Morgan'd
// guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
builder.RequireThat(
!(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes),
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
}
@@ -0,0 +1,320 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Pure, stateless redaction + truncation primitives used by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// (which operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> + its <c>DetailsJson</c>).
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) so the
/// byte-exact redaction logic lives in ONE place.
/// </summary>
/// <remarks>
/// <para>
/// Each stage method is a pure function of its inputs (no instance state). The
/// only side effects are diagnostics-only: a warning log line and an
/// <paramref name="onFailure"/> callback invocation when a redactor faults, so
/// the caller can bump its redaction-failure health counter. The callbacks are
/// passed in (rather than the counter interface) to keep this helper free of
/// any DI / health-metric coupling.
/// </para>
/// <para>
/// The regex CACHE and per-call options resolution live in
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.AuditRegexCache"/> /
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// — they carry per-instance state (lazy compile, 100 ms compile budget,
/// sentinel entries). This helper only holds the stateless stages that
/// operate once the compiled regex set / redact list / cap has already been
/// resolved.
/// </para>
/// </remarks>
internal static class AuditRedactionPrimitives
{
/// <summary>Marker replacing redacted header values, body matches, and SQL parameter values.</summary>
public const string RedactedMarker = "<redacted>";
/// <summary>Over-redaction marker emitted when a redactor stage itself faults.</summary>
public const string RedactorErrorMarker = "<redacted: redactor error>";
/// <summary>
/// Marker used by the outer never-throws safety net when the entire redaction
/// pipeline fails catastrophically — all potentially-sensitive string fields are
/// set to this value so no raw payload leaks on an unexpected fault.
/// Deliberately equal to <see cref="RedactorErrorMarker"/>: both represent a
/// defensive scrub-everything fallback.
/// </summary>
public const string OverRedactedEventMarker = RedactorErrorMarker;
/// <summary>
/// JSON serializer options used to re-emit redacted summaries. The
/// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
/// (which contains <c>&lt;</c> / <c>&gt;</c>) survives unescaped — matching
/// the legacy filter's output byte-for-byte.
/// </summary>
public static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// Parse <paramref name="json"/> as the documented
/// <c>{"headers": {...}, "body": ...}</c> shape and replace values whose
/// header NAME (case-insensitive) is in <paramref name="redactList"/> with
/// <see cref="RedactedMarker"/>. Re-serialises and returns the result.
/// No-op pass-through for inputs that are not JSON-object-shaped or do not
/// carry a top-level <c>headers</c> object. On any unexpected fault the
/// field is over-redacted with <see cref="RedactorErrorMarker"/> and
/// <paramref name="onFailure"/> is invoked.
/// </summary>
public static string? RedactHeaders(
string? json,
IList<string> redactList,
ILogger logger,
Action onFailure)
{
if (json is null)
{
return null;
}
// Cheap structural pre-check: only attempt JSON parsing when the input
// actually looks like a JSON object. Saves the JsonDocument allocation
// on the (very common) non-JSON ErrorDetail / Extra fields.
var trimmed = json.AsSpan().TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{')
{
return json;
}
try
{
JsonNode? root;
try
{
root = JsonNode.Parse(json);
}
catch (JsonException)
{
// Not parseable JSON — leave the field alone (no error, no
// redaction). Emitters not yet using the documented shape get
// a transparent pass.
return json;
}
if (root is not JsonObject obj || obj["headers"] is not JsonObject headers)
{
// No "headers" object at the top level — nothing to redact.
return json;
}
// Build a case-insensitive lookup of the redact list so we can do
// one O(1) check per header name without an inner Any() loop.
var redactSet = new HashSet<string>(redactList, StringComparer.OrdinalIgnoreCase);
// Take a snapshot of names first — we cannot mutate while
// enumerating the JsonObject.
var names = new List<string>(headers.Count);
foreach (var kvp in headers)
{
names.Add(kvp.Key);
}
foreach (var name in names)
{
if (redactSet.Contains(name))
{
headers[name] = JsonValue.Create(RedactedMarker);
}
}
return obj.ToJsonString(RedactedSummaryJsonOptions);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Header redactor faulted; over-redacting field with '{Marker}'",
RedactorErrorMarker);
try { onFailure(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
}
/// <summary>
/// Apply each compiled body-redactor regex to <paramref name="value"/> in
/// turn, replacing every match with <see cref="RedactedMarker"/>. If any
/// single regex match throws (most commonly
/// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted
/// with <see cref="RedactorErrorMarker"/> and <paramref name="onFailure"/>
/// is invoked — the user-facing action is never aborted.
/// </summary>
public static string? RedactBody(
string? value,
IReadOnlyList<Regex> regexes,
ILogger logger,
Action onFailure)
{
if (value is null)
{
return null;
}
var current = value;
foreach (var rx in regexes)
{
try
{
current = rx.Replace(current, RedactedMarker);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Body redactor '{Pattern}' faulted; over-redacting field with '{Marker}'",
rx.ToString(), RedactorErrorMarker);
try { onFailure(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
}
return current;
}
/// <summary>
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
/// shape; for each parameter whose NAME matches
/// <paramref name="paramNameRegex"/>, replace its value with
/// <see cref="RedactedMarker"/>. Re-serialise. No-op pass-through when the
/// input is not parseable JSON, is not a JSON object, or does not carry a
/// top-level <c>"parameters"</c> object. On any unexpected fault the field
/// is over-redacted with <see cref="RedactorErrorMarker"/> and
/// <paramref name="onFailure"/> is invoked.
/// </summary>
public static string? RedactSqlParameters(
string? json,
Regex paramNameRegex,
ILogger logger,
Action onFailure)
{
if (json is null)
{
return null;
}
var trimmed = json.AsSpan().TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{')
{
return json;
}
try
{
JsonNode? root;
try
{
root = JsonNode.Parse(json);
}
catch (JsonException)
{
return json;
}
if (root is not JsonObject obj || obj["parameters"] is not JsonObject parameters)
{
return json;
}
// Snapshot the names — mutating during enumeration is unsupported.
var names = new List<string>(parameters.Count);
foreach (var kvp in parameters)
{
names.Add(kvp.Key);
}
var anyChanged = false;
foreach (var name in names)
{
bool matched;
try
{
matched = paramNameRegex.IsMatch(name);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
RedactorErrorMarker);
try { onFailure(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
if (matched)
{
parameters[name] = JsonValue.Create(RedactedMarker);
anyChanged = true;
}
}
// Avoid re-serialising (which would normalise whitespace / order)
// when no parameter matched — keeps the on-disk row byte-identical
// to the emitter's output on the no-match path.
return anyChanged ? obj.ToJsonString(RedactedSummaryJsonOptions) : json;
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
RedactorErrorMarker);
try { onFailure(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
}
/// <summary>
/// Truncate <paramref name="value"/> to <paramref name="cap"/> UTF-8 bytes,
/// setting <paramref name="truncated"/> to <c>true</c> when the value was
/// shortened. Null passes through as null.
/// </summary>
public static string? TruncateField(string? value, int cap, ref bool truncated)
{
if (value is null)
{
return null;
}
var result = TruncateUtf8(value, cap);
// Char-count comparison is sufficient: TruncateUtf8 only ever shortens the
// string, so result.Length < value.Length iff bytes were removed.
if (result.Length != value.Length)
{
truncated = true;
}
return result;
}
/// <summary>
/// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
/// the cap position until the byte is NOT a continuation byte
/// (<c>byte &amp; 0xC0 == 0x80</c>), and decodes the resulting prefix —
/// guaranteeing the returned string never splits a multi-byte sequence.
/// </summary>
public static string TruncateUtf8(string value, int capBytes)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var bytes = Encoding.UTF8.GetBytes(value);
if (bytes.Length <= capBytes)
{
return value;
}
var boundary = capBytes;
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
{
boundary--;
}
return Encoding.UTF8.GetString(bytes, 0, boundary);
}
}
@@ -0,0 +1,95 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Per-instance compiled-regex cache for audit body / SQL-parameter redactors
/// used by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>.
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) to
/// centralize compile rules (50 ms per-match timeout, 100 ms compile budget,
/// invalid-pattern sentinel).
/// </summary>
/// <remarks>
/// <para>
/// Lazy population keyed by pattern string: each pattern is compiled on first
/// use and cached forever. A failed compile (or a compile slower than 100 ms)
/// caches a sentinel so the failing compile is not retried on every event. The
/// failure is logged once on first encounter. <see cref="ConcurrentDictionary{TKey,TValue}"/>
/// is the right primitive because the owning redactor is a DI singleton on the
/// audit hot-path.
/// </para>
/// </remarks>
internal sealed class AuditRegexCache
{
/// <summary>
/// Per-match regex timeout. Catastrophic-backtracking patterns trip a
/// <see cref="RegexMatchTimeoutException"/> when a single match takes longer
/// than this; the caller then over-redacts the offending field. 50 ms is
/// generous for normal patterns yet short enough that the audit hot-path is
/// not held up by a misconfigured regex.
/// </summary>
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
private readonly ConcurrentDictionary<string, CompiledRegex> _cache = new();
private readonly ILogger _logger;
public AuditRegexCache(ILogger logger) => _logger = logger;
/// <summary>
/// Resolve a compiled regex from the cache, compiling it on first use.
/// Returns <c>false</c> for patterns that are invalid OR whose compile took
/// longer than 100 ms (the spec calls catastrophic-backtracking guesses at
/// compile time "invalid"); the failure is logged once and the sentinel
/// cache entry prevents repeat compile attempts.
/// </summary>
public bool TryGet(string pattern, out Regex? regex)
{
var entry = _cache.GetOrAdd(pattern, Compile);
regex = entry.Regex;
return entry.Regex != null;
}
private CompiledRegex Compile(string pattern)
{
try
{
var swStart = System.Diagnostics.Stopwatch.GetTimestamp();
var rx = new Regex(pattern, RegexOptions.Compiled, RegexMatchTimeout);
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - swStart)
* 1000d / System.Diagnostics.Stopwatch.Frequency;
if (elapsedMs > 100)
{
_logger.LogWarning(
"Body redactor pattern compiled in {Elapsed}ms (> 100ms cap); rejecting '{Pattern}'",
elapsedMs, pattern);
return CompiledRegex.Invalid;
}
return new CompiledRegex(rx);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Body redactor pattern '{Pattern}' failed to compile; skipping",
pattern);
return CompiledRegex.Invalid;
}
}
/// <summary>
/// Cache entry for a body-redactor pattern. Carries the working
/// <see cref="Regex"/> on the success path, or the <see cref="Invalid"/>
/// sentinel for patterns that failed to compile (or exceeded the 100 ms
/// compile budget).
/// </summary>
private readonly struct CompiledRegex
{
public static readonly CompiledRegex Invalid = new(null);
public Regex? Regex { get; }
public CompiledRegex(Regex? regex) => Regex = regex;
}
}
@@ -1,587 +0,0 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Default <see cref="IAuditPayloadFilter"/>. Bundle A established the
/// truncation backbone; Bundle B chains HTTP header redaction (M5-T3) BEFORE
/// truncation so redactors operate on the full payload and the cap then trims
/// the redacted result.
/// </summary>
/// <remarks>
/// <para>
/// Uses <see cref="IOptionsMonitor{TOptions}"/> (not <see cref="IOptions{TOptions}"/>)
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
/// singleton. <see cref="Apply"/> reads <see cref="IOptionsMonitor{T}.CurrentValue"/>
/// on every call, and the regex cache is keyed by pattern string — patterns
/// added via a live config change compile on first use of the next event;
/// patterns removed simply stop being looked up. No <c>OnChange</c> subscription
/// or explicit cache invalidation is required (the
/// <c>AuditLogOptionsBindingTests</c> fixture in <c>ZB.MOM.WW.ScadaBridge.AuditLog.Tests</c>
/// pins this behaviour).
/// </para>
/// <para>
/// "Error row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>) — every other status, including the
/// non-terminal <c>Attempted</c>, the parked/discarded terminals, and the
/// short-circuit <c>Skipped</c>, receives the larger error cap so a verbose
/// error body survives.
/// </para>
/// <para>
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
/// increments the <c>AuditRedactionFailure</c> health metric via the injected
/// <see cref="IAuditRedactionFailureCounter"/>. Each redactor stage runs in
/// its own try/catch — a failure in (say) the header redactor still lets the
/// SQL parameter redactor and the truncator run on the remaining fields.
/// </para>
/// <para>
/// Stage order (each runs on every applicable field):
/// header redaction → body regex redaction → truncation. The SQL-parameter
/// stage piggybacks on the body-redactor path; both run BEFORE truncation so
/// the cap trims the redacted result, never bytes the redactor intended to
/// hide.
/// </para>
/// </remarks>
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
private const string RedactedMarker = "<redacted>";
private const string RedactorErrorMarker = "<redacted: redactor error>";
/// <summary>
/// Per-match regex timeout. Catastrophic-backtracking patterns trip a
/// <see cref="RegexMatchTimeoutException"/> when a single match takes
/// longer than this; the offending field is then over-redacted with
/// <see cref="RedactorErrorMarker"/> and the failure counter is bumped.
/// 50 ms is generous for normal patterns yet short enough that the
/// audit hot-path isn't held up by a misconfigured regex.
/// </summary>
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
/// <summary>
/// JSON serializer options used to re-emit redacted summaries. The
/// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
/// (which contains <c>&lt;</c> / <c>&gt;</c>) survives unescaped — the
/// header-redaction tests grep for the literal marker, and the downstream
/// UI / log readers would rather see <c>&lt;redacted&gt;</c> than
/// <c><redacted></c>. The summaries are persisted to the audit
/// table and rendered in trusted-internal contexts only, so the relaxed
/// HTML-escaping rules do not introduce an XSS surface.
/// </summary>
private static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly IOptionsMonitor<AuditLogOptions> _options;
private readonly ILogger<DefaultAuditPayloadFilter> _logger;
private readonly IAuditRedactionFailureCounter _failureCounter;
/// <summary>
/// Compiled-regex cache keyed by pattern string. Lazy population: each
/// pattern is compiled on first use and cached forever (the entry's
/// <see cref="CompiledRegex"/> carries either the working <see cref="Regex"/>
/// or a sentinel marking the pattern as invalid so we don't retry the
/// failing compile on every call). ConcurrentDictionary is the right
/// thread-safety primitive here because the filter is a DI singleton
/// shared across the audit hot-path.
/// </summary>
private readonly ConcurrentDictionary<string, CompiledRegex> _regexCache = new();
/// <summary>
/// Primary constructor used by DI — pulls the optional redaction-failure
/// counter from the container; a NoOp default is registered in
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
/// </summary>
/// <param name="options">Live-reloadable audit log options.</param>
/// <param name="logger">Logger for redaction diagnostics.</param>
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
public DefaultAuditPayloadFilter(
IOptionsMonitor<AuditLogOptions> options,
ILogger<DefaultAuditPayloadFilter> logger,
IAuditRedactionFailureCounter? failureCounter = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
}
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
try
{
var opts = _options.CurrentValue;
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
// replay exactly what the caller sent and what we returned. Other channels
// keep the global 8 KiB / 64 KiB policy.
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
var cap = rawEvent.Channel == AuditChannel.ApiInbound
? opts.InboundMaxBytes
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
// --- Header-redaction stage (runs BEFORE truncation) ----------
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
var response = RedactHeaders(rawEvent.ResponseSummary, opts.HeaderRedactList);
var errorDetail = rawEvent.ErrorDetail;
var extra = rawEvent.Extra;
// --- Body-regex stage (also runs BEFORE truncation) -----------
// Resolves the active regex set per event so per-target overrides
// bound to AuditEvent.Target are picked up; effectively a no-op
// when neither GlobalBodyRedactors nor the per-target additions
// are configured.
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
if (bodyRegexes.Count > 0)
{
request = RedactBody(request, bodyRegexes);
response = RedactBody(response, bodyRegexes);
errorDetail = RedactBody(errorDetail, bodyRegexes);
extra = RedactBody(extra, bodyRegexes);
}
// --- SQL parameter redaction stage (DbOutbound only) ----------
// Parses the M4 AuditingDbCommand RequestSummary shape
// {"sql":"...","parameters":{...}} and redacts parameter VALUES
// whose NAME matches the per-connection regex. Opt-in: no
// PerTargetOverrides[connectionName].RedactSqlParamsMatching =>
// no-op. Channel-guarded so the same regex can never accidentally
// touch an ApiOutbound row.
if (rawEvent.Channel == AuditChannel.DbOutbound
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
{
request = RedactSqlParameters(request, sqlParamRegex!);
}
// --- Truncation stage -----------------------------------------
var truncated = false;
request = TruncateField(request, cap, ref truncated);
response = TruncateField(response, cap, ref truncated);
errorDetail = TruncateField(errorDetail, cap, ref truncated);
extra = TruncateField(extra, cap, ref truncated);
return rawEvent with
{
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = rawEvent.PayloadTruncated || truncated,
};
}
catch (Exception ex)
{
// Audit is best-effort: over-redact rather than fail the caller.
// The per-stage try/catches above already handle redactor faults
// and increment the counter; this catch covers any unexpected
// surprise in the surrounding orchestration code.
_logger.LogWarning(
ex,
"Payload filter failed; returning raw event with PayloadTruncated=true");
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
return rawEvent with { PayloadTruncated = true };
}
}
/// <summary>
/// Parse <paramref name="json"/> as the documented
/// <c>{"headers": {...}, "body": ...}</c> shape and replace values whose
/// header NAME (case-insensitive) is in <paramref name="redactList"/> with
/// <see cref="RedactedMarker"/>. Re-serialises and returns the result.
/// </summary>
/// <remarks>
/// No-op pass-through for inputs that aren't JSON-shaped — emitters that
/// have not yet adopted the convention (the M2 site emitters today, which
/// leave RequestSummary null on outbound API calls) get a transparent
/// pass. If the redactor itself throws, we over-redact the whole field
/// with <see cref="RedactorErrorMarker"/> and bump the failure counter.
/// </remarks>
private string? RedactHeaders(string? json, IList<string> redactList)
{
if (json is null)
{
return null;
}
// Cheap structural pre-check: only attempt JSON parsing when the input
// actually looks like a JSON object. Saves the JsonDocument allocation
// on the (very common) non-JSON ErrorDetail / Extra fields.
var trimmed = json.AsSpan().TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{')
{
return json;
}
try
{
JsonNode? root;
try
{
root = JsonNode.Parse(json);
}
catch (JsonException)
{
// Not parseable JSON — leave the field alone (no error, no
// redaction). Emitters not yet using the documented shape get
// a transparent pass; Bundle C will update them.
return json;
}
if (root is not JsonObject obj || obj["headers"] is not JsonObject headers)
{
// No "headers" object at the top level — nothing to redact.
return json;
}
// Build a case-insensitive lookup of the redact list so we can do
// one O(1) check per header name without an inner Any() loop.
var redactSet = new HashSet<string>(redactList, StringComparer.OrdinalIgnoreCase);
// Take a snapshot of names first — we cannot mutate while
// enumerating the JsonObject.
var names = new List<string>(headers.Count);
foreach (var kvp in headers)
{
names.Add(kvp.Key);
}
foreach (var name in names)
{
if (redactSet.Contains(name))
{
headers[name] = JsonValue.Create(RedactedMarker);
}
}
return obj.ToJsonString(RedactedSummaryJsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Header redactor faulted; over-redacting field with '{Marker}'",
RedactorErrorMarker);
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
}
/// <summary>
/// Combine the global and per-target body-redactor lists for a single
/// event, returning the compiled-regex set to apply. Patterns that failed
/// compilation are silently skipped — the compile-time failure was logged
/// once on first encounter; we never let one bad pattern starve the rest.
/// </summary>
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
{
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
var perTargetAdditions = (target != null
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
&& over.AdditionalBodyRedactors is { Count: > 0 })
? over.AdditionalBodyRedactors
: null;
if (!hasGlobal && perTargetAdditions == null)
{
return Array.Empty<Regex>();
}
var result = new List<Regex>();
if (hasGlobal)
{
foreach (var pattern in opts.GlobalBodyRedactors)
{
if (TryGetCompiledRegex(pattern, out var rx))
{
result.Add(rx!);
}
}
}
if (perTargetAdditions != null)
{
foreach (var pattern in perTargetAdditions)
{
if (TryGetCompiledRegex(pattern, out var rx))
{
result.Add(rx!);
}
}
}
return result;
}
/// <summary>
/// Resolve a compiled regex from the cache, compiling it on first use.
/// Returns <c>false</c> for patterns that are invalid OR whose compile
/// took longer than 100 ms (the spec calls catastrophic-backtracking
/// guesses at compile time "invalid"); the failure is logged once and
/// the sentinel cache entry prevents repeat compile attempts.
/// </summary>
private bool TryGetCompiledRegex(string pattern, out Regex? regex)
{
var entry = _regexCache.GetOrAdd(pattern, CompileRegex);
regex = entry.Regex;
return entry.Regex != null;
}
private CompiledRegex CompileRegex(string pattern)
{
try
{
var swStart = System.Diagnostics.Stopwatch.GetTimestamp();
var rx = new Regex(pattern, RegexOptions.Compiled, RegexMatchTimeout);
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - swStart)
* 1000d / System.Diagnostics.Stopwatch.Frequency;
if (elapsedMs > 100)
{
_logger.LogWarning(
"Body redactor pattern compiled in {Elapsed}ms (> 100ms cap); rejecting '{Pattern}'",
elapsedMs, pattern);
return CompiledRegex.Invalid;
}
return new CompiledRegex(rx);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Body redactor pattern '{Pattern}' failed to compile; skipping",
pattern);
return CompiledRegex.Invalid;
}
}
/// <summary>
/// Apply each compiled body-redactor regex to <paramref name="value"/> in
/// turn, replacing every match with <see cref="RedactedMarker"/>. If any
/// single regex match throws (most commonly
/// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted
/// with <see cref="RedactorErrorMarker"/> and the failure counter is
/// incremented — the user-facing action is never aborted.
/// </summary>
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
{
if (value is null)
{
return null;
}
var current = value;
foreach (var rx in regexes)
{
try
{
current = rx.Replace(current, RedactedMarker);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Body redactor '{Pattern}' faulted; over-redacting field with '{Marker}'",
rx.ToString(), RedactorErrorMarker);
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
}
return current;
}
/// <summary>
/// Resolve the per-connection SQL parameter redaction regex for the given
/// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
/// connection name optionally followed by <c>.&lt;sql-snippet&gt;</c> for
/// disambiguation; the per-target dictionary is keyed by the connection
/// name alone, so we strip the snippet suffix before lookup. Patterns are
/// compiled with case-insensitive matching to match the documented
/// behaviour.
/// </summary>
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
{
regex = null;
if (string.IsNullOrEmpty(target))
{
return false;
}
var dot = target.IndexOf('.');
var connectionKey = dot < 0 ? target : target[..dot];
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
{
return false;
}
// Force case-insensitivity per the spec — even if the operator wrote
// the pattern without an IgnoreCase flag. The compile cache key folds
// the option to keep the entries unambiguous.
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
if (!TryGetCompiledRegex(cacheKey, out regex))
{
return false;
}
return true;
}
/// <summary>
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
/// shape; for each parameter whose NAME matches
/// <paramref name="paramNameRegex"/>, replace its value with
/// <see cref="RedactedMarker"/>. Re-serialise.
/// </summary>
/// <remarks>
/// No-op pass-through when the input isn't parseable JSON, isn't a JSON
/// object, or doesn't carry a top-level <c>"parameters"</c> object. On
/// any unexpected fault the field is over-redacted with
/// <see cref="RedactorErrorMarker"/> and the failure counter is bumped.
/// </remarks>
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
{
if (json is null)
{
return null;
}
var trimmed = json.AsSpan().TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{')
{
return json;
}
try
{
JsonNode? root;
try
{
root = JsonNode.Parse(json);
}
catch (JsonException)
{
return json;
}
if (root is not JsonObject obj || obj["parameters"] is not JsonObject parameters)
{
return json;
}
// Snapshot the names — mutating during enumeration is unsupported.
var names = new List<string>(parameters.Count);
foreach (var kvp in parameters)
{
names.Add(kvp.Key);
}
var anyChanged = false;
foreach (var name in names)
{
bool matched;
try
{
matched = paramNameRegex.IsMatch(name);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
RedactorErrorMarker);
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
if (matched)
{
parameters[name] = JsonValue.Create(RedactedMarker);
anyChanged = true;
}
}
// Avoid re-serialising (which would normalise whitespace / order)
// when no parameter matched — keeps the on-disk row byte-identical
// to the emitter's output on the no-match path.
return anyChanged ? obj.ToJsonString(RedactedSummaryJsonOptions) : json;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
RedactorErrorMarker);
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
return RedactorErrorMarker;
}
}
private static string? TruncateField(string? value, int cap, ref bool truncated)
{
if (value is null)
{
return null;
}
var result = TruncateUtf8(value, cap);
if (result.Length != value.Length)
{
truncated = true;
}
return result;
}
/// <summary>
/// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
/// the cap position until the byte is NOT a continuation byte
/// (<c>byte &amp; 0xC0 == 0x80</c>), and decodes the resulting prefix —
/// guaranteeing the returned string never splits a multi-byte sequence.
/// </summary>
private static string TruncateUtf8(string value, int capBytes)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var bytes = Encoding.UTF8.GetBytes(value);
if (bytes.Length <= capBytes)
{
return value;
}
var boundary = capBytes;
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
{
boundary--;
}
return Encoding.UTF8.GetString(bytes, 0, boundary);
}
private static bool IsErrorStatus(AuditStatus status) => status switch
{
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
_ => true,
};
/// <summary>
/// Cache entry for a body-redactor pattern. Carries the working
/// <see cref="Regex"/> on the success path, or the
/// <see cref="Invalid"/> sentinel for patterns that failed to compile
/// (or exceeded the 100 ms compile budget). The sentinel lets us skip
/// repeat compile attempts on every event without re-throwing on the
/// hot-path.
/// </summary>
private readonly struct CompiledRegex
{
public static readonly CompiledRegex Invalid = new(null);
/// <summary>Gets the compiled <see cref="System.Text.RegularExpressions.Regex"/>, or <c>null</c> when the pattern was invalid.</summary>
public Regex? Regex { get; }
/// <summary>Initializes a new <see cref="CompiledRegex"/> wrapping the given compiled regex instance.</summary>
/// <param name="regex">The pre-compiled regex, or <c>null</c> to represent an invalid pattern.</param>
public CompiledRegex(Regex? regex) => Regex = regex;
}
}
@@ -1,31 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Filters an <see cref="AuditEvent"/> between construction and persistence —
/// truncates oversized payload fields, applies header/body/SQL-parameter
/// redaction, sets <see cref="AuditEvent.PayloadTruncated"/>.
/// </summary>
/// <remarks>
/// <para>
/// Pure function: returns a filtered COPY of the input via <c>with</c>
/// expressions; never throws (over-redacts on internal failure and increments
/// the <c>AuditRedactionFailure</c> health metric).
/// </para>
/// <para>
/// Wired in M5 between event construction and the writer chain
/// (<c>FallbackAuditWriter.WriteAsync</c>, <c>CentralAuditWriter.WriteAsync</c>,
/// and the <c>AuditLogIngestActor</c> handlers).
/// </para>
/// </remarks>
public interface IAuditPayloadFilter
{
/// <summary>
/// Apply the configured truncation + redaction policy to <paramref name="rawEvent"/>
/// and return a filtered copy. MUST NOT throw — on internal failure, over-redact
/// and surface the failure via the audit-redaction-failure health metric.
/// </summary>
/// <param name="rawEvent">The unfiltered audit event to process.</param>
AuditEvent Apply(AuditEvent rawEvent);
}
@@ -1,9 +1,9 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Counter sink invoked by <see cref="DefaultAuditPayloadFilter"/> every time
/// a redactor (header / body regex / SQL parameter) throws and the filter has
/// to over-redact the offending field with the
/// Counter sink invoked by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// every time a redactor (header / body regex / SQL parameter) throws and the
/// redactor has to over-redact the offending field with the
/// <c>&lt;redacted: redactor error&gt;</c> marker. Bundle C bridges this into
/// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>.
/// </summary>
@@ -1,79 +0,0 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
/// HTTP header redaction for the always-sensitive defaults
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
/// redaction, or truncation — those stages need
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
/// over-redact safely, never throw, never miss a header that's on the
/// default sensitive list.
/// </summary>
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
{
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
private static readonly string[] DefaultHeaderRedactList =
{
"Authorization",
"X-Api-Key",
"Cookie",
"Set-Cookie",
};
private static readonly Regex HeaderRegex = new(
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private SafeDefaultAuditPayloadFilter() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
ArgumentNullException.ThrowIfNull(rawEvent);
try
{
return rawEvent with
{
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
};
}
catch
{
// Over-redact: drop both summaries entirely so a malformed parse
// path never leaks the original. The contract is "never throw."
return rawEvent with
{
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
};
}
}
private static string? RedactHeaders(string? summary)
{
if (string.IsNullOrEmpty(summary)) return summary;
return HeaderRegex.Replace(summary, m =>
{
var name = m.Groups["name"].Value;
foreach (var sensitive in DefaultHeaderRedactList)
{
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
{
return $"{name}: [REDACTED]";
}
}
return m.Value;
});
}
}
@@ -0,0 +1,99 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using static ZB.MOM.WW.ScadaBridge.AuditLog.Payload.AuditRedactionPrimitives;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
/// <summary>
/// Minimal always-safe <see cref="IAuditRedactor"/> fallback for composition
/// roots that bypass the full <see cref="ScadaBridgeAuditRedactor"/>.
/// Performs line-oriented HTTP header
/// redaction for the always-sensitive defaults (Authorization, X-Api-Key,
/// Cookie, Set-Cookie) on the <c>RequestSummary</c> / <c>ResponseSummary</c>
/// fields carried inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>. Does NOT
/// perform body-regex redaction, SQL-parameter redaction, or truncation — those
/// need <see cref="ScadaBridgeAuditRedactor"/> with live options. Contract:
/// over-redact safely, never throw, never miss a header on the default
/// sensitive list.
/// </summary>
public sealed class SafeDefaultAuditRedactor : IAuditRedactor
{
/// <summary>Singleton instance — the redactor is stateless and side-effect-free.</summary>
public static SafeDefaultAuditRedactor Instance { get; } = new SafeDefaultAuditRedactor();
private static readonly string[] DefaultHeaderRedactList =
{
"Authorization",
"X-Api-Key",
"Cookie",
"Set-Cookie",
};
private static readonly Regex HeaderRegex = new(
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private SafeDefaultAuditRedactor() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
ArgumentNullException.ThrowIfNull(rawEvent);
// Fast path: no DetailsJson means no summaries to scrub.
if (string.IsNullOrEmpty(rawEvent.DetailsJson))
{
return rawEvent;
}
try
{
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);
var scrubbed = d with
{
RequestSummary = RedactHeaders(d.RequestSummary),
ResponseSummary = RedactHeaders(d.ResponseSummary),
};
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(scrubbed) };
}
catch
{
// Over-redact: suppress ALL sensitive free-text fields so a failure
// on any internal path never leaks the original. The contract is
// "never throw." Uses the shared OverRedactedEventMarker so all
// redactor safety-nets emit the same sentinel string.
var safe = new AuditDetails
{
RequestSummary = OverRedactedEventMarker,
ResponseSummary = OverRedactedEventMarker,
ErrorDetail = OverRedactedEventMarker,
ErrorMessage = OverRedactedEventMarker,
Extra = OverRedactedEventMarker,
PayloadTruncated = true,
};
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
}
}
private static string? RedactHeaders(string? summary)
{
if (string.IsNullOrEmpty(summary)) return summary;
return HeaderRegex.Replace(summary, m =>
{
var name = m.Groups["name"].Value;
foreach (var sensitive in DefaultHeaderRedactList)
{
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
{
// Use the shared RedactedMarker so line-format and JSON-format
// header redaction emit the same sentinel string.
return $"{name}: {RedactedMarker}";
}
}
return m.Value;
});
}
}
@@ -0,0 +1,349 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
/// <summary>
/// Canonical <see cref="IAuditRedactor"/> implementation for ScadaBridge —
/// operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> and its <see cref="AuditEvent.DetailsJson"/>
/// payload bag. The ScadaBridge request/response/error/extra summaries travel
/// inside <c>DetailsJson</c> as a <see cref="AuditDetails"/> record (serialized
/// by <see cref="AuditDetailsCodec"/>); this redactor deserializes them, applies
/// the header → body-regex → SQL-parameter → byte-safe truncation pipeline,
/// re-serializes, and returns a filtered COPY.
/// </summary>
/// <remarks>
/// <para>
/// Cap selection is faithful to the original pipeline, translated onto canonical
/// fields:
/// <list type="bullet">
/// <item>The <c>ApiInbound</c> branch keys on <see cref="AuditEvent.Category"/>
/// (= <c>AuditChannel.ToString()</c> per <see cref="AuditFieldBuilders.BuildCategory"/>)
/// → <see cref="AuditLogOptions.InboundMaxBytes"/>.</item>
/// <item>The "error row" branch reproduces the legacy
/// <c>IsErrorStatus(Status)</c> rule — Status NOT IN (<c>Delivered</c>,
/// <c>Submitted</c>, <c>Forwarded</c>) → <see cref="AuditLogOptions.ErrorCapBytes"/>.
/// The fine-grained status is read from <see cref="AuditDetails.Status"/>
/// when present (it must be — <see cref="AuditOutcome"/> alone cannot
/// reproduce <c>IsErrorStatus</c>, since <c>Attempted</c>/<c>Skipped</c>
/// project to <see cref="AuditOutcome.Success"/> yet take the error cap).
/// When <see cref="AuditDetails.Status"/> is absent/unparseable the
/// canonical <see cref="AuditEvent.Outcome"/> is the fallback:
/// <see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
/// → error cap.</item>
/// </list>
/// </para>
/// <para>
/// MUST NOT throw — wrapped in try/catch; over-redacts (drops ALL sensitive free-text
/// fields to a safe marker) on any internal failure, mirroring
/// <see cref="SafeDefaultAuditRedactor"/>.
/// </para>
/// </remarks>
public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
{
private const string OverRedactedMarker = AuditRedactionPrimitives.OverRedactedEventMarker;
private readonly IOptionsMonitor<AuditLogOptions> _options;
private readonly ILogger<ScadaBridgeAuditRedactor> _logger;
private readonly IAuditRedactionFailureCounter _failureCounter;
private readonly AuditRegexCache _regexCache;
/// <summary>
/// Primary constructor used by DI — pulls the optional redaction-failure
/// counter from the container; a NoOp default is used when none is supplied.
/// </summary>
/// <param name="options">Live-reloadable audit log options.</param>
/// <param name="logger">Logger for redaction diagnostics.</param>
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
public ScadaBridgeAuditRedactor(
IOptionsMonitor<AuditLogOptions> options,
ILogger<ScadaBridgeAuditRedactor> logger,
IAuditRedactionFailureCounter? failureCounter = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
_regexCache = new AuditRegexCache(_logger);
}
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
try
{
var opts = _options.CurrentValue;
// --- Fast path -------------------------------------------------
// Mirror the legacy filter's non-JSON pre-check: when there is no
// DetailsJson payload to scrub AND the Target is within the cap,
// there is nothing to redact or truncate. Return the input
// unchanged so the common case stays cheap (no Deserialize, no
// re-Serialize, same instance back).
var detailsEmpty = string.IsNullOrEmpty(rawEvent.DetailsJson);
var targetWithinCap = rawEvent.Target is null
|| Encoding.UTF8.GetByteCount(rawEvent.Target) <= opts.DefaultCapBytes;
if (detailsEmpty && targetWithinCap)
{
return rawEvent;
}
// --- Slow path -------------------------------------------------
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);
// Cap selection. Channel = canonical Category (the ApiInbound
// branch); error-cap selection reproduces the legacy
// IsErrorStatus(Status) — read from d.Status when present, else
// fall back to the canonical Outcome.
var cap = SelectCap(opts, rawEvent.Category, d.Status, rawEvent.Outcome);
// --- Header-redaction stage (runs BEFORE truncation) ----------
var request = RedactHeaders(d.RequestSummary, opts.HeaderRedactList);
var response = RedactHeaders(d.ResponseSummary, opts.HeaderRedactList);
var errorDetail = d.ErrorDetail;
var extra = d.Extra;
// --- Body-regex stage (also runs BEFORE truncation) -----------
// Per-target additions key on the canonical Target.
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
if (bodyRegexes.Count > 0)
{
request = RedactBody(request, bodyRegexes);
response = RedactBody(response, bodyRegexes);
errorDetail = RedactBody(errorDetail, bodyRegexes);
extra = RedactBody(extra, bodyRegexes);
}
// --- SQL parameter redaction stage (DbOutbound only) ----------
// Channel-guarded on the canonical Category; connection key is the
// Target prefix before the first '.'.
if (string.Equals(rawEvent.Category, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
{
request = RedactSqlParameters(request, sqlParamRegex!);
}
// --- Truncation stage -----------------------------------------
var truncated = false;
request = TruncateField(request, cap, ref truncated);
response = TruncateField(response, cap, ref truncated);
errorDetail = TruncateField(errorDetail, cap, ref truncated);
extra = TruncateField(extra, cap, ref truncated);
var rewritten = d with
{
RequestSummary = request,
ResponseSummary = response,
ErrorDetail = errorDetail,
Extra = extra,
PayloadTruncated = d.PayloadTruncated || truncated,
};
// Target length cap (canonical top-level field). Cap at the default
// byte ceiling so an absurd Target cannot blow the storage column.
var cappedTarget = TruncateTarget(rawEvent.Target, opts.DefaultCapBytes);
return rawEvent with
{
DetailsJson = AuditDetailsCodec.Serialize(rewritten),
Target = cappedTarget,
};
}
catch (Exception ex)
{
// Audit is best-effort: over-redact rather than fail the caller.
// Drop the summaries entirely (mirroring SafeDefault's catch path)
// and flag PayloadTruncated so downstream readers know the row was
// scrubbed defensively.
_logger.LogWarning(
ex,
"Canonical audit redactor failed; over-redacting DetailsJson and flagging PayloadTruncated");
IncrementFailureCounter();
return OverRedact(rawEvent);
}
}
/// <summary>
/// Pick the truncation cap. <paramref name="category"/> = canonical Category
/// (= channel name): <c>ApiInbound</c> → <see cref="AuditLogOptions.InboundMaxBytes"/>.
/// Otherwise the legacy <c>IsErrorStatus</c> rule decides between the error
/// and default caps, preferring the fine-grained <paramref name="detailsStatus"/>
/// (from <c>DetailsJson</c>) and falling back to the canonical
/// <paramref name="outcome"/> when status is absent/unparseable.
/// </summary>
private static int SelectCap(
AuditLogOptions opts,
string? category,
string? detailsStatus,
AuditOutcome outcome)
{
if (string.Equals(category, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal))
{
return opts.InboundMaxBytes;
}
return IsErrorRow(detailsStatus, outcome) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
}
/// <summary>
/// Reproduce the legacy <c>IsErrorStatus(Status)</c> error-cap predicate on
/// the canonical record: Status NOT IN (<c>Delivered</c>, <c>Submitted</c>,
/// <c>Forwarded</c>) → error row. When the fine-grained status is present in
/// <c>DetailsJson</c> it is authoritative; otherwise the canonical
/// <see cref="AuditOutcome"/> is the fallback
/// (<see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
/// → error row).
/// </summary>
private static bool IsErrorRow(string? detailsStatus, AuditOutcome outcome)
{
if (!string.IsNullOrEmpty(detailsStatus)
&& Enum.TryParse<AuditStatus>(detailsStatus, ignoreCase: false, out var status))
{
return status switch
{
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
_ => true,
};
}
// No usable status — fall back to the canonical outcome.
return outcome != AuditOutcome.Success;
}
private string? RedactHeaders(string? json, IList<string> redactList)
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
=> AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
=> AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);
private static string? TruncateField(string? value, int cap, ref bool truncated)
=> AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);
private static string? TruncateTarget(string? target, int cap)
=> target is null ? null : AuditRedactionPrimitives.TruncateUtf8(target, cap);
/// <summary>
/// Combine the global and per-target body-redactor lists, returning the
/// compiled-regex set to apply. Patterns that failed compilation are
/// silently skipped.
/// </summary>
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
{
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
var perTargetAdditions = (target != null
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
&& over.AdditionalBodyRedactors is { Count: > 0 })
? over.AdditionalBodyRedactors
: null;
if (!hasGlobal && perTargetAdditions == null)
{
return Array.Empty<Regex>();
}
var result = new List<Regex>();
if (hasGlobal)
{
foreach (var pattern in opts.GlobalBodyRedactors)
{
if (_regexCache.TryGet(pattern, out var rx))
{
result.Add(rx!);
}
}
}
if (perTargetAdditions != null)
{
foreach (var pattern in perTargetAdditions)
{
if (_regexCache.TryGet(pattern, out var rx))
{
result.Add(rx!);
}
}
}
return result;
}
/// <summary>
/// Resolve the per-connection SQL parameter redaction regex for the given
/// target. Connection key = everything before the first <c>.</c> in
/// <paramref name="target"/>. Patterns are forced case-insensitive.
/// </summary>
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
{
regex = null;
if (string.IsNullOrEmpty(target))
{
return false;
}
var dot = target.IndexOf('.');
var connectionKey = dot < 0 ? target : target[..dot];
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
{
return false;
}
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
return _regexCache.TryGet(cacheKey, out regex);
}
/// <summary>
/// Over-redaction copy returned from the never-throws catch: suppress ALL
/// potentially-sensitive string fields inside <c>DetailsJson</c> to a safe
/// marker and flag <see cref="AuditDetails.PayloadTruncated"/>. "All sensitive
/// fields" = <c>RequestSummary</c>, <c>ResponseSummary</c>, <c>ErrorDetail</c>,
/// <c>ErrorMessage</c>, and <c>Extra</c> — all body-regex redaction targets
/// that can carry sensitive values. Best-effort re-serialise; if even that
/// fails, return the input with no sensitive fields via a minimal details bag.
/// </summary>
private static AuditEvent OverRedact(AuditEvent rawEvent)
{
try
{
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson) with
{
RequestSummary = OverRedactedMarker,
ResponseSummary = OverRedactedMarker,
ErrorDetail = OverRedactedMarker,
ErrorMessage = OverRedactedMarker,
Extra = OverRedactedMarker,
PayloadTruncated = true,
};
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(d) };
}
catch
{
var safe = new AuditDetails
{
RequestSummary = OverRedactedMarker,
ResponseSummary = OverRedactedMarker,
ErrorDetail = OverRedactedMarker,
ErrorMessage = OverRedactedMarker,
Extra = OverRedactedMarker,
PayloadTruncated = true,
};
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
}
}
/// <summary>
/// Bumps the injected redaction-failure counter, swallowing any fault per
/// alog.md §7. Passed as the <c>onFailure</c> callback to the shared
/// primitives and called from the top-level catch.
/// </summary>
private void IncrementFailureCounter()
{
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
}
}
@@ -3,13 +3,16 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
@@ -62,19 +65,19 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
services.AddOptions<AuditLogOptions>()
.Bind(config.GetSection(ConfigSectionName))
.ValidateOnStart();
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
// Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
// "AuditLog" section, registers the validator, and enables ValidateOnStart in
// one call. Same section path as before; AddAuditLog is call-once per
// collection, and the helper's TryAddEnumerable is idempotent for the
// validator (a strict improvement over the previous AddSingleton).
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
// M5 Bundle A: payload filter — truncates oversized RequestSummary /
// ResponseSummary / ErrorDetail / Extra fields between event
// construction and persistence. Bundle B layers header / body /
// SQL-parameter redaction onto the same singleton; Bundle C wires it
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor
// paths. Singleton — the filter is stateless and the IOptionsMonitor
// dependency picks up M5-T8 hot reloads on its own.
services.AddSingleton<IAuditPayloadFilter, DefaultAuditPayloadFilter>();
// C3 (Task 2.5): the canonical IAuditRedactor is wired as
// ScadaBridgeAuditRedactor — same truncation + header / body /
// SQL-parameter redaction as the original pipeline, applied between
// event construction and persistence. Singleton — stateless; the
// IOptionsMonitor dependency picks up hot reloads on its own.
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
// Bundle C replaces this binding with the Site Health Monitoring
@@ -113,7 +116,7 @@ public static class ServiceCollectionExtensions
// The script-thread surface is FallbackAuditWriter (primary + ring +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
// abort the user-facing action.
// Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired
// C3 (Task 2.5): the canonical IAuditRedactor singleton above is wired
// through the factory so every event written through this surface is
// truncated + redacted before it hits SQLite (and the ring on
// failure).
@@ -122,7 +125,7 @@ public static class ServiceCollectionExtensions
ring: sp.GetRequiredService<RingBufferFallback>(),
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
redactor: sp.GetRequiredService<IAuditRedactor>()));
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
// central/test composition roots that have no SiteCommunicationActor.
@@ -200,7 +203,7 @@ public static class ServiceCollectionExtensions
// is intentionally distinct from IAuditWriter so site composition roots
// do not accidentally bind it; central composition roots that include
// AddConfigurationDatabase get a working implementation transparently.
// Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so
// C3 (Task 2.5): wire the canonical IAuditRedactor into the factory so
// NotificationOutboxActor + Inbound API rows are truncated + redacted
// before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
@@ -208,7 +211,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditPayloadFilter>(),
sp.GetRequiredService<IAuditRedactor>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// SourceNode-stamping (Task 12): wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API)
@@ -228,7 +231,7 @@ public static class ServiceCollectionExtensions
/// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
/// <see cref="HealthMetricsAuditRedactionFailureCounter"/> bridges so the
/// FallbackAuditWriter primary-failure counter AND the
/// DefaultAuditPayloadFilter redactor-failure counter both surface in the
/// <see cref="ScadaBridgeAuditRedactor"/> redactor-failure counter both surface in the
/// site health report payload as
/// <c>SiteHealthReport.SiteAuditWriteFailures</c> +
/// <c>SiteHealthReport.AuditRedactionFailure</c>.
@@ -1,7 +1,8 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -31,43 +32,45 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly RingBufferFallback _ring;
private readonly IAuditWriteFailureCounter _failureCounter;
private readonly ILogger<FallbackAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter;
private readonly IAuditRedactor _redactor;
private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary>
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditPayloadFilter"/>
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditRedactor"/>
/// here so every event written via the site hot path is truncated +
/// header/body/SQL-param redacted before it hits both the primary SQLite
/// writer AND the ring fallback. The parameter is optional (defaults to
/// no filtering) so the long tail of test composition roots that don't
/// care about the filter need no change — the production
/// the always-safe <see cref="SafeDefaultAuditRedactor"/>) so the long
/// tail of test composition roots that don't care about the redactor need
/// no change — the production
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration
/// always passes the real filter through.
/// always passes the real redactor through.
/// </summary>
/// <param name="primary">The primary audit writer (typically the SQLite writer).</param>
/// <param name="ring">Drop-oldest ring buffer used to stash events when the primary fails.</param>
/// <param name="failureCounter">Counter incremented on each primary failure for health reporting.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="filter">Optional payload filter applied before writing; null means no filtering.</param>
/// <param name="redactor">Optional canonical redactor applied before writing; null means the always-safe default.</param>
public FallbackAuditWriter(
IAuditWriter primary,
RingBufferFallback ring,
IAuditWriteFailureCounter failureCounter,
ILogger<FallbackAuditWriter> logger,
IAuditPayloadFilter? filter = null)
IAuditRedactor? redactor = null)
{
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to a null filter — over-redact instead.
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
// redaction with the hard-coded sensitive defaults (Authorization,
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
// doesn't bind the real options never persists those headers
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
// AuditLog-008: never default to a null redactor — over-redact instead.
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
// SafeDefaultAuditRedactor performs HTTP header redaction with the
// hard-coded sensitive defaults (Authorization, X-Api-Key, Cookie,
// Set-Cookie) on the DetailsJson summaries so a test composition root
// that doesn't bind the real options never persists those headers
// verbatim. The full ScadaBridgeAuditRedactor (truncation + body /
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
}
/// <inheritdoc />
@@ -75,14 +78,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
{
ArgumentNullException.ThrowIfNull(evt);
// Filter once, up-front. The filtered event flows BOTH to the primary
// Redact once, up-front. The redacted event flows BOTH to the primary
// and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The filter contract is
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
// to SafeDefaultAuditPayloadFilter so header redaction is always
// applied even in composition roots that don't wire the real filter).
var filtered = _filter.Apply(evt);
// already been truncated and redacted. The redactor contract is
// "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
// to SafeDefaultAuditRedactor so header redaction is always applied
// even in composition roots that don't wire the real redactor).
var filtered = _redactor.Apply(evt);
try
{
@@ -6,10 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Audit Log (#23) M5 Bundle C — bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL
/// parameter redactor stage throws and the filter has to over-redact the
/// offending field) into <see cref="ISiteHealthCollector"/> so the count
/// surfaces in the site health report payload as
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// a header / body / SQL parameter redactor stage throws and the redactor has
/// to over-redact the offending field) into <see cref="ISiteHealthCollector"/>
/// so the count surfaces in the site health report payload as
/// <c>SiteHealthReport.AuditRedactionFailure</c>.
/// </summary>
/// <remarks>
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -2,10 +2,12 @@ using System.Threading.Channels;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -18,15 +20,27 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary>
/// <remarks>
/// <para>
/// The schema is bootstrapped in the constructor (Bundle B-T1). The
/// Channel-based <see cref="WriteAsync"/> hot-path + Bundle D
/// <see cref="ReadPendingAsync"/> / <see cref="MarkForwardedAsync"/> support
/// surface are wired in Bundle B-T2.
/// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables:
/// the append-only canonical <c>audit_event</c> (the 10 canonical
/// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
/// the mutable operational <c>audit_forward_state</c> sidecar that carries the
/// forwarding lifecycle (<see cref="AuditForwardState"/>), a duplicated
/// <c>OccurredAtUtc</c> for the drain index range-scan, a precomputed
/// <c>IsCachedKind</c> flag that drives the cached/non-cached drain split without
/// re-parsing <c>DetailsJson</c> on the read hot-path, plus attempt bookkeeping.
/// </para>
/// <para>
/// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention,
/// recreated per deployment), so C4's schema change is an in-place RESET: the new
/// tables are created and the old single 24-column <c>AuditLog</c> table is
/// DROP-ped if present. No SQLite data migration is performed (and none is
/// needed) — any rows in a pre-C4 <c>AuditLog</c> table are within the retention
/// window and are discarded by the drop.
/// </para>
/// <para>
/// Site rows always carry <see cref="AuditForwardState.Pending"/> on first
/// insert; the central row-shape's <c>IngestedAtUtc</c> column does NOT live in
/// the site SQLite schema — central stamps it on ingest.
/// insert; the central row-shape's <c>IngestedAtUtc</c> is a DetailsJson field
/// stamped by central on ingest, not a site column.
/// </para>
/// </remarks>
public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable
@@ -35,8 +49,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY)
// is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably
// surfaced across all SQLite builds. We treat any constraint error on insert
// as a duplicate-eventid race and swallow it (first-write-wins) — the index
// on EventId is the only constraint on this table, so this scope is precise.
// as a duplicate-eventid race and swallow it (first-write-wins) — the PRIMARY
// KEY on audit_event.EventId is the constraint that fires first, so this scope
// is precise (the sidecar insert for the same EventId is in the same
// transaction and never reached once audit_event's insert throws).
private const int SqliteErrorConstraint = 19;
private readonly SqliteConnection _connection;
@@ -97,6 +113,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
_readConnection = new SqliteConnection(connectionString);
_readConnection.Open();
// PRAGMA foreign_keys is a per-connection setting. Set it on the read
// connection as well so that any future read-path change (e.g. a
// DELETE that may be added later) also benefits from FK enforcement.
// Pure SELECT queries are unaffected — this is defensive belt-and-
// suspenders for the read connection.
using (var pragmaCmd = _readConnection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
pragmaCmd.ExecuteNonQuery();
}
_writeQueue = Channel.CreateBounded<PendingAuditEvent>(
new BoundedChannelOptions(_options.ChannelCapacity)
{
@@ -140,95 +167,81 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery();
}
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceNode TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
ParentExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
cmd.ExecuteNonQuery();
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
// table that already exists from a pre-ExecutionId build, so an
// auditlog.db created by an older build needs the column ALTER-ed in.
// The file is durable across restart/failover by design (7-day
// retention), so without this step every WriteAsync on an upgraded
// deployment would bind $ExecutionId against a missing column and the
// best-effort write path would silently drop every site audit row.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. The column is
// nullable with no default, so any row written before this migration
// reads back ExecutionId = null (back-compat).
AddColumnIfMissing("ExecutionId", "TEXT NULL");
// Audit Log #23 (ParentExecutionId): same idempotent upgrade path as
// ExecutionId above. A deployment that already ran the ExecutionId
// branch has an auditlog.db with the 21-column schema and no
// ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it,
// so it is ALTER-ed in here. Nullable with no default — rows written
// before this migration read back ParentExecutionId = null.
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
// SourceNode stamping: same idempotent upgrade path as ExecutionId /
// ParentExecutionId above. A deployment that already ran the
// ParentExecutionId branch has an auditlog.db with the 22-column
// schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot
// add it, so it is ALTER-ed in here. Nullable with no default — rows
// written before this migration read back SourceNode = null.
AddColumnIfMissing("SourceNode", "TEXT NULL");
}
/// <summary>
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
/// it is not already present (used for <c>ExecutionId</c> and
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
/// here to match the rest of this writer's bootstrap DDL.
/// </summary>
private void AddColumnIfMissing(string columnName, string columnDefinition)
{
using var probe = _connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
probe.Parameters.AddWithValue("$name", columnName);
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
if (exists)
// Enable FK enforcement on the WRITE connection. PRAGMA foreign_keys is
// a per-connection, per-session setting in SQLite — it is NOT persisted
// in the database file, so every new connection that may INSERT into
// audit_forward_state must set it for the FK
// audit_forward_state.EventId → audit_event.EventId
// to be a real runtime guard rather than decorative DDL. The write
// connection owns all INSERTs (and the MarkForwardedAsync /
// MarkReconciledAsync UPDATEs), so setting it here — after WAL is
// established, before the CREATE TABLEs — ensures the FK is live for
// every insert that follows. The existing insert order (audit_event
// first, then audit_forward_state, inside the same transaction) already
// satisfies the FK, so no pre-existing rows can violate the constraint.
using (var pragmaCmd = _connection.CreateCommand())
{
return;
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
pragmaCmd.ExecuteNonQuery();
}
using var alter = _connection.CreateCommand();
// Column name + definition are caller-controlled constants, never user
// input — safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
alter.ExecuteNonQuery();
// C4 (Task 2.5) — in-place reset. The site store is EPHEMERAL (≈7-day
// retention, recreated per deployment), so we do NOT migrate the old
// single 24-column AuditLog table to the new two-table shape: any rows
// it holds are within the retention window and discarded. DROP it if a
// pre-C4 deployment left it behind, then CREATE the two new tables. This
// is safe precisely BECAUSE the site store is ephemeral — never do this
// on a durable store (the central SQL Server side keeps its shim until
// C5 and is migrated, not reset).
using (var dropCmd = _connection.CreateCommand())
{
dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;";
dropCmd.ExecuteNonQuery();
}
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
-- Canonical, append-only / write-once: the 10 fields of the canonical
-- ZB.MOM.WW.Audit.AuditEvent stored directly (DetailsJson carries the
-- ScadaBridge domain fields). No forwarding state lives here that is
-- the audit_forward_state sidecar's concern.
CREATE TABLE IF NOT EXISTS audit_event (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Actor TEXT NOT NULL,
Action TEXT NOT NULL,
Outcome TEXT NOT NULL,
Category TEXT NULL,
Target TEXT NULL,
SourceNode TEXT NULL,
CorrelationId TEXT NULL,
DetailsJson TEXT NULL,
PRIMARY KEY (EventId)
);
-- Operational, mutable: the forwarding lifecycle for each canonical
-- row. OccurredAtUtc is duplicated here so the drain range-scan stays
-- on this one table's index; IsCachedKind is precomputed at insert so
-- the cached/non-cached drain split never re-parses DetailsJson on the
-- read hot-path.
CREATE TABLE IF NOT EXISTS audit_forward_state (
EventId TEXT NOT NULL,
ForwardState TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
IsCachedKind INTEGER NOT NULL,
AttemptCount INTEGER NOT NULL DEFAULT 0,
LastAttemptUtc TEXT NULL,
PRIMARY KEY (EventId),
FOREIGN KEY (EventId) REFERENCES audit_event(EventId)
);
-- Drain index: every read filters on (ForwardState, IsCachedKind) and
-- range-scans/orders by OccurredAtUtc, so this composite covers the
-- four reads + the backlog COUNT/MIN.
CREATE INDEX IF NOT EXISTS IX_fwd
ON audit_forward_state (ForwardState, IsCachedKind, OccurredAtUtc);
""";
cmd.ExecuteNonQuery();
}
/// <inheritdoc />
@@ -236,14 +249,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
{
ArgumentNullException.ThrowIfNull(evt);
// Site rows always carry a non-null ForwardState; central rows leave it
// null. Force Pending on enqueue so callers can pass a bare AuditEvent
// without thinking about site-vs-central provenance.
var siteEvt = evt.ForwardState is null
? evt with { ForwardState = AuditForwardState.Pending }
: evt;
var pending = new PendingAuditEvent(siteEvt);
// The canonical record carries no ForwardState (a site-storage-only
// concern). Site rows always start Pending; the sidecar row is written
// alongside the canonical row in the same transaction.
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
// than throw when full — exactly the hot-path back-pressure semantics
@@ -316,96 +325,99 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
using var transaction = _connection.BeginTransaction();
try
{
using var cmd = _connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = """
INSERT INTO AuditLog (
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
// INSERT 1: the canonical row, stored DIRECTLY (the 10 canonical
// fields straight off the AuditEvent — no Decompose; audit_event
// holds canonical shape, not the legacy 24-column shape).
using var eventCmd = _connection.CreateCommand();
eventCmd.Transaction = transaction;
eventCmd.CommandText = """
INSERT INTO audit_event (
EventId, OccurredAtUtc, Actor, Action, Outcome,
Category, Target, SourceNode, CorrelationId, DetailsJson
) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target,
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId, $ParentExecutionId
$EventId, $OccurredAtUtc, $Actor, $Action, $Outcome,
$Category, $Target, $SourceNode, $CorrelationId, $DetailsJson
);
""";
var eEventId = eventCmd.Parameters.Add("$EventId", SqliteType.Text);
var eOccurredAt = eventCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var eActor = eventCmd.Parameters.Add("$Actor", SqliteType.Text);
var eAction = eventCmd.Parameters.Add("$Action", SqliteType.Text);
var eOutcome = eventCmd.Parameters.Add("$Outcome", SqliteType.Text);
var eCategory = eventCmd.Parameters.Add("$Category", SqliteType.Text);
var eTarget = eventCmd.Parameters.Add("$Target", SqliteType.Text);
var eSourceNode = eventCmd.Parameters.Add("$SourceNode", SqliteType.Text);
var eCorrelationId = eventCmd.Parameters.Add("$CorrelationId", SqliteType.Text);
var eDetailsJson = eventCmd.Parameters.Add("$DetailsJson", SqliteType.Text);
var pEventId = cmd.Parameters.Add("$EventId", SqliteType.Text);
var pOccurredAt = cmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var pChannel = cmd.Parameters.Add("$Channel", SqliteType.Text);
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text);
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text);
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text);
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text);
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text);
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text);
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text);
var pTarget = cmd.Parameters.Add("$Target", SqliteType.Text);
var pStatus = cmd.Parameters.Add("$Status", SqliteType.Text);
var pHttpStatus = cmd.Parameters.Add("$HttpStatus", SqliteType.Integer);
var pDurationMs = cmd.Parameters.Add("$DurationMs", SqliteType.Integer);
var pErrorMessage = cmd.Parameters.Add("$ErrorMessage", SqliteType.Text);
var pErrorDetail = cmd.Parameters.Add("$ErrorDetail", SqliteType.Text);
var pRequestSummary = cmd.Parameters.Add("$RequestSummary", SqliteType.Text);
var pResponseSummary = cmd.Parameters.Add("$ResponseSummary", SqliteType.Text);
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text);
// INSERT 2: the operational sidecar row. ForwardState=Pending,
// OccurredAtUtc duplicated for the drain index, IsCachedKind
// precomputed (so the read split never parses DetailsJson),
// AttemptCount=0, LastAttemptUtc=NULL.
using var fwdCmd = _connection.CreateCommand();
fwdCmd.Transaction = transaction;
fwdCmd.CommandText = """
INSERT INTO audit_forward_state (
EventId, ForwardState, OccurredAtUtc, IsCachedKind, AttemptCount, LastAttemptUtc
) VALUES (
$EventId, $ForwardState, $OccurredAtUtc, $IsCachedKind, 0, NULL
);
""";
var fEventId = fwdCmd.Parameters.Add("$EventId", SqliteType.Text);
var fForwardState = fwdCmd.Parameters.Add("$ForwardState", SqliteType.Text);
var fOccurredAt = fwdCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var fIsCachedKind = fwdCmd.Parameters.Add("$IsCachedKind", SqliteType.Integer);
foreach (var pending in batch)
{
var e = pending.Event;
pEventId.Value = e.EventId.ToString();
pOccurredAt.Value = e.OccurredAtUtc.ToString("o");
pChannel.Value = e.Channel.ToString();
pKind.Value = e.Kind.ToString();
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
var evt = pending.Event;
// Canonical OccurredAtUtc is UTC by construction; store the
// round-trip "o" form so string comparison stays monotonic
// (the drain range-scan and ORDER BY rely on it).
var occurredText = evt.OccurredAtUtc.UtcDateTime.ToString(
"o", System.Globalization.CultureInfo.InvariantCulture);
eEventId.Value = evt.EventId.ToString();
eOccurredAt.Value = occurredText;
// Canonical Actor is a required non-null string.
eActor.Value = evt.Actor ?? string.Empty;
eAction.Value = evt.Action;
eOutcome.Value = evt.Outcome.ToString();
eCategory.Value = (object?)evt.Category ?? DBNull.Value;
eTarget.Value = (object?)evt.Target ?? DBNull.Value;
// SourceNode-stamping: caller-provided value wins (preserves
// rows reconciled in from other nodes via the same writer);
// otherwise stamp from the local INodeIdentityProvider. The
// event record itself is NOT mutated — stamping is at write
// time only. If the provider also returns null (unconfigured
// node), the row's SourceNode stays NULL — operators see
// "needs config" via the schema, not a magic fallback string.
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
pActor.Value = (object?)e.Actor ?? DBNull.Value;
pTarget.Value = (object?)e.Target ?? DBNull.Value;
pStatus.Value = e.Status.ToString();
pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value;
pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value;
pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value;
pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value;
pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value;
pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value;
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
// node), the column stays NULL — operators see "needs config"
// via the schema, not a magic fallback string.
var sourceNode = evt.SourceNode ?? _nodeIdentity.NodeName;
eSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
eCorrelationId.Value = (object?)evt.CorrelationId?.ToString() ?? DBNull.Value;
eDetailsJson.Value = (object?)evt.DetailsJson ?? DBNull.Value;
fEventId.Value = evt.EventId.ToString();
fForwardState.Value = pending.ForwardState.ToString();
fOccurredAt.Value = occurredText;
fIsCachedKind.Value = IsCachedKind(evt.DetailsJson) ? 1 : 0;
try
{
cmd.ExecuteNonQuery();
eventCmd.ExecuteNonQuery();
fwdCmd.ExecuteNonQuery();
pending.Completion.TrySetResult();
}
catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint)
{
// Duplicate EventId — first-write-wins (alog.md §11).
// Treat as success: the lifecycle event is durably
// recorded under the first writer's payload.
// Duplicate EventId — first-write-wins (alog.md §11). The
// audit_event PRIMARY KEY throws before the sidecar insert
// runs, so neither table gains a second row. Treat as
// success: the lifecycle event is durably recorded under
// the first writer's payload.
_logger.LogDebug(ex,
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
e.EventId);
evt.EventId);
pending.Completion.TrySetResult();
}
}
@@ -427,17 +439,36 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry
// drain (joined with the operational tracking row + pushed via
// IngestCachedTelemetryAsync into the central dual-write transaction).
// ReadPendingAsync EXCLUDES these so the audit-only drain doesn't double-emit
// them; ReadPendingCachedTelemetryAsync below is the dedicated read surface
// the new SiteAuditTelemetryActor cached-drain uses.
private static readonly string[] CachedTelemetryKindNames =
// C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query
// filtered on (Kind IN (...)); it is now precomputed into the sidecar's
// IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
// integer predicate, not a JSON parse. ReadPendingAsync drains everything
// with IsCachedKind=0; ReadPendingCachedTelemetryAsync drains IsCachedKind=1.
private static readonly HashSet<AuditKind> CachedTelemetryKinds = new()
{
nameof(AuditKind.CachedSubmit),
nameof(AuditKind.ApiCallCached),
nameof(AuditKind.DbWriteCached),
nameof(AuditKind.CachedResolve),
AuditKind.CachedSubmit,
AuditKind.ApiCallCached,
AuditKind.DbWriteCached,
AuditKind.CachedResolve,
};
/// <summary>
/// C4: precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical
/// row's <c>DetailsJson</c>. Parses the <see cref="AuditDetails.Kind"/>
/// discriminator via <see cref="AuditDetailsCodec"/> and returns <c>true</c>
/// iff it is one of the cached-lifecycle kinds
/// (<see cref="AuditKind.CachedSubmit"/>, <see cref="AuditKind.ApiCallCached"/>,
/// <see cref="AuditKind.DbWriteCached"/>, <see cref="AuditKind.CachedResolve"/>).
/// Runs once per event at INSERT time so the cached/non-cached drain split is
/// a cheap integer predicate on read, never a JSON parse on the hot path.
/// </summary>
private static bool IsCachedKind(string? detailsJson)
{
var details = AuditDetailsCodec.Deserialize(detailsJson);
var kind = AuditRowProjection.ParseEnum(details.Kind, AuditKind.InboundRequest);
return CachedTelemetryKinds.Contains(kind);
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
{
@@ -449,47 +480,35 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-005: read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode
// gives us a stable snapshot of the table while writes proceed on the
// gives us a stable snapshot of the tables while writes proceed on the
// writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is
// not thread-safe.
// AuditLog-001: NOT IN ($cached1,$cached2,$cached3,$cached4) excludes the
// cached-lifecycle kinds — they flow through ReadPendingCachedTelemetryAsync
// + the combined-telemetry drain. Kind is stored as the enum's name (see
// FlushBatch's pKind.Value), so a string-IN against the constant kind
// names matches the on-disk shape exactly.
// C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// lifecycle kinds (IsCachedKind=1) flow through
// ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
// split is a precomputed integer predicate on the indexed sidecar, not
// a DetailsJson parse. Ordering is by the sidecar's OccurredAtUtc with
// EventId as the deterministic tiebreaker.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState = $pending
AND Kind NOT IN ($k0, $k1, $k2, $k3)
ORDER BY OccurredAtUtc ASC, EventId ASC
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState = $pending
AND fs.IsCachedKind = 0
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
return Task.FromResult(ReadRows(cmd, limit));
}
}
@@ -502,42 +521,29 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// AuditLog-001: dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to the four
// cached AuditKinds. Same _readConnection + _readLock pattern so the
// hot-path writer is not contended.
// AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
// Same _readConnection + _readLock pattern so the hot-path writer is not
// contended.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState = $pending
AND Kind IN ($k0, $k1, $k2, $k3)
ORDER BY OccurredAtUtc ASC, EventId ASC
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState = $pending
AND fs.IsCachedKind = 1
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
return Task.FromResult(ReadRows(cmd, limit));
}
}
@@ -563,34 +569,27 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on
// _writeLock.
// _writeLock. C4: JOIN the sidecar and filter on ForwardState='Forwarded'
// (no IsCachedKind split — both cached and non-cached Forwarded rows are
// returned, as before).
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState = $forwarded
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
return Task.FromResult(ReadRows(cmd, limit));
}
}
@@ -608,11 +607,25 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
// Build a single IN (...) parameter list so we issue one UPDATE per
// batch regardless of size. Each id is bound as its own parameter,
// so no string concatenation of user data ever enters the SQL.
// C4: flip the sidecar — UPDATE audit_forward_state, not the canonical
// audit_event (which is append-only / write-once). Bump AttemptCount +
// stamp LastAttemptUtc so operators can see how many drain passes a row
// took to forward. Build a single IN (...) parameter list so we issue
// one UPDATE per batch regardless of size. Each id is bound as its own
// parameter, so no string concatenation of user data ever enters the SQL.
//
// Defensive state guard: only transition rows that are still Pending or
// Forwarded (i.e. not yet Reconciled). Without this guard a mis-called
// batch that includes a Reconciled EventId would silently demote it back
// to Forwarded — a state regression that would cause duplicate central
// ingestion. Symmetric with MarkReconciledAsync's
// WHERE ForwardState IN ($pending, $forwarded)
// guard. Current callers only pass Pending IDs, so normal-path behaviour
// is unchanged; the guard is purely defensive.
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE AuditLog SET ForwardState = $forwarded WHERE EventId IN (");
sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
for (int i = 0; i < eventIds.Count; i++)
{
if (i > 0) sb.Append(',');
@@ -623,6 +636,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
sb.Append(");");
cmd.CommandText = sb.ToString();
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString(
"o", System.Globalization.CultureInfo.InvariantCulture));
cmd.ExecuteNonQuery();
return Task.CompletedTask;
@@ -639,22 +655,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
// AuditLog-005: read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync.
// decoupling as ReadPendingAsync. C4: JOIN the sidecar; the range scan
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
// Both Pending and Forwarded rows are returned (the central reconciliation
// puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested
// row is safe).
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState IN ($pending, $forwarded)
AND OccurredAtUtc >= $since
ORDER BY OccurredAtUtc ASC, EventId ASC
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
FROM audit_event ae
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
WHERE fs.ForwardState IN ($pending, $forwarded)
AND fs.OccurredAtUtc >= $since
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
@@ -666,14 +684,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
"o", System.Globalization.CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("$limit", batchSize);
var rows = new List<AuditEvent>(Math.Min(batchSize, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
return Task.FromResult(ReadRows(cmd, batchSize));
}
}
@@ -691,8 +702,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
// C4: flip the sidecar from Pending/Forwarded → Reconciled. Rows
// already Reconciled are left untouched (idempotent re-call), and the
// canonical audit_event row is never modified.
var sb = new System.Text.StringBuilder();
sb.Append("UPDATE AuditLog SET ForwardState = $reconciled ")
sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
for (int i = 0; i < eventIds.Count; i++)
{
@@ -724,18 +738,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes.
// C4: count over the sidecar (audit_forward_state) — the canonical
// audit_event table carries no ForwardState. The IX_fwd index makes both
// aggregates cheap (count is a covering scan, min is the first key).
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Single round-trip — COUNT(*) + MIN(OccurredAtUtc) over the same
// index range avoids a second scan. The IX_SiteAuditLog_ForwardState_Occurred
// index makes both aggregates cheap (count is a covering scan, min
// is the first key).
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT COUNT(*), MIN(OccurredAtUtc)
FROM AuditLog
FROM audit_forward_state
WHERE ForwardState = $pending;
""";
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
@@ -786,35 +799,48 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
/// <summary>
/// Executes <paramref name="cmd"/> (one of the four reads, each already
/// projecting the 10 <c>audit_event</c> columns in canonical order) and
/// materialises the rows via <see cref="MapRow"/>.
/// </summary>
private static IReadOnlyList<AuditEvent> ReadRows(SqliteCommand cmd, int capacityHint)
{
var rows = new List<AuditEvent>(Math.Min(capacityHint, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return rows;
}
/// <summary>
/// C4: builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
/// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because
/// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>.
/// <c>Outcome</c> is stored as the enum's name; the safe
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> degrades an unknown/renamed
/// value gracefully rather than throwing.
/// </summary>
private static AuditEvent MapRow(SqliteDataReader reader)
{
return new AuditEvent
{
EventId = Guid.Parse(reader.GetString(0)),
OccurredAtUtc = DateTime.Parse(reader.GetString(1),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind),
Channel = Enum.Parse<AuditChannel>(reader.GetString(2)),
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
PayloadTruncated = reader.GetInt32(18) != 0,
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
OccurredAtUtc = new DateTimeOffset(DateTime.SpecifyKind(
DateTime.Parse(reader.GetString(1),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind),
DateTimeKind.Utc)),
Actor = reader.GetString(2),
Action = reader.GetString(3),
Outcome = AuditRowProjection.ParseEnum(reader.GetString(4), AuditOutcome.Success),
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
};
}
@@ -898,15 +924,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private sealed class PendingAuditEvent
{
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
/// <param name="evt">The audit event to persist.</param>
public PendingAuditEvent(AuditEvent evt)
/// <param name="evt">The canonical audit event to persist.</param>
/// <param name="forwardState">Initial site-local forwarding state written to the sidecar row (always Pending for fresh events).</param>
public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
{
Event = evt;
ForwardState = forwardState;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>The audit event to persist.</summary>
/// <summary>The canonical audit event to persist.</summary>
public AuditEvent Event { get; }
/// <summary>Initial forwarding state for this row's sidecar (bound to audit_forward_state.ForwardState).</summary>
public AuditForwardState ForwardState { get; }
/// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; }
}
@@ -1,8 +1,8 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
var channel = ChannelStringToEnum(context.Channel);
return new CachedCallTelemetry(
Audit: new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = context.TrackedOperationId.Value,
Audit: ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: kind,
status: status,
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
target: context.Target,
correlationId: context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId,
executionId: context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning
// inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6.
ParentExecutionId = context.ParentExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId,
parentExecutionId: context.ParentExecutionId,
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
sourceInstanceId: context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
SourceScript = context.SourceScript,
Target = context.Target,
Status = status,
HttpStatus = httpStatus,
DurationMs = context.DurationMs,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
},
sourceScript: context.SourceScript,
httpStatus: httpStatus,
durationMs: context.DurationMs,
errorMessage: lastError),
Operational: new SiteCallOperational(
TrackedOperationId: context.TrackedOperationId,
Channel: context.Channel,
@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract
// failed, which is itself best-effort-handled.
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them.
var d = AuditRowProjection.Decompose(telemetry.Audit);
_logger.LogWarning(ex,
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
d.EventId, d.Kind, d.Status);
}
}
@@ -128,9 +130,12 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
return;
}
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript)
// ride inside DetailsJson — decompose once for this packet.
var audit = AuditRowProjection.Decompose(telemetry.Audit);
try
{
switch (telemetry.Audit.Kind)
switch (audit.Kind)
{
case AuditKind.CachedSubmit:
// Enqueue — insert-if-not-exists with the operational
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
telemetry.Operational.TrackedOperationId,
telemetry.Operational.Channel,
telemetry.Operational.Target,
telemetry.Audit.SourceInstanceId,
telemetry.Audit.SourceScript,
audit.SourceInstanceId,
audit.SourceScript,
sourceNode: _nodeIdentity?.NodeName,
ct).ConfigureAwait(false);
break;
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// forwarder.
_logger.LogWarning(
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
telemetry.Audit.Kind, telemetry.Audit.EventId);
audit.Kind, audit.EventId);
break;
}
}
@@ -1,5 +1,5 @@
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -2,10 +2,11 @@ using Akka.Actor;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
// row stays Pending (still not in emittedEventIds) and
// central reconciliation will pick it up.
_logger.LogWarning(
"Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.",
auditRow.EventId, auditRow.Kind);
"Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
auditRow.EventId, auditRow.Action);
continue;
}
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static CachedTelemetryPacket BuildCachedPacket(
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
{
var sourceSite = auditRow.SourceSiteId ?? string.Empty;
// C3: SourceSiteId + Channel ride inside the canonical record's
// DetailsJson — decompose to read them.
var audit = AuditRowProjection.Decompose(auditRow);
var sourceSite = audit.SourceSiteId ?? string.Empty;
// Channel string form mirrors the AuditChannel-to-string convention used
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
var channelString = auditRow.Channel.ToString();
var channelString = audit.Channel.ToString();
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
var operationalDto = new SiteCallOperationalDto
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -61,7 +61,9 @@ public static class BundleCommands
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names");
// Inbound API keys are not transported between environments (re-arch C4) — no
// --api-keys option. Re-create keys and re-grant their method scopes on the
// destination via the admin UI/CLI.
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
var includeDepsOption = new Option<bool>("--include-dependencies")
{
@@ -85,7 +87,6 @@ public static class BundleCommands
cmd.Add(dbConnectionsOption);
cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption);
cmd.Add(apiKeysOption);
cmd.Add(apiMethodsOption);
cmd.Add(includeDepsOption);
cmd.Add(sourceEnvOption);
@@ -106,7 +107,6 @@ public static class BundleCommands
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
NotificationListNames: result.GetValue(notificationListsOption),
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
ApiKeyNames: result.GetValue(apiKeysOption),
ApiMethodNames: result.GetValue(apiMethodsOption),
IncludeDependencies: includeDeps,
Passphrase: passphrase,
@@ -37,44 +37,108 @@ public static class SecurityCommands
group.Add(listCmd);
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true };
var createMethodsOption = new Option<string>("--methods")
{
Description = "Comma-separated API method names this key may call (e.g. \"MethodA,MethodB\")",
Required = true
};
var createCmd = new Command("create") { Description = "Create an API key" };
createCmd.Add(nameOption);
createCmd.Add(createMethodsOption);
createCmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var methods = ParseMethods(result.GetValue(createMethodsOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name));
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateApiKeyCommand(name, methods),
onSuccess: PrintCreatedKey);
});
group.Add(createCmd);
var idOption = new Option<int>("--id") { Description = "API key ID", Required = true };
var deleteKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete an API key" };
deleteCmd.Add(idOption);
deleteCmd.Add(deleteKeyIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var keyId = result.GetValue(deleteKeyIdOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id));
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(keyId));
});
group.Add(deleteCmd);
var updateIdOption = new Option<int>("--id") { Description = "API key ID", Required = true };
var updateKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateKeyIdOption);
updateCmd.Add(enabledOption);
updateCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(updateIdOption);
var keyId = result.GetValue(updateKeyIdOption)!;
var enabled = result.GetValue(enabledOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled));
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(keyId, enabled));
});
group.Add(updateCmd);
var setMethodsKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
var setMethodsOption = new Option<string>("--methods")
{
Description = "Comma-separated API method names this key may call (replaces the existing set)",
Required = true
};
var setMethodsCmd = new Command("set-methods") { Description = "Replace the method-scopes on an API key" };
setMethodsCmd.Add(setMethodsKeyIdOption);
setMethodsCmd.Add(setMethodsOption);
setMethodsCmd.SetAction(async (ParseResult result) =>
{
var keyId = result.GetValue(setMethodsKeyIdOption)!;
var methods = ParseMethods(result.GetValue(setMethodsOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetApiKeyMethodsCommand(keyId, methods));
});
group.Add(setMethodsCmd);
return group;
}
/// <summary>
/// Splits a comma-separated <c>--methods</c> value into a trimmed, non-empty list of
/// method names. A null/empty value yields an empty list (the server rejects an empty
/// scope set if its rules require one).
/// </summary>
/// <param name="raw">The raw delimited option value.</param>
private static IReadOnlyList<string> ParseMethods(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return Array.Empty<string>();
return raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
}
/// <summary>
/// Renders the create-key response, surfacing the one-time bearer token prominently —
/// it is the only moment the secret is available and cannot be retrieved afterwards.
/// The advisory line is written to stderr so that piping stdout captures only the token.
/// </summary>
/// <param name="json">The JSON success body returned by the management API.</param>
internal static int PrintCreatedKey(string json)
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
var keyId = root.TryGetProperty("keyId", out var k) ? k.GetString() : null;
var token = root.TryGetProperty("token", out var t) ? t.GetString() : null;
Console.WriteLine($"API key created. KeyId: {keyId}");
Console.WriteLine();
Console.Error.WriteLine("Save this token now — it will not be shown again:");
Console.WriteLine($" {token}");
return 0;
}
private static Command BuildRoleMapping(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
@@ -31,20 +33,35 @@ public static class AuthEndpoints
return;
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
context.Response.Redirect($"/login?error={errorMsg}");
return;
}
// Map LDAP groups to roles
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
// (Task 1.1 ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
// The full RoleMappingResult — including PermittedSiteIds and the
// system-wide flag — is carried in the mapping's opaque Scope so the
// site-scope→SiteId claims below are built exactly as before.
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// The ScadaBridge mapper carries the full RoleMappingResult in the seam's
// opaque Scope (see ScadaBridgeGroupRoleMapper). Guard the unwrap (review I4):
// a future/alternate IGroupRoleMapper<string> could leave Scope null or set a
// different type. Rather than throw InvalidCastException mid-login, fall back to
// the most restrictive interpretation — not a system-wide deployment and no
// permitted sites — so no SiteId claims are stamped (deny-by-omission). The real
// ScadaBridge mapper always supplies a RoleMappingResult, so behaviour is unchanged.
var scope = roleMapping.Scope is RoleMappingResult mapped
? mapped
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
// Build claims from LDAP auth + role mapping.
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
@@ -52,27 +69,40 @@ public static class AuthEndpoints
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
// SlidingExpiration = true). A frozen absolute claim would contradict
// the documented sliding-refresh policy.
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
var claims = new List<Claim>
{
new(ClaimTypes.Name, authResult.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
new(ClaimTypes.Name, resolvedUsername),
new(JwtTokenService.DisplayNameClaimType, displayName),
new(JwtTokenService.UsernameClaimType, resolvedUsername),
};
foreach (var role in roleMappingResult.Roles)
foreach (var role in roleMapping.Roles)
{
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
}
if (!roleMappingResult.IsSystemWideDeployment)
if (!scope.IsSystemWideDeployment)
{
foreach (var siteId in roleMappingResult.PermittedSiteIds)
foreach (var siteId in scope.PermittedSiteIds)
{
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
}
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
// Task 1.5: name the role/name claim types explicitly so the cookie
// principal's IsInRole / [Authorize(Roles=…)] resolve against the same
// canonical types we mint (JwtTokenService.RoleClaimType = ZbClaimTypes.Role,
// ClaimTypes.Name = ZbClaimTypes.Name). The policies use
// RequireClaim(RoleClaimType, …) which checks type+value directly, but
// pinning roleType keeps IsInRole-style checks consistent and survives the
// cookie serialize/round-trip.
var identity = new ClaimsIdentity(
claims,
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
nameType: ClaimTypes.Name,
roleType: JwtTokenService.RoleClaimType);
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(
@@ -94,33 +124,43 @@ public static class AuthEndpoints
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
return Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed." },
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
statusCode: 401);
}
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// Guard the opaque-Scope unwrap (review I4); see the matching note on
// /auth/login. Fall back to no site-scope rather than throwing if a future
// mapper leaves Scope null or sets a different type.
var scope = roleMapping.Scope is RoleMappingResult mapped
? mapped
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
var token = jwtService.GenerateToken(
authResult.DisplayName ?? username,
authResult.Username ?? username,
roleMappingResult.Roles,
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
displayName,
resolvedUsername,
roleMapping.Roles,
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
username = authResult.Username ?? username,
display_name = authResult.DisplayName ?? username,
roles = roleMappingResult.Roles,
username = resolvedUsername,
display_name = displayName,
roles = roleMapping.Roles,
});
}).DisableAntiforgery();
@@ -1,4 +1,4 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
/// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
@@ -20,7 +20,7 @@ public partial class AuditDrilldownDrawer
/// The row to render. When null the drawer renders nothing — the host
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
/// </summary>
[Parameter] public AuditEvent? Event { get; set; }
[Parameter] public AuditEventView? Event { get; set; }
/// <summary>
/// True when the host wants the drawer visible. We deliberately keep
@@ -1,4 +1,4 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
@@ -66,7 +66,7 @@ public partial class AuditEventDetail
/// The row to render. Required and non-null — the host (drawer or modal)
/// only mounts this component once it has a row to show.
/// </summary>
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
[Parameter, EditorRequired] public AuditEventView Event { get; set; } = null!;
private const string RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>";
@@ -303,7 +303,7 @@ public partial class AuditEventDetail
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
/// </summary>
private static string BuildCurlCommand(AuditEvent ev)
private static string BuildCurlCommand(AuditEventView ev)
{
var sb = new StringBuilder();
sb.Append("curl");
@@ -1,6 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@inject IAuditLogQueryService QueryService
@@ -103,7 +102,7 @@
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
private RenderFragment RenderCell(string key, AuditEventView row) => __builder =>
{
switch (key)
{
@@ -1,7 +1,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -61,7 +61,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new();
private readonly List<AuditEventView> _rows = new();
private int _pageNumber = 1;
private bool _loading;
private string? _error;
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// <summary>
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
/// drawer. The event payload is the full <see cref="AuditEventView"/>.
/// </summary>
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
[Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
private int _pageSize => Math.Max(1, PageSize);
@@ -289,7 +289,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
}
}
private async Task HandleRowClick(AuditEvent row)
private async Task HandleRowClick(AuditEventView row)
{
if (OnRowSelected.HasDelegate)
{
@@ -1,4 +1,4 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@* Execution-Tree Node Detail Modal (Task 3).
Opened from an execution-tree node double-click. Given an ExecutionId it
@@ -2,7 +2,6 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
private IReadOnlyList<AuditEventView> _rows = Array.Empty<AuditEventView>();
// The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow;
private AuditEventView? _selectedRow;
private bool _loading;
private string? _error;
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
_loading = true;
_error = null;
_selectedRow = null;
_rows = Array.Empty<AuditEvent>();
_rows = Array.Empty<AuditEventView>();
if (ExecutionId is null)
{
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>();
_rows = Array.Empty<AuditEventView>();
_selectedRow = null;
}
finally
@@ -144,7 +143,7 @@ public partial class ExecutionDetailModal
}
}
private void SelectRow(AuditEvent row) => _selectedRow = row;
private void SelectRow(AuditEventView row) => _selectedRow = row;
private void BackToList() => _selectedRow = null;
@@ -1,26 +1,28 @@
@inherits LayoutComponentBase
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
@* Hamburger toggle: visible only on viewports <lg.
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="sidebar-collapse">
@* The side-rail chassis (brand bar + responsive hamburger) is the shared
ZB.MOM.WW.Theme ThemeShell. NavMenu fills the rail's <Nav> slot with the
policy-gated nav groups; the session/sign-out block fills <RailFooter>. *@
<ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
<Nav>
<NavMenu />
</div>
<main class="flex-grow-1 p-3">
@Body
</main>
</div>
</Nav>
<RailFooter>
<AuthorizeView>
<Authorized>
@* CentralUI-024: claim type resolved via JwtTokenService. *@
<span class="rail-user">@context.User.GetDisplayName()</span>
<form method="post" action="/auth/logout" data-enhance="false">
@* CentralUI-017: logout is a state-changing POST and is
CSRF-protected — the antiforgery token is required. *@
<AntiforgeryToken />
<button type="submit" class="rail-btn">Sign Out</button>
</form>
</Authorized>
</AuthorizeView>
</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
@* Global host for IDialogService. One instance per layout renders all confirm/prompt
dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@
@@ -1,323 +1,117 @@
@using System.Linq
@using ZB.MOM.WW.ScadaBridge.Security
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
<nav class="sidebar d-flex flex-column">
<div class="brand"><span class="mark">&#9646;</span> ScadaBridge</div>
@* Rail navigation — rendered inside ThemeShell's <Nav> slot. The chassis
(brand bar + responsive hamburger) belongs to ThemeShell; this component
contributes only the nav items. Collapsible sections use the kit's
NavRailSection (<details>); their open/closed state is persisted client-side
by the kit's nav-state.js (localStorage, keyed by Key) — no JS interop here. *@
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
<ul class="nav flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
</li>
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
<AuthorizeView>
<Authorized>
@* Admin section — Admin role only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="adminContext">
<NavSection Title="Admin"
Expanded="@_expanded.Contains("admin")"
OnToggle="@(() => ToggleAsync("admin"))">
<li class="nav-item">
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
</li>
@* Import Bundle requires Admin only — Design role is not sufficient.
Export Bundle lives in the Design section (RequireDesign). *@
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
<AuthorizeView>
<Authorized>
@* Admin section — Administrator role only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="adminContext">
<NavRailSection Title="Admin" Key="admin">
<NavRailItem Href="/admin/ldap-mappings" Text="LDAP Mappings" />
<NavRailItem Href="/admin/sites" Text="Sites" />
<NavRailItem Href="/admin/api-keys" Text="API Keys" />
@* Import Bundle requires Administrator only — Designer role is not sufficient.
Export Bundle lives in the Design section (RequireDesign). *@
<NavRailItem Href="/design/transport/import" Text="Import Bundle" />
</NavRailSection>
</Authorized>
</AuthorizeView>
@* Design section — Design role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="designContext">
<NavSection Title="Design"
Expanded="@_expanded.Contains("design")"
OnToggle="@(() => ToggleAsync("design"))">
<li class="nav-item">
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Design section — Designer role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="designContext">
<NavRailSection Title="Design" Key="design">
<NavRailItem Href="/design/templates" Text="Templates" />
<NavRailItem Href="/design/shared-scripts" Text="Shared Scripts" />
<NavRailItem Href="/design/connections" Text="Connections" />
<NavRailItem Href="/design/external-systems" Text="External Systems" />
<NavRailItem Href="/design/transport/export" Text="Export Bundle" />
</NavRailSection>
</Authorized>
</AuthorizeView>
@* Deployment section — Deployment role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="deploymentContext">
<NavSection Title="Deployment"
Expanded="@_expanded.Contains("deployment")"
OnToggle="@(() => ToggleAsync("deployment"))">
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Deployment section — Deployer role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="deploymentContext">
<NavRailSection Title="Deployment" Key="deployment">
<NavRailItem Href="/deployment/topology" Text="Topology" />
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
</NavRailSection>
</Authorized>
</AuthorizeView>
@* Notifications — mixed-role section; each item gated by its own policy.
The section is ungated: every authenticated user holds at least one of
Admin/Design/Deployment, so it always has a visible child. *@
<NavSection Title="Notifications"
Expanded="@_expanded.Contains("notifications")"
OnToggle="@(() => ToggleAsync("notifications"))">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="notifAdminContext">
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
</li>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="notifDesignContext">
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
</li>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="notifDeploymentContext">
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
</li>
</Authorized>
</AuthorizeView>
</NavSection>
@* Site Calls — Site Call Audit (#22). Deployment-role only,
matching the Notification Report page's gate; the whole
section sits inside the policy block so a non-Deployment
user does not see the heading. *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="siteCallsContext">
<NavSection Title="Site Calls"
Expanded="@_expanded.Contains("sitecalls")"
OnToggle="@(() => ToggleAsync("sitecalls"))">
<li class="nav-item">
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployment-role only (Component-CentralUI).
The section is ungated because Health Dashboard is always
a visible child. *@
<NavSection Title="Monitoring"
Expanded="@_expanded.Contains("monitoring")"
OnToggle="@(() => ToggleAsync("monitoring"))">
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
</li>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="monitoringContext">
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
</li>
</Authorized>
</AuthorizeView>
</NavSection>
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
Configuration Audit Log (IAuditService config-change
viewer). The whole section sits inside the policy block:
a non-audit user does not even see the heading.
OperationalAudit is satisfied by the Admin, Audit, and
AuditReadOnly roles. *@
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
<Authorized Context="auditContext">
<NavSection Title="Audit"
Expanded="@_expanded.Contains("audit")"
OnToggle="@(() => ToggleAsync("audit"))">
<li class="nav-item">
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Notifications — mixed-role section; each item gated by its own policy.
The section is ungated: every authenticated user holds at least one of
Admin/Design/Deployment, so it always has a visible child. *@
<NavRailSection Title="Notifications" Key="notifications">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="notifAdminContext">
<NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
</Authorized>
</AuthorizeView>
</ul>
</div>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="notifDesignContext">
<NavRailItem Href="/notifications/lists" Text="Notification Lists" />
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="notifDeploymentContext">
<NavRailItem Href="/notifications/report" Text="Notification Report" />
<NavRailItem Href="/notifications/kpis" Text="Notification KPIs" />
</Authorized>
</AuthorizeView>
</NavRailSection>
<AuthorizeView>
<Authorized>
<div class="border-top px-3 py-2">
<div class="d-flex justify-content-between align-items-center">
@* CentralUI-024: claim type resolved via JwtTokenService. *@
<span class="text-body-secondary small">@context.User.GetDisplayName()</span>
<form method="post" action="/auth/logout" data-enhance="false">
@* CentralUI-017: logout is a state-changing POST and is
CSRF-protected — the antiforgery token is required. *@
<AntiforgeryToken />
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
</form>
</div>
</div>
</Authorized>
</AuthorizeView>
</nav>
@* Site Calls — Site Call Audit (#22). Deployer-role only,
matching the Notification Report page's gate; the whole
section sits inside the policy block so a non-Deployer
user does not see the heading. *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="siteCallsContext">
<NavRailSection Title="Site Calls" Key="sitecalls">
<NavRailItem Href="/site-calls/report" Text="Site Calls" />
</NavRailSection>
</Authorized>
</AuthorizeView>
@code {
// Expanded-section state persists in the "scadabridge_nav" cookie, written
// by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a
// comma-separated list of section ids.
@* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployer-role only (Component-CentralUI).
The section is ungated because Health Dashboard is always
a visible child. *@
<NavRailSection Title="Monitoring" Key="monitoring">
<NavRailItem Href="/monitoring/health" Text="Health Dashboard" />
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="monitoringContext">
<NavRailItem Href="/monitoring/event-logs" Text="Event Logs" />
<NavRailItem Href="/monitoring/parked-messages" Text="Parked Messages" />
</Authorized>
</AuthorizeView>
</NavRailSection>
// Every collapsible section id. Also the allow-list for parsing the cookie.
private static readonly string[] SectionIds =
{ "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" };
// The currently-expanded sections. Populated from the cookie on first
// render; mutated by ToggleAsync and by navigating into a section.
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
// Hydrate from the cookie. Until this completes the sidebar paints
// collapsed (the "collapsed by default" state) — matching how TreeView
// hydrates its expand state in OnAfterRenderAsync(firstRender).
string saved;
try
{
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
}
catch (JSDisconnectedException)
{
return;
}
foreach (var id in saved.Split(
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Array.IndexOf(SectionIds, id) >= 0)
{
_expanded.Add(id);
}
}
// The section of the page we loaded on is always expanded.
if (EnsureCurrentSectionExpanded())
{
await PersistAsync();
}
StateHasChanged();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
// Navigating into a collapsed section expands it (and remembers it).
if (EnsureCurrentSectionExpanded())
{
_ = PersistAsync();
_ = InvokeAsync(StateHasChanged);
}
}
private async Task ToggleAsync(string id)
{
if (!_expanded.Remove(id))
{
_expanded.Add(id);
}
await PersistAsync();
}
// Adds the current page's section to _expanded; returns true if it changed.
private bool EnsureCurrentSectionExpanded()
{
var section = CurrentSection();
return section is not null && _expanded.Add(section);
}
// Maps the current URL's first path segment to a section id, or null for
// sectionless pages (Dashboard, Login).
private string? CurrentSection()
{
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
var firstSegment = relative.Split('?', '#')[0]
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return firstSegment switch
{
"admin" => "admin",
"design" => "design",
"deployment" => "deployment",
"notifications" => "notifications",
"site-calls" => "sitecalls",
"monitoring" => "monitoring",
"audit" => "audit",
_ => null,
};
}
private async Task PersistAsync()
{
try
{
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
}
catch (JSDisconnectedException)
{
// The circuit is gone — nothing to persist to.
}
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
Configuration Audit Log (IAuditService config-change
viewer). The whole section sits inside the policy block:
a non-audit user does not even see the heading.
OperationalAudit is satisfied by the Administrator and
Viewer roles (post-Task-1.7 canonical collapse: former
Audit→Administrator, AuditReadOnly→Viewer). *@
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
<Authorized Context="auditContext">
<NavRailSection Title="Audit" Key="audit">
<NavRailItem Href="/audit/log" Text="Audit Log" />
<NavRailItem Href="/audit/configuration" Text="Configuration Audit Log" />
</NavRailSection>
</Authorized>
</AuthorizeView>
</Authorized>
</AuthorizeView>
@@ -1,35 +0,0 @@
@* A collapsible sidebar nav section: an uppercase-eyebrow header button that
toggles the visibility of its child nav items. The header <li> and the item
<li>s (ChildContent) render as siblings inside NavMenu's <ul>. *@
<li class="nav-item">
<button type="button"
class="nav-section-toggle"
@onclick="OnToggle"
aria-expanded="@(Expanded ? "true" : "false")">
<i class="bi @(Expanded ? "bi-chevron-down" : "bi-chevron-right")" aria-hidden="true"></i>
<span>@Title</span>
</button>
</li>
@if (Expanded)
{
@ChildContent
}
@code {
/// <summary>Section label shown in the header (e.g. "Deployment").</summary>
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
/// <summary>Whether the section is expanded — its items rendered.</summary>
[Parameter]
public bool Expanded { get; set; }
/// <summary>Raised when the header button is clicked.</summary>
[Parameter]
public EventCallback OnToggle { get; set; }
/// <summary>The section's nav items, rendered only while expanded.</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,9 +1,11 @@
@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@page "/admin/api-keys/{KeyId}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@@ -46,15 +48,16 @@
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
else if (_saved && _newlyCreatedToken != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
<div class="d-flex align-items-center mt-2">
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
@@ -66,39 +69,37 @@
{
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
@* Name is fixed on edit — the seam has no rename. *@
<input type="text" class="form-control form-control-sm" @bind="_formName" disabled="@IsEditMode" />
</div>
<div class="mb-2">
<label class="form-label small">API Method Access</label>
@if (_allMethods.Count == 0)
{
<div class="form-text">
No API methods configured.
<a href="/design/external-systems">Create one</a> to grant access.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var method in _allMethods.OrderBy(m => m.Name))
{
var checkboxId = $"method-access-{method.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedMethodNames.Contains(method.Name)"
@onchange="e => ToggleMethod(method.Name, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">@method.Name</label>
</div>
}
</div>
<div class="form-text">
Callers using this key can invoke any checked method. At least one is required.
</div>
}
</div>
@if (IsEditMode)
{
<div class="mb-2">
<label class="form-label small">API Method Access</label>
@if (_allMethods.Count == 0)
{
<div class="form-text">
No API methods configured.
<a href="/design/external-systems">Create one</a> to grant access.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var method in _allMethods.OrderBy(m => m.Name))
{
var checkboxId = $"method-access-{method.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedMethodIds.Contains(method.Id)"
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">@method.Name</label>
</div>
}
</div>
<div class="form-text">
Callers using this key can invoke any checked method.
</div>
}
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
@@ -111,21 +112,26 @@
</div>
@code {
[Parameter] public int? Id { get; set; }
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam.
// Keys are identified by an opaque string KeyId; method access is a set of method
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
[Parameter] public string? KeyId { get; set; }
private bool IsEditMode => _editingKey != null;
private ApiKey? _editingKey;
private InboundApiKeyInfo? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private string? _newlyCreatedToken;
private string? _newlyCreatedKeyId;
private bool _loading = true;
private bool _saved;
private List<ApiMethod> _allMethods = new();
private HashSet<int> _initialMethodIds = new();
private HashSet<int> _selectedMethodIds = new();
// Selection set is method NAMES (scopes), not method ids.
private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
private ToastNotification _toast = default!;
@@ -133,22 +139,23 @@
{
try
{
if (Id.HasValue)
// Methods always come from SQL Server (methods stay on the repository).
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
if (!string.IsNullOrWhiteSpace(KeyId))
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
// No single-key getter on the seam — locate this key in the full list.
var all = await ApiKeyAdmin.ListAsync();
_editingKey = all.FirstOrDefault(k => string.Equals(k.KeyId, KeyId, StringComparison.Ordinal));
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
_errorMessage = $"API key '{KeyId}' not found.";
}
else
{
_formName = _editingKey.Name;
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_initialMethodIds = _allMethods
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
.Select(m => m.Id)
.ToHashSet();
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
_selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
}
}
}
@@ -162,40 +169,38 @@
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (!IsEditMode && string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
// The seam/server reject empty scope sets; validate in the UI for a clear message.
if (_selectedMethodNames.Count == 0)
{
_formError = "Select at least one API method for this key.";
return;
}
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
var changedIds = _selectedMethodIds
.Except(_initialMethodIds)
.Concat(_initialMethodIds.Except(_selectedMethodIds))
.ToHashSet();
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
// Edit: name is fixed; only the method-scope set is mutable.
var ok = await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
if (!ok)
{
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
else ids.Remove(_editingKey.Id);
method.ApprovedApiKeyIds = ids.Count == 0
? null
: string.Join(",", ids.OrderBy(x => x));
await InboundApiRepository.UpdateApiMethodAsync(method);
_formError = $"API key '{_editingKey.Name}' was not found. Reload and retry.";
return;
}
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
_newlyCreatedKeyId = created.KeyId;
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
_saved = true;
}
}
@@ -207,28 +212,18 @@
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void ToggleMethod(int methodId, bool isChecked)
private void ToggleMethod(string methodName, bool isChecked)
{
if (isChecked) _selectedMethodIds.Add(methodId);
else _selectedMethodIds.Remove(methodId);
}
private static HashSet<int> ParseApprovedKeyIds(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet<int>();
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
.Where(id => id > 0)
.ToHashSet();
if (isChecked) _selectedMethodNames.Add(methodName);
else _selectedMethodNames.Remove(methodName);
}
private async Task CopyKeyToClipboard()
{
if (_newlyCreatedKeyValue == null) return;
if (_newlyCreatedToken == null) return;
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
@@ -236,12 +231,4 @@
_toast.ShowError("Copy failed.");
}
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}
@@ -1,9 +1,8 @@
@page "/admin/api-keys"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
@@ -44,29 +43,29 @@
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Key ID</th>
<th>Name</th>
<th>Key Hash</th>
<th>Methods</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var key in FilteredKeys)
{
<tr @key="key.Id">
<td>@key.Id</td>
<tr @key="key.KeyId">
<td><code>@TruncateKeyId(key.KeyId)</code></td>
<td>
@key.Name
@if (!key.IsEnabled)
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</td>
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
<td>@key.Methods.Count</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
data-bs-toggle="dropdown"
@@ -75,7 +74,7 @@
<li>
<button class="dropdown-item"
@onclick="() => ToggleKey(key)">
@(key.IsEnabled ? "Disable" : "Enable")
@(key.Enabled ? "Disable" : "Enable")
</button>
</li>
<li><hr class="dropdown-divider" /></li>
@@ -98,14 +97,17 @@
</div>
@code {
private List<ApiKey> _keys = new();
// Inbound-API key re-arch (C3): this page reads keys from the IInboundApiKeyAdmin seam
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
private List<InboundApiKeyInfo> _keys = new();
private bool _loading = true;
private string? _errorMessage;
private string _search = string.Empty;
private ToastNotification _toast = default!;
private IEnumerable<ApiKey> FilteredKeys =>
private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
string.IsNullOrWhiteSpace(_search)
? _keys
: _keys.Where(k =>
@@ -122,7 +124,7 @@
_errorMessage = null;
try
{
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
_keys = (await ApiKeyAdmin.ListAsync()).ToList();
}
catch (Exception ex)
{
@@ -131,20 +133,28 @@
_loading = false;
}
private static string MaskKeyValue(string keyValue)
// Show a short, recognizable prefix of the opaque KeyId rather than the full 32-char value.
private static string TruncateKeyId(string keyId)
{
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
if (string.IsNullOrEmpty(keyId)) return keyId;
return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
}
private async Task ToggleKey(ApiKey key)
private async Task ToggleKey(InboundApiKeyInfo key)
{
try
{
key.IsEnabled = !key.IsEnabled;
await InboundApiRepository.UpdateApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
var newEnabled = !key.Enabled;
// The seam persists; there is no separate SaveChangesAsync.
var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
if (!ok)
{
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
await LoadDataAsync();
return;
}
_toast.ShowSuccess($"API key '{key.Name}' {(newEnabled ? "enabled" : "disabled")}.");
await LoadDataAsync();
}
catch (Exception ex)
{
@@ -152,7 +162,7 @@
}
}
private async Task DeleteKey(ApiKey key)
private async Task DeleteKey(InboundApiKeyInfo key)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete API Key",
@@ -162,8 +172,13 @@
try
{
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
await InboundApiRepository.SaveChangesAsync();
var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
if (!ok)
{
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
await LoadDataAsync();
return;
}
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
await LoadDataAsync();
}
@@ -30,11 +30,12 @@
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
<option value="@Roles.Administrator">Administrator</option>
<option value="@Roles.Designer">Designer</option>
<option value="@Roles.Deployer">Deployer</option>
<option value="@Roles.Viewer">Viewer</option>
</select>
<div class="form-text">Deployment role: configure site scope below after saving.</div>
<div class="form-text">Deployer role: configure site scope below after saving.</div>
</div>
@if (_formError != null)
{
@@ -2,7 +2,6 @@
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Security
@inject IAuditLogQueryService AuditLogQueryService
@@ -2,7 +2,7 @@ using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
[Inject] private NavigationManager Navigation { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter;
private AuditEvent? _selectedEvent;
private AuditEventView? _selectedEvent;
private bool _drawerOpen;
private string? _initialInstanceSearch;
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
_currentFilter = filter;
}
private void HandleRowSelected(AuditEvent row)
private void HandleRowSelected(AuditEventView row)
{
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
// the selected row and open the drilldown drawer — the drawer is fully
@@ -1,9 +1,10 @@
@page "/"
@attribute [Authorize]
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject IInboundApiRepository InboundApiRepository
@inject IInboundApiKeyAdmin ApiKeyAdmin
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -108,7 +109,7 @@
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
_apiKeyCount = (await ApiKeyAdmin.ListAsync()).Count;
}
catch
{
@@ -3,9 +3,12 @@
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject NavigationManager NavigationManager
@@ -44,14 +47,14 @@
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var key in _allKeys)
{
var checkboxId = $"approved-key-{key.Id}";
var checkboxId = $"approved-key-{key.KeyId}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedKeyIds.Contains(key.Id)"
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
checked="@_selectedKeyIds.Contains(key.KeyId)"
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">
@key.Name
@if (!key.IsEnabled)
@if (!key.Enabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
@@ -195,9 +198,15 @@
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
// Inbound-API key re-arch (C3): the approved-keys list is driven by the IInboundApiKeyAdmin
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to
// per-key method-scopes. Keys are identified by an opaque string KeyId.
private ApiMethod? _existing;
private List<ApiKey> _allKeys = new();
private HashSet<int> _selectedKeyIds = new();
private List<InboundApiKeyInfo> _allKeys = new();
private HashSet<string> _selectedKeyIds = new(StringComparer.Ordinal);
// Keys approved for this method when the form loaded (empty on create), for diffing on save.
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
private bool _showTestRun;
private bool _running;
@@ -209,7 +218,8 @@
{
try
{
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
// All keys come from the seam (hash-free projection).
_allKeys = (await ApiKeyAdmin.ListAsync()).ToList();
}
catch (Exception ex) { _formError = ex.Message; }
@@ -225,7 +235,10 @@
_timeoutSeconds = _existing.TimeoutSeconds;
_params = _existing.ParameterDefinitions;
_returns = _existing.ReturnDefinition;
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
// Seed approved keys from the seam: which keys' scopes contain this method.
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
}
}
catch (Exception ex) { _formError = ex.Message; }
@@ -233,25 +246,12 @@
_loading = false;
}
private static HashSet<int> ParseApprovedKeyIds(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet<int>();
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
.Where(id => id > 0)
.ToHashSet();
}
private void ToggleKey(int keyId, bool isChecked)
private void ToggleKey(string keyId, bool isChecked)
{
if (isChecked) _selectedKeyIds.Add(keyId);
else _selectedKeyIds.Remove(keyId);
}
private string? SerializeApprovedKeyIds() =>
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
private async Task Save()
{
_formError = null;
@@ -263,15 +263,18 @@
try
{
var approvedKeyIds = SerializeApprovedKeyIds();
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
// key scopes against it. The method entity stays in SQL Server; we leave the
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
string methodName;
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
_existing.ApprovedApiKeyIds = approvedKeyIds;
await InboundApiRepository.UpdateApiMethodAsync(_existing);
methodName = _existing.Name;
}
else
{
@@ -279,17 +282,81 @@
{
TimeoutSeconds = _timeoutSeconds,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim(),
ApprovedApiKeyIds = approvedKeyIds
ReturnDefinition = _returns?.Trim()
};
await InboundApiRepository.AddApiMethodAsync(m);
methodName = m.Name;
}
await InboundApiRepository.SaveChangesAsync();
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
if (!await ReconcileKeyScopesAsync(methodName)) return;
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
/// <summary>
/// Pushes this method's name into / out of each affected key's scope set. Returns false
/// (leaving a form error) when a revoke would empty a key's scopes — the server rejects
/// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected
/// key so we never clobber unrelated method-scopes.
/// </summary>
private async Task<bool> ReconcileKeyScopesAsync(string methodName)
{
var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal)
.Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal))
.ToHashSet(StringComparer.Ordinal);
// Read each affected key's CURRENT full scope set so add/remove preserves other methods.
var currentMethodsByKey = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
foreach (var keyId in affected)
{
currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId);
}
var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal);
var plan = ApiMethodKeyScopeReconciler.Reconcile(
methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById);
// Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it).
if (plan.EmptyScopeKeyNames.Count > 0)
{
var names = string.Join(", ", plan.EmptyScopeKeyNames);
_formError =
$"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " +
"Disable or delete the key instead, or grant it another method first.";
return false;
}
try
{
foreach (var update in plan.Updates)
{
var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
if (!ok)
throw new InvalidOperationException(
$"Key '{NameFor(update.KeyId)}' was not found in the key store.");
}
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Method '{methodName}' was saved, but updating approved-key scopes failed partway: {ex.Message} " +
"Some keys may be partially updated — review them on the API Keys page and retry.", ex);
}
// Selection is now the baseline (matters if save is retried without reload).
_initialKeyIds = new HashSet<string>(_selectedKeyIds, StringComparer.Ordinal);
return true;
}
// Returns the display name for a keyId if available from the loaded key list, else the id itself.
private string NameFor(string keyId) =>
_allKeys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))?.Name ?? keyId;
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
@@ -138,14 +138,15 @@
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
</fieldset>
<fieldset class="mb-4" data-testid="group-api-keys">
<legend class="h6">API Keys</legend>
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
</fieldset>
<fieldset class="mb-4" data-testid="group-api-methods">
<legend class="h6">API Methods</legend>
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
<strong>API keys are not part of config transport.</strong> Inbound API keys
live in each environment's own secret store and cannot be exported. After
importing, re-create the keys on the destination and re-grant their method
scopes via the admin UI/CLI.
</div>
</fieldset>
<div class="d-flex justify-content-end gap-2 mt-4">
@@ -261,10 +262,7 @@
{
<li>SmtpConfig: @s.Host</li>
}
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ApiKey: @k.Name</li>
}
@* Inbound API keys are not transported (re-arch C4) — methods only. *@
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ApiMethod: @m.Name</li>
@@ -69,7 +69,7 @@ public partial class TransportExport : ComponentBase
private List<DatabaseConnectionDefinition> _dbConnections = new();
private List<NotificationList> _notificationLists = new();
private List<SmtpConfiguration> _smtpConfigs = new();
private List<ApiKey> _apiKeys = new();
// Inbound API keys are not transported between environments (re-arch C4); only methods.
private List<ApiMethod> _apiMethods = new();
// ---- Step 1: selection state ----
@@ -82,7 +82,7 @@ public partial class TransportExport : ComponentBase
private readonly HashSet<int> _selectedDbConnections = new();
private readonly HashSet<int> _selectedNotificationLists = new();
private readonly HashSet<int> _selectedSmtpConfigs = new();
private readonly HashSet<int> _selectedApiKeys = new();
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
private readonly HashSet<int> _selectedApiMethods = new();
private string _filter = string.Empty;
private bool _includeDependencies = true;
@@ -124,7 +124,7 @@ public partial class TransportExport : ComponentBase
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
}
catch (Exception ex)
@@ -169,7 +169,6 @@ public partial class TransportExport : ComponentBase
|| _selectedDbConnections.Count > 0
|| _selectedNotificationLists.Count > 0
|| _selectedSmtpConfigs.Count > 0
|| _selectedApiKeys.Count > 0
|| _selectedApiMethods.Count > 0;
private bool PassphraseValid =>
@@ -205,7 +204,7 @@ public partial class TransportExport : ComponentBase
DatabaseConnectionIds: _selectedDbConnections.ToList(),
NotificationListIds: _selectedNotificationLists.ToList(),
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
ApiKeyIds: _selectedApiKeys.ToList(),
// Inbound API keys are not transported (re-arch C4) — methods only.
ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies);
}
@@ -393,7 +392,6 @@ public partial class TransportExport : ComponentBase
_selectedDbConnections.Clear();
_selectedNotificationLists.Clear();
_selectedSmtpConfigs.Clear();
_selectedApiKeys.Clear();
_selectedApiMethods.Clear();
_filter = string.Empty;
_includeDependencies = true;
@@ -3,34 +3,10 @@
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
<div class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm" style="max-width: 400px; width: 100%;">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">ScadaBridge</h4>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
}
<form method="post" action="/auth/login" data-enhance="false">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
required autocomplete="username" autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</div>
</div>
<LoginCard Product="ScadaBridge" Action="/auth/login" Error="@ErrorMessage">
<AntiforgeryToken />
</LoginCard>
@code {
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorMessage { get; set; }
[SupplyParameterFromQuery(Name = "error")] public string? ErrorMessage { get; set; }
}
@@ -0,0 +1,99 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Pure helper for the inbound-API-key re-arch (C3) on the API-method form. The approval
/// relationship moved from <c>ApiMethod.ApprovedApiKeyIds</c> (a CSV on the method) onto
/// per-key method-scopes managed through <c>IInboundApiKeyAdmin</c>. When an operator edits
/// the "Approved API Keys" list for one method, we must reconcile that method's NAME into (or
/// out of) each affected key's scope set — without touching keys whose membership did not change.
/// </summary>
/// <remarks>
/// Factored out of the Blazor component so the add/remove/empty-guard logic is unit-testable
/// independent of bUnit rendering.
/// </remarks>
public static class ApiMethodKeyScopeReconciler
{
/// <summary>
/// The recomputed scope set for one key that must be persisted via
/// <c>IInboundApiKeyAdmin.SetMethodsAsync</c>.
/// </summary>
/// <param name="KeyId">The key whose scopes changed.</param>
/// <param name="NewMethods">The full new method-scope set for that key (always non-empty —
/// the empty case is reported as a conflict instead).</param>
public sealed record KeyScopeUpdate(string KeyId, IReadOnlyList<string> NewMethods);
/// <summary>
/// Outcome of reconciling one method's approved-key selection against the keys' current scopes.
/// </summary>
/// <param name="Updates">Per-affected-key new scope sets to push. Empty when nothing changed.</param>
/// <param name="EmptyScopeKeyNames">Display names of keys for which revoking this method would
/// leave zero scopes. When this is non-empty the caller MUST NOT apply <see cref="Updates"/> —
/// it should surface a validation error and abort, because the server rejects empty scope sets.</param>
public sealed record ReconcileResult(
IReadOnlyList<KeyScopeUpdate> Updates,
IReadOnlyList<string> EmptyScopeKeyNames);
/// <summary>
/// Computes the scope edits needed so that exactly the keys in <paramref name="selectedKeyIds"/>
/// are scoped to <paramref name="methodName"/>.
/// </summary>
/// <param name="methodName">The method whose approval set is being edited. Must already exist
/// in the store (save the ApiMethod entity FIRST so its name resolves).</param>
/// <param name="selectedKeyIds">Keys the operator wants approved for this method (after edit).</param>
/// <param name="initialKeyIds">Keys that were approved for this method when the form loaded
/// (empty on create).</param>
/// <param name="currentMethodsByKey">Each affected key's CURRENT full scope set, keyed by KeyId.
/// Read fresh from the seam right before reconciling so concurrent edits do not get clobbered.</param>
/// <param name="keyNamesById">Display names by KeyId, for human-readable empty-scope messages.</param>
public static ReconcileResult Reconcile(
string methodName,
IReadOnlySet<string> selectedKeyIds,
IReadOnlySet<string> initialKeyIds,
IReadOnlyDictionary<string, IReadOnlyList<string>> currentMethodsByKey,
IReadOnlyDictionary<string, string> keyNamesById)
{
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
ArgumentNullException.ThrowIfNull(selectedKeyIds);
ArgumentNullException.ThrowIfNull(initialKeyIds);
ArgumentNullException.ThrowIfNull(currentMethodsByKey);
ArgumentNullException.ThrowIfNull(keyNamesById);
var added = selectedKeyIds.Except(initialKeyIds, StringComparer.Ordinal);
var removed = initialKeyIds.Except(selectedKeyIds, StringComparer.Ordinal);
var updates = new List<KeyScopeUpdate>();
var emptyScopeKeyNames = new List<string>();
// Approving: add this method's name to the key's current scope set (idempotent).
foreach (var keyId in added)
{
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
? m
: (IReadOnlyList<string>)Array.Empty<string>();
var next = new HashSet<string>(current, StringComparer.Ordinal) { methodName };
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
}
// Revoking: remove this method's name. If that empties the key, it is a conflict —
// the server rejects empty scope sets, so we report it and the caller must abort.
foreach (var keyId in removed)
{
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
? m
: (IReadOnlyList<string>)Array.Empty<string>();
var next = new HashSet<string>(current, StringComparer.Ordinal);
next.Remove(methodName);
if (next.Count == 0)
{
emptyScopeKeyNames.Add(
keyNamesById.TryGetValue(keyId, out var name) ? name : keyId);
continue;
}
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
}
return new ReconcileResult(updates, emptyScopeKeyNames);
}
}
@@ -0,0 +1,104 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Flattened, typed view of a canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for the
/// Central UI audit pages. C3 (Task 2.5) made the canonical record the seam type — the
/// query service decomposes it into this view (via <see cref="AuditRowProjection"/>) so the
/// existing razor bindings (<c>row.Channel</c>, <c>Event.Status</c>, <c>evt.RequestSummary</c>,
/// …) keep working against typed properties rather than parsing <c>DetailsJson</c> inline.
/// </summary>
/// <remarks>
/// This is presentation-only: it carries the same field surface the bespoke
/// <c>Commons.Entities.Audit.AuditEvent</c> exposed before C3. <c>ForwardState</c> is always
/// null on the central read path (it is site-storage-only and not carried on canonical rows).
/// </remarks>
public sealed record AuditEventView
{
/// <summary>Idempotency key.</summary>
public Guid EventId { get; init; }
/// <summary>UTC timestamp when the audited action occurred.</summary>
public DateTime OccurredAtUtc { get; init; }
/// <summary>UTC ingest timestamp (central-set); null until ingest.</summary>
public DateTime? IngestedAtUtc { get; init; }
/// <summary>Trust-boundary channel.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind.</summary>
public AuditKind Kind { get; init; }
/// <summary>Per-operation correlation id.</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Originating execution id.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>Spawning execution id; null for top-level runs.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated.</summary>
public string? SourceSiteId { get; init; }
/// <summary>Cluster node that emitted the event.</summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor.</summary>
public string? Actor { get; init; }
/// <summary>Target of the action.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable.</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the action in ms.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when summaries were truncated.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; always null on the central read path.</summary>
public AuditForwardState? ForwardState { get; init; }
/// <summary>
/// Decomposes a canonical <see cref="AuditEvent"/> into a flat view for the UI.
/// </summary>
public static AuditEventView From(AuditEvent evt)
{
var r = AuditRowProjection.Decompose(evt);
return new AuditEventView
{
EventId = r.EventId,
OccurredAtUtc = r.OccurredAtUtc,
IngestedAtUtc = r.IngestedAtUtc,
Channel = r.Channel,
Kind = r.Kind,
CorrelationId = r.CorrelationId,
ExecutionId = r.ExecutionId,
ParentExecutionId = r.ParentExecutionId,
SourceSiteId = r.SourceSiteId,
SourceNode = r.SourceNode,
SourceInstanceId = r.SourceInstanceId,
SourceScript = r.SourceScript,
Actor = r.Actor,
Target = r.Target,
Status = r.Status,
HttpStatus = r.HttpStatus,
DurationMs = r.DurationMs,
ErrorMessage = r.ErrorMessage,
ErrorDetail = r.ErrorDetail,
RequestSummary = r.RequestSummary,
ResponseSummary = r.ResponseSummary,
PayloadTruncated = r.PayloadTruncated,
Extra = r.Extra,
ForwardState = null,
};
}
}
@@ -1,6 +1,5 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -121,7 +120,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
{
break;
}
await writer.WriteLineAsync(FormatCsvRow(evt));
await writer.WriteLineAsync(FormatCsvRow(AuditEventView.From(evt)));
written++;
}
@@ -140,7 +139,9 @@ public sealed class AuditLogExportService : IAuditLogExportService
var last = page[^1];
cursor = new AuditLogPaging(
PageSize: pageSize,
AfterOccurredAtUtc: last.OccurredAtUtc,
// C3: canonical OccurredAtUtc is a DateTimeOffset; the keyset
// cursor column is a UTC DateTime.
AfterOccurredAtUtc: last.OccurredAtUtc.UtcDateTime,
AfterEventId: last.EventId);
}
@@ -169,13 +170,13 @@ public sealed class AuditLogExportService : IAuditLogExportService
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
/// <summary>
/// Serialises one <see cref="AuditEvent"/> as a CSV row (no trailing newline).
/// Serialises one <see cref="AuditEventView"/> as a CSV row (no trailing newline).
/// Each nullable column renders as the empty string when null; non-null
/// scalars use invariant culture so an export taken on one locale parses
/// cleanly on another.
/// </summary>
/// <param name="evt">The audit event to format as a CSV row.</param>
internal static string FormatCsvRow(AuditEvent evt)
/// <param name="evt">The audit event view to format as a CSV row.</param>
internal static string FormatCsvRow(AuditEventView evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
@@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -93,7 +92,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
public int DefaultPageSize => 100;
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
public async Task<IReadOnlyList<AuditEventView>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default)
@@ -101,17 +100,22 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
ArgumentNullException.ThrowIfNull(filter);
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
// C3 (Task 2.5): the repository seam returns canonical records; decompose
// each into a flat AuditEventView so the audit pages keep binding to typed
// properties.
// Test-seam ctor: use the injected repository directly.
if (_injectedRepository is not null)
{
return await _injectedRepository.QueryAsync(filter, effective, ct);
var rows = await _injectedRepository.QueryAsync(filter, effective, ct);
return rows.Select(AuditEventView.From).ToList();
}
// Production: a fresh scope (and thus a fresh DbContext) per query so the
// page's auto-load never shares the circuit-scoped context.
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
return await repository.QueryAsync(filter, effective, ct);
var result = await repository.QueryAsync(filter, effective, ct);
return result.Select(AuditEventView.From).ToList();
}
/// <inheritdoc/>
@@ -36,11 +36,11 @@ public sealed class BindingTester : IBindingTester
CancellationToken ct = default)
{
// CentralUI-side role guard — sites don't enforce envelope-level
// roles, so the Design check must happen here before any cross-cluster
// roles, so the Designer check must happen here before any cross-cluster
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
// IsInRole, per c1e16cf).
var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{
return new ReadTagValuesResult(
Array.Empty<TagReadOutcome>(),
@@ -43,9 +43,9 @@ public sealed class BrowseService : IBrowseService
CancellationToken cancellationToken = default)
{
// CentralUI-side role guard — sites don't enforce envelope-level roles,
// so the Design check must happen here before any cross-cluster traffic.
// so the Designer check must happen here before any cross-cluster traffic.
var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{
return new BrowseNodeResult(
Array.Empty<BrowseNode>(),
@@ -1,4 +1,3 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -18,13 +17,18 @@ public interface IAuditLogQueryService
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
/// rows with no cursor (first page). The repository orders by
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
/// <see cref="AuditEventView.OccurredAtUtc"/> + <see cref="AuditEventView.EventId"/>
/// back as the cursor for the next page.
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the repository seam returns the canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c>; this facade decomposes each row into a flat
/// <see cref="AuditEventView"/> so the audit pages keep binding to typed properties.
/// </remarks>
/// <param name="filter">Filter criteria applied to the audit log query.</param>
/// <param name="paging">Optional paging cursor; defaults to first page when null.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<AuditEvent>> QueryAsync(
Task<IReadOnlyList<AuditEventView>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default);
@@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="ZB.MOM.WW.Theme" />
</ItemGroup>
<ItemGroup>
@@ -11,3 +11,4 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.Theme
@@ -1,379 +0,0 @@
/* ============================================================================
Technical-Light design system portable theme layer
----------------------------------------------------------------------------
A refined technical-light aesthetic: warm-neutral paper, hairline rules,
IBM Plex type, monospace tabular numerics, status carried by colour. Built
to layer over Bootstrap 5 via --bs-* overrides, but every rule below works
standalone Bootstrap is optional.
HOW TO ADOPT
1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the
@font-face url() paths below to wherever you serve them.
2. Include this file once, globally. Add view-specific rules in a separate
stylesheet never edit the token block per-view.
3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.*
helpers; do not hand-pick hex values in feature CSS.
========================================================================= */
/* Vendored fonts (embedded woff2, no network/CDN fetch)
Adjust these url()s to your asset route. If you cannot vendor the fonts the
--sans / --mono fallback stacks below degrade gracefully to system fonts. */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal; font-weight: 400; font-display: swap;
src: url('../fonts/ibm-plex-sans-400.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal; font-weight: 600; font-display: swap;
src: url('../fonts/ibm-plex-sans-600.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal; font-weight: 500; font-display: swap;
src: url('../fonts/ibm-plex-mono-500.woff2') format('woff2');
}
/* Design tokens
The single source of truth. Re-theme by editing only this block. */
:root {
/* Surfaces & ink */
--paper: #f4f4f1; /* page background — warm off-white, never pure */
--card: #ffffff; /* raised surfaces: cards, bars, table heads */
--ink: #1b1d21; /* primary text */
--ink-soft: #5a6066; /* secondary text, labels */
--ink-faint: #8b9097; /* tertiary text, captions, units */
--rule: #e4e4df; /* hairline borders / row dividers */
--rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */
/* Accent */
--accent: #2f5fd0; /* links, sort arrows, primary actions */
--accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */
/* Status — foreground */
--ok: #2f9e44;
--warn: #e8920c;
--bad: #e03131;
--idle: #868e96;
/* Status — tinted backgrounds (pair with the matching foreground) */
--ok-bg: #e9f6ec;
--warn-bg: #fdf1dd;
--bad-bg: #fceaea;
--idle-bg: #eef0f2;
/* Type stacks — Plex first, graceful system fallback */
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
/* Bootstrap 5 overrides — harmless if Bootstrap is absent */
--bs-body-bg: var(--paper);
--bs-body-color: var(--ink);
--bs-body-font-family: var(--sans);
--bs-body-font-size: 0.9rem;
--bs-primary: var(--accent);
--bs-border-color: var(--rule);
--bs-emphasis-color: var(--ink);
}
/* Base
The faint top-right radial is the one deliberate flourish a soft sheen,
not a gradient wash. Keep it subtle. */
body {
background:
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 0.9rem;
-webkit-font-smoothing: antialiased;
}
/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */
.numeric,
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-deep); text-decoration: underline; }
/* App chrome: top bar
One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta
text and any status pill pushed hard right. */
.app-bar {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 0.85rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--rule-strong);
}
.app-bar .brand {
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */
.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; }
.app-bar .spacer { flex: 1; } /* pushes meta/pill right */
.app-bar .meta {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* Connection / liveness pill
A rounded pill with a dot, driven entirely by data-state. Use for any
live-link health indicator (websocket, SSE, polling). */
.conn-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--rule-strong);
color: var(--ink-soft);
background: var(--card);
}
.conn-pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--idle);
}
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* Status text helpers
Recolour a value in place counts, ratios, error totals. */
.s-ok { color: var(--ok); }
.s-warn { color: var(--warn); }
.s-bad { color: var(--bad); }
.s-idle { color: var(--idle); }
/* State chip
Compact rectangular badge for an enumerated state (bound/recovering/).
Squarer than the pill; use the pill for liveness, the chip for state. */
.chip {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 4px;
border: 1px solid transparent;
}
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
/* Panel the base raised surface
A white card with a hairline border and 8px radius. .panel-head is the
uppercase eyebrow label that sits on top. */
.panel {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
}
.panel-head {
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
/* Page wrapper
Centred, capped width, even gutter. */
.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; }
/* Reveal-on-paint
Add .rise to top-level sections; stagger with inline animation-delay
(.02s, .08s, .14s ) so panels settle in sequence, not all at once. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; }
/*
COMPONENT LIBRARY
Generic, reusable pieces. View-specific layout belongs in a separate sheet.
*/
/* KPI / aggregate cards
A responsive strip of headline numbers. .agg-card.alert / .caution tint the
whole card when a watched metric goes non-zero. */
.agg-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } }
.agg-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 0.7rem 0.9rem;
}
.agg-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
}
.agg-value {
margin-top: 0.25rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */
font-size: 0.85rem;
font-weight: 400;
color: var(--ink-faint);
}
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
.agg-card.alert .agg-value { color: var(--bad); }
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
.agg-card.caution .agg-value { color: #b56a00; }
/* Metric card + key/value rows
A .panel-head over a stack of .kv rows: label left, monospace value right.
Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 0.85rem;
margin-bottom: 1rem;
}
.metric-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
overflow: hidden;
}
.metric-card .panel-head { margin: 0; }
.kv {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.32rem 0.9rem;
font-size: 0.85rem;
}
.kv:nth-child(even) { background: #fbfbf9; }
.kv .k { color: var(--ink-soft); }
.kv .v {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
text-align: right;
}
.kv .v.warn { color: var(--warn); }
.kv .v.bad { color: var(--bad); }
.kv .v.ok { color: var(--ok); }
/* Toolbar
Filter/search row that sits inside a .panel above a table. */
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
.toolbar .spacer { flex: 1; }
.tb-search { max-width: 280px; }
.tb-state { max-width: 150px; }
.tb-check {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap;
user-select: none;
}
.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); }
/* Data table
Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric
columns get .num (right-aligned, monospace). Rows are clickable by default
drop the cursor/hover rules if yours are not. */
.table-wrap { overflow-x: auto; }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.45rem 0.8rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--rule);
}
.data-table th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
background: #fbfbf9;
position: sticky;
top: 0;
}
.data-table th.num,
.data-table td.num { text-align: right; font-family: var(--mono); }
.data-table th.sortable { cursor: pointer; user-select: none; }
.data-table th.sortable:hover { color: var(--ink); }
.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); }
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
.data-table tbody tr:hover { background: #f3f6fd; }
.data-table tbody tr:last-child td { border-bottom: none; }
.empty-row {
text-align: center !important;
color: var(--ink-faint);
padding: 1.6rem !important;
font-style: italic;
}
/* Direction / category tag
Tiny inline tag for a per-row category (e.g. read vs write). */
.dir-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
.dir-write { color: #8a5a00; background: var(--warn-bg); }
/* Inline notice
A .panel with a warning tint for "this thing is gone / degraded" banners. */
.notice {
padding: 0.85rem 1.1rem;
margin-bottom: 1rem;
color: #b56a00;
background: var(--warn-bg);
border-color: #efd6a6;
}
@@ -1,18 +0,0 @@
// Sidebar nav collapse state — persisted in the `scadabridge_nav` cookie so it
// survives full page reloads and reconnects. Invoked from NavMenu.razor via
// JS interop (window.navState.get / .set), mirroring window.treeviewStorage.
window.navState = {
// Returns the raw cookie value (comma-separated expanded section ids), or
// an empty string when the cookie is absent.
get: function () {
const match = document.cookie.match(/(?:^|;\s*)scadabridge_nav=([^;]*)/);
return match ? decodeURIComponent(match[1]) : "";
},
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
// (JS must write it) and not sensitive.
set: function (value) {
const oneYearSeconds = 60 * 60 * 24 * 365;
document.cookie = "scadabridge_nav=" + encodeURIComponent(value) +
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
}
};
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// Registered with <c>ValidateOnStart()</c> so a bad <c>appsettings.json</c>
/// fails fast at boot rather than failing far from the cause.
/// </summary>
public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
{
/// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary>
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
@@ -19,77 +19,51 @@ public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>
};
/// <summary>
/// Validates the cluster options, returning a failure result if any critical settings are misconfigured.
/// Validates the cluster options, recording a failure if any critical settings are misconfigured.
/// </summary>
/// <param name="name">Named options instance name (unused; all instances are validated identically).</param>
/// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">The cluster options to validate.</param>
public ValidateOptionsResult Validate(string? name, ClusterOptions options)
protected override void Validate(ValidationBuilder builder, ClusterOptions options)
{
var failures = new List<string>();
// CI-012: design doc states "both nodes are seed nodes — each node lists
// both itself and its partner" so a properly-configured deployment lists
// two. Accepting a single-seed configuration silently defeats the
// "no startup ordering dependency" guarantee called out by
// Component-ClusterInfrastructure.md (Node Configuration).
builder.RequireThat(options.SeedNodes is not null && options.SeedNodes.Count >= 2,
"ClusterOptions.SeedNodes must contain at least 2 seed nodes "
+ "(Component-ClusterInfrastructure.md → Node Configuration: "
+ "both nodes are seed nodes); a single-seed configuration defeats "
+ "the no-startup-ordering-dependency guarantee.");
if (options.SeedNodes is null || options.SeedNodes.Count < 2)
{
// CI-012: design doc states "both nodes are seed nodes — each node lists
// both itself and its partner" so a properly-configured deployment lists
// two. Accepting a single-seed configuration silently defeats the
// "no startup ordering dependency" guarantee called out by
// Component-ClusterInfrastructure.md (Node Configuration).
failures.Add(
"ClusterOptions.SeedNodes must contain at least 2 seed nodes "
+ "(Component-ClusterInfrastructure.md → Node Configuration: "
+ "both nodes are seed nodes); a single-seed configuration defeats "
+ "the no-startup-ordering-dependency guarantee.");
}
builder.RequireThat(
!string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
&& AllowedStrategies.Contains(options.SplitBrainResolverStrategy),
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
if (string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
|| !AllowedStrategies.Contains(options.SplitBrainResolverStrategy))
{
failures.Add(
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
}
builder.RequireThat(options.MinNrOfMembers == 1,
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
"any other value blocks the cluster singleton after failover and halts all data collection.");
if (options.MinNrOfMembers != 1)
{
failures.Add(
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
"any other value blocks the cluster singleton after failover and halts all data collection.");
}
builder.RequireThat(options.StableAfter > TimeSpan.Zero,
"ClusterOptions.StableAfter must be a positive duration.");
if (options.StableAfter <= TimeSpan.Zero)
{
failures.Add("ClusterOptions.StableAfter must be a positive duration.");
}
builder.RequireThat(options.HeartbeatInterval > TimeSpan.Zero,
"ClusterOptions.HeartbeatInterval must be a positive duration.");
if (options.HeartbeatInterval <= TimeSpan.Zero)
{
failures.Add("ClusterOptions.HeartbeatInterval must be a positive duration.");
}
builder.RequireThat(options.FailureDetectionThreshold > TimeSpan.Zero,
"ClusterOptions.FailureDetectionThreshold must be a positive duration.");
if (options.FailureDetectionThreshold <= TimeSpan.Zero)
{
failures.Add("ClusterOptions.FailureDetectionThreshold must be a positive duration.");
}
builder.RequireThat(options.HeartbeatInterval < options.FailureDetectionThreshold,
$"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " +
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
"declared unreachable before a heartbeat can arrive.");
if (options.HeartbeatInterval >= options.FailureDetectionThreshold)
{
failures.Add(
$"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " +
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
"declared unreachable before a heartbeat can arrive.");
}
if (!options.DownIfAlone)
{
failures.Add(
"ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
+ "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
+ "oldest node can run as an isolated single-node cluster during a partition while the "
+ "younger node forms its own, producing two live clusters.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
builder.RequireThat(options.DownIfAlone,
"ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
+ "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
+ "oldest node can run as an isolated single-node cluster during a partition while the "
+ "younger node forms its own, producing two live clusters.");
}
}
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup>
<ItemGroup>
@@ -1,137 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
/// <summary>
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
/// site rows leave IngestedAtUtc null until ingest. Append-only.
/// </summary>
/// <remarks>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
/// <c>datetime2</c> column shape required by the AuditLog table.
/// </remarks>
public sealed record AuditEvent
{
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
/// <summary>
/// UTC timestamp when the audited action occurred at its source. The value
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
/// <c>datetime2</c> read where the value bypassed the EF converter) is
/// re-tagged as UTC rather than treated as local time downstream. Producers
/// are still expected to supply values that ARE genuinely UTC — the setter
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
/// </summary>
public DateTime OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
private readonly DateTime _occurredAtUtc;
/// <summary>
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
/// The value MUST be in UTC when non-null; the init-setter forces
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
/// <see cref="OccurredAtUtc"/>'s contract.
/// </summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
init => _ingestedAtUtc = value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
/// run was spawned by another; null for top-level runs. Lets a spawned
/// execution point back at its spawner for cross-run correlation.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Stamped by the writing node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
/// has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}
@@ -1,68 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
/// <summary>
/// An inbound-API bearer credential. Per ConfigurationDatabase-012 the plaintext key
/// is never persisted: the entity stores only <see cref="KeyHash"/>, a deterministic
/// keyed hash of the key (HMAC-SHA256 with a server-side pepper). The plaintext is
/// generated at creation, shown to the operator exactly once, and then discarded.
/// </summary>
public class ApiKey
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Display name for the API key.</summary>
public string Name { get; set; }
/// <summary>
/// Deterministic keyed hash of the API key value. This is the only form of the
/// credential persisted; the plaintext key is never stored. Authentication hashes
/// the presented candidate with the same scheme and compares against this value.
/// </summary>
public string KeyHash { get; set; }
/// <summary>When false, the key is rejected even if the hash matches.</summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Creates an API key from a plaintext value, immediately hashing it with the
/// unpeppered default hasher (<see cref="ApiKeyHasher.Default"/>) so the entity
/// never holds the plaintext. Production code paths that have a configured pepper
/// should use <see cref="FromHash(string, string)"/> with a peppered hash instead.
/// </summary>
/// <param name="name">Display name for the API key.</param>
/// <param name="keyValue">Plaintext key value; hashed immediately and never stored.</param>
public ApiKey(string name, string keyValue)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
if (keyValue is null) throw new ArgumentNullException(nameof(keyValue));
KeyHash = ApiKeyHasher.Default.Hash(keyValue);
}
/// <summary>
/// Parameterless constructor for the EF Core materializer. Application code uses
/// <see cref="ApiKey(string, string)"/> or <see cref="FromHash(string, string)"/>.
/// </summary>
private ApiKey()
{
Name = string.Empty;
KeyHash = string.Empty;
}
/// <summary>
/// Creates an API key from an already-computed key hash. Used by the creation
/// path, which generates a random key, hashes it with the configured (peppered)
/// <see cref="IApiKeyHasher"/>, and stores only the resulting hash.
/// </summary>
/// <param name="name">Display name for the API key.</param>
/// <param name="keyHash">Pre-computed keyed hash of the API key value.</param>
public static ApiKey FromHash(string name, string keyHash)
{
return new ApiKey
{
Name = name ?? throw new ArgumentNullException(nameof(name)),
KeyHash = keyHash ?? throw new ArgumentNullException(nameof(keyHash)),
};
}
}
@@ -8,8 +8,6 @@ public class ApiMethod
public string Name { get; set; }
/// <summary>Gets or sets the C# script body executed when the method is invoked.</summary>
public string Script { get; set; }
/// <summary>Gets or sets the JSON-serialised list of API key IDs approved for this method, or <c>null</c> for unrestricted.</summary>
public string? ApprovedApiKeyIds { get; set; }
/// <summary>Gets or sets the JSON Schema describing the accepted parameters, or <c>null</c> if the method takes no parameters.</summary>
public string? ParameterDefinitions { get; set; }
/// <summary>Gets or sets the JSON Schema describing the return type, or <c>null</c> if the method returns nothing.</summary>
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -4,30 +4,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface IInboundApiRepository
{
// ApiKey
/// <summary>Retrieves an API key by ID.</summary>
/// <param name="id">The API key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all API keys.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default);
/// <summary>Retrieves an API key by value.</summary>
/// <param name="keyValue">The API key value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default);
/// <summary>Adds a new API key.</summary>
/// <param name="apiKey">The API key to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
/// <summary>Updates an existing API key.</summary>
/// <param name="apiKey">The API key to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
/// <summary>Deletes an API key by ID.</summary>
/// <param name="id">The API key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default);
// ApiKey persistence retired (re-arch C5): inbound API keys live in the shared
// ZB.MOM.WW.Auth.ApiKeys SQLite store, not the SQL Server configuration DB. The
// former GetApiKeyByIdAsync / GetAllApiKeysAsync / GetApiKeyByValueAsync /
// AddApiKeyAsync / UpdateApiKeyAsync / DeleteApiKeyAsync / GetApprovedKeysForMethodAsync
// methods were removed with the SQL Server ApiKey entity.
// ApiMethod
/// <summary>Retrieves an API method by ID.</summary>
@@ -41,10 +22,6 @@ public interface IInboundApiRepository
/// <param name="name">The API method name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>Retrieves API keys approved for a method.</summary>
/// <param name="methodId">The API method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default);
/// <summary>Adds a new API method.</summary>
/// <param name="method">The API method to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -0,0 +1,75 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
/// <summary>
/// Read-side projection of one inbound API key, as surfaced by the management seam.
/// Hash-free by construction — the secret is never carried here; it is shown ONCE at
/// creation via <see cref="InboundApiKeyCreated"/>.
/// </summary>
/// <param name="KeyId">Stable key identifier (the middle segment of the token).</param>
/// <param name="Name">Operator-facing display name.</param>
/// <param name="Enabled">True while the key is active (not revoked/disabled).</param>
/// <param name="Methods">The API-method names this key is scoped to call, sorted ordinally.</param>
/// <param name="CreatedUtc">When the key was created.</param>
/// <param name="LastUsedUtc">When the key last authenticated a request, if ever.</param>
public sealed record InboundApiKeyInfo(
string KeyId,
string Name,
bool Enabled,
IReadOnlyList<string> Methods,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc);
/// <summary>
/// Result of creating a key. <see cref="Token"/> is the assembled bearer token
/// (<c>sbk_&lt;keyId&gt;_&lt;secret&gt;</c>) and is the ONLY moment the secret is available —
/// it is never retrievable afterwards.
/// </summary>
/// <param name="KeyId">The new key's identifier.</param>
/// <param name="Token">The bearer token, shown once.</param>
public sealed record InboundApiKeyCreated(string KeyId, string Token);
/// <summary>
/// App-facing management seam for inbound API keys. This is the single shared path CLI
/// and CentralUI use to create / list / enable / disable / delete inbound keys and edit
/// their method-scopes. The interface lives in Commons and is deliberately free of any
/// dependency on the underlying auth library, so consumers depend only on this contract.
/// </summary>
/// <remarks>
/// Mutating operations (<see cref="CreateAsync"/>, <see cref="SetEnabledAsync"/>,
/// <see cref="SetMethodsAsync"/>, <see cref="DeleteAsync"/>) may <b>throw</b> on
/// store-level or configuration failures (e.g. an unavailable pepper) rather than
/// exclusively signalling failure via their <c>bool</c> return — callers must handle
/// exceptions in addition to checking the return value.
/// </remarks>
public interface IInboundApiKeyAdmin
{
/// <summary>Creates a new key scoped to <paramref name="methods"/> and returns its
/// identifier plus the bearer token (shown once).</summary>
Task<InboundApiKeyCreated> CreateAsync(
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default);
/// <summary>Lists all inbound keys (hash-free projection).</summary>
Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default);
/// <summary>Enables or disables a key without changing its secret. Returns false if
/// the key does not exist.</summary>
Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default);
/// <summary>Replaces the method-scope set on a key without changing its secret.
/// Returns false if the key does not exist.</summary>
Task<bool> SetMethodsAsync(
string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default);
/// <summary>Removes a key (revoke-then-delete). Returns false if the key could not be
/// deleted.</summary>
Task<bool> DeleteAsync(string keyId, CancellationToken ct = default);
/// <summary>Returns the method-scope set for a key, or an empty list if not found.</summary>
/// <remarks>Enumerates the full key list (O(n)); intended for admin-scale use, not hot paths.</remarks>
Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default);
/// <summary>Returns the identifiers of all keys whose scopes contain
/// <paramref name="methodName"/>.</summary>
/// <remarks>Enumerates the full key list (O(n)); intended for admin-scale use, not hot paths.</remarks>
Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default);
}
@@ -0,0 +1,31 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Resolves the <c>Actor</c> for an audit row from the current authenticated
/// principal (Phase 3 of the audit re-architecture). User-facing emit sites
/// (the inbound API middleware on a cookie/LDAP-authenticated request) read
/// <see cref="CurrentActor"/> so the canonical <c>AuditEvent.Actor</c> records
/// the real authenticated user, rather than a generic system/identity fallback.
/// </summary>
/// <remarks>
/// <para>The seam is deliberately ASP.NET-free (a plain <c>string?</c>) so it can
/// live in Commons and be consumed by any project without pulling an HTTP
/// dependency. The HTTP-backed implementation
/// (<c>ZB.MOM.WW.ScadaBridge.Security.HttpAuditActorAccessor</c>) reads the
/// authenticated principal off <c>IHttpContextAccessor.HttpContext?.User</c>.</para>
/// <para>This seam is for the <em>authenticated, interactive</em> actor only.
/// System-originated emitters (script/notification/db-outbound) keep their own
/// system actor/fallback and do NOT consult this accessor — there is no
/// interactive principal to read in those flows.</para>
/// </remarks>
public interface IAuditActorAccessor
{
/// <summary>
/// The actor string for the currently authenticated principal, or
/// <c>null</c> when there is no authenticated interactive user (no ambient
/// request, or an unauthenticated / auth-failure request). A null result
/// signals the caller to fall back to its existing actor (API-key name,
/// "system", etc.) — an unauthenticated principal is never echoed back.
/// </summary>
string? CurrentActor { get; }
}
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -7,6 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
/// Failures must NEVER abort the user-facing action.
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the event type is the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>.
/// The local seam is retained (rather than collapsed onto <c>ZB.MOM.WW.Audit.IAuditWriter</c>)
/// so it stays a distinct DI binding from <see cref="ICentralAuditWriter"/> and so the many
/// existing site/central implementations and test fakes keep their identity.
/// </remarks>
public interface IAuditWriter
{
/// <summary>
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
/// </summary>
/// <remarks>
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
/// AuditLog-001: cached-lifecycle audit kinds
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
/// <summary>
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
/// whose audit kind belongs to the cached-call lifecycle
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -1,3 +1,4 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,4 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
@@ -1,13 +1,14 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListApiKeysCommand;
public record CreateApiKeyCommand(string Name);
public record DeleteApiKeyCommand(int ApiKeyId);
public record CreateApiKeyCommand(string Name, IReadOnlyList<string> Methods);
public record DeleteApiKeyCommand(string KeyId);
public record ListRoleMappingsCommand;
public record CreateRoleMappingCommand(string LdapGroupName, string Role);
public record UpdateRoleMappingCommand(int MappingId, string LdapGroupName, string Role);
public record DeleteRoleMappingCommand(int MappingId);
public record UpdateApiKeyCommand(int ApiKeyId, bool IsEnabled);
public record UpdateApiKeyCommand(string KeyId, bool IsEnabled);
public record SetApiKeyMethodsCommand(string KeyId, IReadOnlyList<string> Methods);
public record ListScopeRulesCommand(int MappingId);
public record AddScopeRuleCommand(int MappingId, int SiteId);
public record DeleteScopeRuleCommand(int ScopeRuleId);
@@ -6,6 +6,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// Exports a bundle. Names rather than IDs in the selection so test scripts can
/// be written without an ID lookup step. <c>All=true</c> overrides the per-type
/// name lists and exports every entity of every supported type.
/// <para>
/// Inbound API keys are intentionally not selectable: per the inbound-API-key
/// re-architecture (C4) keys are not transported between environments; only API
/// methods travel. Re-create keys and re-grant their method scopes on the
/// destination via the admin UI/CLI.
/// </para>
/// </summary>
public sealed record ExportBundleCommand(
bool All,
@@ -15,7 +21,6 @@ public sealed record ExportBundleCommand(
IReadOnlyList<string>? DatabaseConnectionNames,
IReadOnlyList<string>? NotificationListNames,
IReadOnlyList<string>? SmtpConfigurationNames,
IReadOnlyList<string>? ApiKeyNames,
IReadOnlyList<string>? ApiMethodNames,
bool IncludeDependencies,
string? Passphrase,

Some files were not shown because too many files have changed in this diff Show More