64 Commits

Author SHA1 Message Date
Joseph Doherty 15752f8c2d fix(security): make auth cookie name configurable, override per env
The auth cookie name was hardcoded to ZB.MOM.WW.ScadaBridge.Auth. Because
browser cookies are scoped by host+path but NOT by port, two ScadaBridge
clusters on the same host (the local docker stack on localhost:9000 and
docker-env2 on localhost:9100) shared one cookie jar: signing into one
overwrote the other's cookie, and since the clusters use different JWT
signing keys + separate Data Protection key rings, the overwritten side
could no longer validate its cookie and the session died.

Add SecurityOptions.CookieName (default = canonical ZB.MOM.WW.ScadaBridge.Auth,
blank falls back to the default) applied via the SecurityOptions-bound cookie
PostConfigure. Override it to ...Auth.env2 in both docker-env2 Central nodes so
the two local clusters no longer collide; the primary cluster keeps the default
so its live sessions and production are unaffected. Adds 3 Security.Tests cases.
2026-06-03 13:11:29 -04:00
Joseph Doherty eabf270d71 docs: complete XML doc coverage (returns, summaries, inheritdoc)
Resolve all 622 issues flagged by the enhanced CommentChecker: add missing
<returns> tags (incl. the standard phrasing on non-generic Task methods),
add missing <summary> tags, and replace misused/redundant <inheritdoc/> on
members that override or implement nothing with real documentation.
Documentation-only — no behavior change; solution builds clean.
2026-06-03 11:39:32 -04:00
Joseph Doherty a050170414 chore(docker): supply DEV-ONLY ApiKeyPepper to local Central nodes
The Auth/Config normalization made ScadaBridge:InboundApi:ApiKeyPepper a hard
Central-only startup requirement (>=16 chars), but the local dev composes never
supplied it, so deploy.sh's freshly-built image crash-looped both Central nodes
on ConfigPreflight validation. Add a clearly-marked DEV-ONLY, insecure pepper
inline to each cluster's Central environment (distinct per environment). These
are NOT real secrets — production injects a true per-env secret out-of-band per
docs/operations/inbound-api-key-reissue.md; the inline values exist only so the
local docker / docker-env2 clusters start.
2026-06-03 05:30:38 -04:00
Joseph Doherty 9f18badf02 build(host): declare ZB.MOM.WW.Theme directly (not transitively via CentralUI)
Host/App.razor uses the kit's <ThemeHead/>/<ThemeScripts/>, but Host had no direct
PackageReference — it relied on CentralUI re-exporting the package transitively.
Add a versionless <PackageReference Include="ZB.MOM.WW.Theme"/> (version pinned by
central PM at Directory.Packages.props) so the declared dependency matches actual
usage and survives any future PrivateAssets/refactor on CentralUI. Additive only;
Host builds clean (0/0).
2026-06-03 04:52:00 -04:00
Joseph Doherty 837fb74ae5 chore(centralui): remove dead .sidebar shell CSS left by the theme cutover
The .sidebar/#sidebar-collapse/.nav-link/.nav-section-toggle block is orphaned —
the side rail is now the ZB.MOM.WW.Theme kit's .side-rail/.rail-link shell, and
no markup references these selectors. Kept the app-only #reconnect-modal and
.script-editor-modal rules (not provided by the kit). 95 lines removed; builds clean.
2026-06-03 04:37:23 -04:00
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
505 changed files with 19026 additions and 9788 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" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" 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.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.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" 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> </ItemGroup>
</Project> </Project>
@@ -22,17 +22,20 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true" "MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
}, },
"Security": { "Security": {
"LdapServer": "scadabridge-ldap", "Ldap": {
"LdapPort": 3893, "Server": "scadabridge-ldap",
"LdapUseTls": false, "Port": 3893,
"AllowInsecureLdap": true, "Transport": "None",
"LdapSearchBase": "dc=scadabridge,dc=local", "AllowInsecure": true,
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local", "SearchBase": "dc=zb,dc=local",
"LdapServiceAccountPassword": "password", "ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15, "JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30, "IdleTimeoutMinutes": 30,
"RequireHttpsCookie": false "RequireHttpsCookie": false,
"CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2"
}, },
"Communication": { "Communication": {
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
@@ -22,17 +22,20 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true" "MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
}, },
"Security": { "Security": {
"LdapServer": "scadabridge-ldap", "Ldap": {
"LdapPort": 3893, "Server": "scadabridge-ldap",
"LdapUseTls": false, "Port": 3893,
"AllowInsecureLdap": true, "Transport": "None",
"LdapSearchBase": "dc=scadabridge,dc=local", "AllowInsecure": true,
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local", "SearchBase": "dc=zb,dc=local",
"LdapServiceAccountPassword": "password", "ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15, "JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30, "IdleTimeoutMinutes": 30,
"RequireHttpsCookie": false "RequireHttpsCookie": false,
"CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2"
}, },
"Communication": { "Communication": {
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
+12
View File
@@ -6,6 +6,12 @@ services:
SCADABRIDGE_CONFIG: Central SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000" ASPNETCORE_URLS: "http://+:5000"
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
# requirement (>=16 chars, per-environment). Distinct from the docker/ cluster's
# pepper per the "different per environment" guidance; real deployments inject a
# true secret out-of-band, never from source control. Both Central nodes share it.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-env2-cluster-0001"
ports: ports:
- "9101:5000" # Web UI + Inbound API - "9101:5000" # Web UI + Inbound API
- "9111:8081" # Akka remoting - "9111:8081" # Akka remoting
@@ -23,6 +29,12 @@ services:
SCADABRIDGE_CONFIG: Central SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000" ASPNETCORE_URLS: "http://+:5000"
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
# requirement (>=16 chars, per-environment). Distinct from the docker/ cluster's
# pepper per the "different per environment" guidance; real deployments inject a
# true secret out-of-band, never from source control. Both Central nodes share it.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-env2-cluster-0001"
ports: ports:
- "9102:5000" # Web UI + Inbound API - "9102:5000" # Web UI + Inbound API
- "9112:8081" # Akka remoting - "9112:8081" # Akka remoting
@@ -22,13 +22,15 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true" "MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
}, },
"Security": { "Security": {
"LdapServer": "scadabridge-ldap", "Ldap": {
"LdapPort": 3893, "Server": "scadabridge-ldap",
"LdapUseTls": false, "Port": 3893,
"AllowInsecureLdap": true, "Transport": "None",
"LdapSearchBase": "dc=scadabridge,dc=local", "AllowInsecure": true,
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local", "SearchBase": "dc=zb,dc=local",
"LdapServiceAccountPassword": "password", "ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15, "JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30, "IdleTimeoutMinutes": 30,
@@ -22,13 +22,15 @@
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true" "MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
}, },
"Security": { "Security": {
"LdapServer": "scadabridge-ldap", "Ldap": {
"LdapPort": 3893, "Server": "scadabridge-ldap",
"LdapUseTls": false, "Port": 3893,
"AllowInsecureLdap": true, "Transport": "None",
"LdapSearchBase": "dc=scadabridge,dc=local", "AllowInsecure": true,
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local", "SearchBase": "dc=zb,dc=local",
"LdapServiceAccountPassword": "password", "ServiceAccountDn": "cn=admin,dc=zb,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15, "JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30, "IdleTimeoutMinutes": 30,
+12
View File
@@ -6,6 +6,12 @@ services:
SCADABRIDGE_CONFIG: Central SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000" ASPNETCORE_URLS: "http://+:5000"
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
# requirement (>=16 chars, per-environment). Real deployments inject a true secret
# out-of-band (env/secret store), never from source control — see
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
ports: ports:
- "9001:5000" # Web UI + Inbound API - "9001:5000" # Web UI + Inbound API
- "9011:8081" # Akka remoting (host access for CLI/debugging) - "9011:8081" # Akka remoting (host access for CLI/debugging)
@@ -23,6 +29,12 @@ services:
SCADABRIDGE_CONFIG: Central SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000" ASPNETCORE_URLS: "http://+:5000"
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
# requirement (>=16 chars, per-environment). Real deployments inject a true secret
# out-of-band (env/secret store), never from source control — see
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
ports: ports:
- "9002:5000" # Web UI + Inbound API - "9002:5000" # Web UI + Inbound API
- "9012:8081" # Akka remoting - "9012:8081" # Akka remoting
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-a-a", "NodeHostname": "scadabridge-site-a-a",
"SiteId": "site-a", "SiteId": "site-a",
"RemotingPort": 8082, "RemotingPort": 8082,
"GrpcPort": 8083 "GrpcPort": 8083,
"MetricsPort": 8084
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-a-b", "NodeHostname": "scadabridge-site-a-b",
"SiteId": "site-a", "SiteId": "site-a",
"RemotingPort": 8082, "RemotingPort": 8082,
"GrpcPort": 8083 "GrpcPort": 8083,
"MetricsPort": 8084
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-b-a", "NodeHostname": "scadabridge-site-b-a",
"SiteId": "site-b", "SiteId": "site-b",
"RemotingPort": 8082, "RemotingPort": 8082,
"GrpcPort": 8083 "GrpcPort": 8083,
"MetricsPort": 8084
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-b-b", "NodeHostname": "scadabridge-site-b-b",
"SiteId": "site-b", "SiteId": "site-b",
"RemotingPort": 8082, "RemotingPort": 8082,
"GrpcPort": 8083 "GrpcPort": 8083,
"MetricsPort": 8084
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-c-a", "NodeHostname": "scadabridge-site-c-a",
"SiteId": "site-c", "SiteId": "site-c",
"RemotingPort": 8082, "RemotingPort": 8082,
"GrpcPort": 8083 "GrpcPort": 8083,
"MetricsPort": 8084
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
+2 -1
View File
@@ -6,7 +6,8 @@
"NodeHostname": "scadabridge-site-c-b", "NodeHostname": "scadabridge-site-c-b",
"SiteId": "site-c", "SiteId": "site-c",
"RemotingPort": 8082, "RemotingPort": 8082,
"GrpcPort": 8083 "GrpcPort": 8083,
"MetricsPort": 8084
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
+4 -3
View File
@@ -18,9 +18,10 @@
- [ ] EF Core migrations have been applied (SQL script reviewed and executed) - [ ] EF Core migrations have been applied (SQL script reviewed and executed)
- [ ] `ScadaBridge:Security:JwtSigningKey` is at least 32 characters, randomly generated - [ ] `ScadaBridge:Security:JwtSigningKey` is at least 32 characters, randomly generated
- [ ] **Both central nodes use the same JwtSigningKey** (required for JWT failover) - [ ] **Both central nodes use the same JwtSigningKey** (required for JWT failover)
- [ ] `ScadaBridge:Security:LdapServer` points to the production LDAP/AD server - [ ] `ScadaBridge:Security:Ldap:Server` points to the production LDAP/AD server
- [ ] `ScadaBridge:Security:LdapUseTls` is `true` (LDAPS required in production) - [ ] `ScadaBridge:Security:Ldap:Transport` is `Ldaps` (LDAPS required in production)
- [ ] `ScadaBridge:Security:AllowInsecureLdap` is `false` - [ ] `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 search base DN is correct for the organization
- [ ] LDAP group-to-role mappings are configured - [ ] LDAP group-to-role mappings are configured
- [ ] Load balancer is configured in front of central UI (sticky sessions not required) - [ ] 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" "MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
}, },
"Security": { "Security": {
"LdapServer": "scadabridge-ldap", "Ldap": {
"LdapPort": 3893, "Server": "scadabridge-ldap",
"LdapUseTls": false, "Port": 3893,
"AllowInsecureLdap": true, "Transport": "None",
"LdapSearchBase": "dc=scadabridge,dc=local", "AllowInsecure": true,
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local", "SearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "password", "ServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"ServiceAccountPassword": "password"
},
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15, "JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30, "IdleTimeoutMinutes": 30,
+1 -1
View File
@@ -67,7 +67,7 @@ For use in `appsettings.Development.json`:
"Ldap": { "Ldap": {
"Server": "localhost", "Server": "localhost",
"Port": 3893, "Port": 3893,
"BaseDN": "dc=scadabridge,dc=local", "BaseDN": "dc=zb,dc=local",
"UseSsl": false "UseSsl": false
}, },
"OpcUa": { "OpcUa": {
+12 -12
View File
@@ -12,7 +12,7 @@ The test LDAP server uses [GLAuth](https://glauth.github.io/), a lightweight LDA
## Base DN ## Base DN
``` ```
dc=scadabridge,dc=local dc=zb,dc=local
``` ```
## Test Users ## 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: 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: The full DNs for all test users:
| Username | Full DN | | Username | Full DN |
|----------|---------| |----------|---------|
| `admin` | `cn=admin,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=scadabridge,dc=local` | | `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=zb,dc=local` |
| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=scadabridge,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=scadabridge,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=scadabridge,dc=local` | | `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=zb,dc=local` |
## Verification ## Verification
@@ -68,9 +68,9 @@ docker ps --filter name=scadabridge-ldap
```bash ```bash
ldapsearch -H ldap://localhost:3893 \ 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 \ -w password \
-b "dc=scadabridge,dc=local" \ -b "dc=zb,dc=local" \
"(objectClass=*)" "(objectClass=*)"
``` ```
@@ -78,9 +78,9 @@ ldapsearch -H ldap://localhost:3893 \
```bash ```bash
ldapsearch -H ldap://localhost:3893 \ 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 \ -w password \
-b "dc=scadabridge,dc=local" \ -b "dc=zb,dc=local" \
"(cn=multi-role)" "(cn=multi-role)"
``` ```
+1 -1
View File
@@ -7,7 +7,7 @@
[backend] [backend]
datastore = "config" datastore = "config"
baseDN = "dc=scadabridge,dc=local" baseDN = "dc=zb,dc=local"
# ── Groups ────────────────────────────────────────────────────────── # ── Groups ──────────────────────────────────────────────────────────
+3 -3
View File
@@ -9,10 +9,10 @@ from ldap3 import Server, Connection, NONE, SUBTREE, SIMPLE
DEFAULT_HOST = "localhost" DEFAULT_HOST = "localhost"
DEFAULT_PORT = 3893 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=... # GLAuth places users under ou=<PrimaryGroupName>,ou=users,dc=...
# The admin user (primarygroup SCADA-Admins) needs search capabilities in config. # 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" DEFAULT_BIND_PASSWORD = "password"
@@ -48,7 +48,7 @@ def cmd_check(args):
def cmd_bind(args): def cmd_bind(args):
"""Test user authentication via bind. """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 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. 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.MxGateway.*" />
<package pattern="ZB.MOM.WW.Health" /> <package pattern="ZB.MOM.WW.Health" />
<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> </packageSource>
</packageSourceMapping> </packageSourceMapping>
<!-- <!--
@@ -1,10 +1,11 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; 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 /// Central-side singleton (per Bundle E wiring) that ingests batches of
/// <see cref="AuditEvent"/> rows pushed from sites via the /// <see cref="AuditEvent"/> rows pushed from sites via the
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side /// <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 /// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
/// silently swallowed (first-write-wins per Bundle A's hardening). /// silently swallowed (first-write-wins per Bundle A's hardening).
/// </summary> /// </summary>
@@ -116,10 +117,10 @@ public class AuditLogIngestActor : ReceiveActor
// Resolve the repository for the whole batch — one DbContext per // Resolve the repository for the whole batch — one DbContext per
// message, mirroring NotificationOutboxActor. The injected-repository // message, mirroring NotificationOutboxActor. The injected-repository
// mode (Bundle D tests) skips the scope entirely. // 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 + // per-message scope when one is available so the row is truncated +
// redacted before InsertIfNotExistsAsync. The single-repository test // 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 // which preserves the small-payload assumptions baked into the
// existing D2 fixtures. // existing D2 fixtures.
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core // 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. // without blocking on sync Dispose() of pending connection cleanup.
if (_injectedRepository is not null) 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); .ConfigureAwait(false);
} }
else else
{ {
await using var scope = _serviceProvider!.CreateAsyncScope(); await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>(); 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 — // M6 Bundle E (T8): central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch // unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard. // simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>(); var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted) await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
private async Task IngestWithRepositoryAsync( private async Task IngestWithRepositoryAsync(
IAuditLogRepository repository, IAuditLogRepository repository,
IAuditPayloadFilter? filter, IAuditRedactor? redactor,
ICentralAuditWriteFailureCounter? failureCounter, ICentralAuditWriteFailureCounter? failureCounter,
IngestAuditEventsCommand cmd, IngestAuditEventsCommand cmd,
DateTime nowUtc, DateTime nowUtc,
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
// repository hardening already swallows duplicate-key races, // repository hardening already swallows duplicate-key races,
// so the same id arriving twice (site retry, reconciliation) // so the same id arriving twice (site retry, reconciliation)
// is a silent no-op. // is a silent no-op.
// Filter BEFORE the IngestedAtUtc stamp so the redacted // Redact BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. Filter // copy carries the central-side ingest timestamp. The redactor
// is contract-bound to never throw. AuditLog-008: a null // 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 // registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs. // pass-through, so HTTP header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance; // C3 transitional shim: IngestedAtUtc is a DetailsJson field on
var filtered = safeFilter.Apply(evt); // the canonical record, so stamp it via the projection helper.
var ingested = filtered with { IngestedAtUtc = nowUtc }; var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filtered = safeRedactor.Apply(evt);
var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false); await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId); accepted.Add(evt.EventId);
} }
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>(); var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>(); var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Bundle C (M5-T6): resolve the filter for the whole batch from // Bundle C (M5-T6): resolve the redactor for the whole batch from
// the scope; null = pass-through for test composition roots that // the scope; null = SafeDefault for test composition roots that
// skip the filter registration. The filter is contract-bound to // skip the redactor registration. The redactor is contract-bound to
// never throw, so we can apply it inside the per-entry try // never throw, so we can apply it inside the per-entry try
// without risking an unbounded blast radius. // 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 // M6 Bundle E (T8): same best-effort central health counter as
// the OnIngestAsync path — null on test composition roots that // the OnIngestAsync path — null on test composition roots that
// skip the registration. // skip the registration.
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
// matching timestamps (debugging convenience, not a // matching timestamps (debugging convenience, not a
// correctness invariant). // correctness invariant).
var ingestedAt = DateTime.UtcNow; var ingestedAt = DateTime.UtcNow;
// Filter the audit half BEFORE the dual-write — only the // Redact the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are filterable; SiteCalls // AuditLog row's payload columns are redactable; SiteCalls
// carries operational state only (status, retry count) and // 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. // to SafeDefault so header redaction always runs.
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance; // C3 transitional shim: IngestedAtUtc is a DetailsJson field
var filteredAudit = safeFilter.Apply(entry.Audit); // on the canonical record, so stamp it via the projection helper.
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt }; var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filteredAudit = safeRedactor.Apply(entry.Audit);
var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt }; var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
await auditRepo.InsertIfNotExistsAsync(auditStamped) await auditRepo.InsertIfNotExistsAsync(auditStamped)
@@ -76,7 +76,12 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
/// <inheritdoc /> /// <summary>
/// Starts the background maintenance loop, firing an immediate first tick and then
/// repeating every <see cref="AuditLogPartitionMaintenanceOptions.IntervalSeconds"/>.
/// </summary>
/// <param name="ct">Cancellation token provided by the host.</param>
/// <returns>A completed task; the loop runs independently on a background thread.</returns>
public Task StartAsync(CancellationToken ct) public Task StartAsync(CancellationToken ct)
{ {
// Linked CTS lets StopAsync's cancellation AND the host's shutdown // Linked CTS lets StopAsync's cancellation AND the host's shutdown
@@ -136,14 +141,21 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
} }
} }
/// <inheritdoc /> /// <summary>
/// Signals the maintenance loop to stop by cancelling its linked token,
/// then returns the loop task so the host can await its completion.
/// </summary>
/// <param name="ct">Cancellation token provided by the host (unused — the internal CTS is cancelled directly).</param>
/// <returns>The background loop task, or a completed task if the loop was never started.</returns>
public Task StopAsync(CancellationToken ct) public Task StopAsync(CancellationToken ct)
{ {
_cts?.Cancel(); _cts?.Cancel();
return _loop ?? Task.CompletedTask; return _loop ?? Task.CompletedTask;
} }
/// <inheritdoc /> /// <summary>
/// Disposes the internal <see cref="CancellationTokenSource"/> used to stop the maintenance loop.
/// </summary>
public void Dispose() public void Dispose()
{ {
_cts?.Dispose(); _cts?.Dispose();
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary> /// <summary>
/// Audit Log (#23) M6 Bundle E (T9) — bridges /// Audit Log (#23) M6 Bundle E (T9) — bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by /// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// parameter redactor stage throws and the filter has to over-redact the /// a header / body / SQL parameter redactor stage throws and the redactor has
/// offending field) into <see cref="AuditCentralHealthSnapshot"/> so the /// to over-redact the offending field) into <see cref="AuditCentralHealthSnapshot"/>
/// failure surfaces on the central health surface as /// so the failure surfaces on the central health surface as
/// <c>AuditCentralHealthSnapshot.AuditRedactionFailure</c>. /// <c>AuditCentralHealthSnapshot.AuditRedactionFailure</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -1,9 +1,10 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{ {
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly ILogger<CentralAuditWriter> _logger; private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter; private readonly IAuditRedactor _redactor;
private readonly ICentralAuditWriteFailureCounter _failureCounter; private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity; private readonly INodeIdentityProvider? _nodeIdentity;
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
/// </summary> /// </summary>
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param> /// <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="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="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> /// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
public CentralAuditWriter( public CentralAuditWriter(
IServiceProvider services, IServiceProvider services,
ILogger<CentralAuditWriter> logger, ILogger<CentralAuditWriter> logger,
IAuditPayloadFilter? filter = null, IAuditRedactor? redactor = null,
ICentralAuditWriteFailureCounter? failureCounter = null, ICentralAuditWriteFailureCounter? failureCounter = null,
INodeIdentityProvider? nodeIdentity = null) INodeIdentityProvider? nodeIdentity = null)
{ {
_services = services ?? throw new ArgumentNullException(nameof(services)); _services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead. // 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 // 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. // Set-Cookie before persistence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance; _redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter(); _failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity; _nodeIdentity = nodeIdentity;
} }
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try try
{ {
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The // Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws". AuditLog-008: _filter is now // redactor contract is "never throws". AuditLog-008: _redactor is
// non-null (SafeDefaultAuditPayloadFilter fallback) so header // now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the // redaction always runs even in composition roots that omit the
// real filter. // real redactor.
var filtered = _filter.Apply(evt); var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins // SourceNode-stamping (Task 12): caller-provided value wins
// (supports any future direct-write callsite that already has its // (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(); await using var scope = _services.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>(); 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); await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
// misbehaving custom counter does, swallowing here keeps the // misbehaving custom counter does, swallowing here keeps the
// best-effort contract intact. // best-effort contract intact.
} }
// Log the input event's identifying fields. These three (EventId, // Log the input event's identifying fields. EventId + Action are
// Kind, Status) are immutable across the filter+stamp chain — the // immutable across the redact+stamp chain — the `with` clones above
// `with` clones above touch only SourceNode and IngestedAtUtc — so // touch only SourceNode and DetailsJson — so referencing `evt` here
// referencing `evt` here is intentional and equivalent to the // is intentional and equivalent to the stamped record for
// stamped record for diagnostics. If you add a field here that the // diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
// stamp chain DOES mutate (e.g., SourceNode), reference the latest // canonical Outcome carries the coarse status (fine-grained Status
// post-stamp record name instead, not `evt`. // lives in DetailsJson).
_logger.LogWarning( _logger.LogWarning(
ex, ex,
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})", "CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
evt.EventId, evt.Kind, evt.Status); evt.EventId, evt.Action, evt.Outcome);
} }
} }
} }
@@ -41,6 +41,7 @@ public interface IPullAuditEventsClient
/// <param name="sinceUtc">Only events with an <c>OccurredAtUtc</c> at or after this cursor time are returned.</param> /// <param name="sinceUtc">Only events with an <c>OccurredAtUtc</c> at or after this cursor time are returned.</param>
/// <param name="batchSize">Maximum number of events to return per call.</param> /// <param name="batchSize">Maximum number of events to return per call.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to the next reconciliation batch with a <c>MoreAvailable</c> flag.</returns>
Task<PullAuditEventsResponse> PullAsync( Task<PullAuditEventsResponse> PullAsync(
string siteId, string siteId,
DateTime sinceUtc, DateTime sinceUtc,
@@ -23,6 +23,7 @@ public interface ISiteEnumerator
/// — the actor calls this once per tick. /// — the actor calls this once per tick.
/// </summary> /// </summary>
/// <param name="ct">Cancellation token for the async enumeration.</param> /// <param name="ct">Cancellation token for the async enumeration.</param>
/// <returns>A task that resolves to the current set of site entries to poll on the next reconciliation tick.</returns>
Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default); Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default);
} }
@@ -2,8 +2,8 @@ using Akka.Actor;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; 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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; 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 // concurrent push, or a retry of this very pull) collapse to
// a no-op courtesy of M2 Bundle A's race-fix on // a no-op courtesy of M2 Bundle A's race-fix on
// InsertIfNotExistsAsync. // 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); await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
_failedInsertAttempts.Remove(evt.EventId); _failedInsertAttempts.Remove(evt.EventId);
advanceForThisRow = true; 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;
} }
} }
@@ -133,6 +133,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// Returns a defensive copy of the per-site latched stalled state. /// Returns a defensive copy of the per-site latched stalled state.
/// Absent sites are interpreted as <c>Stalled=false</c> by consumers. /// Absent sites are interpreted as <c>Stalled=false</c> by consumers.
/// </summary> /// </summary>
/// <returns>A snapshot dictionary mapping each known site ID to its current stalled state.</returns>
public IReadOnlyDictionary<string, bool> Snapshot() => public IReadOnlyDictionary<string, bool> Snapshot() =>
new Dictionary<string, bool>(_state); new Dictionary<string, bool>(_state);
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.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 /// drop in-flight investigations, too long would defeat the partition-switch
/// purge's purpose. /// purge's purpose.
/// </summary> /// </summary>
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions> public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOptions>
{ {
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary> /// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
public const int MinRetentionDays = 30; public const int MinRetentionDays = 30;
@@ -28,43 +28,29 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
public const int MaxInboundMaxBytes = 16_777_216; public const int MaxInboundMaxBytes = 16_777_216;
/// <inheritdoc /> /// <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) // Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
{ // guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
failures.Add( builder.RequireThat(
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " + !(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
"must be > 0; it drives payload-summary truncation in audit writers."); $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
} $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
if (options.ErrorCapBytes < options.DefaultCapBytes) // Valid when InboundMaxBytes is within [Min, Max] inclusive. The De Morgan'd
{ // guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
failures.Add( builder.RequireThat(
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " + !(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes),
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " + $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
"the error-row cap is intended to capture more detail than the happy-path summary."); $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
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);
} }
} }
@@ -0,0 +1,342 @@
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>
/// <param name="json">The raw JSON string to redact; null passes through as null.</param>
/// <param name="redactList">Header names (case-insensitive) whose values should be replaced.</param>
/// <param name="logger">Logger for warning diagnostics on redactor faults.</param>
/// <param name="onFailure">Callback invoked when the redactor stage faults; used to increment health counters.</param>
/// <returns>The re-serialized JSON with redacted header values, the original string if nothing was redacted, or <see cref="RedactorErrorMarker"/> on fault.</returns>
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>
/// <param name="value">The string to redact; null passes through as null.</param>
/// <param name="regexes">Compiled body-redaction regexes applied in order.</param>
/// <param name="logger">Logger for warning diagnostics on redactor faults.</param>
/// <param name="onFailure">Callback invoked when a regex match faults; used to increment health counters.</param>
/// <returns>The value with all regex matches replaced by <see cref="RedactedMarker"/>, or <see cref="RedactorErrorMarker"/> on fault.</returns>
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>
/// <param name="json">The raw JSON string to redact; null passes through as null.</param>
/// <param name="paramNameRegex">Compiled regex matched against each SQL parameter name.</param>
/// <param name="logger">Logger for warning diagnostics on redactor faults.</param>
/// <param name="onFailure">Callback invoked when the redactor stage faults; used to increment health counters.</param>
/// <returns>The re-serialized JSON with matched parameter values replaced by <see cref="RedactedMarker"/>, the original string if no parameters matched, or <see cref="RedactorErrorMarker"/> on fault.</returns>
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>
/// <param name="value">The string to truncate; null passes through as null.</param>
/// <param name="cap">Maximum number of UTF-8 bytes to retain.</param>
/// <param name="truncated">Set to <c>true</c> when the value was shortened; unchanged otherwise.</param>
/// <returns>The truncated string, the original string if within the cap, or <c>null</c> if the input was null.</returns>
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>
/// <param name="value">The string to truncate.</param>
/// <param name="capBytes">Maximum number of UTF-8 bytes in the returned string.</param>
/// <returns>The truncated string guaranteed not to split a multi-byte UTF-8 sequence, or the original string if within the cap.</returns>
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,103 @@
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;
/// <summary>Initializes the cache with the logger used to report compile failures.</summary>
/// <param name="logger">Logger for recording invalid or slow-compile pattern warnings.</param>
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>
/// <param name="pattern">The regex pattern string to look up or compile.</param>
/// <param name="regex">The compiled <see cref="Regex"/>, or <c>null</c> if the pattern is invalid.</param>
/// <returns><c>true</c> if the pattern compiled successfully; <c>false</c> if it is invalid or too slow to compile.</returns>
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);
/// <summary>The compiled regex, or <c>null</c> when this entry represents an invalid pattern.</summary>
public Regex? Regex { get; }
/// <summary>Initializes the entry with the compiled regex (or <c>null</c> for the invalid sentinel).</summary>
/// <param name="regex">The compiled <see cref="Regex"/>, or <c>null</c> for a failed compile.</param>
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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary> /// <summary>
/// Counter sink invoked by <see cref="DefaultAuditPayloadFilter"/> every time /// Counter sink invoked by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// a redactor (header / body regex / SQL parameter) throws and the filter has /// every time a redactor (header / body regex / SQL parameter) throws and the
/// to over-redact the offending field with the /// redactor has to over-redact the offending field with the
/// <c>&lt;redacted: redactor error&gt;</c> marker. Bundle C bridges this into /// <c>&lt;redacted: redactor error&gt;</c> marker. Bundle C bridges this into
/// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>. /// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>.
/// </summary> /// </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,107 @@
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() { }
/// <summary>
/// Applies line-oriented header redaction to the default sensitive headers
/// (<c>Authorization</c>, <c>X-Api-Key</c>, <c>Cookie</c>, <c>Set-Cookie</c>)
/// found in <c>RequestSummary</c> and <c>ResponseSummary</c> inside
/// <paramref name="rawEvent"/>.<c>DetailsJson</c>. Never throws; over-redacts on
/// any internal failure.
/// </summary>
/// <param name="rawEvent">The audit event whose details JSON is to be redacted.</param>
/// <returns>A new <see cref="AuditEvent"/> with sensitive headers replaced by the redacted marker, or an over-redacted sentinel on failure.</returns>
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,354 @@
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);
}
/// <summary>
/// Applies the full redaction pipeline to <paramref name="rawEvent"/> and returns a
/// filtered copy; returns the same instance unchanged on the fast path. Never throws.
/// </summary>
/// <param name="rawEvent">The raw audit event to redact.</param>
/// <returns>A redacted copy of <paramref name="rawEvent"/>, or the original instance when no changes are needed.</returns>
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.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; 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.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; 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;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
namespace ZB.MOM.WW.ScadaBridge.AuditLog; namespace ZB.MOM.WW.ScadaBridge.AuditLog;
@@ -62,19 +65,19 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.). // M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
services.AddOptions<AuditLogOptions>() // Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
.Bind(config.GetSection(ConfigSectionName)) // "AuditLog" section, registers the validator, and enables ValidateOnStart in
.ValidateOnStart(); // one call. Same section path as before; AddAuditLog is call-once per
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>(); // 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 / // C3 (Task 2.5): the canonical IAuditRedactor is wired as
// ResponseSummary / ErrorDetail / Extra fields between event // ScadaBridgeAuditRedactor — same truncation + header / body /
// construction and persistence. Bundle B layers header / body / // SQL-parameter redaction as the original pipeline, applied between
// SQL-parameter redaction onto the same singleton; Bundle C wires it // event construction and persistence. Singleton — stateless; the
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor // IOptionsMonitor dependency picks up hot reloads on its own.
// paths. Singleton — the filter is stateless and the IOptionsMonitor services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
// dependency picks up M5-T8 hot reloads on its own.
services.AddSingleton<IAuditPayloadFilter, DefaultAuditPayloadFilter>();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default; // M5 Bundle B: per-stage redactor-failure counter. NoOp default;
// Bundle C replaces this binding with the Site Health Monitoring // 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 + // The script-thread surface is FallbackAuditWriter (primary + ring +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER // counter), not the raw SqliteAuditWriter — primary failures must NEVER
// abort the user-facing action. // 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 // through the factory so every event written through this surface is
// truncated + redacted before it hits SQLite (and the ring on // truncated + redacted before it hits SQLite (and the ring on
// failure). // failure).
@@ -122,7 +125,7 @@ public static class ServiceCollectionExtensions
ring: sp.GetRequiredService<RingBufferFallback>(), ring: sp.GetRequiredService<RingBufferFallback>(),
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(), failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(), logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
filter: sp.GetRequiredService<IAuditPayloadFilter>())); redactor: sp.GetRequiredService<IAuditRedactor>()));
// ISiteStreamAuditClient: NoOp default. This binding remains correct for // ISiteStreamAuditClient: NoOp default. This binding remains correct for
// central/test composition roots that have no SiteCommunicationActor. // 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 // is intentionally distinct from IAuditWriter so site composition roots
// do not accidentally bind it; central composition roots that include // do not accidentally bind it; central composition roots that include
// AddConfigurationDatabase get a working implementation transparently. // 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 // NotificationOutboxActor + Inbound API rows are truncated + redacted
// before they hit MS SQL. // before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter // M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
@@ -208,7 +211,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter( services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
sp, sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(), sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditPayloadFilter>(), sp.GetRequiredService<IAuditRedactor>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(), sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// SourceNode-stamping (Task 12): wire the local node identity so // SourceNode-stamping (Task 12): wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API) // central-origin rows (Notification Outbox dispatch, Inbound API)
@@ -228,7 +231,7 @@ public static class ServiceCollectionExtensions
/// real <see cref="HealthMetricsAuditWriteFailureCounter"/> / /// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
/// <see cref="HealthMetricsAuditRedactionFailureCounter"/> bridges so the /// <see cref="HealthMetricsAuditRedactionFailureCounter"/> bridges so the
/// FallbackAuditWriter primary-failure counter AND 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 /// site health report payload as
/// <c>SiteHealthReport.SiteAuditWriteFailures</c> + /// <c>SiteHealthReport.SiteAuditWriteFailures</c> +
/// <c>SiteHealthReport.AuditRedactionFailure</c>. /// <c>SiteHealthReport.AuditRedactionFailure</c>.
@@ -1,7 +1,8 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; 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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -31,43 +32,45 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly RingBufferFallback _ring; private readonly RingBufferFallback _ring;
private readonly IAuditWriteFailureCounter _failureCounter; private readonly IAuditWriteFailureCounter _failureCounter;
private readonly ILogger<FallbackAuditWriter> _logger; private readonly ILogger<FallbackAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter; private readonly IAuditRedactor _redactor;
private readonly SemaphoreSlim _drainGate = new(1, 1); private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary> /// <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 + /// here so every event written via the site hot path is truncated +
/// header/body/SQL-param redacted before it hits both the primary SQLite /// header/body/SQL-param redacted before it hits both the primary SQLite
/// writer AND the ring fallback. The parameter is optional (defaults to /// writer AND the ring fallback. The parameter is optional (defaults to
/// no filtering) so the long tail of test composition roots that don't /// the always-safe <see cref="SafeDefaultAuditRedactor"/>) so the long
/// care about the filter need no change — the production /// tail of test composition roots that don't care about the redactor need
/// no change — the production
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration /// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration
/// always passes the real filter through. /// always passes the real redactor through.
/// </summary> /// </summary>
/// <param name="primary">The primary audit writer (typically the SQLite writer).</param> /// <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="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="failureCounter">Counter incremented on each primary failure for health reporting.</param>
/// <param name="logger">Logger for diagnostics.</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( public FallbackAuditWriter(
IAuditWriter primary, IAuditWriter primary,
RingBufferFallback ring, RingBufferFallback ring,
IAuditWriteFailureCounter failureCounter, IAuditWriteFailureCounter failureCounter,
ILogger<FallbackAuditWriter> logger, ILogger<FallbackAuditWriter> logger,
IAuditPayloadFilter? filter = null) IAuditRedactor? redactor = null)
{ {
_primary = primary ?? throw new ArgumentNullException(nameof(primary)); _primary = primary ?? throw new ArgumentNullException(nameof(primary));
_ring = ring ?? throw new ArgumentNullException(nameof(ring)); _ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter)); _failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to a null filter — over-redact instead. // AuditLog-008: never default to a null redactor — over-redact instead.
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header // C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
// redaction with the hard-coded sensitive defaults (Authorization, // SafeDefaultAuditRedactor performs HTTP header redaction with the
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that // hard-coded sensitive defaults (Authorization, X-Api-Key, Cookie,
// doesn't bind the real options never persists those headers // Set-Cookie) on the DetailsJson summaries so a test composition root
// verbatim. The real DefaultAuditPayloadFilter (truncation + body / // 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. // SQL-param redaction) is wired by AddAuditLog and takes precedence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance; _redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -75,14 +78,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
{ {
ArgumentNullException.ThrowIfNull(evt); 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 // and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has // drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The filter contract is // already been truncated and redacted. The redactor contract is
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults // "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
// to SafeDefaultAuditPayloadFilter so header redaction is always // to SafeDefaultAuditRedactor so header redaction is always applied
// applied even in composition roots that don't wire the real filter). // even in composition roots that don't wire the real redactor).
var filtered = _filter.Apply(evt); var filtered = _redactor.Apply(evt);
try try
{ {
@@ -6,10 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary> /// <summary>
/// Audit Log (#23) M5 Bundle C — bridges /// Audit Log (#23) M5 Bundle C — bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by /// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL /// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// parameter redactor stage throws and the filter has to over-redact the /// a header / body / SQL parameter redactor stage throws and the redactor has
/// offending field) into <see cref="ISiteHealthCollector"/> so the count /// to over-redact the offending field) into <see cref="ISiteHealthCollector"/>
/// surfaces in the site health report payload as /// so the count surfaces in the site health report payload as
/// <c>SiteHealthReport.AuditRedactionFailure</c>. /// <c>SiteHealthReport.AuditRedactionFailure</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Channels; using System.Threading.Channels;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -96,6 +96,7 @@ public sealed class RingBufferFallback
/// must call <see cref="Complete"/> first. /// must call <see cref="Complete"/> first.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token to abort the async enumeration.</param> /// <param name="cancellationToken">Cancellation token to abort the async enumeration.</param>
/// <returns>An async sequence of buffered <see cref="AuditEvent"/> values in FIFO order.</returns>
public async IAsyncEnumerable<AuditEvent> DrainAsync( public async IAsyncEnumerable<AuditEvent> DrainAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -69,7 +69,9 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
_refreshInterval = refreshInterval ?? DefaultRefreshInterval; _refreshInterval = refreshInterval ?? DefaultRefreshInterval;
} }
/// <inheritdoc /> /// <summary>Starts the background polling loop, running an immediate first probe before entering the timed cycle.</summary>
/// <param name="ct">Cancellation token signalling host shutdown.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StartAsync(CancellationToken ct) public Task StartAsync(CancellationToken ct)
{ {
// Linked CTS lets StopAsync's cancellation AND the host's shutdown // Linked CTS lets StopAsync's cancellation AND the host's shutdown
@@ -123,14 +125,16 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
} }
} }
/// <inheritdoc /> /// <summary>Signals the polling loop to stop and waits for it to complete.</summary>
/// <param name="ct">Cancellation token (not used; the internal CTS governs shutdown).</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken ct) public Task StopAsync(CancellationToken ct)
{ {
_cts?.Cancel(); _cts?.Cancel();
return _loop ?? Task.CompletedTask; return _loop ?? Task.CompletedTask;
} }
/// <inheritdoc /> /// <summary>Releases the internal <see cref="CancellationTokenSource"/> used to stop the polling loop.</summary>
public void Dispose() public void Dispose()
{ {
_cts?.Dispose(); _cts?.Dispose();
@@ -2,10 +2,12 @@ using System.Threading.Channels;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; 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.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; 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; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
@@ -18,15 +20,27 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The schema is bootstrapped in the constructor (Bundle B-T1). The /// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables:
/// Channel-based <see cref="WriteAsync"/> hot-path + Bundle D /// the append-only canonical <c>audit_event</c> (the 10 canonical
/// <see cref="ReadPendingAsync"/> / <see cref="MarkForwardedAsync"/> support /// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
/// surface are wired in Bundle B-T2. /// 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>
/// <para> /// <para>
/// Site rows always carry <see cref="AuditForwardState.Pending"/> on first /// Site rows always carry <see cref="AuditForwardState.Pending"/> on first
/// insert; the central row-shape's <c>IngestedAtUtc</c> column does NOT live in /// insert; the central row-shape's <c>IngestedAtUtc</c> is a DetailsJson field
/// the site SQLite schema — central stamps it on ingest. /// stamped by central on ingest, not a site column.
/// </para> /// </para>
/// </remarks> /// </remarks>
public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable 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) // on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY)
// is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably // is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably
// surfaced across all SQLite builds. We treat any constraint error on insert // 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 // as a duplicate-eventid race and swallow it (first-write-wins) — the PRIMARY
// on EventId is the only constraint on this table, so this scope is precise. // 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 const int SqliteErrorConstraint = 19;
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
@@ -97,6 +113,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
_readConnection = new SqliteConnection(connectionString); _readConnection = new SqliteConnection(connectionString);
_readConnection.Open(); _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>( _writeQueue = Channel.CreateBounded<PendingAuditEvent>(
new BoundedChannelOptions(_options.ChannelCapacity) new BoundedChannelOptions(_options.ChannelCapacity)
{ {
@@ -140,110 +167,98 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery(); pragmaCmd.ExecuteNonQuery();
} }
// 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())
{
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
pragmaCmd.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(); using var cmd = _connection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
CREATE TABLE IF NOT EXISTS AuditLog ( -- Canonical, append-only / write-once: the 10 fields of the canonical
EventId TEXT NOT NULL, -- ZB.MOM.WW.Audit.AuditEvent stored directly (DetailsJson carries the
OccurredAtUtc TEXT NOT NULL, -- ScadaBridge domain fields). No forwarding state lives here that is
Channel TEXT NOT NULL, -- the audit_forward_state sidecar's concern.
Kind TEXT NOT NULL, CREATE TABLE IF NOT EXISTS audit_event (
CorrelationId TEXT NULL, EventId TEXT NOT NULL,
SourceSiteId TEXT NULL, OccurredAtUtc TEXT NOT NULL,
SourceNode TEXT NULL, Actor TEXT NOT NULL,
SourceInstanceId TEXT NULL, Action TEXT NOT NULL,
SourceScript TEXT NULL, Outcome TEXT NOT NULL,
Actor TEXT NULL, Category TEXT NULL,
Target TEXT NULL, Target TEXT NULL,
Status TEXT NOT NULL, SourceNode TEXT NULL,
HttpStatus INTEGER NULL, CorrelationId TEXT NULL,
DurationMs INTEGER NULL, DetailsJson TEXT 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) PRIMARY KEY (EventId)
); );
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc); -- 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(); 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> /// <summary>
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when /// Enqueues an audit event for asynchronous batched persistence to SQLite.
/// it is not already present (used for <c>ExecutionId</c> and /// Back-pressure is applied when the write channel is full.
/// <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> /// </summary>
private void AddColumnIfMissing(string columnName, string columnDefinition) /// <param name="evt">The audit event to persist.</param>
{ /// <param name="ct">Cancellation token.</param>
using var probe = _connection.CreateCommand(); /// <returns>A task that completes when the event has been persisted.</returns>
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)
{
return;
}
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();
}
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
// Site rows always carry a non-null ForwardState; central rows leave it // The canonical record carries no ForwardState (a site-storage-only
// null. Force Pending on enqueue so callers can pass a bare AuditEvent // concern). Site rows always start Pending; the sidecar row is written
// without thinking about site-vs-central provenance. // alongside the canonical row in the same transaction.
var siteEvt = evt.ForwardState is null var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
? evt with { ForwardState = AuditForwardState.Pending }
: evt;
var pending = new PendingAuditEvent(siteEvt);
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather // CreateBounded(FullMode=Wait) means WriteAsync will await room rather
// than throw when full — exactly the hot-path back-pressure semantics // than throw when full — exactly the hot-path back-pressure semantics
@@ -316,96 +331,99 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
using var transaction = _connection.BeginTransaction(); using var transaction = _connection.BeginTransaction();
try try
{ {
using var cmd = _connection.CreateCommand(); // INSERT 1: the canonical row, stored DIRECTLY (the 10 canonical
cmd.Transaction = transaction; // fields straight off the AuditEvent — no Decompose; audit_event
cmd.CommandText = """ // holds canonical shape, not the legacy 24-column shape).
INSERT INTO AuditLog ( using var eventCmd = _connection.CreateCommand();
EventId, OccurredAtUtc, Channel, Kind, CorrelationId, eventCmd.Transaction = transaction;
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, eventCmd.CommandText = """
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, INSERT INTO audit_event (
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, EventId, OccurredAtUtc, Actor, Action, Outcome,
ExecutionId, ParentExecutionId Category, Target, SourceNode, CorrelationId, DetailsJson
) VALUES ( ) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $EventId, $OccurredAtUtc, $Actor, $Action, $Outcome,
$SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target, $Category, $Target, $SourceNode, $CorrelationId, $DetailsJson
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId, $ParentExecutionId
); );
"""; """;
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); // INSERT 2: the operational sidecar row. ForwardState=Pending,
var pOccurredAt = cmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text); // OccurredAtUtc duplicated for the drain index, IsCachedKind
var pChannel = cmd.Parameters.Add("$Channel", SqliteType.Text); // precomputed (so the read split never parses DetailsJson),
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text); // AttemptCount=0, LastAttemptUtc=NULL.
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text); using var fwdCmd = _connection.CreateCommand();
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text); fwdCmd.Transaction = transaction;
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text); fwdCmd.CommandText = """
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text); INSERT INTO audit_forward_state (
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text); EventId, ForwardState, OccurredAtUtc, IsCachedKind, AttemptCount, LastAttemptUtc
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text); ) VALUES (
var pTarget = cmd.Parameters.Add("$Target", SqliteType.Text); $EventId, $ForwardState, $OccurredAtUtc, $IsCachedKind, 0, NULL
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 fEventId = fwdCmd.Parameters.Add("$EventId", SqliteType.Text);
var pErrorMessage = cmd.Parameters.Add("$ErrorMessage", SqliteType.Text); var fForwardState = fwdCmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pErrorDetail = cmd.Parameters.Add("$ErrorDetail", SqliteType.Text); var fOccurredAt = fwdCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
var pRequestSummary = cmd.Parameters.Add("$RequestSummary", SqliteType.Text); var fIsCachedKind = fwdCmd.Parameters.Add("$IsCachedKind", SqliteType.Integer);
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);
foreach (var pending in batch) foreach (var pending in batch)
{ {
var e = pending.Event; var evt = pending.Event;
pEventId.Value = e.EventId.ToString(); // Canonical OccurredAtUtc is UTC by construction; store the
pOccurredAt.Value = e.OccurredAtUtc.ToString("o"); // round-trip "o" form so string comparison stays monotonic
pChannel.Value = e.Channel.ToString(); // (the drain range-scan and ORDER BY rely on it).
pKind.Value = e.Kind.ToString(); var occurredText = evt.OccurredAtUtc.UtcDateTime.ToString(
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value; "o", System.Globalization.CultureInfo.InvariantCulture);
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
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 // SourceNode-stamping: caller-provided value wins (preserves
// rows reconciled in from other nodes via the same writer); // rows reconciled in from other nodes via the same writer);
// otherwise stamp from the local INodeIdentityProvider. The // otherwise stamp from the local INodeIdentityProvider. The
// event record itself is NOT mutated — stamping is at write // event record itself is NOT mutated — stamping is at write
// time only. If the provider also returns null (unconfigured // time only. If the provider also returns null (unconfigured
// node), the row's SourceNode stays NULL — operators see // node), the column stays NULL — operators see "needs config"
// "needs config" via the schema, not a magic fallback string. // via the schema, not a magic fallback string.
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName; var sourceNode = evt.SourceNode ?? _nodeIdentity.NodeName;
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value; eSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value; eCorrelationId.Value = (object?)evt.CorrelationId?.ToString() ?? DBNull.Value;
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value; eDetailsJson.Value = (object?)evt.DetailsJson ?? DBNull.Value;
pActor.Value = (object?)e.Actor ?? DBNull.Value;
pTarget.Value = (object?)e.Target ?? DBNull.Value; fEventId.Value = evt.EventId.ToString();
pStatus.Value = e.Status.ToString(); fForwardState.Value = pending.ForwardState.ToString();
pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value; fOccurredAt.Value = occurredText;
pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value; fIsCachedKind.Value = IsCachedKind(evt.DetailsJson) ? 1 : 0;
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;
try try
{ {
cmd.ExecuteNonQuery(); eventCmd.ExecuteNonQuery();
fwdCmd.ExecuteNonQuery();
pending.Completion.TrySetResult(); pending.Completion.TrySetResult();
} }
catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint) catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint)
{ {
// Duplicate EventId — first-write-wins (alog.md §11). // Duplicate EventId — first-write-wins (alog.md §11). The
// Treat as success: the lifecycle event is durably // audit_event PRIMARY KEY throws before the sidecar insert
// recorded under the first writer's payload. // 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, _logger.LogDebug(ex,
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter", "Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
e.EventId); evt.EventId);
pending.Completion.TrySetResult(); pending.Completion.TrySetResult();
} }
} }
@@ -427,18 +445,43 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry // AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry
// drain (joined with the operational tracking row + pushed via // drain (joined with the operational tracking row + pushed via
// IngestCachedTelemetryAsync into the central dual-write transaction). // IngestCachedTelemetryAsync into the central dual-write transaction).
// ReadPendingAsync EXCLUDES these so the audit-only drain doesn't double-emit // C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query
// them; ReadPendingCachedTelemetryAsync below is the dedicated read surface // filtered on (Kind IN (...)); it is now precomputed into the sidecar's
// the new SiteAuditTelemetryActor cached-drain uses. // IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
private static readonly string[] CachedTelemetryKindNames = // 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), AuditKind.CachedSubmit,
nameof(AuditKind.ApiCallCached), AuditKind.ApiCallCached,
nameof(AuditKind.DbWriteCached), AuditKind.DbWriteCached,
nameof(AuditKind.CachedResolve), AuditKind.CachedResolve,
}; };
/// <inheritdoc /> /// <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);
}
/// <summary>
/// Returns up to <paramref name="limit"/> non-cached pending audit events, oldest first.
/// Cached-lifecycle kinds are excluded; use <see cref="ReadPendingCachedTelemetryAsync"/> for those.
/// </summary>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of pending audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default) public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
{ {
if (limit <= 0) if (limit <= 0)
@@ -449,51 +492,45 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-005: read via the dedicated _readConnection so this scan // AuditLog-005: read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central // (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode // 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 // writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is // multiple concurrent read callers since SqliteConnection itself is
// not thread-safe. // not thread-safe.
// AuditLog-001: NOT IN ($cached1,$cached2,$cached3,$cached4) excludes the // C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// cached-lifecycle kinds — they flow through ReadPendingCachedTelemetryAsync // lifecycle kinds (IsCachedKind=1) flow through
// + the combined-telemetry drain. Kind is stored as the enum's name (see // ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
// FlushBatch's pKind.Value), so a string-IN against the constant kind // split is a precomputed integer predicate on the indexed sidecar, not
// names matches the on-disk shape exactly. // a DetailsJson parse. Ordering is by the sidecar's OccurredAtUtc with
// EventId as the deterministic tiebreaker.
lock (_readLock) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState = $pending
FROM AuditLog AND fs.IsCachedKind = 0
WHERE ForwardState = $pending ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
AND Kind NOT IN ($k0, $k1, $k2, $k3)
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); 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); cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256)); return Task.FromResult(ReadRows(cmd, limit));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
/// <inheritdoc /> /// <summary>
/// Returns up to <paramref name="limit"/> pending cached-lifecycle audit events, oldest first.
/// Only rows with cached-call kinds (CachedSubmit, ApiCallCached, DbWriteCached, CachedResolve) are included.
/// </summary>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of pending cached-telemetry audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync( public Task<IReadOnlyList<AuditEvent>> ReadPendingCachedTelemetryAsync(
int limit, CancellationToken ct = default) int limit, CancellationToken ct = default)
{ {
@@ -502,42 +539,29 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0."); throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
} }
// AuditLog-001: dedicated read surface for the cached-call lifecycle // AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to the four // drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
// cached AuditKinds. Same _readConnection + _readLock pattern so the // Same _readConnection + _readLock pattern so the hot-path writer is not
// hot-path writer is not contended. // contended.
lock (_readLock) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState = $pending
FROM AuditLog AND fs.IsCachedKind = 1
WHERE ForwardState = $pending ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
AND Kind IN ($k0, $k1, $k2, $k3)
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); 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); cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256)); return Task.FromResult(ReadRows(cmd, limit));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -554,6 +578,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// </summary> /// </summary>
/// <param name="limit">Maximum number of rows to return.</param> /// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of forwarded audit events.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default) public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
{ {
if (limit <= 0) if (limit <= 0)
@@ -563,34 +588,27 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection / // AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on // _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) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState = $forwarded
FROM AuditLog ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString()); cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$limit", limit); cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256)); return Task.FromResult(ReadRows(cmd, limit));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -608,11 +626,25 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand(); using var cmd = _connection.CreateCommand();
// Build a single IN (...) parameter list so we issue one UPDATE per // C4: flip the sidecar — UPDATE audit_forward_state, not the canonical
// batch regardless of size. Each id is bound as its own parameter, // audit_event (which is append-only / write-once). Bump AttemptCount +
// so no string concatenation of user data ever enters the SQL. // 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(); 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++) for (int i = 0; i < eventIds.Count; i++)
{ {
if (i > 0) sb.Append(','); if (i > 0) sb.Append(',');
@@ -623,13 +655,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
sb.Append(");"); sb.Append(");");
cmd.CommandText = sb.ToString(); cmd.CommandText = sb.ToString();
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.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(); cmd.ExecuteNonQuery();
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
/// <inheritdoc /> /// <summary>
/// Returns up to <paramref name="batchSize"/> pending or forwarded audit events
/// with <see cref="AuditEvent.OccurredAtUtc"/> &gt;= <paramref name="sinceUtc"/>, oldest first.
/// Used by the M6 reconciliation-pull handler.
/// </summary>
/// <param name="sinceUtc">Lower bound timestamp (UTC) for event occurrence.</param>
/// <param name="batchSize">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of audit events since the given timestamp.</returns>
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync( public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
DateTime sinceUtc, int batchSize, CancellationToken ct = default) DateTime sinceUtc, int batchSize, CancellationToken ct = default)
{ {
@@ -639,22 +682,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
// AuditLog-005: read via _readConnection / _readLock — same lock- // 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) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _readConnection.CreateCommand(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, FROM audit_event ae
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, JOIN audit_forward_state fs ON fs.EventId = ae.EventId
ExecutionId, ParentExecutionId WHERE fs.ForwardState IN ($pending, $forwarded)
FROM AuditLog AND fs.OccurredAtUtc >= $since
WHERE ForwardState IN ($pending, $forwarded) ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
AND OccurredAtUtc >= $since
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit; LIMIT $limit;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
@@ -666,14 +711,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
"o", System.Globalization.CultureInfo.InvariantCulture)); "o", System.Globalization.CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("$limit", batchSize); cmd.Parameters.AddWithValue("$limit", batchSize);
var rows = new List<AuditEvent>(Math.Min(batchSize, 256)); return Task.FromResult(ReadRows(cmd, batchSize));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
} }
} }
@@ -691,8 +729,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand(); 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(); 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 ("); .Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
for (int i = 0; i < eventIds.Count; i++) for (int i = 0; i < eventIds.Count; i++)
{ {
@@ -724,18 +765,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// central outage the Pending backlog can grow to hundreds of thousands // central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no // of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes. // 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) lock (_readLock)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); 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(); using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT COUNT(*), MIN(OccurredAtUtc) SELECT COUNT(*), MIN(OccurredAtUtc)
FROM AuditLog FROM audit_forward_state
WHERE ForwardState = $pending; WHERE ForwardState = $pending;
"""; """;
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString()); cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
@@ -786,35 +826,48 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
? value ? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc); : 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) private static AuditEvent MapRow(SqliteDataReader reader)
{ {
return new AuditEvent return new AuditEvent
{ {
EventId = Guid.Parse(reader.GetString(0)), EventId = Guid.Parse(reader.GetString(0)),
OccurredAtUtc = DateTime.Parse(reader.GetString(1), OccurredAtUtc = new DateTimeOffset(DateTime.SpecifyKind(
System.Globalization.CultureInfo.InvariantCulture, DateTime.Parse(reader.GetString(1),
System.Globalization.DateTimeStyles.RoundtripKind), System.Globalization.CultureInfo.InvariantCulture,
Channel = Enum.Parse<AuditChannel>(reader.GetString(2)), System.Globalization.DateTimeStyles.RoundtripKind),
Kind = Enum.Parse<AuditKind>(reader.GetString(3)), DateTimeKind.Utc)),
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), Actor = reader.GetString(2),
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5), Action = reader.GetString(3),
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6), Outcome = AuditRowProjection.ParseEnum(reader.GetString(4), AuditOutcome.Success),
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7), Category = reader.IsDBNull(5) ? null : reader.GetString(5),
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8), Target = reader.IsDBNull(6) ? null : reader.GetString(6),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9), SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
Target = reader.IsDBNull(10) ? null : reader.GetString(10), CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
Status = Enum.Parse<AuditStatus>(reader.GetString(11)), DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
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)),
}; };
} }
@@ -841,6 +894,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
/// <summary>Asynchronously disposes the audit writer and releases resources.</summary> /// <summary>Asynchronously disposes the audit writer and releases resources.</summary>
/// <returns>A <see cref="ValueTask"/> that completes when all resources have been released.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
Task? writerLoop; Task? writerLoop;
@@ -898,15 +952,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private sealed class PendingAuditEvent private sealed class PendingAuditEvent
{ {
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary> /// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
/// <param name="evt">The audit event to persist.</param> /// <param name="evt">The canonical audit event to persist.</param>
public PendingAuditEvent(AuditEvent evt) /// <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; Event = evt;
ForwardState = forwardState;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
} }
/// <summary>The audit event to persist.</summary> /// <summary>The canonical audit event to persist.</summary>
public AuditEvent Event { get; } 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> /// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; } public TaskCompletionSource Completion { get; }
} }
@@ -1,8 +1,8 @@
using Microsoft.Extensions.Logging; 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.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
var channel = ChannelStringToEnum(context.Channel); var channel = ChannelStringToEnum(context.Channel);
return new CachedCallTelemetry( return new CachedCallTelemetry(
Audit: new AuditEvent Audit: ScadaBridgeAuditEventFactory.Create(
{ channel: channel,
EventId = Guid.NewGuid(), kind: kind,
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc), status: status,
Channel = channel, occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
Kind = kind, target: context.Target,
CorrelationId = context.TrackedOperationId.Value, correlationId: context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script // Audit Log #23 (ExecutionId Task 4): the originating script
// execution's per-run correlation id, threaded through the S&F // execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat). // buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId, executionId: context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning // Audit Log #23 (ParentExecutionId Task 6): the spawning
// inbound-API request's ExecutionId, threaded through the S&F // inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows // buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a // correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6. // non-routed run and on rows buffered before Task 6.
ParentExecutionId = context.ParentExecutionId, parentExecutionId: context.ParentExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite, sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId, sourceInstanceId: context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now // Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the // threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the // retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows. // script-side cached rows do. Null on pre-Task-4 buffered rows.
SourceScript = context.SourceScript, sourceScript: context.SourceScript,
Target = context.Target, httpStatus: httpStatus,
Status = status, durationMs: context.DurationMs,
HttpStatus = httpStatus, errorMessage: lastError),
DurationMs = context.DurationMs,
ErrorMessage = lastError,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
TrackedOperationId: context.TrackedOperationId, TrackedOperationId: context.TrackedOperationId,
Channel: context.Channel, Channel: context.Channel,
@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging; 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;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// FallbackAuditWriter) handles transient writer failures upstream; // FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract // a throw bubbling up here means the writer's own swallow contract
// failed, which is itself best-effort-handled. // 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, _logger.LogWarning(ex,
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})", "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; 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 try
{ {
switch (telemetry.Audit.Kind) switch (audit.Kind)
{ {
case AuditKind.CachedSubmit: case AuditKind.CachedSubmit:
// Enqueue — insert-if-not-exists with the operational // Enqueue — insert-if-not-exists with the operational
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
telemetry.Operational.TrackedOperationId, telemetry.Operational.TrackedOperationId,
telemetry.Operational.Channel, telemetry.Operational.Channel,
telemetry.Operational.Target, telemetry.Operational.Target,
telemetry.Audit.SourceInstanceId, audit.SourceInstanceId,
telemetry.Audit.SourceScript, audit.SourceScript,
sourceNode: _nodeIdentity?.NodeName, sourceNode: _nodeIdentity?.NodeName,
ct).ConfigureAwait(false); ct).ConfigureAwait(false);
break; break;
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// forwarder. // forwarder.
_logger.LogWarning( _logger.LogWarning(
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}", "CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
telemetry.Audit.Kind, telemetry.Audit.EventId); audit.Kind, audit.EventId);
break; break;
} }
} }
@@ -1,5 +1,5 @@
using Akka.Actor; 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.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
@@ -44,6 +44,9 @@ public sealed class ClusterClientSiteAuditClient : ISiteStreamAuditClient
private readonly IActorRef _siteCommunicationActor; private readonly IActorRef _siteCommunicationActor;
private readonly TimeSpan _askTimeout; private readonly TimeSpan _askTimeout;
/// <summary>
/// Initializes a new instance that forwards audit telemetry to central via the site's <c>SiteCommunicationActor</c>.
/// </summary>
/// <param name="siteCommunicationActor"> /// <param name="siteCommunicationActor">
/// The site's <c>SiteCommunicationActor</c> — it forwards the ingest command /// The site's <c>SiteCommunicationActor</c> — it forwards the ingest command
/// over the registered central ClusterClient and routes the reply back to /// over the registered central ClusterClient and routes the reply back to
@@ -22,6 +22,7 @@ public interface ISiteStreamAuditClient
/// </summary> /// </summary>
/// <param name="batch">The batch of audit events to forward.</param> /// <param name="batch">The batch of audit events to forward.</param>
/// <param name="ct">Cancellation token for the operation.</param> /// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the ingest acknowledgement containing accepted event IDs.</returns>
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct); Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary> /// <summary>
@@ -42,5 +43,6 @@ public interface ISiteStreamAuditClient
/// </remarks> /// </remarks>
/// <param name="batch">The batch of cached-call telemetry packets to forward.</param> /// <param name="batch">The batch of cached-call telemetry packets to forward.</param>
/// <param name="ct">Cancellation token for the operation.</param> /// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the ingest acknowledgement containing accepted event IDs.</returns>
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct); Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
} }
@@ -2,10 +2,11 @@ using Akka.Actor;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; 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;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
// row stays Pending (still not in emittedEventIds) and // row stays Pending (still not in emittedEventIds) and
// central reconciliation will pick it up. // central reconciliation will pick it up.
_logger.LogWarning( _logger.LogWarning(
"Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.", "Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
auditRow.EventId, auditRow.Kind); auditRow.EventId, auditRow.Action);
continue; continue;
} }
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static CachedTelemetryPacket BuildCachedPacket( private static CachedTelemetryPacket BuildCachedPacket(
AuditEvent auditRow, TrackingStatusSnapshot snapshot) 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 // Channel string form mirrors the AuditChannel-to-string convention used
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket. // by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
var channelString = auditRow.Channel.ToString(); var channelString = audit.Channel.ToString();
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty; var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
var operationalDto = new SiteCallOperationalDto var operationalDto = new SiteCallOperationalDto
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -13,6 +13,7 @@ public static class ApiMethodCommands
/// <param name="formatOption">Global option for the output format.</param> /// <param name="formatOption">Global option for the output format.</param>
/// <param name="usernameOption">Global option for the authentication username.</param> /// <param name="usernameOption">Global option for the authentication username.</param>
/// <param name="passwordOption">Global option for the authentication password.</param> /// <param name="passwordOption">Global option for the authentication password.</param>
/// <returns>The configured <c>api-method</c> command with all subcommands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("api-method") { Description = "Manage inbound API methods" }; var command = new Command("api-method") { Description = "Manage inbound API methods" };
@@ -18,6 +18,7 @@ public static class AuditCommands
/// <param name="formatOption">Global <c>--format</c> option for output format.</param> /// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param> /// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param> /// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>audit</c> <see cref="Command"/> with all sub-commands attached.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("audit") { Description = "Query and export the centralized audit log" }; var command = new Command("audit") { Description = "Query and export the centralized audit log" };
@@ -74,6 +74,7 @@ public static class AuditExportHelpers
/// </summary> /// </summary>
/// <param name="args">The export arguments containing filters and format.</param> /// <param name="args">The export arguments containing filters and format.</param>
/// <param name="now">The current time for resolving relative time specifications.</param> /// <param name="now">The current time for resolving relative time specifications.</param>
/// <returns>The full query string (including the leading <c>?</c>) for the export endpoint.</returns>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{ {
var parts = new List<string>(); var parts = new List<string>();
@@ -116,6 +117,7 @@ public static class AuditExportHelpers
/// <param name="args">The export arguments containing filters and output file path.</param> /// <param name="args">The export arguments containing filters and output file path.</param>
/// <param name="output">Text writer for command output messages.</param> /// <param name="output">Text writer for command output messages.</param>
/// <param name="now">The current time for resolving relative time specifications.</param> /// <param name="now">The current time for resolving relative time specifications.</param>
/// <returns>0 on success, 1 on general error, or 2 on authorization failure.</returns>
public static async Task<int> RunExportAsync( public static async Task<int> RunExportAsync(
ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now)
{ {
@@ -178,6 +180,8 @@ public static class AuditExportHelpers
/// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or /// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or
/// has no <c>code</c> property — callers fall back to "ERROR" in that case. /// has no <c>code</c> property — callers fall back to "ERROR" in that case.
/// </summary> /// </summary>
/// <param name="body">The HTTP response body string to parse for an error code.</param>
/// <returns>The <c>code</c> string from the JSON error envelope, or null if absent or unparseable.</returns>
internal static string? TryExtractErrorCode(string body) internal static string? TryExtractErrorCode(string body)
{ {
if (string.IsNullOrWhiteSpace(body)) if (string.IsNullOrWhiteSpace(body))
@@ -43,6 +43,7 @@ public static class AuditFormatterFactory
/// </summary> /// </summary>
/// <param name="format">Format name; <c>table</c> selects the table formatter, any other value selects JSONL.</param> /// <param name="format">Format name; <c>table</c> selects the table formatter, any other value selects JSONL.</param>
/// <param name="notices">Writer for notice messages emitted during formatting.</param> /// <param name="notices">Writer for notice messages emitted during formatting.</param>
/// <returns>The <see cref="IAuditFormatter"/> appropriate for the requested format.</returns>
public static IAuditFormatter Create(string format, TextWriter notices) public static IAuditFormatter Create(string format, TextWriter notices)
{ {
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
@@ -50,6 +50,7 @@ public static class AuditLogCommands
/// <param name="formatOption">Global output format option.</param> /// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param> /// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param> /// <param name="passwordOption">Global password option.</param>
/// <returns>The configured <c>audit-config</c> command with all sub-commands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("audit-config") { Description = "Query the configuration-change audit log" }; var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
@@ -61,6 +61,7 @@ public static class AuditQueryHelpers
/// <param name="spec">The time specification string.</param> /// <param name="spec">The time specification string.</param>
/// <param name="now">The current time used as reference for relative specs.</param> /// <param name="now">The current time used as reference for relative specs.</param>
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception> /// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
/// <returns>The resolved absolute <see cref="DateTimeOffset"/> in UTC.</returns>
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now) public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
{ {
if (string.IsNullOrWhiteSpace(spec)) if (string.IsNullOrWhiteSpace(spec))
@@ -103,6 +104,7 @@ public static class AuditQueryHelpers
/// <param name="now">The current time for resolving relative time specs.</param> /// <param name="now">The current time for resolving relative time specs.</param>
/// <param name="afterOccurredAtUtc">Optional keyset cursor timestamp.</param> /// <param name="afterOccurredAtUtc">Optional keyset cursor timestamp.</param>
/// <param name="afterEventId">Optional keyset cursor event ID.</param> /// <param name="afterEventId">Optional keyset cursor event ID.</param>
/// <returns>A URL query string (starting with <c>?</c>) containing the encoded filter parameters, or an empty string if no parameters are set.</returns>
public static string BuildQueryString( public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
{ {
@@ -169,6 +171,7 @@ public static class AuditQueryHelpers
/// <param name="formatter">The audit result formatter.</param> /// <param name="formatter">The audit result formatter.</param>
/// <param name="output">The output writer for results.</param> /// <param name="output">The output writer for results.</param>
/// <param name="now">The current time for resolving relative time specs.</param> /// <param name="now">The current time for resolving relative time specs.</param>
/// <returns>A task that resolves to <c>0</c> on success, <c>1</c> on HTTP/transport error, or <c>2</c> on authorization failure.</returns>
public static async Task<int> RunQueryAsync( public static async Task<int> RunQueryAsync(
ManagementHttpClient client, ManagementHttpClient client,
AuditQueryArgs args, AuditQueryArgs args,
@@ -14,6 +14,7 @@ public static class AuditVerifyChainHelpers
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected. /// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
/// </summary> /// </summary>
/// <param name="month">The month string to validate in YYYY-MM format.</param> /// <param name="month">The month string to validate in YYYY-MM format.</param>
/// <returns><c>true</c> if the string is a well-formed YYYY-MM value with a real month; otherwise <c>false</c>.</returns>
public static bool IsValidMonth(string? month) public static bool IsValidMonth(string? month)
=> !string.IsNullOrWhiteSpace(month) => !string.IsNullOrWhiteSpace(month)
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
@@ -61,7 +61,9 @@ public static class BundleCommands
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host 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 apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
var includeDepsOption = new Option<bool>("--include-dependencies") var includeDepsOption = new Option<bool>("--include-dependencies")
{ {
@@ -85,7 +87,6 @@ public static class BundleCommands
cmd.Add(dbConnectionsOption); cmd.Add(dbConnectionsOption);
cmd.Add(notificationListsOption); cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption); cmd.Add(smtpConfigsOption);
cmd.Add(apiKeysOption);
cmd.Add(apiMethodsOption); cmd.Add(apiMethodsOption);
cmd.Add(includeDepsOption); cmd.Add(includeDepsOption);
cmd.Add(sourceEnvOption); cmd.Add(sourceEnvOption);
@@ -106,7 +107,6 @@ public static class BundleCommands
DatabaseConnectionNames: result.GetValue(dbConnectionsOption), DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
NotificationListNames: result.GetValue(notificationListsOption), NotificationListNames: result.GetValue(notificationListsOption),
SmtpConfigurationNames: result.GetValue(smtpConfigsOption), SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
ApiKeyNames: result.GetValue(apiKeysOption),
ApiMethodNames: result.GetValue(apiMethodsOption), ApiMethodNames: result.GetValue(apiMethodsOption),
IncludeDependencies: includeDeps, IncludeDependencies: includeDeps,
Passphrase: passphrase, Passphrase: passphrase,
@@ -307,6 +307,13 @@ public static class BundleCommands
// for the post-write summary line. // for the post-write summary line.
internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded
/// <summary>
/// Decodes a base64 string into <paramref name="outputPath"/> in chunked fashion to avoid
/// large intermediate allocations. Returns the total number of decoded bytes written.
/// </summary>
/// <param name="base64">The base64-encoded content to decode and write.</param>
/// <param name="outputPath">Destination file path; created or overwritten.</param>
/// <returns>Total number of bytes written to the output file.</returns>
internal static long StreamBase64ToFile(string base64, string outputPath) internal static long StreamBase64ToFile(string base64, string outputPath)
{ {
if (base64 is null) throw new ArgumentNullException(nameof(base64)); if (base64 is null) throw new ArgumentNullException(nameof(base64));
@@ -17,6 +17,7 @@ internal static class CliOptions
/// typo (e.g. <c>--format tabel</c>) is rejected with a clear parse error rather /// typo (e.g. <c>--format tabel</c>) is rejected with a clear parse error rather
/// than silently falling through to JSON. /// than silently falling through to JSON.
/// </summary> /// </summary>
/// <returns>The configured <c>--format</c> option constrained to "json" or "table".</returns>
internal static Option<string> CreateFormatOption() internal static Option<string> CreateFormatOption()
{ {
var formatOption = new Option<string>("--format") var formatOption = new Option<string>("--format")
@@ -30,6 +30,7 @@ internal static class CommandHelpers
/// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way, /// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way,
/// closing CLI-017's regression. /// closing CLI-017's regression.
/// </param> /// </param>
/// <returns>A task that resolves to the process exit code (0 = success, 1 = error, 2 = authorization failure).</returns>
internal static async Task<int> ExecuteCommandAsync( internal static async Task<int> ExecuteCommandAsync(
ParseResult result, ParseResult result,
Option<string> urlOption, Option<string> urlOption,
@@ -110,6 +111,7 @@ internal static class CommandHelpers
/// <param name="result">Parsed command-line result.</param> /// <param name="result">Parsed command-line result.</param>
/// <param name="formatOption">The <c>--format</c> option definition.</param> /// <param name="formatOption">The <c>--format</c> option definition.</param>
/// <param name="config">Loaded CLI configuration providing the default format fallback.</param> /// <param name="config">Loaded CLI configuration providing the default format fallback.</param>
/// <returns>The resolved format string (e.g. <c>"json"</c> or <c>"table"</c>).</returns>
internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config) internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config)
{ {
// GetResult returns non-null only when the option was actually present on the // GetResult returns non-null only when the option was actually present on the
@@ -130,6 +132,7 @@ internal static class CommandHelpers
/// </summary> /// </summary>
/// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param> /// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param>
/// <param name="envValue">Fallback value from the config file or environment variable.</param> /// <param name="envValue">Fallback value from the config file or environment variable.</param>
/// <returns>The command-line value when non-empty; otherwise the environment fallback (may be null).</returns>
internal static string? ResolveCredential(string? commandLineValue, string? envValue) internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue; => string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
@@ -140,6 +143,7 @@ internal static class CommandHelpers
/// an unhandled <see cref="UriFormatException"/>. /// an unhandled <see cref="UriFormatException"/>.
/// </summary> /// </summary>
/// <param name="url">URL string to validate.</param> /// <param name="url">URL string to validate.</param>
/// <returns><c>true</c> when the URL is an absolute http or https URL; otherwise <c>false</c>.</returns>
internal static bool IsValidManagementUrl(string? url) internal static bool IsValidManagementUrl(string? url)
{ {
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))
@@ -154,6 +158,7 @@ internal static class CommandHelpers
/// </summary> /// </summary>
/// <param name="response">Response received from the management API.</param> /// <param name="response">Response received from the management API.</param>
/// <param name="format">Output format (<c>json</c> or <c>table</c>).</param> /// <param name="format">Output format (<c>json</c> or <c>table</c>).</param>
/// <returns>The process exit code (0 = success, 1 = error, 2 = authorization failure).</returns>
internal static int HandleResponse(ManagementResponse response, string format) internal static int HandleResponse(ManagementResponse response, string format)
{ {
if (response.JsonData != null) if (response.JsonData != null)
@@ -192,6 +197,8 @@ internal static class CommandHelpers
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials /// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.) /// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
/// </summary> /// </summary>
/// <param name="response">The management response to inspect for authorization failure signals.</param>
/// <returns><c>true</c> when the response signals an authorization failure (HTTP 403 or FORBIDDEN/UNAUTHORIZED code).</returns>
internal static bool IsAuthorizationFailure(ManagementResponse response) internal static bool IsAuthorizationFailure(ManagementResponse response)
{ {
if (response.StatusCode == 403) if (response.StatusCode == 403)
@@ -13,6 +13,7 @@ public static class DataConnectionCommands
/// <param name="formatOption">Global output format option.</param> /// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param> /// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param> /// <param name="passwordOption">Global password option.</param>
/// <returns>The configured <c>data-connection</c> <see cref="Command"/> with all subcommands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("data-connection") { Description = "Manage data connections" }; var command = new Command("data-connection") { Description = "Manage data connections" };
@@ -15,6 +15,7 @@ public static class DebugCommands
/// <param name="formatOption">Shared output format option.</param> /// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param> /// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param> /// <param name="passwordOption">Shared password option for authentication.</param>
/// <returns>The configured <c>debug</c> command with snapshot and stream subcommands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("debug") { Description = "Runtime debugging" }; var command = new Command("debug") { Description = "Runtime debugging" };
@@ -27,6 +27,7 @@ internal static class DebugStreamHelpers
/// </summary> /// </summary>
/// <param name="ex">The exception thrown by HubConnection.StartAsync.</param> /// <param name="ex">The exception thrown by HubConnection.StartAsync.</param>
/// <param name="cancellationRequested">True when the user requested cancellation (Ctrl+C) before the exception was thrown.</param> /// <param name="cancellationRequested">True when the user requested cancellation (Ctrl+C) before the exception was thrown.</param>
/// <returns>A <see cref="ConnectFailure"/> describing whether the failure was a cancellation and the appropriate exit code.</returns>
internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested) internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested)
{ {
if (cancellationRequested && ex is OperationCanceledException) if (cancellationRequested && ex is OperationCanceledException)
@@ -43,6 +44,7 @@ internal static class DebugStreamHelpers
/// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0. /// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0.
/// </summary> /// </summary>
/// <param name="exitTask">The task whose result is the intended exit code, set by OnStreamTerminated or the Closed handler.</param> /// <param name="exitTask">The task whose result is the intended exit code, set by OnStreamTerminated or the Closed handler.</param>
/// <returns>A task that resolves to the process exit code (0 for graceful exit or pure Ctrl+C, non-zero for error).</returns>
internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask) internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask)
{ {
if (exitTask.IsCompletedSuccessfully) if (exitTask.IsCompletedSuccessfully)
@@ -13,6 +13,7 @@ public static class DeployCommands
/// <param name="formatOption">Global output format option.</param> /// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param> /// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param> /// <param name="passwordOption">Global password option.</param>
/// <returns>The configured <c>deploy</c> <see cref="Command"/> with all sub-commands attached.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("deploy") { Description = "Deployment operations" }; var command = new Command("deploy") { Description = "Deployment operations" };
@@ -13,6 +13,7 @@ public static class ExternalSystemCommands
/// <param name="formatOption">Global option for the output format.</param> /// <param name="formatOption">Global option for the output format.</param>
/// <param name="usernameOption">Global option for the authentication username.</param> /// <param name="usernameOption">Global option for the authentication username.</param>
/// <param name="passwordOption">Global option for the authentication password.</param> /// <param name="passwordOption">Global option for the authentication password.</param>
/// <returns>The fully configured <c>external-system</c> <see cref="Command"/> with all subcommands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("external-system") { Description = "Manage external systems" }; var command = new Command("external-system") { Description = "Manage external systems" };
@@ -13,6 +13,7 @@ public static class HealthCommands
/// <param name="formatOption">Global <c>--format</c> option for output format.</param> /// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param> /// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param> /// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>health</c> command with all sub-commands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("health") { Description = "Health monitoring" }; var command = new Command("health") { Description = "Health monitoring" };
@@ -13,6 +13,7 @@ public static class NotificationCommands
/// <param name="formatOption">Global <c>--format</c> option for output format.</param> /// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param> /// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param> /// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>notification</c> command with all sub-commands registered.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("notification") { Description = "Manage notification lists" }; var command = new Command("notification") { Description = "Manage notification lists" };
@@ -131,6 +132,7 @@ public static class NotificationCommands
/// null when omitted so the server-side handler preserves the existing values. /// null when omitted so the server-side handler preserves the existing values.
/// </summary> /// </summary>
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param> /// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
/// <returns>An <see cref="UpdateSmtpConfigCommand"/> populated from the parsed result.</returns>
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result) internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
{ {
var id = result.GetValue(SmtpIdOption); var id = result.GetValue(SmtpIdOption);
@@ -13,6 +13,7 @@ public static class SecurityCommands
/// <param name="formatOption">Shared output format option.</param> /// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param> /// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param> /// <param name="passwordOption">Shared password option for authentication.</param>
/// <returns>The configured <c>security</c> command with all subcommands attached.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("security") { Description = "Manage security settings" }; var command = new Command("security") { Description = "Manage security settings" };
@@ -37,44 +38,109 @@ public static class SecurityCommands
group.Add(listCmd); group.Add(listCmd);
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true }; 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" }; var createCmd = new Command("create") { Description = "Create an API key" };
createCmd.Add(nameOption); createCmd.Add(nameOption);
createCmd.Add(createMethodsOption);
createCmd.SetAction(async (ParseResult result) => createCmd.SetAction(async (ParseResult result) =>
{ {
var name = result.GetValue(nameOption)!; var name = result.GetValue(nameOption)!;
var methods = ParseMethods(result.GetValue(createMethodsOption));
return await CommandHelpers.ExecuteCommandAsync( 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); 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" }; var deleteCmd = new Command("delete") { Description = "Delete an API key" };
deleteCmd.Add(idOption); deleteCmd.Add(deleteKeyIdOption);
deleteCmd.SetAction(async (ParseResult result) => deleteCmd.SetAction(async (ParseResult result) =>
{ {
var id = result.GetValue(idOption); var keyId = result.GetValue(deleteKeyIdOption)!;
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id)); result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(keyId));
}); });
group.Add(deleteCmd); 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 enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
var updateCmd = new Command("update") { Description = "Enable or disable an API key" }; var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
updateCmd.Add(updateIdOption); updateCmd.Add(updateKeyIdOption);
updateCmd.Add(enabledOption); updateCmd.Add(enabledOption);
updateCmd.SetAction(async (ParseResult result) => updateCmd.SetAction(async (ParseResult result) =>
{ {
var id = result.GetValue(updateIdOption); var keyId = result.GetValue(updateKeyIdOption)!;
var enabled = result.GetValue(enabledOption); var enabled = result.GetValue(enabledOption);
return await CommandHelpers.ExecuteCommandAsync( 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); 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; 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>
/// <returns>Exit code 0.</returns>
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) 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" }; var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
@@ -13,6 +13,7 @@ public static class SiteCommands
/// <param name="formatOption">Global output format option.</param> /// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param> /// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param> /// <param name="passwordOption">Global password option.</param>
/// <returns>The configured <c>site</c> command with all subcommands attached.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("site") { Description = "Manage sites" }; var command = new Command("site") { Description = "Manage sites" };
@@ -11,6 +11,7 @@ public static class TemplateCommands
/// <param name="formatOption">Shared output format option.</param> /// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param> /// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param> /// <param name="passwordOption">Shared password option for authentication.</param>
/// <returns>The fully configured <c>template</c> command with all its subcommands.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("template") { Description = "Manage templates" }; var command = new Command("template") { Description = "Manage templates" };
@@ -61,6 +61,7 @@ public static class AuditExportEndpoints
/// </summary> /// </summary>
/// <param name="context">The HTTP context for the current request.</param> /// <param name="context">The HTTP context for the current request.</param>
/// <param name="exportService">The export service used to stream audit rows as CSV.</param> /// <param name="exportService">The export service used to stream audit rows as CSV.</param>
/// <returns>A task representing the asynchronous export streaming operation.</returns>
internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService) internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
{ {
var filter = ParseFilter(context.Request.Query); var filter = ParseFilter(context.Request.Query);
@@ -94,6 +95,7 @@ public static class AuditExportEndpoints
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name. /// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks> /// </remarks>
/// <param name="query">The query string parameters from the HTTP request.</param> /// <param name="query">The query string parameters from the HTTP request.</param>
/// <returns>An <see cref="AuditLogQueryFilter"/> populated from the query string values.</returns>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{ {
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]); var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
@@ -17,6 +19,7 @@ public static class AuthEndpoints
{ {
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary> /// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoints to.</param> /// <param name="endpoints">The route builder to add the endpoints to.</param>
/// <returns>The same <paramref name="endpoints"/> instance, for call chaining.</returns>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints) public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
{ {
endpoints.MapPost("/auth/login", async (HttpContext context) => endpoints.MapPost("/auth/login", async (HttpContext context) =>
@@ -31,20 +34,35 @@ public static class AuthEndpoints
return; return;
} }
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>(); var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>(); 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); var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Success) 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}"); context.Response.Redirect($"/login?error={errorMsg}");
return; return;
} }
// Map LDAP groups to roles // Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); // (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. // Build claims from LDAP auth + role mapping.
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped // CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
@@ -52,27 +70,40 @@ public static class AuthEndpoints
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout, // (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
// SlidingExpiration = true). A frozen absolute claim would contradict // SlidingExpiration = true). A frozen absolute claim would contradict
// the documented sliding-refresh policy. // 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> var claims = new List<Claim>
{ {
new(ClaimTypes.Name, authResult.Username ?? username), new(ClaimTypes.Name, resolvedUsername),
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username), new(JwtTokenService.DisplayNameClaimType, displayName),
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username), new(JwtTokenService.UsernameClaimType, resolvedUsername),
}; };
foreach (var role in roleMappingResult.Roles) foreach (var role in roleMapping.Roles)
{ {
claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); 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)); 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); var principal = new ClaimsPrincipal(identity);
await context.SignInAsync( await context.SignInAsync(
@@ -94,33 +125,43 @@ public static class AuthEndpoints
return Results.Json(new { error = "Username and password are required." }, statusCode: 400); 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 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); var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Success) if (!authResult.Succeeded)
{ {
return Results.Json( return Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed." }, new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
statusCode: 401); 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( var token = jwtService.GenerateToken(
authResult.DisplayName ?? username, displayName,
authResult.Username ?? username, resolvedUsername,
roleMappingResult.Roles, roleMapping.Roles,
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds); scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new return Results.Json(new
{ {
access_token = token, access_token = token,
token_type = "Bearer", token_type = "Bearer",
username = authResult.Username ?? username, username = resolvedUsername,
display_name = authResult.DisplayName ?? username, display_name = displayName,
roles = roleMappingResult.Roles, roles = roleMapping.Roles,
}); });
}).DisableAntiforgery(); }).DisableAntiforgery();
@@ -158,6 +199,7 @@ public static class AuthEndpoints
/// server-side. See CentralUI-020. /// server-side. See CentralUI-020.
/// </summary> /// </summary>
/// <param name="context">The current HTTP context used to check authentication state and write the response.</param> /// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public static Task HandlePing(HttpContext context) public static Task HandlePing(HttpContext context)
{ {
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
@@ -179,6 +221,7 @@ public static class AuthEndpoints
/// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null) /// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
/// so the middleware is free to slide the expiry on activity. /// so the middleware is free to slide the expiry on activity.
/// </summary> /// </summary>
/// <returns>An <see cref="AuthenticationProperties"/> instance with <see cref="AuthenticationProperties.IsPersistent"/> set to <c>true</c> and no fixed expiry.</returns>
public static AuthenticationProperties BuildSignInProperties() => new() public static AuthenticationProperties BuildSignInProperties() => new()
{ {
IsPersistent = true IsPersistent = true
@@ -20,6 +20,7 @@ public static class ClaimsPrincipalExtensions
/// <see cref="UnknownUser"/> when the claim is absent. /// <see cref="UnknownUser"/> when the claim is absent.
/// </summary> /// </summary>
/// <param name="principal">The claims principal to read the username from.</param> /// <param name="principal">The claims principal to read the username from.</param>
/// <returns>The username claim value, or <see cref="UnknownUser"/> if absent.</returns>
public static string GetUsername(this ClaimsPrincipal principal) public static string GetUsername(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser; => principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser;
@@ -28,6 +29,7 @@ public static class ClaimsPrincipalExtensions
/// the claim is absent. /// the claim is absent.
/// </summary> /// </summary>
/// <param name="principal">The claims principal to read the display name from.</param> /// <param name="principal">The claims principal to read the display name from.</param>
/// <returns>The display name claim value, or <c>null</c> if the claim is absent.</returns>
public static string? GetDisplayName(this ClaimsPrincipal principal) public static string? GetDisplayName(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value; => principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
@@ -37,6 +39,7 @@ public static class ClaimsPrincipalExtensions
/// ten components (CentralUI-024). /// ten components (CentralUI-024).
/// </summary> /// </summary>
/// <param name="authStateProvider">The Blazor authentication state provider to read from.</param> /// <param name="authStateProvider">The Blazor authentication state provider to read from.</param>
/// <returns>A task that resolves to the current user's audit username, or <see cref="UnknownUser"/> if not authenticated.</returns>
public static async Task<string> GetCurrentUsernameAsync( public static async Task<string> GetCurrentUsernameAsync(
this AuthenticationStateProvider authStateProvider) this AuthenticationStateProvider authStateProvider)
{ {
@@ -38,6 +38,7 @@ public sealed class SiteScopeService
/// True when the user is not restricted to a site subset (no <c>SiteId</c> /// True when the user is not restricted to a site subset (no <c>SiteId</c>
/// claims). System-wide users see and act on every site. /// claims). System-wide users see and act on every site.
/// </summary> /// </summary>
/// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns>
public async Task<bool> IsSystemWideAsync() public async Task<bool> IsSystemWideAsync()
=> (await ResolveAsync()).IsSystemWide; => (await ResolveAsync()).IsSystemWide;
@@ -46,6 +47,7 @@ public sealed class SiteScopeService
/// system-wide user (callers should consult <see cref="IsSystemWideAsync"/> /// system-wide user (callers should consult <see cref="IsSystemWideAsync"/>
/// or use the filter/allowed helpers, which already account for that). /// or use the filter/allowed helpers, which already account for that).
/// </summary> /// </summary>
/// <returns>A task that resolves to the set of permitted site IDs (empty for system-wide users).</returns>
public async Task<IReadOnlySet<int>> PermittedSiteIdsAsync() public async Task<IReadOnlySet<int>> PermittedSiteIdsAsync()
=> (await ResolveAsync()).Sites; => (await ResolveAsync()).Sites;
@@ -54,6 +56,7 @@ public sealed class SiteScopeService
/// see. A system-wide user gets the full list back unchanged. /// see. A system-wide user gets the full list back unchanged.
/// </summary> /// </summary>
/// <param name="sites">The full set of sites to filter.</param> /// <param name="sites">The full set of sites to filter.</param>
/// <returns>A task that resolves to the filtered list of sites the user is permitted to see.</returns>
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites) public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
{ {
var (isSystemWide, allowed) = await ResolveAsync(); var (isSystemWide, allowed) = await ResolveAsync();
@@ -67,6 +70,7 @@ public sealed class SiteScopeService
/// Must be re-checked server-side before any mutating cross-site command. /// Must be re-checked server-side before any mutating cross-site command.
/// </summary> /// </summary>
/// <param name="siteId">The <c>Site.Id</c> to check.</param> /// <param name="siteId">The <c>Site.Id</c> to check.</param>
/// <returns>A task that resolves to <c>true</c> when the user may operate on the given site.</returns>
public async Task<bool> IsSiteAllowedAsync(int siteId) public async Task<bool> IsSiteAllowedAsync(int siteId)
{ {
var (isSystemWide, allowed) = await ResolveAsync(); var (isSystemWide, allowed) = await ResolveAsync();
@@ -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 @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8). @* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Components; 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; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8). /// 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 /// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional /// Close buttons; the single-row detail body (read-only fields, conditional
/// Error/Request/Response/Extra subsections, and action buttons) is delegated /// 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 /// The row to render. When null the drawer renders nothing — the host
/// page uses this together with <see cref="IsOpen"/> to drive visibility. /// page uses this together with <see cref="IsOpen"/> to drive visibility.
/// </summary> /// </summary>
[Parameter] public AuditEvent? Event { get; set; } [Parameter] public AuditEventView? Event { get; set; }
/// <summary> /// <summary>
/// True when the host wants the drawer visible. We deliberately keep /// 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 @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8). @* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; 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; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit; 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) /// The row to render. Required and non-null — the host (drawer or modal)
/// only mounts this component once it has a row to show. /// only mounts this component once it has a row to show.
/// </summary> /// </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 RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>"; 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 /// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly. /// the verb explicitly.
/// </summary> /// </summary>
private static string BuildCurlCommand(AuditEvent ev) private static string BuildCurlCommand(AuditEventView ev)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("curl"); sb.Append("curl");
@@ -114,6 +114,7 @@ public sealed class AuditQueryModel
/// With one or more Channels selected, the union of the channel-specific kind /// With one or more Channels selected, the union of the channel-specific kind
/// lists is returned (deduplicated and order-stable on first-seen). /// lists is returned (deduplicated and order-stable on first-seen).
/// </summary> /// </summary>
/// <returns>The deduplicated, order-stable list of <see cref="AuditKind"/> values applicable to the selected channels.</returns>
public IReadOnlyList<AuditKind> VisibleKinds() public IReadOnlyList<AuditKind> VisibleKinds()
{ {
if (Channels.Count == 0) if (Channels.Count == 0)
@@ -1,6 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @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.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@inject IAuditLogQueryService QueryService @inject IAuditLogQueryService QueryService
@@ -103,7 +102,7 @@
return n.Length >= 8 ? n[..8] : n; 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) switch (key)
{ {
@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; 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.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; 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 ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths"; private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new(); private readonly List<AuditEventView> _rows = new();
private int _pageNumber = 1; private int _pageNumber = 1;
private bool _loading; private bool _loading;
private string? _error; private string? _error;
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// <summary> /// <summary>
/// Raised when the user clicks a row. Bundle C wires this to the drilldown /// 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> /// </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. // Effective page size used when paging. Mirrors PageSize but bounded > 0.
private int _pageSize => Math.Max(1, PageSize); 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) if (OnRowSelected.HasDelegate)
{ {
@@ -411,6 +411,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// </summary> /// </summary>
/// <param name="columnKey">The stable key of the resized column.</param> /// <param name="columnKey">The stable key of the resized column.</param>
/// <param name="widthPx">The new column width in pixels.</param> /// <param name="widthPx">The new column width in pixels.</param>
/// <returns>A task that completes when the column width has been persisted and the component re-rendered.</returns>
[JSInvokable] [JSInvokable]
public async Task OnColumnResized(string columnKey, int widthPx) public async Task OnColumnResized(string columnKey, int widthPx)
{ {
@@ -431,6 +432,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// </summary> /// </summary>
/// <param name="fromKey">The stable key of the column being dragged.</param> /// <param name="fromKey">The stable key of the column being dragged.</param>
/// <param name="toKey">The stable key of the target column drop slot.</param> /// <param name="toKey">The stable key of the target column drop slot.</param>
/// <returns>A task that completes when the column order has been persisted and the component re-rendered.</returns>
[JSInvokable] [JSInvokable]
public async Task OnColumnReordered(string fromKey, string toKey) public async Task OnColumnReordered(string fromKey, string toKey)
{ {
@@ -472,6 +474,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// <summary> /// <summary>
/// Releases the .NET object reference held for JS interop callbacks. /// Releases the .NET object reference held for JS interop callbacks.
/// </summary> /// </summary>
/// <returns>A completed value task.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
_selfRef?.Dispose(); _selfRef?.Dispose();
@@ -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). @* Execution-Tree Node Detail Modal (Task 3).
Opened from an execution-tree node double-click. Given an ExecutionId it 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;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services; 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.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
[Parameter] public EventCallback OnClose { get; set; } [Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes. // 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. // The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow; private AuditEventView? _selectedRow;
private bool _loading; private bool _loading;
private string? _error; private string? _error;
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
_loading = true; _loading = true;
_error = null; _error = null;
_selectedRow = null; _selectedRow = null;
_rows = Array.Empty<AuditEvent>(); _rows = Array.Empty<AuditEventView>();
if (ExecutionId is null) if (ExecutionId is null)
{ {
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
// degrades the modal to an inline error banner rather than killing // degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow. // the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}"; _error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>(); _rows = Array.Empty<AuditEventView>();
_selectedRow = null; _selectedRow = null;
} }
finally 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; private void BackToList() => _selectedRow = null;
@@ -1,26 +1,28 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;"> @* The side-rail chassis (brand bar + responsive hamburger) is the shared
@* Hamburger toggle: visible only on viewports <lg. ZB.MOM.WW.Theme ThemeShell. NavMenu fills the rail's <Nav> slot with the
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@ policy-gated nav groups; the session/sign-out block fills <RailFooter>. *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start" <ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
type="button" <Nav>
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">
<NavMenu /> <NavMenu />
</div> </Nav>
<RailFooter>
<main class="flex-grow-1 p-3"> <AuthorizeView>
@Body <Authorized>
</main> @* CentralUI-024: claim type resolved via JwtTokenService. *@
</div> <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 @* Global host for IDialogService. One instance per layout renders all confirm/prompt
dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@ dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@
@@ -1,323 +1,117 @@
@using System.Linq
@using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Security
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
<nav class="sidebar d-flex flex-column"> @* Rail navigation — rendered inside ThemeShell's <Nav> slot. The chassis
<div class="brand"><span class="mark">&#9646;</span> ScadaBridge</div> (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;"> <NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
<ul class="nav flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
</li>
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
@* Admin section — Admin role only *@ @* Admin section — Administrator role only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="adminContext"> <Authorized Context="adminContext">
<NavSection Title="Admin" <NavRailSection Title="Admin" Key="admin">
Expanded="@_expanded.Contains("admin")" <NavRailItem Href="/admin/ldap-mappings" Text="LDAP Mappings" />
OnToggle="@(() => ToggleAsync("admin"))"> <NavRailItem Href="/admin/sites" Text="Sites" />
<li class="nav-item"> <NavRailItem Href="/admin/api-keys" Text="API Keys" />
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink> @* Import Bundle requires Administrator only — Designer role is not sufficient.
</li> Export Bundle lives in the Design section (RequireDesign). *@
<li class="nav-item"> <NavRailItem Href="/design/transport/import" Text="Import Bundle" />
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink> </NavRailSection>
</li> </Authorized>
<li class="nav-item"> </AuthorizeView>
<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>
@* Design section — Design role *@ @* Design section — Designer role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="designContext"> <Authorized Context="designContext">
<NavSection Title="Design" <NavRailSection Title="Design" Key="design">
Expanded="@_expanded.Contains("design")" <NavRailItem Href="/design/templates" Text="Templates" />
OnToggle="@(() => ToggleAsync("design"))"> <NavRailItem Href="/design/shared-scripts" Text="Shared Scripts" />
<li class="nav-item"> <NavRailItem Href="/design/connections" Text="Connections" />
<NavLink class="nav-link" href="/design/templates">Templates</NavLink> <NavRailItem Href="/design/external-systems" Text="External Systems" />
</li> <NavRailItem Href="/design/transport/export" Text="Export Bundle" />
<li class="nav-item"> </NavRailSection>
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink> </Authorized>
</li> </AuthorizeView>
<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>
@* Deployment section — Deployment role *@ @* Deployment section — Deployer role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment"> <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="deploymentContext"> <Authorized Context="deploymentContext">
<NavSection Title="Deployment" <NavRailSection Title="Deployment" Key="deployment">
Expanded="@_expanded.Contains("deployment")" <NavRailItem Href="/deployment/topology" Text="Topology" />
OnToggle="@(() => ToggleAsync("deployment"))"> <NavRailItem Href="/deployment/deployments" Text="Deployments" />
<li class="nav-item"> <NavRailItem Href="/deployment/debug-view" Text="Debug View" />
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink> </NavRailSection>
</li> </Authorized>
<li class="nav-item"> </AuthorizeView>
<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>
@* Notifications — mixed-role section; each item gated by its own policy. @* Notifications — mixed-role section; each item gated by its own policy.
The section is ungated: every authenticated user holds at least one of The section is ungated: every authenticated user holds at least one of
Admin/Design/Deployment, so it always has a visible child. *@ Admin/Design/Deployment, so it always has a visible child. *@
<NavSection Title="Notifications" <NavRailSection Title="Notifications" Key="notifications">
Expanded="@_expanded.Contains("notifications")" <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
OnToggle="@(() => ToggleAsync("notifications"))"> <Authorized Context="notifAdminContext">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
<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>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</ul> <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
</div> <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> @* Site Calls — Site Call Audit (#22). Deployer-role only,
<Authorized> matching the Notification Report page's gate; the whole
<div class="border-top px-3 py-2"> section sits inside the policy block so a non-Deployer
<div class="d-flex justify-content-between align-items-center"> user does not see the heading. *@
@* CentralUI-024: claim type resolved via JwtTokenService. *@ <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<span class="text-body-secondary small">@context.User.GetDisplayName()</span> <Authorized Context="siteCallsContext">
<form method="post" action="/auth/logout" data-enhance="false"> <NavRailSection Title="Site Calls" Key="sitecalls">
@* CentralUI-017: logout is a state-changing POST and is <NavRailItem Href="/site-calls/report" Text="Site Calls" />
CSRF-protected — the antiforgery token is required. *@ </NavRailSection>
<AntiforgeryToken /> </Authorized>
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button> </AuthorizeView>
</form>
</div>
</div>
</Authorized>
</AuthorizeView>
</nav>
@code { @* Monitoring — Health Dashboard is all-roles; Event Logs and
// Expanded-section state persists in the "scadabridge_nav" cookie, written Parked Messages are Deployer-role only (Component-CentralUI).
// by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a The section is ungated because Health Dashboard is always
// comma-separated list of section ids. 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. @* Audit — gated on the OperationalAudit policy (#23 M7-T15
private static readonly string[] SectionIds = / Bundle G). Hosts the Audit Log page (#23 M7) and the
{ "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" }; Configuration Audit Log (IAuditService config-change
viewer). The whole section sits inside the policy block:
// The currently-expanded sections. Populated from the cookie on first a non-audit user does not even see the heading.
// render; mutated by ToggleAsync and by navigating into a section. OperationalAudit is satisfied by the Administrator and
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal); Viewer roles (post-Task-1.7 canonical collapse: former
Audit→Administrator, AuditReadOnly→Viewer). *@
protected override void OnInitialized() <AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
{ <Authorized Context="auditContext">
Navigation.LocationChanged += OnLocationChanged; <NavRailSection Title="Audit" Key="audit">
} <NavRailItem Href="/audit/log" Text="Audit Log" />
<NavRailItem Href="/audit/configuration" Text="Configuration Audit Log" />
protected override async Task OnAfterRenderAsync(bool firstRender) </NavRailSection>
{ </Authorized>
if (!firstRender) </AuthorizeView>
{ </Authorized>
return; </AuthorizeView>
}
// 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;
}
}
@@ -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/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.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi @using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject IInboundApiRepository InboundApiRepository @inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS @inject IJSRuntime JS
@@ -46,15 +48,16 @@
{ {
<LoadingSpinner IsLoading="true" /> <LoadingSpinner IsLoading="true" />
} }
else if (_saved && _newlyCreatedKeyValue != null) else if (_saved && _newlyCreatedToken != null)
{ {
<div class="alert alert-success"> <div class="alert alert-success">
<strong>New API Key Created</strong> <strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1"> <div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
<code class="me-2">@_newlyCreatedKeyValue</code> <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> <button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div> </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> </div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a> <a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
} }
@@ -66,39 +69,37 @@
{ {
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Name</label> <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> </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) @if (_formError != null)
{ {
<div class="text-danger small mt-2">@_formError</div> <div class="text-danger small mt-2">@_formError</div>
@@ -111,21 +112,26 @@
</div> </div>
@code { @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 bool IsEditMode => _editingKey != null;
private ApiKey? _editingKey; private InboundApiKeyInfo? _editingKey;
private string _formName = string.Empty; private string _formName = string.Empty;
private string? _formError; private string? _formError;
private string? _errorMessage; private string? _errorMessage;
private string? _newlyCreatedKeyValue; private string? _newlyCreatedToken;
private string? _newlyCreatedKeyId;
private bool _loading = true; private bool _loading = true;
private bool _saved; private bool _saved;
private List<ApiMethod> _allMethods = new(); private List<ApiMethod> _allMethods = new();
private HashSet<int> _initialMethodIds = new(); // Selection set is method NAMES (scopes), not method ids.
private HashSet<int> _selectedMethodIds = new(); private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
@@ -133,22 +139,23 @@
{ {
try 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) if (_editingKey == null)
{ {
_errorMessage = $"API key with ID {Id.Value} not found."; _errorMessage = $"API key '{KeyId}' not found.";
} }
else else
{ {
_formName = _editingKey.Name; _formName = _editingKey.Name;
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList(); var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
_initialMethodIds = _allMethods _selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
.Select(m => m.Id)
.ToHashSet();
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
} }
} }
} }
@@ -162,40 +169,38 @@
private async Task SaveKey() private async Task SaveKey()
{ {
_formError = null; _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 try
{ {
if (_editingKey != null) if (_editingKey != null)
{ {
_editingKey.Name = _formName.Trim(); // Edit: name is fixed; only the method-scope set is mutable.
await InboundApiRepository.UpdateApiKeyAsync(_editingKey); var ok = await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
if (!ok)
var changedIds = _selectedMethodIds
.Except(_initialMethodIds)
.Concat(_initialMethodIds.Except(_selectedMethodIds))
.ToHashSet();
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
{ {
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds); _formError = $"API key '{_editingKey.Name}' was not found. Reload and retry.";
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id); return;
else ids.Remove(_editingKey.Id);
method.ApprovedApiKeyIds = ids.Count == 0
? null
: string.Join(",", ids.OrderBy(x => x));
await InboundApiRepository.UpdateApiMethodAsync(method);
} }
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys"); NavigationManager.NavigateTo("/admin/api-keys");
} }
else else
{ {
var keyValue = GenerateApiKey(); var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true }; _newlyCreatedKeyId = created.KeyId;
await InboundApiRepository.AddApiKeyAsync(key); _newlyCreatedToken = created.Token; // shown once; never persisted client-side.
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
_saved = true; _saved = true;
} }
} }
@@ -207,28 +212,18 @@
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys"); 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); if (isChecked) _selectedMethodNames.Add(methodName);
else _selectedMethodIds.Remove(methodId); else _selectedMethodNames.Remove(methodName);
}
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 async Task CopyKeyToClipboard() private async Task CopyKeyToClipboard()
{ {
if (_newlyCreatedKeyValue == null) return; if (_newlyCreatedToken == null) return;
try try
{ {
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue); await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
_toast.ShowSuccess("Copied to clipboard."); _toast.ShowSuccess("Copied to clipboard.");
} }
catch catch
@@ -236,12 +231,4 @@
_toast.ShowError("Copy failed."); _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" @page "/admin/api-keys"
@using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository @inject IInboundApiKeyAdmin ApiKeyAdmin
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IDialogService Dialog @inject IDialogService Dialog
@@ -44,29 +43,29 @@
<table class="table table-sm table-striped table-hover"> <table class="table table-sm table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>ID</th> <th>Key ID</th>
<th>Name</th> <th>Name</th>
<th>Key Hash</th> <th>Methods</th>
<th style="width: 160px;">Actions</th> <th style="width: 160px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var key in FilteredKeys) @foreach (var key in FilteredKeys)
{ {
<tr @key="key.Id"> <tr @key="key.KeyId">
<td>@key.Id</td> <td><code>@TruncateKeyId(key.KeyId)</code></td>
<td> <td>
@key.Name @key.Name
@if (!key.IsEnabled) @if (!key.Enabled)
{ {
<span class="badge bg-secondary ms-1">Disabled</span> <span class="badge bg-secondary ms-1">Disabled</span>
} }
</td> </td>
<td><code>@MaskKeyValue(key.KeyHash)</code></td> <td>@key.Methods.Count</td>
<td> <td>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2" <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"> <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2" <button class="btn btn-outline-secondary btn-sm py-0 px-2"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
@@ -75,7 +74,7 @@
<li> <li>
<button class="dropdown-item" <button class="dropdown-item"
@onclick="() => ToggleKey(key)"> @onclick="() => ToggleKey(key)">
@(key.IsEnabled ? "Disable" : "Enable") @(key.Enabled ? "Disable" : "Enable")
</button> </button>
</li> </li>
<li><hr class="dropdown-divider" /></li> <li><hr class="dropdown-divider" /></li>
@@ -98,14 +97,17 @@
</div> </div>
@code { @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 bool _loading = true;
private string? _errorMessage; private string? _errorMessage;
private string _search = string.Empty; private string _search = string.Empty;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private IEnumerable<ApiKey> FilteredKeys => private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
string.IsNullOrWhiteSpace(_search) string.IsNullOrWhiteSpace(_search)
? _keys ? _keys
: _keys.Where(k => : _keys.Where(k =>
@@ -122,7 +124,7 @@
_errorMessage = null; _errorMessage = null;
try try
{ {
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList(); _keys = (await ApiKeyAdmin.ListAsync()).ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -131,20 +133,28 @@
_loading = false; _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); if (string.IsNullOrEmpty(keyId)) return keyId;
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..]; return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
} }
private async Task ToggleKey(ApiKey key) private async Task ToggleKey(InboundApiKeyInfo key)
{ {
try try
{ {
key.IsEnabled = !key.IsEnabled; var newEnabled = !key.Enabled;
await InboundApiRepository.UpdateApiKeyAsync(key); // The seam persists; there is no separate SaveChangesAsync.
await InboundApiRepository.SaveChangesAsync(); var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}."); 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) catch (Exception ex)
{ {
@@ -152,7 +162,7 @@
} }
} }
private async Task DeleteKey(ApiKey key) private async Task DeleteKey(InboundApiKeyInfo key)
{ {
var confirmed = await Dialog.ConfirmAsync( var confirmed = await Dialog.ConfirmAsync(
"Delete API Key", "Delete API Key",
@@ -162,8 +172,13 @@
try try
{ {
await InboundApiRepository.DeleteApiKeyAsync(key.Id); var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
await InboundApiRepository.SaveChangesAsync(); 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."); _toast.ShowSuccess($"API key '{key.Name}' deleted.");
await LoadDataAsync(); await LoadDataAsync();
} }
@@ -30,11 +30,12 @@
<label class="form-label small">Role</label> <label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole"> <select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option> <option value="">Select role...</option>
<option value="Admin">Admin</option> <option value="@Roles.Administrator">Administrator</option>
<option value="Design">Design</option> <option value="@Roles.Designer">Designer</option>
<option value="Deployment">Deployment</option> <option value="@Roles.Deployer">Deployer</option>
<option value="@Roles.Viewer">Viewer</option>
</select> </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> </div>
@if (_formError != null) @if (_formError != null)
{ {
@@ -2,7 +2,6 @@
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] @attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @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.Audit
@using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Security
@inject IAuditLogQueryService AuditLogQueryService @inject IAuditLogQueryService AuditLogQueryService
@@ -2,7 +2,7 @@ using System.Globalization;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities; 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.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
[Inject] private NavigationManager Navigation { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter; private AuditLogQueryFilter? _currentFilter;
private AuditEvent? _selectedEvent; private AuditEventView? _selectedEvent;
private bool _drawerOpen; private bool _drawerOpen;
private string? _initialInstanceSearch; private string? _initialInstanceSearch;
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
_currentFilter = filter; _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 // 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 // the selected row and open the drilldown drawer — the drawer is fully
@@ -254,6 +254,7 @@ public partial class AuditLogPage : IDisposable
/// Builds the CSV export URL for the given filter, encoding all active filter dimensions as query parameters. /// Builds the CSV export URL for the given filter, encoding all active filter dimensions as query parameters.
/// </summary> /// </summary>
/// <param name="filter">Currently applied filter; null returns the bare export endpoint.</param> /// <param name="filter">Currently applied filter; null returns the bare export endpoint.</param>
/// <returns>The relative URL with encoded filter dimensions as query parameters.</returns>
internal static string BuildExportUrl(AuditLogQueryFilter? filter) internal static string BuildExportUrl(AuditLogQueryFilter? filter)
{ {
const string basePath = "/api/centralui/audit/export"; const string basePath = "/api/centralui/audit/export";
@@ -1,9 +1,10 @@
@page "/" @page "/"
@attribute [Authorize] @attribute [Authorize]
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository @inject ITemplateEngineRepository TemplateEngineRepository
@inject IInboundApiRepository InboundApiRepository @inject IInboundApiKeyAdmin ApiKeyAdmin
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -108,7 +109,7 @@
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count; _siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count; _dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count; _templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count; _apiKeyCount = (await ApiKeyAdmin.ListAsync()).Count;
} }
catch catch
{ {
@@ -3,9 +3,12 @@
@using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi @using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @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 @using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository @inject IInboundApiRepository InboundApiRepository
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService @inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@@ -44,14 +47,14 @@
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;"> <div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var key in _allKeys) @foreach (var key in _allKeys)
{ {
var checkboxId = $"approved-key-{key.Id}"; var checkboxId = $"approved-key-{key.KeyId}";
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId" <input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedKeyIds.Contains(key.Id)" checked="@_selectedKeyIds.Contains(key.KeyId)"
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" /> @onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId"> <label class="form-check-label" for="@checkboxId">
@key.Name @key.Name
@if (!key.IsEnabled) @if (!key.Enabled)
{ {
<span class="badge bg-secondary ms-1">Disabled</span> <span class="badge bg-secondary ms-1">Disabled</span>
} }
@@ -195,9 +198,15 @@
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>(); = 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 ApiMethod? _existing;
private List<ApiKey> _allKeys = new(); private List<InboundApiKeyInfo> _allKeys = new();
private HashSet<int> _selectedKeyIds = 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 _showTestRun;
private bool _running; private bool _running;
@@ -209,7 +218,8 @@
{ {
try 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; } catch (Exception ex) { _formError = ex.Message; }
@@ -225,7 +235,10 @@
_timeoutSeconds = _existing.TimeoutSeconds; _timeoutSeconds = _existing.TimeoutSeconds;
_params = _existing.ParameterDefinitions; _params = _existing.ParameterDefinitions;
_returns = _existing.ReturnDefinition; _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; } catch (Exception ex) { _formError = ex.Message; }
@@ -233,25 +246,12 @@
_loading = false; _loading = false;
} }
private static HashSet<int> ParseApprovedKeyIds(string? value) private void ToggleKey(string keyId, bool isChecked)
{
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)
{ {
if (isChecked) _selectedKeyIds.Add(keyId); if (isChecked) _selectedKeyIds.Add(keyId);
else _selectedKeyIds.Remove(keyId); else _selectedKeyIds.Remove(keyId);
} }
private string? SerializeApprovedKeyIds() =>
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
private async Task Save() private async Task Save()
{ {
_formError = null; _formError = null;
@@ -263,15 +263,18 @@
try 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) if (_existing != null)
{ {
_existing.Script = _script; _existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds; _existing.TimeoutSeconds = _timeoutSeconds;
_existing.ParameterDefinitions = _params?.Trim(); _existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim(); _existing.ReturnDefinition = _returns?.Trim();
_existing.ApprovedApiKeyIds = approvedKeyIds;
await InboundApiRepository.UpdateApiMethodAsync(_existing); await InboundApiRepository.UpdateApiMethodAsync(_existing);
methodName = _existing.Name;
} }
else else
{ {
@@ -279,17 +282,81 @@
{ {
TimeoutSeconds = _timeoutSeconds, TimeoutSeconds = _timeoutSeconds,
ParameterDefinitions = _params?.Trim(), ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim(), ReturnDefinition = _returns?.Trim()
ApprovedApiKeyIds = approvedKeyIds
}; };
await InboundApiRepository.AddApiMethodAsync(m); await InboundApiRepository.AddApiMethodAsync(m);
methodName = m.Name;
} }
await InboundApiRepository.SaveChangesAsync(); 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"); NavigationManager.NavigateTo("/design/external-systems");
} }
catch (Exception ex) { _formError = ex.Message; } 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 GoBack() => NavigationManager.NavigateTo("/design/external-systems");
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun; private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
@@ -138,14 +138,15 @@
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs) @RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
</fieldset> </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"> <fieldset class="mb-4" data-testid="group-api-methods">
<legend class="h6">API Methods</legend> <legend class="h6">API Methods</legend>
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods) @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> </fieldset>
<div class="d-flex justify-content-end gap-2 mt-4"> <div class="d-flex justify-content-end gap-2 mt-4">
@@ -261,10 +262,7 @@
{ {
<li>SmtpConfig: @s.Host</li> <li>SmtpConfig: @s.Host</li>
} }
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase)) @* Inbound API keys are not transported (re-arch C4) — methods only. *@
{
<li>ApiKey: @k.Name</li>
}
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) @foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
{ {
<li>ApiMethod: @m.Name</li> <li>ApiMethod: @m.Name</li>
@@ -69,7 +69,7 @@ public partial class TransportExport : ComponentBase
private List<DatabaseConnectionDefinition> _dbConnections = new(); private List<DatabaseConnectionDefinition> _dbConnections = new();
private List<NotificationList> _notificationLists = new(); private List<NotificationList> _notificationLists = new();
private List<SmtpConfiguration> _smtpConfigs = 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(); private List<ApiMethod> _apiMethods = new();
// ---- Step 1: selection state ---- // ---- Step 1: selection state ----
@@ -82,7 +82,7 @@ public partial class TransportExport : ComponentBase
private readonly HashSet<int> _selectedDbConnections = new(); private readonly HashSet<int> _selectedDbConnections = new();
private readonly HashSet<int> _selectedNotificationLists = new(); private readonly HashSet<int> _selectedNotificationLists = new();
private readonly HashSet<int> _selectedSmtpConfigs = 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 readonly HashSet<int> _selectedApiMethods = new();
private string _filter = string.Empty; private string _filter = string.Empty;
private bool _includeDependencies = true; private bool _includeDependencies = true;
@@ -124,7 +124,7 @@ public partial class TransportExport : ComponentBase
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList(); _dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList(); _notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).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(); _apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
} }
catch (Exception ex) catch (Exception ex)
@@ -169,7 +169,6 @@ public partial class TransportExport : ComponentBase
|| _selectedDbConnections.Count > 0 || _selectedDbConnections.Count > 0
|| _selectedNotificationLists.Count > 0 || _selectedNotificationLists.Count > 0
|| _selectedSmtpConfigs.Count > 0 || _selectedSmtpConfigs.Count > 0
|| _selectedApiKeys.Count > 0
|| _selectedApiMethods.Count > 0; || _selectedApiMethods.Count > 0;
private bool PassphraseValid => private bool PassphraseValid =>
@@ -183,6 +182,7 @@ public partial class TransportExport : ComponentBase
/// importer enforces its own strength + lockout policies. /// importer enforces its own strength + lockout policies.
/// </summary> /// </summary>
/// <param name="s">The passphrase string to score.</param> /// <param name="s">The passphrase string to score.</param>
/// <returns>An integer from 0 (blank) to 4 (long, mixed case, digits, and symbols).</returns>
internal static int PassphraseStrength(string s) internal static int PassphraseStrength(string s)
{ {
if (string.IsNullOrEmpty(s)) return 0; if (string.IsNullOrEmpty(s)) return 0;
@@ -205,7 +205,7 @@ public partial class TransportExport : ComponentBase
DatabaseConnectionIds: _selectedDbConnections.ToList(), DatabaseConnectionIds: _selectedDbConnections.ToList(),
NotificationListIds: _selectedNotificationLists.ToList(), NotificationListIds: _selectedNotificationLists.ToList(),
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(), SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
ApiKeyIds: _selectedApiKeys.ToList(), // Inbound API keys are not transported (re-arch C4) — methods only.
ApiMethodIds: _selectedApiMethods.ToList(), ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies); IncludeDependencies: _includeDependencies);
} }
@@ -262,6 +262,7 @@ public partial class TransportExport : ComponentBase
/// knows exactly what an unencrypted export would leak. /// knows exactly what an unencrypted export would leak.
/// </summary> /// </summary>
/// <param name="resolved">The resolved export closure whose secret fields are counted.</param> /// <param name="resolved">The resolved export closure whose secret fields are counted.</param>
/// <returns>The total number of non-empty secret fields across all external systems, SMTP configs, and database connections.</returns>
internal static int CountSecrets(ResolvedExport resolved) internal static int CountSecrets(ResolvedExport resolved)
{ {
var count = 0; var count = 0;
@@ -368,6 +369,7 @@ public partial class TransportExport : ComponentBase
/// </summary> /// </summary>
/// <param name="sourceEnvironment">The environment label to embed in the filename (sanitised to filename-safe characters).</param> /// <param name="sourceEnvironment">The environment label to embed in the filename (sanitised to filename-safe characters).</param>
/// <param name="nowUtc">Timestamp to use for the datetime segment; defaults to <see cref="DateTimeOffset.UtcNow"/> when null.</param> /// <param name="nowUtc">Timestamp to use for the datetime segment; defaults to <see cref="DateTimeOffset.UtcNow"/> when null.</param>
/// <returns>A filename of the form <c>scadabundle-{env}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.</returns>
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null) internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
{ {
var safe = SanitizeForFilename(sourceEnvironment); var safe = SanitizeForFilename(sourceEnvironment);
@@ -393,7 +395,6 @@ public partial class TransportExport : ComponentBase
_selectedDbConnections.Clear(); _selectedDbConnections.Clear();
_selectedNotificationLists.Clear(); _selectedNotificationLists.Clear();
_selectedSmtpConfigs.Clear(); _selectedSmtpConfigs.Clear();
_selectedApiKeys.Clear();
_selectedApiMethods.Clear(); _selectedApiMethods.Clear();
_filter = string.Empty; _filter = string.Empty;
_includeDependencies = true; _includeDependencies = true;
@@ -429,6 +430,7 @@ public partial class TransportExport : ComponentBase
/// <param name="all">The full resolved list including both seed and auto-included items.</param> /// <param name="all">The full resolved list including both seed and auto-included items.</param>
/// <param name="seed">The set of explicitly selected item ids.</param> /// <param name="seed">The set of explicitly selected item ids.</param>
/// <param name="idOf">Function that extracts the integer id from an item.</param> /// <param name="idOf">Function that extracts the integer id from an item.</param>
/// <returns>Items from <paramref name="all"/> whose ids are not in <paramref name="seed"/> (auto-included dependencies).</returns>
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf) internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
{ {
return all.Where(x => !seed.Contains(idOf(x))).ToList(); return all.Where(x => !seed.Contains(idOf(x))).ToList();
@@ -3,34 +3,10 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
<div class="d-flex align-items-center justify-content-center min-vh-100"> <LoginCard Product="ScadaBridge" Action="/auth/login" Error="@ErrorMessage">
<div class="card shadow-sm" style="max-width: 400px; width: 100%;"> <AntiforgeryToken />
<div class="card-body p-4"> </LoginCard>
<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>
@code { @code {
[SupplyParameterFromQuery(Name = "error")] [SupplyParameterFromQuery(Name = "error")] public string? ErrorMessage { get; set; }
public string? ErrorMessage { get; set; }
} }
@@ -18,6 +18,7 @@ internal static class DurationInput
/// <c>sec</c> unit. /// <c>sec</c> unit.
/// </summary> /// </summary>
/// <param name="duration">The duration to split, or null for unset.</param> /// <param name="duration">The duration to split, or null for unset.</param>
/// <returns>A tuple of the numeric string and unit token (ms/sec/min), or <c>(null, "sec")</c> for null or non-positive input.</returns>
internal static (string? Value, string Unit) Split(TimeSpan? duration) internal static (string? Value, string Unit) Split(TimeSpan? duration)
{ {
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec"); if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
@@ -34,6 +35,7 @@ internal static class DurationInput
/// </summary> /// </summary>
/// <param name="value">The numeric string entered by the user.</param> /// <param name="value">The numeric string entered by the user.</param>
/// <param name="unit">The selected unit token (ms, sec, or min).</param> /// <param name="unit">The selected unit token (ms, sec, or min).</param>
/// <returns>The composed <see cref="TimeSpan"/>, or <c>null</c> for blank, unparseable, or non-positive input.</returns>
internal static TimeSpan? Compose(string? value, string unit) internal static TimeSpan? Compose(string? value, string unit)
{ {
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
@@ -18,6 +18,7 @@ public interface IDialogService
/// <param name="danger">When <c>true</c>, the confirm button renders in /// <param name="danger">When <c>true</c>, the confirm button renders in
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary /// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
/// "Confirm" button is shown.</param> /// "Confirm" button is shown.</param>
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
Task<bool> ConfirmAsync(string title, string message, bool danger = false); Task<bool> ConfirmAsync(string title, string message, bool danger = false);
/// <summary> /// <summary>
@@ -28,5 +29,6 @@ public interface IDialogService
/// <param name="label">Label rendered above the input field.</param> /// <param name="label">Label rendered above the input field.</param>
/// <param name="initialValue">Pre-populated value for the input field.</param> /// <param name="initialValue">Pre-populated value for the input field.</param>
/// <param name="placeholder">Optional placeholder shown when the input is empty.</param> /// <param name="placeholder">Optional placeholder shown when the input is empty.</param>
/// <returns>A task that resolves to the entered string, or <c>null</c> if the user cancels.</returns>
Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null); Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null);
} }
@@ -46,6 +46,7 @@ internal static class SchemaBuilderModel
/// </summary> /// </summary>
/// <param name="json">JSON Schema string to parse, or null/empty to return the fallback.</param> /// <param name="json">JSON Schema string to parse, or null/empty to return the fallback.</param>
/// <param name="fallback">The <see cref="SchemaNode"/> to return when the input cannot be parsed.</param> /// <param name="fallback">The <see cref="SchemaNode"/> to return when the input cannot be parsed.</param>
/// <returns>The parsed <see cref="SchemaNode"/> tree, or <paramref name="fallback"/> if the input is empty or malformed.</returns>
public static SchemaNode Parse(string? json, SchemaNode fallback) public static SchemaNode Parse(string? json, SchemaNode fallback)
{ {
if (string.IsNullOrWhiteSpace(json)) return fallback; if (string.IsNullOrWhiteSpace(json)) return fallback;
@@ -66,15 +67,18 @@ internal static class SchemaBuilderModel
} }
/// <summary>Default empty object schema (parameters mode default).</summary> /// <summary>Default empty object schema (parameters mode default).</summary>
/// <returns>A new <see cref="SchemaNode"/> with type <c>object</c>.</returns>
public static SchemaNode NewObject() => new() { Type = "object" }; public static SchemaNode NewObject() => new() { Type = "object" };
/// <summary>Default scalar schema (return mode default).</summary> /// <summary>Default scalar schema (return mode default).</summary>
/// <returns>A new <see cref="SchemaNode"/> with type <c>string</c>.</returns>
public static SchemaNode NewValue() => new() { Type = "string" }; public static SchemaNode NewValue() => new() { Type = "string" };
/// <summary> /// <summary>
/// Serializes a <see cref="SchemaNode"/> tree to its canonical JSON Schema string. /// Serializes a <see cref="SchemaNode"/> tree to its canonical JSON Schema string.
/// </summary> /// </summary>
/// <param name="node">The schema node to serialize.</param> /// <param name="node">The schema node to serialize.</param>
/// <returns>The canonical JSON Schema string representing the node tree.</returns>
public static string Serialize(SchemaNode node) public static string Serialize(SchemaNode node)
{ {
using var stream = new System.IO.MemoryStream(); using var stream = new System.IO.MemoryStream();

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