Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15752f8c2d | |||
| eabf270d71 | |||
| a050170414 | |||
| 9f18badf02 | |||
| 837fb74ae5 | |||
| 58352a67cb | |||
| b9516e6721 | |||
| 957203ec7b | |||
| 6fb545d75b | |||
| 6d75bdb372 | |||
| e1589497f1 | |||
| b3de8408fa | |||
| bc0e5bfd37 | |||
| 635461c0fd | |||
| 68a6bd1720 | |||
| 1737d15f04 | |||
| 946d3e2aef | |||
| c27b2c3d5f | |||
| db707bb0de | |||
| 5aaf9e2923 | |||
| adfb4d385c | |||
| 3d77dc003c | |||
| 4118452e72 | |||
| b104760b3a | |||
| 6ae605160c | |||
| c185a567f5 | |||
| a0938f708b | |||
| afa55981d5 | |||
| b13d7b3d28 | |||
| 731cfd3bfc | |||
| d1191fddf9 | |||
| 107e524914 | |||
| 8219b8ee18 | |||
| 6518e93424 | |||
| 7f7ea3f3c9 | |||
| 55099b19f6 | |||
| 7e25efa790 | |||
| d09def2be0 | |||
| 1fcc4f5c2b | |||
| a94558c289 | |||
| 4db8c373af | |||
| ac34dac479 | |||
| 9230afa25f | |||
| aaad38958e | |||
| 145d2668e2 | |||
| 9668a4e84a | |||
| 6dbbc7ad04 | |||
| aac59c9fae | |||
| 9bca6aae61 | |||
| 7d16f8f275 | |||
| ccf43312e8 | |||
| a5f8651b0f | |||
| 15a626390b | |||
| 782fb73015 | |||
| 547b685a42 | |||
| 877f2e200b | |||
| c41cb41c7b | |||
| fe25ac3e51 | |||
| bbc9f09268 | |||
| 43f5886024 | |||
| f743ffaad2 | |||
| b3070c0bda | |||
| 20a31835cf | |||
| 59dca0d5fd |
@@ -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.
|
||||
@@ -75,8 +75,17 @@
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -22,17 +22,20 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
"RequireHttpsCookie": false
|
||||
"RequireHttpsCookie": false,
|
||||
"CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2"
|
||||
},
|
||||
"Communication": {
|
||||
"DeploymentTimeout": "00:02:00",
|
||||
|
||||
@@ -22,17 +22,20 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
"RequireHttpsCookie": false
|
||||
"RequireHttpsCookie": false,
|
||||
"CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2"
|
||||
},
|
||||
"Communication": {
|
||||
"DeploymentTimeout": "00:02:00",
|
||||
|
||||
@@ -6,6 +6,12 @@ services:
|
||||
SCADABRIDGE_CONFIG: Central
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
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:
|
||||
- "9101:5000" # Web UI + Inbound API
|
||||
- "9111:8081" # Akka remoting
|
||||
@@ -23,6 +29,12 @@ services:
|
||||
SCADABRIDGE_CONFIG: Central
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
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:
|
||||
- "9102:5000" # Web UI + Inbound API
|
||||
- "9112:8081" # Akka remoting
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -6,6 +6,12 @@ services:
|
||||
SCADABRIDGE_CONFIG: Central
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
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:
|
||||
- "9001:5000" # Web UI + Inbound API
|
||||
- "9011:8081" # Akka remoting (host access for CLI/debugging)
|
||||
@@ -23,6 +29,12 @@ services:
|
||||
SCADABRIDGE_CONFIG: Central
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
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:
|
||||
- "9002:5000" # Web UI + Inbound API
|
||||
- "9012:8081" # Akka remoting
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"NodeHostname": "scadabridge-site-a-a",
|
||||
"SiteId": "site-a",
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"NodeHostname": "scadabridge-site-a-b",
|
||||
"SiteId": "site-a",
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"NodeHostname": "scadabridge-site-b-a",
|
||||
"SiteId": "site-b",
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"NodeHostname": "scadabridge-site-b-b",
|
||||
"SiteId": "site-b",
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"NodeHostname": "scadabridge-site-c-a",
|
||||
"SiteId": "site-c",
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"NodeHostname": "scadabridge-site-c-b",
|
||||
"SiteId": "site-c",
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
- [ ] EF Core migrations have been applied (SQL script reviewed and executed)
|
||||
- [ ] `ScadaBridge:Security:JwtSigningKey` is at least 32 characters, randomly generated
|
||||
- [ ] **Both central nodes use the same JwtSigningKey** (required for JWT failover)
|
||||
- [ ] `ScadaBridge:Security:LdapServer` points to the production LDAP/AD server
|
||||
- [ ] `ScadaBridge:Security:LdapUseTls` is `true` (LDAPS required in production)
|
||||
- [ ] `ScadaBridge:Security:AllowInsecureLdap` is `false`
|
||||
- [ ] `ScadaBridge:Security:Ldap:Server` points to the production LDAP/AD server
|
||||
- [ ] `ScadaBridge:Security:Ldap:Transport` is `Ldaps` (LDAPS required in production)
|
||||
- [ ] `ScadaBridge:Security:Ldap:AllowInsecure` is `false`
|
||||
- [ ] LDAP service-account password supplied via env var `ScadaBridge__Security__Ldap__ServiceAccountPassword` (renamed from `ScadaBridge__Security__LdapServiceAccountPassword` in the Task 1.4 nested-config cutover)
|
||||
- [ ] LDAP search base DN is correct for the organization
|
||||
- [ ] LDAP group-to-role mappings are configured
|
||||
- [ ] Load balancer is configured in front of central UI (sticky sessions not required)
|
||||
|
||||
@@ -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.
|
||||
@@ -246,13 +246,15 @@ These are clones of `docker/central-node-a/appsettings.Central.json` and `docker
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=scadabridge,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -67,7 +67,7 @@ For use in `appsettings.Development.json`:
|
||||
"Ldap": {
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"BaseDN": "dc=scadabridge,dc=local",
|
||||
"BaseDN": "dc=zb,dc=local",
|
||||
"UseSsl": false
|
||||
},
|
||||
"OpcUa": {
|
||||
|
||||
@@ -12,7 +12,7 @@ The test LDAP server uses [GLAuth](https://glauth.github.io/), a lightweight LDA
|
||||
## Base DN
|
||||
|
||||
```
|
||||
dc=scadabridge,dc=local
|
||||
dc=zb,dc=local
|
||||
```
|
||||
|
||||
## Test Users
|
||||
@@ -41,20 +41,20 @@ All users have the password `password`.
|
||||
Users bind with their full DN, which includes the primary group as an OU:
|
||||
|
||||
```
|
||||
cn=<username>,ou=<PrimaryGroupName>,ou=users,dc=scadabridge,dc=local
|
||||
cn=<username>,ou=<PrimaryGroupName>,ou=users,dc=zb,dc=local
|
||||
```
|
||||
|
||||
For example: `cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local`
|
||||
For example: `cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local`
|
||||
|
||||
The full DNs for all test users:
|
||||
|
||||
| Username | Full DN |
|
||||
|----------|---------|
|
||||
| `admin` | `cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local` |
|
||||
| `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=scadabridge,dc=local` |
|
||||
| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=scadabridge,dc=local` |
|
||||
| `site-deployer` | `cn=site-deployer,ou=SCADA-Deploy-SiteA,ou=users,dc=scadabridge,dc=local` |
|
||||
| `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local` |
|
||||
| `admin` | `cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local` |
|
||||
| `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=zb,dc=local` |
|
||||
| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=zb,dc=local` |
|
||||
| `site-deployer` | `cn=site-deployer,ou=SCADA-Deploy-SiteA,ou=users,dc=zb,dc=local` |
|
||||
| `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=zb,dc=local` |
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -68,9 +68,9 @@ docker ps --filter name=scadabridge-ldap
|
||||
|
||||
```bash
|
||||
ldapsearch -H ldap://localhost:3893 \
|
||||
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local" \
|
||||
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local" \
|
||||
-w password \
|
||||
-b "dc=scadabridge,dc=local" \
|
||||
-b "dc=zb,dc=local" \
|
||||
"(objectClass=*)"
|
||||
```
|
||||
|
||||
@@ -78,9 +78,9 @@ ldapsearch -H ldap://localhost:3893 \
|
||||
|
||||
```bash
|
||||
ldapsearch -H ldap://localhost:3893 \
|
||||
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local" \
|
||||
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local" \
|
||||
-w password \
|
||||
-b "dc=scadabridge,dc=local" \
|
||||
-b "dc=zb,dc=local" \
|
||||
"(cn=multi-role)"
|
||||
```
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
[backend]
|
||||
datastore = "config"
|
||||
baseDN = "dc=scadabridge,dc=local"
|
||||
baseDN = "dc=zb,dc=local"
|
||||
|
||||
# ── Groups ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ from ldap3 import Server, Connection, NONE, SUBTREE, SIMPLE
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 3893
|
||||
DEFAULT_BASE_DN = "dc=scadabridge,dc=local"
|
||||
DEFAULT_BASE_DN = "dc=zb,dc=local"
|
||||
# GLAuth places users under ou=<PrimaryGroupName>,ou=users,dc=...
|
||||
# The admin user (primarygroup SCADA-Admins) needs search capabilities in config.
|
||||
DEFAULT_BIND_DN = "cn=admin,ou=SCADA-Admins,ou=users,dc=scadabridge,dc=local"
|
||||
DEFAULT_BIND_DN = "cn=admin,ou=SCADA-Admins,ou=users,dc=zb,dc=local"
|
||||
DEFAULT_BIND_PASSWORD = "password"
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def cmd_check(args):
|
||||
def cmd_bind(args):
|
||||
"""Test user authentication via bind.
|
||||
|
||||
GLAuth DN format: cn=<user>,ou=<PrimaryGroup>,ou=users,dc=scadabridge,dc=local
|
||||
GLAuth DN format: cn=<user>,ou=<PrimaryGroup>,ou=users,dc=zb,dc=local
|
||||
Since we don't know the user's primary group upfront, we search for the user first
|
||||
to discover the full DN, then rebind with that DN.
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
<package pattern="ZB.MOM.WW.MxGateway.*" />
|
||||
<package pattern="ZB.MOM.WW.Health" />
|
||||
<package pattern="ZB.MOM.WW.Health.*" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||
<package pattern="ZB.MOM.WW.Configuration" />
|
||||
<package pattern="ZB.MOM.WW.Auth" />
|
||||
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||
<package pattern="ZB.MOM.WW.Audit" />
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
<!--
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
@@ -13,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// Central-side singleton (per Bundle E wiring) that ingests batches of
|
||||
/// <see cref="AuditEvent"/> rows pushed from sites via the
|
||||
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
|
||||
/// <see cref="AuditEvent.IngestedAtUtc"/> and inserted idempotently via
|
||||
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
|
||||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
|
||||
/// silently swallowed (first-write-wins per Bundle A's hardening).
|
||||
/// </summary>
|
||||
@@ -116,10 +117,10 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// Resolve the repository for the whole batch — one DbContext per
|
||||
// message, mirroring NotificationOutboxActor. The injected-repository
|
||||
// mode (Bundle D tests) skips the scope entirely.
|
||||
// Bundle C (M5-T6): the IAuditPayloadFilter is also resolved from the
|
||||
// Bundle C (M5-T6): the IAuditRedactor is also resolved from the
|
||||
// per-message scope when one is available so the row is truncated +
|
||||
// redacted before InsertIfNotExistsAsync. The single-repository test
|
||||
// ctor has no service provider — it falls through with no filter,
|
||||
// ctor has no service provider — it falls through with no redactor,
|
||||
// which preserves the small-payload assumptions baked into the
|
||||
// existing D2 fixtures.
|
||||
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
|
||||
@@ -127,19 +128,19 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// without blocking on sync Dispose() of pending connection cleanup.
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
await IngestWithRepositoryAsync(_injectedRepository, filter: null, failureCounter: null, cmd, nowUtc, accepted)
|
||||
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var scope = _serviceProvider!.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
||||
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||
// M6 Bundle E (T8): central health counter is best-effort —
|
||||
// unregistered (test composition roots) means the per-row catch
|
||||
// simply logs without surfacing on the health dashboard.
|
||||
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
|
||||
await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted)
|
||||
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
|
||||
private async Task IngestWithRepositoryAsync(
|
||||
IAuditLogRepository repository,
|
||||
IAuditPayloadFilter? filter,
|
||||
IAuditRedactor? redactor,
|
||||
ICentralAuditWriteFailureCounter? failureCounter,
|
||||
IngestAuditEventsCommand cmd,
|
||||
DateTime nowUtc,
|
||||
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// repository hardening already swallows duplicate-key races,
|
||||
// so the same id arriving twice (site retry, reconciliation)
|
||||
// is a silent no-op.
|
||||
// Filter BEFORE the IngestedAtUtc stamp so the redacted
|
||||
// copy carries the central-side ingest timestamp. Filter
|
||||
// Redact BEFORE the IngestedAtUtc stamp so the redacted
|
||||
// copy carries the central-side ingest timestamp. The redactor
|
||||
// is contract-bound to never throw. AuditLog-008: a null
|
||||
// filter (test composition root, no IAuditPayloadFilter
|
||||
// redactor (test composition root, no IAuditRedactor
|
||||
// registered) now falls back to the SafeDefault rather than
|
||||
// pass-through, so HTTP header redaction always runs.
|
||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
var filtered = safeFilter.Apply(evt);
|
||||
var ingested = filtered with { IngestedAtUtc = nowUtc };
|
||||
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on
|
||||
// the canonical record, so stamp it via the projection helper.
|
||||
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
var filtered = safeRedactor.Apply(evt);
|
||||
var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
|
||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||
accepted.Add(evt.EventId);
|
||||
}
|
||||
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
// Bundle C (M5-T6): resolve the filter for the whole batch from
|
||||
// the scope; null = pass-through for test composition roots that
|
||||
// skip the filter registration. The filter is contract-bound to
|
||||
// Bundle C (M5-T6): resolve the redactor for the whole batch from
|
||||
// the scope; null = SafeDefault for test composition roots that
|
||||
// skip the redactor registration. The redactor is contract-bound to
|
||||
// never throw, so we can apply it inside the per-entry try
|
||||
// without risking an unbounded blast radius.
|
||||
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
||||
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||
// M6 Bundle E (T8): same best-effort central health counter as
|
||||
// the OnIngestAsync path — null on test composition roots that
|
||||
// skip the registration.
|
||||
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// matching timestamps (debugging convenience, not a
|
||||
// correctness invariant).
|
||||
var ingestedAt = DateTime.UtcNow;
|
||||
// Filter the audit half BEFORE the dual-write — only the
|
||||
// AuditLog row's payload columns are filterable; SiteCalls
|
||||
// Redact the audit half BEFORE the dual-write — only the
|
||||
// AuditLog row's payload columns are redactable; SiteCalls
|
||||
// carries operational state only (status, retry count) and
|
||||
// is left untouched. AuditLog-008: null filter falls back
|
||||
// is left untouched. AuditLog-008: null redactor falls back
|
||||
// to SafeDefault so header redaction always runs.
|
||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
var filteredAudit = safeFilter.Apply(entry.Audit);
|
||||
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
|
||||
// C3 transitional shim: IngestedAtUtc is a DetailsJson field
|
||||
// on the canonical record, so stamp it via the projection helper.
|
||||
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
var filteredAudit = safeRedactor.Apply(entry.Audit);
|
||||
var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
|
||||
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
|
||||
|
||||
await auditRepo.InsertIfNotExistsAsync(auditStamped)
|
||||
|
||||
@@ -76,7 +76,12 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
|
||||
_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)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
return _loop ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Disposes the internal <see cref="CancellationTokenSource"/> used to stop the maintenance loop.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Dispose();
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M6 Bundle E (T9) — bridges
|
||||
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
|
||||
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL
|
||||
/// parameter redactor stage throws and the filter has to over-redact the
|
||||
/// offending field) into <see cref="AuditCentralHealthSnapshot"/> so the
|
||||
/// failure surfaces on the central health surface as
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
|
||||
/// a header / body / SQL parameter redactor stage throws and the redactor has
|
||||
/// to over-redact the offending field) into <see cref="AuditCentralHealthSnapshot"/>
|
||||
/// so the failure surfaces on the central health surface as
|
||||
/// <c>AuditCentralHealthSnapshot.AuditRedactionFailure</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<CentralAuditWriter> _logger;
|
||||
private readonly IAuditPayloadFilter _filter;
|
||||
private readonly IAuditRedactor _redactor;
|
||||
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
||||
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||
|
||||
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
/// </summary>
|
||||
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
|
||||
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
|
||||
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
|
||||
/// <param name="redactor">Optional canonical redactor for truncation and redaction; defaults to the always-safe default.</param>
|
||||
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
|
||||
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
|
||||
public CentralAuditWriter(
|
||||
IServiceProvider services,
|
||||
ILogger<CentralAuditWriter> logger,
|
||||
IAuditPayloadFilter? filter = null,
|
||||
IAuditRedactor? redactor = null,
|
||||
ICentralAuditWriteFailureCounter? failureCounter = null,
|
||||
INodeIdentityProvider? nodeIdentity = null)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
// AuditLog-008: never default to null — over-redact instead.
|
||||
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
|
||||
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
|
||||
// SafeDefaultAuditRedactor applies HTTP header redaction with
|
||||
// hard-coded sensitive defaults so a composition root that omits the
|
||||
// real filter still scrubs Authorization / X-Api-Key / Cookie /
|
||||
// real redactor still scrubs Authorization / X-Api-Key / Cookie /
|
||||
// Set-Cookie before persistence.
|
||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
||||
_nodeIdentity = nodeIdentity;
|
||||
}
|
||||
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
|
||||
try
|
||||
{
|
||||
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
|
||||
// filter contract is "never throws". AuditLog-008: _filter is now
|
||||
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
|
||||
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
|
||||
// redactor contract is "never throws". AuditLog-008: _redactor is
|
||||
// now non-null (SafeDefaultAuditRedactor fallback) so header
|
||||
// redaction always runs even in composition roots that omit the
|
||||
// real filter.
|
||||
var filtered = _filter.Apply(evt);
|
||||
// real redactor.
|
||||
var filtered = _redactor.Apply(evt);
|
||||
|
||||
// SourceNode-stamping (Task 12): caller-provided value wins
|
||||
// (supports any future direct-write callsite that already has its
|
||||
@@ -124,7 +126,9 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
|
||||
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the
|
||||
// canonical record, so stamp it via the projection helper.
|
||||
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
|
||||
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
// misbehaving custom counter does, swallowing here keeps the
|
||||
// best-effort contract intact.
|
||||
}
|
||||
// Log the input event's identifying fields. These three (EventId,
|
||||
// Kind, Status) are immutable across the filter+stamp chain — the
|
||||
// `with` clones above touch only SourceNode and IngestedAtUtc — so
|
||||
// referencing `evt` here is intentional and equivalent to the
|
||||
// stamped record for diagnostics. If you add a field here that the
|
||||
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
|
||||
// post-stamp record name instead, not `evt`.
|
||||
// Log the input event's identifying fields. EventId + Action are
|
||||
// immutable across the redact+stamp chain — the `with` clones above
|
||||
// touch only SourceNode and DetailsJson — so referencing `evt` here
|
||||
// is intentional and equivalent to the stamped record for
|
||||
// diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
|
||||
// canonical Outcome carries the coarse status (fine-grained Status
|
||||
// lives in DetailsJson).
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
||||
evt.EventId, evt.Kind, evt.Status);
|
||||
"CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
|
||||
evt.EventId, evt.Action, evt.Outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="batchSize">Maximum number of events to return per call.</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(
|
||||
string siteId,
|
||||
DateTime sinceUtc,
|
||||
|
||||
@@ -23,6 +23,7 @@ public interface ISiteEnumerator
|
||||
/// — the actor calls this once per tick.
|
||||
/// </summary>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
@@ -258,7 +258,9 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
||||
// concurrent push, or a retry of this very pull) collapse to
|
||||
// a no-op courtesy of M2 Bundle A's race-fix on
|
||||
// InsertIfNotExistsAsync.
|
||||
var ingested = evt with { IngestedAtUtc = nowUtc };
|
||||
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
|
||||
// stamp it via the projection helper.
|
||||
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
|
||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||
_failedInsertAttempts.Remove(evt.EventId);
|
||||
advanceForThisRow = true;
|
||||
@@ -299,9 +301,11 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
if (advanceForThisRow && evt.OccurredAtUtc > maxOccurred)
|
||||
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
|
||||
var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
|
||||
if (advanceForThisRow && occurredUtc > maxOccurred)
|
||||
{
|
||||
maxOccurred = evt.OccurredAtUtc;
|
||||
maxOccurred = occurredUtc;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
|
||||
/// Returns a defensive copy of the per-site latched stalled state.
|
||||
/// Absent sites are interpreted as <c>Stalled=false</c> by consumers.
|
||||
/// </summary>
|
||||
/// <returns>A snapshot dictionary mapping each known site ID to its current stalled state.</returns>
|
||||
public IReadOnlyDictionary<string, bool> Snapshot() =>
|
||||
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;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
/// drop in-flight investigations, too long would defeat the partition-switch
|
||||
/// purge's purpose.
|
||||
/// </summary>
|
||||
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
|
||||
public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOptions>
|
||||
{
|
||||
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
||||
public const int MinRetentionDays = 30;
|
||||
@@ -28,43 +28,29 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
|
||||
public const int MaxInboundMaxBytes = 16_777_216;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
|
||||
protected override void Validate(ValidationBuilder builder, AuditLogOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
builder.RequireThat(options.DefaultCapBytes > 0,
|
||||
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
|
||||
"must be > 0; it drives payload-summary truncation in audit writers.");
|
||||
|
||||
var failures = new List<string>();
|
||||
builder.RequireThat(options.ErrorCapBytes >= options.DefaultCapBytes,
|
||||
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
|
||||
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
|
||||
"the error-row cap is intended to capture more detail than the happy-path summary.");
|
||||
|
||||
if (options.DefaultCapBytes <= 0)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
|
||||
"must be > 0; it drives payload-summary truncation in audit writers.");
|
||||
}
|
||||
// Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
|
||||
// guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
|
||||
builder.RequireThat(
|
||||
!(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
|
||||
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
|
||||
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
||||
|
||||
if (options.ErrorCapBytes < options.DefaultCapBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
|
||||
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
|
||||
"the error-row cap is intended to capture more detail than the happy-path summary.");
|
||||
}
|
||||
|
||||
if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
|
||||
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
||||
}
|
||||
|
||||
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
|
||||
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
|
||||
}
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
// Valid when InboundMaxBytes is within [Min, Max] inclusive. The De Morgan'd
|
||||
// guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
|
||||
builder.RequireThat(
|
||||
!(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes),
|
||||
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
|
||||
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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><</c> / <c>></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 & 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><</c> / <c>></c>) survives unescaped — the
|
||||
/// header-redaction tests grep for the literal marker, and the downstream
|
||||
/// UI / log readers would rather see <c><redacted></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>.<sql-snippet></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 & 0xC0 == 0x80</c>), and decodes the resulting prefix —
|
||||
/// guaranteeing the returned string never splits a multi-byte sequence.
|
||||
/// </summary>
|
||||
private static string TruncateUtf8(string value, int capBytes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
if (bytes.Length <= capBytes)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var boundary = capBytes;
|
||||
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
|
||||
{
|
||||
boundary--;
|
||||
}
|
||||
return Encoding.UTF8.GetString(bytes, 0, boundary);
|
||||
}
|
||||
|
||||
private static bool IsErrorStatus(AuditStatus status) => status switch
|
||||
{
|
||||
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Cache entry for a body-redactor pattern. Carries the working
|
||||
/// <see cref="Regex"/> on the success path, or the
|
||||
/// <see cref="Invalid"/> sentinel for patterns that failed to compile
|
||||
/// (or exceeded the 100 ms compile budget). The sentinel lets us skip
|
||||
/// repeat compile attempts on every event without re-throwing on the
|
||||
/// hot-path.
|
||||
/// </summary>
|
||||
private readonly struct CompiledRegex
|
||||
{
|
||||
public static readonly CompiledRegex Invalid = new(null);
|
||||
|
||||
/// <summary>Gets the compiled <see cref="System.Text.RegularExpressions.Regex"/>, or <c>null</c> when the pattern was invalid.</summary>
|
||||
public Regex? Regex { get; }
|
||||
|
||||
/// <summary>Initializes a new <see cref="CompiledRegex"/> wrapping the given compiled regex instance.</summary>
|
||||
/// <param name="regex">The pre-compiled regex, or <c>null</c> to represent an invalid pattern.</param>
|
||||
public CompiledRegex(Regex? regex) => Regex = regex;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Filters an <see cref="AuditEvent"/> between construction and persistence —
|
||||
/// truncates oversized payload fields, applies header/body/SQL-parameter
|
||||
/// redaction, sets <see cref="AuditEvent.PayloadTruncated"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Pure function: returns a filtered COPY of the input via <c>with</c>
|
||||
/// expressions; never throws (over-redacts on internal failure and increments
|
||||
/// the <c>AuditRedactionFailure</c> health metric).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Wired in M5 between event construction and the writer chain
|
||||
/// (<c>FallbackAuditWriter.WriteAsync</c>, <c>CentralAuditWriter.WriteAsync</c>,
|
||||
/// and the <c>AuditLogIngestActor</c> handlers).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IAuditPayloadFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply the configured truncation + redaction policy to <paramref name="rawEvent"/>
|
||||
/// and return a filtered copy. MUST NOT throw — on internal failure, over-redact
|
||||
/// and surface the failure via the audit-redaction-failure health metric.
|
||||
/// </summary>
|
||||
/// <param name="rawEvent">The unfiltered audit event to process.</param>
|
||||
AuditEvent Apply(AuditEvent rawEvent);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Counter sink invoked by <see cref="DefaultAuditPayloadFilter"/> every time
|
||||
/// a redactor (header / body regex / SQL parameter) throws and the filter has
|
||||
/// to over-redact the offending field with the
|
||||
/// Counter sink invoked by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
||||
/// every time a redactor (header / body regex / SQL parameter) throws and the
|
||||
/// redactor has to over-redact the offending field with the
|
||||
/// <c><redacted: redactor error></c> marker. Bundle C bridges this into
|
||||
/// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
|
||||
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
|
||||
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
|
||||
/// HTTP header redaction for the always-sensitive defaults
|
||||
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
|
||||
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
|
||||
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
|
||||
/// redaction, or truncation — those stages need
|
||||
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
|
||||
/// over-redact safely, never throw, never miss a header that's on the
|
||||
/// default sensitive list.
|
||||
/// </summary>
|
||||
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
|
||||
{
|
||||
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
|
||||
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
|
||||
|
||||
private static readonly string[] DefaultHeaderRedactList =
|
||||
{
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
};
|
||||
|
||||
private static readonly Regex HeaderRegex = new(
|
||||
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private SafeDefaultAuditPayloadFilter() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rawEvent);
|
||||
try
|
||||
{
|
||||
return rawEvent with
|
||||
{
|
||||
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
|
||||
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Over-redact: drop both summaries entirely so a malformed parse
|
||||
// path never leaks the original. The contract is "never throw."
|
||||
return rawEvent with
|
||||
{
|
||||
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
|
||||
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string? RedactHeaders(string? summary)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary)) return summary;
|
||||
|
||||
return HeaderRegex.Replace(summary, m =>
|
||||
{
|
||||
var name = m.Groups["name"].Value;
|
||||
foreach (var sensitive in DefaultHeaderRedactList)
|
||||
{
|
||||
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"{name}: [REDACTED]";
|
||||
}
|
||||
}
|
||||
return m.Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
|
||||
|
||||
@@ -62,19 +65,19 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
|
||||
services.AddOptions<AuditLogOptions>()
|
||||
.Bind(config.GetSection(ConfigSectionName))
|
||||
.ValidateOnStart();
|
||||
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
|
||||
// Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
|
||||
// "AuditLog" section, registers the validator, and enables ValidateOnStart in
|
||||
// one call. Same section path as before; AddAuditLog is call-once per
|
||||
// collection, and the helper's TryAddEnumerable is idempotent for the
|
||||
// validator (a strict improvement over the previous AddSingleton).
|
||||
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
|
||||
|
||||
// M5 Bundle A: payload filter — truncates oversized RequestSummary /
|
||||
// ResponseSummary / ErrorDetail / Extra fields between event
|
||||
// construction and persistence. Bundle B layers header / body /
|
||||
// SQL-parameter redaction onto the same singleton; Bundle C wires it
|
||||
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor
|
||||
// paths. Singleton — the filter is stateless and the IOptionsMonitor
|
||||
// dependency picks up M5-T8 hot reloads on its own.
|
||||
services.AddSingleton<IAuditPayloadFilter, DefaultAuditPayloadFilter>();
|
||||
// C3 (Task 2.5): the canonical IAuditRedactor is wired as
|
||||
// ScadaBridgeAuditRedactor — same truncation + header / body /
|
||||
// SQL-parameter redaction as the original pipeline, applied between
|
||||
// event construction and persistence. Singleton — stateless; the
|
||||
// IOptionsMonitor dependency picks up hot reloads on its own.
|
||||
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
|
||||
|
||||
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
|
||||
// Bundle C replaces this binding with the Site Health Monitoring
|
||||
@@ -113,7 +116,7 @@ public static class ServiceCollectionExtensions
|
||||
// The script-thread surface is FallbackAuditWriter (primary + ring +
|
||||
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
|
||||
// abort the user-facing action.
|
||||
// Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired
|
||||
// C3 (Task 2.5): the canonical IAuditRedactor singleton above is wired
|
||||
// through the factory so every event written through this surface is
|
||||
// truncated + redacted before it hits SQLite (and the ring on
|
||||
// failure).
|
||||
@@ -122,7 +125,7 @@ public static class ServiceCollectionExtensions
|
||||
ring: sp.GetRequiredService<RingBufferFallback>(),
|
||||
failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
|
||||
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
||||
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
|
||||
redactor: sp.GetRequiredService<IAuditRedactor>()));
|
||||
|
||||
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
|
||||
// central/test composition roots that have no SiteCommunicationActor.
|
||||
@@ -200,7 +203,7 @@ public static class ServiceCollectionExtensions
|
||||
// is intentionally distinct from IAuditWriter so site composition roots
|
||||
// do not accidentally bind it; central composition roots that include
|
||||
// AddConfigurationDatabase get a working implementation transparently.
|
||||
// Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so
|
||||
// C3 (Task 2.5): wire the canonical IAuditRedactor into the factory so
|
||||
// NotificationOutboxActor + Inbound API rows are truncated + redacted
|
||||
// before they hit MS SQL.
|
||||
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
|
||||
@@ -208,7 +211,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
|
||||
sp,
|
||||
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
||||
sp.GetRequiredService<IAuditPayloadFilter>(),
|
||||
sp.GetRequiredService<IAuditRedactor>(),
|
||||
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
|
||||
// SourceNode-stamping (Task 12): wire the local node identity so
|
||||
// central-origin rows (Notification Outbox dispatch, Inbound API)
|
||||
@@ -228,7 +231,7 @@ public static class ServiceCollectionExtensions
|
||||
/// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
|
||||
/// <see cref="HealthMetricsAuditRedactionFailureCounter"/> bridges so the
|
||||
/// FallbackAuditWriter primary-failure counter AND the
|
||||
/// DefaultAuditPayloadFilter redactor-failure counter both surface in the
|
||||
/// <see cref="ScadaBridgeAuditRedactor"/> redactor-failure counter both surface in the
|
||||
/// site health report payload as
|
||||
/// <c>SiteHealthReport.SiteAuditWriteFailures</c> +
|
||||
/// <c>SiteHealthReport.AuditRedactionFailure</c>.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
|
||||
@@ -31,43 +32,45 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
||||
private readonly RingBufferFallback _ring;
|
||||
private readonly IAuditWriteFailureCounter _failureCounter;
|
||||
private readonly ILogger<FallbackAuditWriter> _logger;
|
||||
private readonly IAuditPayloadFilter _filter;
|
||||
private readonly IAuditRedactor _redactor;
|
||||
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditPayloadFilter"/>
|
||||
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditRedactor"/>
|
||||
/// here so every event written via the site hot path is truncated +
|
||||
/// header/body/SQL-param redacted before it hits both the primary SQLite
|
||||
/// writer AND the ring fallback. The parameter is optional (defaults to
|
||||
/// no filtering) so the long tail of test composition roots that don't
|
||||
/// care about the filter need no change — the production
|
||||
/// the always-safe <see cref="SafeDefaultAuditRedactor"/>) so the long
|
||||
/// tail of test composition roots that don't care about the redactor need
|
||||
/// no change — the production
|
||||
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration
|
||||
/// always passes the real filter through.
|
||||
/// always passes the real redactor through.
|
||||
/// </summary>
|
||||
/// <param name="primary">The primary audit writer (typically the SQLite writer).</param>
|
||||
/// <param name="ring">Drop-oldest ring buffer used to stash events when the primary fails.</param>
|
||||
/// <param name="failureCounter">Counter incremented on each primary failure for health reporting.</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
/// <param name="filter">Optional payload filter applied before writing; null means no filtering.</param>
|
||||
/// <param name="redactor">Optional canonical redactor applied before writing; null means the always-safe default.</param>
|
||||
public FallbackAuditWriter(
|
||||
IAuditWriter primary,
|
||||
RingBufferFallback ring,
|
||||
IAuditWriteFailureCounter failureCounter,
|
||||
ILogger<FallbackAuditWriter> logger,
|
||||
IAuditPayloadFilter? filter = null)
|
||||
IAuditRedactor? redactor = null)
|
||||
{
|
||||
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
||||
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
|
||||
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
// AuditLog-008: never default to a null filter — over-redact instead.
|
||||
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
|
||||
// redaction with the hard-coded sensitive defaults (Authorization,
|
||||
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
|
||||
// doesn't bind the real options never persists those headers
|
||||
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
|
||||
// AuditLog-008: never default to a null redactor — over-redact instead.
|
||||
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
|
||||
// SafeDefaultAuditRedactor performs HTTP header redaction with the
|
||||
// hard-coded sensitive defaults (Authorization, X-Api-Key, Cookie,
|
||||
// Set-Cookie) on the DetailsJson summaries so a test composition root
|
||||
// that doesn't bind the real options never persists those headers
|
||||
// verbatim. The full ScadaBridgeAuditRedactor (truncation + body /
|
||||
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
|
||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -75,14 +78,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
// Filter once, up-front. The filtered event flows BOTH to the primary
|
||||
// Redact once, up-front. The redacted event flows BOTH to the primary
|
||||
// and (on failure) to the ring buffer — so a primary outage that
|
||||
// drains later still hands the SqliteAuditWriter a row that has
|
||||
// already been truncated and redacted. The filter contract is
|
||||
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
|
||||
// to SafeDefaultAuditPayloadFilter so header redaction is always
|
||||
// applied even in composition roots that don't wire the real filter).
|
||||
var filtered = _filter.Apply(evt);
|
||||
// already been truncated and redacted. The redactor contract is
|
||||
// "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
|
||||
// to SafeDefaultAuditRedactor so header redaction is always applied
|
||||
// even in composition roots that don't wire the real redactor).
|
||||
var filtered = _redactor.Apply(evt);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
+4
-4
@@ -6,10 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M5 Bundle C — bridges
|
||||
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
|
||||
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL
|
||||
/// parameter redactor stage throws and the filter has to over-redact the
|
||||
/// offending field) into <see cref="ISiteHealthCollector"/> so the count
|
||||
/// surfaces in the site health report payload as
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
|
||||
/// a header / body / SQL parameter redactor stage throws and the redactor has
|
||||
/// to over-redact the offending field) into <see cref="ISiteHealthCollector"/>
|
||||
/// so the count surfaces in the site health report payload as
|
||||
/// <c>SiteHealthReport.AuditRedactionFailure</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
|
||||
@@ -96,6 +96,7 @@ public sealed class RingBufferFallback
|
||||
/// must call <see cref="Complete"/> first.
|
||||
/// </summary>
|
||||
/// <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(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -69,7 +69,9 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
|
||||
_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)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
return _loop ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Releases the internal <see cref="CancellationTokenSource"/> used to stop the polling loop.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Dispose();
|
||||
|
||||
@@ -2,10 +2,12 @@ using System.Threading.Channels;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
|
||||
@@ -18,15 +20,27 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The schema is bootstrapped in the constructor (Bundle B-T1). The
|
||||
/// Channel-based <see cref="WriteAsync"/> hot-path + Bundle D
|
||||
/// <see cref="ReadPendingAsync"/> / <see cref="MarkForwardedAsync"/> support
|
||||
/// surface are wired in Bundle B-T2.
|
||||
/// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables:
|
||||
/// the append-only canonical <c>audit_event</c> (the 10 canonical
|
||||
/// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
|
||||
/// the mutable operational <c>audit_forward_state</c> sidecar that carries the
|
||||
/// forwarding lifecycle (<see cref="AuditForwardState"/>), a duplicated
|
||||
/// <c>OccurredAtUtc</c> for the drain index range-scan, a precomputed
|
||||
/// <c>IsCachedKind</c> flag that drives the cached/non-cached drain split without
|
||||
/// re-parsing <c>DetailsJson</c> on the read hot-path, plus attempt bookkeeping.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention,
|
||||
/// recreated per deployment), so C4's schema change is an in-place RESET: the new
|
||||
/// tables are created and the old single 24-column <c>AuditLog</c> table is
|
||||
/// DROP-ped if present. No SQLite data migration is performed (and none is
|
||||
/// needed) — any rows in a pre-C4 <c>AuditLog</c> table are within the retention
|
||||
/// window and are discarded by the drop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Site rows always carry <see cref="AuditForwardState.Pending"/> on first
|
||||
/// insert; the central row-shape's <c>IngestedAtUtc</c> column does NOT live in
|
||||
/// the site SQLite schema — central stamps it on ingest.
|
||||
/// insert; the central row-shape's <c>IngestedAtUtc</c> is a DetailsJson field
|
||||
/// stamped by central on ingest, not a site column.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable
|
||||
@@ -35,8 +49,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY)
|
||||
// is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably
|
||||
// surfaced across all SQLite builds. We treat any constraint error on insert
|
||||
// as a duplicate-eventid race and swallow it (first-write-wins) — the index
|
||||
// on EventId is the only constraint on this table, so this scope is precise.
|
||||
// as a duplicate-eventid race and swallow it (first-write-wins) — the PRIMARY
|
||||
// KEY on audit_event.EventId is the constraint that fires first, so this scope
|
||||
// is precise (the sidecar insert for the same EventId is in the same
|
||||
// transaction and never reached once audit_event's insert throws).
|
||||
private const int SqliteErrorConstraint = 19;
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
@@ -97,6 +113,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
_readConnection = new SqliteConnection(connectionString);
|
||||
_readConnection.Open();
|
||||
|
||||
// PRAGMA foreign_keys is a per-connection setting. Set it on the read
|
||||
// connection as well so that any future read-path change (e.g. a
|
||||
// DELETE that may be added later) also benefits from FK enforcement.
|
||||
// Pure SELECT queries are unaffected — this is defensive belt-and-
|
||||
// suspenders for the read connection.
|
||||
using (var pragmaCmd = _readConnection.CreateCommand())
|
||||
{
|
||||
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
|
||||
pragmaCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
_writeQueue = Channel.CreateBounded<PendingAuditEvent>(
|
||||
new BoundedChannelOptions(_options.ChannelCapacity)
|
||||
{
|
||||
@@ -140,110 +167,98 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
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();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS AuditLog (
|
||||
EventId TEXT NOT NULL,
|
||||
OccurredAtUtc TEXT NOT NULL,
|
||||
Channel TEXT NOT NULL,
|
||||
Kind TEXT NOT NULL,
|
||||
CorrelationId TEXT NULL,
|
||||
SourceSiteId TEXT NULL,
|
||||
SourceNode TEXT NULL,
|
||||
SourceInstanceId TEXT NULL,
|
||||
SourceScript TEXT NULL,
|
||||
Actor TEXT NULL,
|
||||
Target TEXT NULL,
|
||||
Status TEXT NOT NULL,
|
||||
HttpStatus INTEGER NULL,
|
||||
DurationMs INTEGER NULL,
|
||||
ErrorMessage TEXT NULL,
|
||||
ErrorDetail TEXT NULL,
|
||||
RequestSummary TEXT NULL,
|
||||
ResponseSummary TEXT NULL,
|
||||
PayloadTruncated INTEGER NOT NULL,
|
||||
Extra TEXT NULL,
|
||||
ForwardState TEXT NOT NULL,
|
||||
ExecutionId TEXT NULL,
|
||||
ParentExecutionId TEXT NULL,
|
||||
-- Canonical, append-only / write-once: the 10 fields of the canonical
|
||||
-- ZB.MOM.WW.Audit.AuditEvent stored directly (DetailsJson carries the
|
||||
-- ScadaBridge domain fields). No forwarding state lives here — that is
|
||||
-- the audit_forward_state sidecar's concern.
|
||||
CREATE TABLE IF NOT EXISTS audit_event (
|
||||
EventId TEXT NOT NULL,
|
||||
OccurredAtUtc TEXT NOT NULL,
|
||||
Actor TEXT NOT NULL,
|
||||
Action TEXT NOT NULL,
|
||||
Outcome TEXT NOT NULL,
|
||||
Category TEXT NULL,
|
||||
Target TEXT NULL,
|
||||
SourceNode TEXT NULL,
|
||||
CorrelationId TEXT NULL,
|
||||
DetailsJson TEXT NULL,
|
||||
PRIMARY KEY (EventId)
|
||||
);
|
||||
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();
|
||||
|
||||
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
|
||||
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
|
||||
// table that already exists from a pre-ExecutionId build, so an
|
||||
// auditlog.db created by an older build needs the column ALTER-ed in.
|
||||
// The file is durable across restart/failover by design (7-day
|
||||
// retention), so without this step every WriteAsync on an upgraded
|
||||
// deployment would bind $ExecutionId against a missing column and the
|
||||
// best-effort write path would silently drop every site audit row.
|
||||
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
|
||||
// probed first and the ALTER skipped when already there. The column is
|
||||
// nullable with no default, so any row written before this migration
|
||||
// reads back ExecutionId = null (back-compat).
|
||||
AddColumnIfMissing("ExecutionId", "TEXT NULL");
|
||||
|
||||
// Audit Log #23 (ParentExecutionId): same idempotent upgrade path as
|
||||
// ExecutionId above. A deployment that already ran the ExecutionId
|
||||
// branch has an auditlog.db with the 21-column schema and no
|
||||
// ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it,
|
||||
// so it is ALTER-ed in here. Nullable with no default — rows written
|
||||
// before this migration read back ParentExecutionId = null.
|
||||
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
|
||||
|
||||
// SourceNode stamping: same idempotent upgrade path as ExecutionId /
|
||||
// ParentExecutionId above. A deployment that already ran the
|
||||
// ParentExecutionId branch has an auditlog.db with the 22-column
|
||||
// schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot
|
||||
// add it, so it is ALTER-ed in here. Nullable with no default — rows
|
||||
// written before this migration read back SourceNode = null.
|
||||
AddColumnIfMissing("SourceNode", "TEXT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
|
||||
/// it is not already present (used for <c>ExecutionId</c> and
|
||||
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
|
||||
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
|
||||
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
|
||||
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
|
||||
/// here to match the rest of this writer's bootstrap DDL.
|
||||
/// Enqueues an audit event for asynchronous batched persistence to SQLite.
|
||||
/// Back-pressure is applied when the write channel is full.
|
||||
/// </summary>
|
||||
private void AddColumnIfMissing(string columnName, string columnDefinition)
|
||||
{
|
||||
using var probe = _connection.CreateCommand();
|
||||
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
|
||||
probe.Parameters.AddWithValue("$name", columnName);
|
||||
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
|
||||
if (exists)
|
||||
{
|
||||
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 />
|
||||
/// <param name="evt">The audit event to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the event has been persisted.</returns>
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
// Site rows always carry a non-null ForwardState; central rows leave it
|
||||
// null. Force Pending on enqueue so callers can pass a bare AuditEvent
|
||||
// without thinking about site-vs-central provenance.
|
||||
var siteEvt = evt.ForwardState is null
|
||||
? evt with { ForwardState = AuditForwardState.Pending }
|
||||
: evt;
|
||||
|
||||
var pending = new PendingAuditEvent(siteEvt);
|
||||
// The canonical record carries no ForwardState (a site-storage-only
|
||||
// concern). Site rows always start Pending; the sidecar row is written
|
||||
// alongside the canonical row in the same transaction.
|
||||
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
|
||||
|
||||
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
|
||||
// than throw when full — exactly the hot-path back-pressure semantics
|
||||
@@ -316,96 +331,99 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.Transaction = transaction;
|
||||
cmd.CommandText = """
|
||||
INSERT INTO AuditLog (
|
||||
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
// INSERT 1: the canonical row, stored DIRECTLY (the 10 canonical
|
||||
// fields straight off the AuditEvent — no Decompose; audit_event
|
||||
// holds canonical shape, not the legacy 24-column shape).
|
||||
using var eventCmd = _connection.CreateCommand();
|
||||
eventCmd.Transaction = transaction;
|
||||
eventCmd.CommandText = """
|
||||
INSERT INTO audit_event (
|
||||
EventId, OccurredAtUtc, Actor, Action, Outcome,
|
||||
Category, Target, SourceNode, CorrelationId, DetailsJson
|
||||
) VALUES (
|
||||
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
|
||||
$SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target,
|
||||
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
|
||||
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
|
||||
$ExecutionId, $ParentExecutionId
|
||||
$EventId, $OccurredAtUtc, $Actor, $Action, $Outcome,
|
||||
$Category, $Target, $SourceNode, $CorrelationId, $DetailsJson
|
||||
);
|
||||
""";
|
||||
var eEventId = eventCmd.Parameters.Add("$EventId", SqliteType.Text);
|
||||
var eOccurredAt = eventCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
|
||||
var eActor = eventCmd.Parameters.Add("$Actor", SqliteType.Text);
|
||||
var eAction = eventCmd.Parameters.Add("$Action", SqliteType.Text);
|
||||
var eOutcome = eventCmd.Parameters.Add("$Outcome", SqliteType.Text);
|
||||
var eCategory = eventCmd.Parameters.Add("$Category", SqliteType.Text);
|
||||
var eTarget = eventCmd.Parameters.Add("$Target", SqliteType.Text);
|
||||
var eSourceNode = eventCmd.Parameters.Add("$SourceNode", SqliteType.Text);
|
||||
var eCorrelationId = eventCmd.Parameters.Add("$CorrelationId", SqliteType.Text);
|
||||
var eDetailsJson = eventCmd.Parameters.Add("$DetailsJson", SqliteType.Text);
|
||||
|
||||
var pEventId = cmd.Parameters.Add("$EventId", SqliteType.Text);
|
||||
var pOccurredAt = cmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
|
||||
var pChannel = cmd.Parameters.Add("$Channel", SqliteType.Text);
|
||||
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text);
|
||||
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text);
|
||||
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text);
|
||||
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text);
|
||||
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text);
|
||||
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text);
|
||||
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text);
|
||||
var pTarget = cmd.Parameters.Add("$Target", SqliteType.Text);
|
||||
var pStatus = cmd.Parameters.Add("$Status", SqliteType.Text);
|
||||
var pHttpStatus = cmd.Parameters.Add("$HttpStatus", SqliteType.Integer);
|
||||
var pDurationMs = cmd.Parameters.Add("$DurationMs", SqliteType.Integer);
|
||||
var pErrorMessage = cmd.Parameters.Add("$ErrorMessage", SqliteType.Text);
|
||||
var pErrorDetail = cmd.Parameters.Add("$ErrorDetail", SqliteType.Text);
|
||||
var pRequestSummary = cmd.Parameters.Add("$RequestSummary", SqliteType.Text);
|
||||
var pResponseSummary = cmd.Parameters.Add("$ResponseSummary", SqliteType.Text);
|
||||
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
|
||||
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
|
||||
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
||||
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
|
||||
var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text);
|
||||
// INSERT 2: the operational sidecar row. ForwardState=Pending,
|
||||
// OccurredAtUtc duplicated for the drain index, IsCachedKind
|
||||
// precomputed (so the read split never parses DetailsJson),
|
||||
// AttemptCount=0, LastAttemptUtc=NULL.
|
||||
using var fwdCmd = _connection.CreateCommand();
|
||||
fwdCmd.Transaction = transaction;
|
||||
fwdCmd.CommandText = """
|
||||
INSERT INTO audit_forward_state (
|
||||
EventId, ForwardState, OccurredAtUtc, IsCachedKind, AttemptCount, LastAttemptUtc
|
||||
) VALUES (
|
||||
$EventId, $ForwardState, $OccurredAtUtc, $IsCachedKind, 0, NULL
|
||||
);
|
||||
""";
|
||||
var fEventId = fwdCmd.Parameters.Add("$EventId", SqliteType.Text);
|
||||
var fForwardState = fwdCmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
||||
var fOccurredAt = fwdCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
|
||||
var fIsCachedKind = fwdCmd.Parameters.Add("$IsCachedKind", SqliteType.Integer);
|
||||
|
||||
foreach (var pending in batch)
|
||||
{
|
||||
var e = pending.Event;
|
||||
pEventId.Value = e.EventId.ToString();
|
||||
pOccurredAt.Value = e.OccurredAtUtc.ToString("o");
|
||||
pChannel.Value = e.Channel.ToString();
|
||||
pKind.Value = e.Kind.ToString();
|
||||
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
|
||||
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
|
||||
var evt = pending.Event;
|
||||
// Canonical OccurredAtUtc is UTC by construction; store the
|
||||
// round-trip "o" form so string comparison stays monotonic
|
||||
// (the drain range-scan and ORDER BY rely on it).
|
||||
var occurredText = evt.OccurredAtUtc.UtcDateTime.ToString(
|
||||
"o", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
eEventId.Value = evt.EventId.ToString();
|
||||
eOccurredAt.Value = occurredText;
|
||||
// Canonical Actor is a required non-null string.
|
||||
eActor.Value = evt.Actor ?? string.Empty;
|
||||
eAction.Value = evt.Action;
|
||||
eOutcome.Value = evt.Outcome.ToString();
|
||||
eCategory.Value = (object?)evt.Category ?? DBNull.Value;
|
||||
eTarget.Value = (object?)evt.Target ?? DBNull.Value;
|
||||
// SourceNode-stamping: caller-provided value wins (preserves
|
||||
// rows reconciled in from other nodes via the same writer);
|
||||
// otherwise stamp from the local INodeIdentityProvider. The
|
||||
// event record itself is NOT mutated — stamping is at write
|
||||
// time only. If the provider also returns null (unconfigured
|
||||
// node), the row's SourceNode stays NULL — operators see
|
||||
// "needs config" via the schema, not a magic fallback string.
|
||||
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
|
||||
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
||||
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
|
||||
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
|
||||
pActor.Value = (object?)e.Actor ?? DBNull.Value;
|
||||
pTarget.Value = (object?)e.Target ?? DBNull.Value;
|
||||
pStatus.Value = e.Status.ToString();
|
||||
pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value;
|
||||
pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value;
|
||||
pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value;
|
||||
pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value;
|
||||
pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value;
|
||||
pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value;
|
||||
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
||||
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
||||
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
||||
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
|
||||
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
|
||||
// node), the column stays NULL — operators see "needs config"
|
||||
// via the schema, not a magic fallback string.
|
||||
var sourceNode = evt.SourceNode ?? _nodeIdentity.NodeName;
|
||||
eSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
||||
eCorrelationId.Value = (object?)evt.CorrelationId?.ToString() ?? DBNull.Value;
|
||||
eDetailsJson.Value = (object?)evt.DetailsJson ?? DBNull.Value;
|
||||
|
||||
fEventId.Value = evt.EventId.ToString();
|
||||
fForwardState.Value = pending.ForwardState.ToString();
|
||||
fOccurredAt.Value = occurredText;
|
||||
fIsCachedKind.Value = IsCachedKind(evt.DetailsJson) ? 1 : 0;
|
||||
|
||||
try
|
||||
{
|
||||
cmd.ExecuteNonQuery();
|
||||
eventCmd.ExecuteNonQuery();
|
||||
fwdCmd.ExecuteNonQuery();
|
||||
pending.Completion.TrySetResult();
|
||||
}
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint)
|
||||
{
|
||||
// Duplicate EventId — first-write-wins (alog.md §11).
|
||||
// Treat as success: the lifecycle event is durably
|
||||
// recorded under the first writer's payload.
|
||||
// Duplicate EventId — first-write-wins (alog.md §11). The
|
||||
// audit_event PRIMARY KEY throws before the sidecar insert
|
||||
// runs, so neither table gains a second row. Treat as
|
||||
// success: the lifecycle event is durably recorded under
|
||||
// the first writer's payload.
|
||||
_logger.LogDebug(ex,
|
||||
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
|
||||
e.EventId);
|
||||
evt.EventId);
|
||||
pending.Completion.TrySetResult();
|
||||
}
|
||||
}
|
||||
@@ -427,18 +445,43 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry
|
||||
// drain (joined with the operational tracking row + pushed via
|
||||
// IngestCachedTelemetryAsync into the central dual-write transaction).
|
||||
// ReadPendingAsync EXCLUDES these so the audit-only drain doesn't double-emit
|
||||
// them; ReadPendingCachedTelemetryAsync below is the dedicated read surface
|
||||
// the new SiteAuditTelemetryActor cached-drain uses.
|
||||
private static readonly string[] CachedTelemetryKindNames =
|
||||
// C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query
|
||||
// filtered on (Kind IN (...)); it is now precomputed into the sidecar's
|
||||
// IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
|
||||
// integer predicate, not a JSON parse. ReadPendingAsync drains everything
|
||||
// with IsCachedKind=0; ReadPendingCachedTelemetryAsync drains IsCachedKind=1.
|
||||
private static readonly HashSet<AuditKind> CachedTelemetryKinds = new()
|
||||
{
|
||||
nameof(AuditKind.CachedSubmit),
|
||||
nameof(AuditKind.ApiCallCached),
|
||||
nameof(AuditKind.DbWriteCached),
|
||||
nameof(AuditKind.CachedResolve),
|
||||
AuditKind.CachedSubmit,
|
||||
AuditKind.ApiCallCached,
|
||||
AuditKind.DbWriteCached,
|
||||
AuditKind.CachedResolve,
|
||||
};
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (limit <= 0)
|
||||
@@ -449,51 +492,45 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// AuditLog-005: read via the dedicated _readConnection so this scan
|
||||
// (which can be expensive when the backlog grows under a central
|
||||
// outage) does not block the batched writer on _writeLock. WAL mode
|
||||
// gives us a stable snapshot of the table while writes proceed on the
|
||||
// gives us a stable snapshot of the tables while writes proceed on the
|
||||
// writer connection. _readLock serialises this connection across
|
||||
// multiple concurrent read callers since SqliteConnection itself is
|
||||
// not thread-safe.
|
||||
// AuditLog-001: NOT IN ($cached1,$cached2,$cached3,$cached4) excludes the
|
||||
// cached-lifecycle kinds — they flow through ReadPendingCachedTelemetryAsync
|
||||
// + the combined-telemetry drain. Kind is stored as the enum's name (see
|
||||
// FlushBatch's pKind.Value), so a string-IN against the constant kind
|
||||
// names matches the on-disk shape exactly.
|
||||
// C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached-
|
||||
// lifecycle kinds (IsCachedKind=1) flow through
|
||||
// ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
|
||||
// split is a precomputed integer predicate on the indexed sidecar, not
|
||||
// a DetailsJson parse. Ordering is by the sidecar's OccurredAtUtc with
|
||||
// EventId as the deterministic tiebreaker.
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $pending
|
||||
AND Kind NOT IN ($k0, $k1, $k2, $k3)
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState = $pending
|
||||
AND fs.IsCachedKind = 0
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
|
||||
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
|
||||
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
|
||||
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, limit));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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(
|
||||
int limit, CancellationToken ct = default)
|
||||
{
|
||||
@@ -502,42 +539,29 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
|
||||
}
|
||||
|
||||
// AuditLog-001: dedicated read surface for the cached-call lifecycle
|
||||
// drain — symmetric to ReadPendingAsync but filtered to the four
|
||||
// cached AuditKinds. Same _readConnection + _readLock pattern so the
|
||||
// hot-path writer is not contended.
|
||||
// AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle
|
||||
// drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
|
||||
// Same _readConnection + _readLock pattern so the hot-path writer is not
|
||||
// contended.
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $pending
|
||||
AND Kind IN ($k0, $k1, $k2, $k3)
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState = $pending
|
||||
AND fs.IsCachedKind = 1
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
|
||||
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
|
||||
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
|
||||
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +578,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
/// </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 forwarded audit events.</returns>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
|
||||
{
|
||||
if (limit <= 0)
|
||||
@@ -563,34 +588,27 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
|
||||
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
|
||||
// _readLock so this query never contends with the batched writer on
|
||||
// _writeLock.
|
||||
// _writeLock. C4: JOIN the sidecar and filter on ForwardState='Forwarded'
|
||||
// (no IsCachedKind split — both cached and non-cached Forwarded rows are
|
||||
// returned, as before).
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $forwarded
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState = $forwarded
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,11 +626,25 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
// Build a single IN (...) parameter list so we issue one UPDATE per
|
||||
// batch regardless of size. Each id is bound as its own parameter,
|
||||
// so no string concatenation of user data ever enters the SQL.
|
||||
// C4: flip the sidecar — UPDATE audit_forward_state, not the canonical
|
||||
// audit_event (which is append-only / write-once). Bump AttemptCount +
|
||||
// stamp LastAttemptUtc so operators can see how many drain passes a row
|
||||
// took to forward. Build a single IN (...) parameter list so we issue
|
||||
// one UPDATE per batch regardless of size. Each id is bound as its own
|
||||
// parameter, so no string concatenation of user data ever enters the SQL.
|
||||
//
|
||||
// Defensive state guard: only transition rows that are still Pending or
|
||||
// Forwarded (i.e. not yet Reconciled). Without this guard a mis-called
|
||||
// batch that includes a Reconciled EventId would silently demote it back
|
||||
// to Forwarded — a state regression that would cause duplicate central
|
||||
// ingestion. Symmetric with MarkReconciledAsync's
|
||||
// WHERE ForwardState IN ($pending, $forwarded)
|
||||
// guard. Current callers only pass Pending IDs, so normal-path behaviour
|
||||
// is unchanged; the guard is purely defensive.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("UPDATE AuditLog SET ForwardState = $forwarded WHERE EventId IN (");
|
||||
sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
|
||||
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
|
||||
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
||||
for (int i = 0; i < eventIds.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
@@ -623,13 +655,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
sb.Append(");");
|
||||
cmd.CommandText = sb.ToString();
|
||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString(
|
||||
"o", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Returns up to <paramref name="batchSize"/> pending or forwarded audit events
|
||||
/// with <see cref="AuditEvent.OccurredAtUtc"/> >= <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(
|
||||
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-
|
||||
// decoupling as ReadPendingAsync.
|
||||
// decoupling as ReadPendingAsync. C4: JOIN the sidecar; the range scan
|
||||
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
|
||||
// Both Pending and Forwarded rows are returned (the central reconciliation
|
||||
// puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested
|
||||
// row is safe).
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState IN ($pending, $forwarded)
|
||||
AND OccurredAtUtc >= $since
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState IN ($pending, $forwarded)
|
||||
AND fs.OccurredAtUtc >= $since
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
@@ -666,14 +711,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
"o", System.Globalization.CultureInfo.InvariantCulture));
|
||||
cmd.Parameters.AddWithValue("$limit", batchSize);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(batchSize, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, batchSize));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,8 +729,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
// C4: flip the sidecar from Pending/Forwarded → Reconciled. Rows
|
||||
// already Reconciled are left untouched (idempotent re-call), and the
|
||||
// canonical audit_event row is never modified.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("UPDATE AuditLog SET ForwardState = $reconciled ")
|
||||
sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
|
||||
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
||||
for (int i = 0; i < eventIds.Count; i++)
|
||||
{
|
||||
@@ -724,18 +765,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// central outage the Pending backlog can grow to hundreds of thousands
|
||||
// of rows and the COUNT(*) scan correspondingly stretches; that no
|
||||
// longer adds tail latency to user-facing audit writes.
|
||||
// C4: count over the sidecar (audit_forward_state) — the canonical
|
||||
// audit_event table carries no ForwardState. The IX_fwd index makes both
|
||||
// aggregates cheap (count is a covering scan, min is the first key).
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Single round-trip — COUNT(*) + MIN(OccurredAtUtc) over the same
|
||||
// index range avoids a second scan. The IX_SiteAuditLog_ForwardState_Occurred
|
||||
// index makes both aggregates cheap (count is a covering scan, min
|
||||
// is the first key).
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT COUNT(*), MIN(OccurredAtUtc)
|
||||
FROM AuditLog
|
||||
FROM audit_forward_state
|
||||
WHERE ForwardState = $pending;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
@@ -786,35 +826,48 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
? value
|
||||
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// Executes <paramref name="cmd"/> (one of the four reads, each already
|
||||
/// projecting the 10 <c>audit_event</c> columns in canonical order) and
|
||||
/// materialises the rows via <see cref="MapRow"/>.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<AuditEvent> ReadRows(SqliteCommand cmd, int capacityHint)
|
||||
{
|
||||
var rows = new List<AuditEvent>(Math.Min(capacityHint, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4: builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
|
||||
/// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because
|
||||
/// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>.
|
||||
/// <c>Outcome</c> is stored as the enum's name; the safe
|
||||
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> degrades an unknown/renamed
|
||||
/// value gracefully rather than throwing.
|
||||
/// </summary>
|
||||
private static AuditEvent MapRow(SqliteDataReader reader)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse(reader.GetString(0)),
|
||||
OccurredAtUtc = DateTime.Parse(reader.GetString(1),
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind),
|
||||
Channel = Enum.Parse<AuditChannel>(reader.GetString(2)),
|
||||
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
|
||||
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
|
||||
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
|
||||
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
|
||||
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
|
||||
PayloadTruncated = reader.GetInt32(18) != 0,
|
||||
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
|
||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
|
||||
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
||||
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
|
||||
OccurredAtUtc = new DateTimeOffset(DateTime.SpecifyKind(
|
||||
DateTime.Parse(reader.GetString(1),
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind),
|
||||
DateTimeKind.Utc)),
|
||||
Actor = reader.GetString(2),
|
||||
Action = reader.GetString(3),
|
||||
Outcome = AuditRowProjection.ParseEnum(reader.GetString(4), AuditOutcome.Success),
|
||||
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
|
||||
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -841,6 +894,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
Task? writerLoop;
|
||||
@@ -898,15 +952,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
private sealed class PendingAuditEvent
|
||||
{
|
||||
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
|
||||
/// <param name="evt">The audit event to persist.</param>
|
||||
public PendingAuditEvent(AuditEvent evt)
|
||||
/// <param name="evt">The canonical audit event to persist.</param>
|
||||
/// <param name="forwardState">Initial site-local forwarding state written to the sidecar row (always Pending for fresh events).</param>
|
||||
public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
|
||||
{
|
||||
Event = evt;
|
||||
ForwardState = forwardState;
|
||||
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
/// <summary>The audit event to persist.</summary>
|
||||
/// <summary>The canonical audit event to persist.</summary>
|
||||
public AuditEvent Event { get; }
|
||||
/// <summary>Initial forwarding state for this row's sidecar (bound to audit_forward_state.ForwardState).</summary>
|
||||
public AuditForwardState ForwardState { get; }
|
||||
/// <summary>Task completion source for write completion signaling.</summary>
|
||||
public TaskCompletionSource Completion { get; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
||||
var channel = ChannelStringToEnum(context.Channel);
|
||||
|
||||
return new CachedCallTelemetry(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||
Channel = channel,
|
||||
Kind = kind,
|
||||
CorrelationId = context.TrackedOperationId.Value,
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
channel: channel,
|
||||
kind: kind,
|
||||
status: status,
|
||||
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||
target: context.Target,
|
||||
correlationId: context.TrackedOperationId.Value,
|
||||
// Audit Log #23 (ExecutionId Task 4): the originating script
|
||||
// execution's per-run correlation id, threaded through the S&F
|
||||
// buffer; null on rows buffered before Task 4 (back-compat).
|
||||
ExecutionId = context.ExecutionId,
|
||||
executionId: context.ExecutionId,
|
||||
// Audit Log #23 (ParentExecutionId Task 6): the spawning
|
||||
// inbound-API request's ExecutionId, threaded through the S&F
|
||||
// buffer alongside ExecutionId so the retry-loop cached rows
|
||||
// correlate back to the cross-execution chain. Null for a
|
||||
// non-routed run and on rows buffered before Task 6.
|
||||
ParentExecutionId = context.ParentExecutionId,
|
||||
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||
SourceInstanceId = context.SourceInstanceId,
|
||||
parentExecutionId: context.ParentExecutionId,
|
||||
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||
sourceInstanceId: context.SourceInstanceId,
|
||||
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
|
||||
// threaded through the S&F buffer alongside ExecutionId — the
|
||||
// retry-loop cached rows carry the same provenance the
|
||||
// script-side cached rows do. Null on pre-Task-4 buffered rows.
|
||||
SourceScript = context.SourceScript,
|
||||
Target = context.Target,
|
||||
Status = status,
|
||||
HttpStatus = httpStatus,
|
||||
DurationMs = context.DurationMs,
|
||||
ErrorMessage = lastError,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
sourceScript: context.SourceScript,
|
||||
httpStatus: httpStatus,
|
||||
durationMs: context.DurationMs,
|
||||
errorMessage: lastError),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: context.TrackedOperationId,
|
||||
Channel: context.Channel,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
// FallbackAuditWriter) handles transient writer failures upstream;
|
||||
// a throw bubbling up here means the writer's own swallow contract
|
||||
// failed, which is itself best-effort-handled.
|
||||
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them.
|
||||
var d = AuditRowProjection.Decompose(telemetry.Audit);
|
||||
_logger.LogWarning(ex,
|
||||
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
|
||||
telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
|
||||
d.EventId, d.Kind, d.Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +130,12 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
return;
|
||||
}
|
||||
|
||||
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript)
|
||||
// ride inside DetailsJson — decompose once for this packet.
|
||||
var audit = AuditRowProjection.Decompose(telemetry.Audit);
|
||||
try
|
||||
{
|
||||
switch (telemetry.Audit.Kind)
|
||||
switch (audit.Kind)
|
||||
{
|
||||
case AuditKind.CachedSubmit:
|
||||
// Enqueue — insert-if-not-exists with the operational
|
||||
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
telemetry.Operational.TrackedOperationId,
|
||||
telemetry.Operational.Channel,
|
||||
telemetry.Operational.Target,
|
||||
telemetry.Audit.SourceInstanceId,
|
||||
telemetry.Audit.SourceScript,
|
||||
audit.SourceInstanceId,
|
||||
audit.SourceScript,
|
||||
sourceNode: _nodeIdentity?.NodeName,
|
||||
ct).ConfigureAwait(false);
|
||||
break;
|
||||
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
// forwarder.
|
||||
_logger.LogWarning(
|
||||
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
|
||||
telemetry.Audit.Kind, telemetry.Audit.EventId);
|
||||
audit.Kind, audit.EventId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
@@ -44,6 +44,9 @@ public sealed class ClusterClientSiteAuditClient : ISiteStreamAuditClient
|
||||
private readonly IActorRef _siteCommunicationActor;
|
||||
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">
|
||||
/// The site's <c>SiteCommunicationActor</c> — it forwards the ingest command
|
||||
/// over the registered central ClusterClient and routes the reply back to
|
||||
|
||||
@@ -22,6 +22,7 @@ public interface ISiteStreamAuditClient
|
||||
/// </summary>
|
||||
/// <param name="batch">The batch of audit events to forward.</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);
|
||||
|
||||
/// <summary>
|
||||
@@ -42,5 +43,6 @@ public interface ISiteStreamAuditClient
|
||||
/// </remarks>
|
||||
/// <param name="batch">The batch of cached-call telemetry packets to forward.</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);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ using Akka.Actor;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
// row stays Pending (still not in emittedEventIds) and
|
||||
// central reconciliation will pick it up.
|
||||
_logger.LogWarning(
|
||||
"Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.",
|
||||
auditRow.EventId, auditRow.Kind);
|
||||
"Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
|
||||
auditRow.EventId, auditRow.Action);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
private static CachedTelemetryPacket BuildCachedPacket(
|
||||
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
|
||||
{
|
||||
var sourceSite = auditRow.SourceSiteId ?? string.Empty;
|
||||
// C3: SourceSiteId + Channel ride inside the canonical record's
|
||||
// DetailsJson — decompose to read them.
|
||||
var audit = AuditRowProjection.Decompose(auditRow);
|
||||
var sourceSite = audit.SourceSiteId ?? string.Empty;
|
||||
// Channel string form mirrors the AuditChannel-to-string convention used
|
||||
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
|
||||
var channelString = auditRow.Channel.ToString();
|
||||
var channelString = audit.Channel.ToString();
|
||||
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
|
||||
|
||||
var operationalDto = new SiteCallOperationalDto
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -13,6 +13,7 @@ public static class ApiMethodCommands
|
||||
/// <param name="formatOption">Global option for the output format.</param>
|
||||
/// <param name="usernameOption">Global option for the authentication username.</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)
|
||||
{
|
||||
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="usernameOption">Global <c>--username</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)
|
||||
{
|
||||
var command = new Command("audit") { Description = "Query and export the centralized audit log" };
|
||||
|
||||
@@ -74,6 +74,7 @@ public static class AuditExportHelpers
|
||||
/// </summary>
|
||||
/// <param name="args">The export arguments containing filters and format.</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)
|
||||
{
|
||||
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="output">Text writer for command output messages.</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(
|
||||
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
|
||||
/// has no <c>code</c> property — callers fall back to "ERROR" in that case.
|
||||
/// </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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class AuditFormatterFactory
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <returns>The <see cref="IAuditFormatter"/> appropriate for the requested format.</returns>
|
||||
public static IAuditFormatter Create(string format, TextWriter notices)
|
||||
{
|
||||
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="usernameOption">Global username 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)
|
||||
{
|
||||
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="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>
|
||||
/// <returns>The resolved absolute <see cref="DateTimeOffset"/> in UTC.</returns>
|
||||
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
|
||||
{
|
||||
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="afterOccurredAtUtc">Optional keyset cursor timestamp.</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(
|
||||
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="output">The output writer for results.</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(
|
||||
ManagementHttpClient client,
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
=> !string.IsNullOrWhiteSpace(month)
|
||||
&& 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 notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
|
||||
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
|
||||
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names");
|
||||
// Inbound API keys are not transported between environments (re-arch C4) — no
|
||||
// --api-keys option. Re-create keys and re-grant their method scopes on the
|
||||
// destination via the admin UI/CLI.
|
||||
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
|
||||
var includeDepsOption = new Option<bool>("--include-dependencies")
|
||||
{
|
||||
@@ -85,7 +87,6 @@ public static class BundleCommands
|
||||
cmd.Add(dbConnectionsOption);
|
||||
cmd.Add(notificationListsOption);
|
||||
cmd.Add(smtpConfigsOption);
|
||||
cmd.Add(apiKeysOption);
|
||||
cmd.Add(apiMethodsOption);
|
||||
cmd.Add(includeDepsOption);
|
||||
cmd.Add(sourceEnvOption);
|
||||
@@ -106,7 +107,6 @@ public static class BundleCommands
|
||||
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
|
||||
NotificationListNames: result.GetValue(notificationListsOption),
|
||||
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
|
||||
ApiKeyNames: result.GetValue(apiKeysOption),
|
||||
ApiMethodNames: result.GetValue(apiMethodsOption),
|
||||
IncludeDependencies: includeDeps,
|
||||
Passphrase: passphrase,
|
||||
@@ -307,6 +307,13 @@ public static class BundleCommands
|
||||
// for the post-write summary line.
|
||||
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)
|
||||
{
|
||||
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
|
||||
/// than silently falling through to JSON.
|
||||
/// </summary>
|
||||
/// <returns>The configured <c>--format</c> option constrained to "json" or "table".</returns>
|
||||
internal static Option<string> CreateFormatOption()
|
||||
{
|
||||
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,
|
||||
/// closing CLI-017's regression.
|
||||
/// </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(
|
||||
ParseResult result,
|
||||
Option<string> urlOption,
|
||||
@@ -110,6 +111,7 @@ internal static class CommandHelpers
|
||||
/// <param name="result">Parsed command-line result.</param>
|
||||
/// <param name="formatOption">The <c>--format</c> option definition.</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)
|
||||
{
|
||||
// GetResult returns non-null only when the option was actually present on the
|
||||
@@ -130,6 +132,7 @@ internal static class CommandHelpers
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <returns>The command-line value when non-empty; otherwise the environment fallback (may be null).</returns>
|
||||
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
|
||||
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
|
||||
|
||||
@@ -140,6 +143,7 @@ internal static class CommandHelpers
|
||||
/// an unhandled <see cref="UriFormatException"/>.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
@@ -154,6 +158,7 @@ internal static class CommandHelpers
|
||||
/// </summary>
|
||||
/// <param name="response">Response received from the management API.</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)
|
||||
{
|
||||
if (response.JsonData != null)
|
||||
@@ -192,6 +197,8 @@ internal static class CommandHelpers
|
||||
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
|
||||
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
|
||||
/// </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)
|
||||
{
|
||||
if (response.StatusCode == 403)
|
||||
|
||||
@@ -13,6 +13,7 @@ public static class DataConnectionCommands
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username 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)
|
||||
{
|
||||
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="usernameOption">Shared username 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)
|
||||
{
|
||||
var command = new Command("debug") { Description = "Runtime debugging" };
|
||||
|
||||
@@ -27,6 +27,7 @@ internal static class DebugStreamHelpers
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <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)
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
if (exitTask.IsCompletedSuccessfully)
|
||||
|
||||
@@ -13,6 +13,7 @@ public static class DeployCommands
|
||||
/// <param name="formatOption">Global output format option.</param>
|
||||
/// <param name="usernameOption">Global username 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)
|
||||
{
|
||||
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="usernameOption">Global option for the authentication username.</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)
|
||||
{
|
||||
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="usernameOption">Global <c>--username</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)
|
||||
{
|
||||
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="usernameOption">Global <c>--username</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)
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
var id = result.GetValue(SmtpIdOption);
|
||||
|
||||
@@ -13,6 +13,7 @@ public static class SecurityCommands
|
||||
/// <param name="formatOption">Shared output format option.</param>
|
||||
/// <param name="usernameOption">Shared username 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)
|
||||
{
|
||||
var command = new Command("security") { Description = "Manage security settings" };
|
||||
@@ -37,44 +38,109 @@ public static class SecurityCommands
|
||||
group.Add(listCmd);
|
||||
|
||||
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true };
|
||||
var createMethodsOption = new Option<string>("--methods")
|
||||
{
|
||||
Description = "Comma-separated API method names this key may call (e.g. \"MethodA,MethodB\")",
|
||||
Required = true
|
||||
};
|
||||
var createCmd = new Command("create") { Description = "Create an API key" };
|
||||
createCmd.Add(nameOption);
|
||||
createCmd.Add(createMethodsOption);
|
||||
createCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var methods = ParseMethods(result.GetValue(createMethodsOption));
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name));
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateApiKeyCommand(name, methods),
|
||||
onSuccess: PrintCreatedKey);
|
||||
});
|
||||
group.Add(createCmd);
|
||||
|
||||
var idOption = new Option<int>("--id") { Description = "API key ID", Required = true };
|
||||
var deleteKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete an API key" };
|
||||
deleteCmd.Add(idOption);
|
||||
deleteCmd.Add(deleteKeyIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var keyId = result.GetValue(deleteKeyIdOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id));
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(keyId));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "API key ID", Required = true };
|
||||
var updateKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
|
||||
var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
|
||||
var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateKeyIdOption);
|
||||
updateCmd.Add(enabledOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(updateIdOption);
|
||||
var keyId = result.GetValue(updateKeyIdOption)!;
|
||||
var enabled = result.GetValue(enabledOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled));
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(keyId, enabled));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
var setMethodsKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
|
||||
var setMethodsOption = new Option<string>("--methods")
|
||||
{
|
||||
Description = "Comma-separated API method names this key may call (replaces the existing set)",
|
||||
Required = true
|
||||
};
|
||||
var setMethodsCmd = new Command("set-methods") { Description = "Replace the method-scopes on an API key" };
|
||||
setMethodsCmd.Add(setMethodsKeyIdOption);
|
||||
setMethodsCmd.Add(setMethodsOption);
|
||||
setMethodsCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var keyId = result.GetValue(setMethodsKeyIdOption)!;
|
||||
var methods = ParseMethods(result.GetValue(setMethodsOption));
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetApiKeyMethodsCommand(keyId, methods));
|
||||
});
|
||||
group.Add(setMethodsCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated <c>--methods</c> value into a trimmed, non-empty list of
|
||||
/// method names. A null/empty value yields an empty list (the server rejects an empty
|
||||
/// scope set if its rules require one).
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw delimited option value.</param>
|
||||
private static IReadOnlyList<string> ParseMethods(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return Array.Empty<string>();
|
||||
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the create-key response, surfacing the one-time bearer token prominently —
|
||||
/// it is the only moment the secret is available and cannot be retrieved afterwards.
|
||||
/// The advisory line is written to stderr so that piping stdout captures only the token.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON success body returned by the management API.</param>
|
||||
/// <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)
|
||||
{
|
||||
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="usernameOption">Global username 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)
|
||||
{
|
||||
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="usernameOption">Shared username 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)
|
||||
{
|
||||
var command = new Command("template") { Description = "Manage templates" };
|
||||
|
||||
@@ -61,6 +61,7 @@ public static class AuditExportEndpoints
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <returns>A task representing the asynchronous export streaming operation.</returns>
|
||||
internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
|
||||
{
|
||||
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.
|
||||
/// </remarks>
|
||||
/// <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)
|
||||
{
|
||||
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||
|
||||
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
@@ -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>
|
||||
/// <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)
|
||||
{
|
||||
endpoints.MapPost("/auth/login", async (HttpContext context) =>
|
||||
@@ -31,20 +34,35 @@ public static class AuthEndpoints
|
||||
return;
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
|
||||
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
|
||||
context.Response.Redirect($"/login?error={errorMsg}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Map LDAP groups to roles
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
|
||||
// (Task 1.1 ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
|
||||
// The full RoleMappingResult — including PermittedSiteIds and the
|
||||
// system-wide flag — is carried in the mapping's opaque Scope so the
|
||||
// site-scope→SiteId claims below are built exactly as before.
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// The ScadaBridge mapper carries the full RoleMappingResult in the seam's
|
||||
// opaque Scope (see ScadaBridgeGroupRoleMapper). Guard the unwrap (review I4):
|
||||
// a future/alternate IGroupRoleMapper<string> could leave Scope null or set a
|
||||
// different type. Rather than throw InvalidCastException mid-login, fall back to
|
||||
// the most restrictive interpretation — not a system-wide deployment and no
|
||||
// permitted sites — so no SiteId claims are stamped (deny-by-omission). The real
|
||||
// ScadaBridge mapper always supplies a RoleMappingResult, so behaviour is unchanged.
|
||||
var scope = roleMapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
// Build claims from LDAP auth + role mapping.
|
||||
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
|
||||
@@ -52,27 +70,40 @@ public static class AuthEndpoints
|
||||
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
|
||||
// SlidingExpiration = true). A frozen absolute claim would contradict
|
||||
// the documented sliding-refresh policy.
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, authResult.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
|
||||
new(ClaimTypes.Name, resolvedUsername),
|
||||
new(JwtTokenService.DisplayNameClaimType, displayName),
|
||||
new(JwtTokenService.UsernameClaimType, resolvedUsername),
|
||||
};
|
||||
|
||||
foreach (var role in roleMappingResult.Roles)
|
||||
foreach (var role in roleMapping.Roles)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
}
|
||||
|
||||
if (!roleMappingResult.IsSystemWideDeployment)
|
||||
if (!scope.IsSystemWideDeployment)
|
||||
{
|
||||
foreach (var siteId in roleMappingResult.PermittedSiteIds)
|
||||
foreach (var siteId in scope.PermittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
// Task 1.5: name the role/name claim types explicitly so the cookie
|
||||
// principal's IsInRole / [Authorize(Roles=…)] resolve against the same
|
||||
// canonical types we mint (JwtTokenService.RoleClaimType = ZbClaimTypes.Role,
|
||||
// ClaimTypes.Name = ZbClaimTypes.Name). The policies use
|
||||
// RequireClaim(RoleClaimType, …) which checks type+value directly, but
|
||||
// pinning roleType keeps IsInRole-style checks consistent and survives the
|
||||
// cookie serialize/round-trip.
|
||||
var identity = new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: JwtTokenService.RoleClaimType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await context.SignInAsync(
|
||||
@@ -94,33 +125,43 @@ public static class AuthEndpoints
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = authResult.ErrorMessage ?? "Authentication failed." },
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// Guard the opaque-Scope unwrap (review I4); see the matching note on
|
||||
// /auth/login. Fall back to no site-scope rather than throwing if a future
|
||||
// mapper leaves Scope null or sets a different type.
|
||||
var scope = roleMapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
authResult.DisplayName ?? username,
|
||||
authResult.Username ?? username,
|
||||
roleMappingResult.Roles,
|
||||
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
|
||||
displayName,
|
||||
resolvedUsername,
|
||||
roleMapping.Roles,
|
||||
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
username = authResult.Username ?? username,
|
||||
display_name = authResult.DisplayName ?? username,
|
||||
roles = roleMappingResult.Roles,
|
||||
username = resolvedUsername,
|
||||
display_name = displayName,
|
||||
roles = roleMapping.Roles,
|
||||
});
|
||||
}).DisableAntiforgery();
|
||||
|
||||
@@ -158,6 +199,7 @@ public static class AuthEndpoints
|
||||
/// server-side. See CentralUI-020.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
|
||||
@@ -179,6 +221,7 @@ public static class AuthEndpoints
|
||||
/// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
|
||||
/// so the middleware is free to slide the expiry on activity.
|
||||
/// </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()
|
||||
{
|
||||
IsPersistent = true
|
||||
|
||||
@@ -20,6 +20,7 @@ public static class ClaimsPrincipalExtensions
|
||||
/// <see cref="UnknownUser"/> when the claim is absent.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser;
|
||||
|
||||
@@ -28,6 +29,7 @@ public static class ClaimsPrincipalExtensions
|
||||
/// the claim is absent.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||
|
||||
@@ -37,6 +39,7 @@ public static class ClaimsPrincipalExtensions
|
||||
/// ten components (CentralUI-024).
|
||||
/// </summary>
|
||||
/// <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(
|
||||
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>
|
||||
/// claims). System-wide users see and act on every site.
|
||||
/// </summary>
|
||||
/// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns>
|
||||
public async Task<bool> IsSystemWideAsync()
|
||||
=> (await ResolveAsync()).IsSystemWide;
|
||||
|
||||
@@ -46,6 +47,7 @@ public sealed class SiteScopeService
|
||||
/// system-wide user (callers should consult <see cref="IsSystemWideAsync"/>
|
||||
/// or use the filter/allowed helpers, which already account for that).
|
||||
/// </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()
|
||||
=> (await ResolveAsync()).Sites;
|
||||
|
||||
@@ -54,6 +56,7 @@ public sealed class SiteScopeService
|
||||
/// see. A system-wide user gets the full list back unchanged.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
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
|
||||
|
||||
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
||||
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
|
||||
/// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer.
|
||||
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
|
||||
/// Close buttons; the single-row detail body (read-only fields, conditional
|
||||
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
|
||||
@@ -20,7 +20,7 @@ public partial class AuditDrilldownDrawer
|
||||
/// The row to render. When null the drawer renders nothing — the host
|
||||
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
||||
/// </summary>
|
||||
[Parameter] public AuditEvent? Event { get; set; }
|
||||
[Parameter] public AuditEventView? Event { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the host wants the drawer visible. We deliberately keep
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
|
||||
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||
@@ -66,7 +66,7 @@ public partial class AuditEventDetail
|
||||
/// The row to render. Required and non-null — the host (drawer or modal)
|
||||
/// only mounts this component once it has a row to show.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public AuditEventView Event { get; set; } = null!;
|
||||
|
||||
private const string RedactionSentinel = "<redacted>";
|
||||
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
||||
@@ -303,7 +303,7 @@ public partial class AuditEventDetail
|
||||
/// outbound audit rows — the audit pipeline does not always capture
|
||||
/// the verb explicitly.
|
||||
/// </summary>
|
||||
private static string BuildCurlCommand(AuditEvent ev)
|
||||
private static string BuildCurlCommand(AuditEventView ev)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("curl");
|
||||
|
||||
@@ -114,6 +114,7 @@ public sealed class AuditQueryModel
|
||||
/// With one or more Channels selected, the union of the channel-specific kind
|
||||
/// lists is returned (deduplicated and order-stable on first-seen).
|
||||
/// </summary>
|
||||
/// <returns>The deduplicated, order-stable list of <see cref="AuditKind"/> values applicable to the selected channels.</returns>
|
||||
public IReadOnlyList<AuditKind> VisibleKinds()
|
||||
{
|
||||
if (Channels.Count == 0)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
@inject IAuditLogQueryService QueryService
|
||||
@@ -103,7 +102,7 @@
|
||||
return n.Length >= 8 ? n[..8] : n;
|
||||
}
|
||||
|
||||
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
|
||||
private RenderFragment RenderCell(string key, AuditEventView row) => __builder =>
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -61,7 +61,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
private const string ColumnOrderStorageKey = "columnOrder";
|
||||
private const string ColumnWidthsStorageKey = "columnWidths";
|
||||
|
||||
private readonly List<AuditEvent> _rows = new();
|
||||
private readonly List<AuditEventView> _rows = new();
|
||||
private int _pageNumber = 1;
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
|
||||
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
|
||||
/// drawer. The event payload is the full <see cref="AuditEventView"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
|
||||
[Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }
|
||||
|
||||
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
|
||||
private int _pageSize => Math.Max(1, PageSize);
|
||||
@@ -289,7 +289,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRowClick(AuditEvent row)
|
||||
private async Task HandleRowClick(AuditEventView row)
|
||||
{
|
||||
if (OnRowSelected.HasDelegate)
|
||||
{
|
||||
@@ -411,6 +411,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="columnKey">The stable key of the resized column.</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]
|
||||
public async Task OnColumnResized(string columnKey, int widthPx)
|
||||
{
|
||||
@@ -431,6 +432,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <returns>A task that completes when the column order has been persisted and the component re-rendered.</returns>
|
||||
[JSInvokable]
|
||||
public async Task OnColumnReordered(string fromKey, string toKey)
|
||||
{
|
||||
@@ -472,6 +474,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Releases the .NET object reference held for JS interop callbacks.
|
||||
/// </summary>
|
||||
/// <returns>A completed value task.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_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).
|
||||
Opened from an execution-tree node double-click. Given an ExecutionId it
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
// The loaded rows for the current execution; empty until a load completes.
|
||||
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
|
||||
private IReadOnlyList<AuditEventView> _rows = Array.Empty<AuditEventView>();
|
||||
|
||||
// The row whose detail is shown; null = list view.
|
||||
private AuditEvent? _selectedRow;
|
||||
private AuditEventView? _selectedRow;
|
||||
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_selectedRow = null;
|
||||
_rows = Array.Empty<AuditEvent>();
|
||||
_rows = Array.Empty<AuditEventView>();
|
||||
|
||||
if (ExecutionId is null)
|
||||
{
|
||||
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
|
||||
// degrades the modal to an inline error banner rather than killing
|
||||
// the SignalR circuit. Never rethrow.
|
||||
_error = $"Could not load this execution's audit rows: {ex.Message}";
|
||||
_rows = Array.Empty<AuditEvent>();
|
||||
_rows = Array.Empty<AuditEventView>();
|
||||
_selectedRow = null;
|
||||
}
|
||||
finally
|
||||
@@ -144,7 +143,7 @@ public partial class ExecutionDetailModal
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectRow(AuditEvent row) => _selectedRow = row;
|
||||
private void SelectRow(AuditEventView row) => _selectedRow = row;
|
||||
|
||||
private void BackToList() => _selectedRow = null;
|
||||
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
|
||||
@* Hamburger toggle: visible only on viewports <lg.
|
||||
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebar-collapse"
|
||||
aria-controls="sidebar-collapse"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||
@* The side-rail chassis (brand bar + responsive hamburger) is the shared
|
||||
ZB.MOM.WW.Theme ThemeShell. NavMenu fills the rail's <Nav> slot with the
|
||||
policy-gated nav groups; the session/sign-out block fills <RailFooter>. *@
|
||||
<ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
|
||||
<Nav>
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main class="flex-grow-1 p-3">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
</Nav>
|
||||
<RailFooter>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||
<span class="rail-user">@context.User.GetDisplayName()</span>
|
||||
<form method="post" action="/auth/logout" data-enhance="false">
|
||||
@* CentralUI-017: logout is a state-changing POST and is
|
||||
CSRF-protected — the antiforgery token is required. *@
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="rail-btn">Sign Out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</RailFooter>
|
||||
<ChildContent>@Body</ChildContent>
|
||||
</ThemeShell>
|
||||
|
||||
@* Global host for IDialogService. One instance per layout renders all confirm/prompt
|
||||
dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@
|
||||
|
||||
@@ -1,323 +1,117 @@
|
||||
@using System.Linq
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.JSInterop
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand"><span class="mark">▮</span> ScadaBridge</div>
|
||||
@* Rail navigation — rendered inside ThemeShell's <Nav> slot. The chassis
|
||||
(brand bar + responsive hamburger) belongs to ThemeShell; this component
|
||||
contributes only the nav items. Collapsible sections use the kit's
|
||||
NavRailSection (<details>); their open/closed state is persisted client-side
|
||||
by the kit's nav-state.js (localStorage, keyed by Key) — no JS interop here. *@
|
||||
|
||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
</li>
|
||||
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Admin role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<NavSection Title="Admin"
|
||||
Expanded="@_expanded.Contains("admin")"
|
||||
OnToggle="@(() => ToggleAsync("admin"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||
</li>
|
||||
@* Import Bundle requires Admin only — Design role is not sufficient.
|
||||
Export Bundle lives in the Design section (RequireDesign). *@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Administrator role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<NavRailSection Title="Admin" Key="admin">
|
||||
<NavRailItem Href="/admin/ldap-mappings" Text="LDAP Mappings" />
|
||||
<NavRailItem Href="/admin/sites" Text="Sites" />
|
||||
<NavRailItem Href="/admin/api-keys" Text="API Keys" />
|
||||
@* Import Bundle requires Administrator only — Designer role is not sufficient.
|
||||
Export Bundle lives in the Design section (RequireDesign). *@
|
||||
<NavRailItem Href="/design/transport/import" Text="Import Bundle" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Design section — Design role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<NavSection Title="Design"
|
||||
Expanded="@_expanded.Contains("design")"
|
||||
OnToggle="@(() => ToggleAsync("design"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@* Design section — Designer role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<NavRailSection Title="Design" Key="design">
|
||||
<NavRailItem Href="/design/templates" Text="Templates" />
|
||||
<NavRailItem Href="/design/shared-scripts" Text="Shared Scripts" />
|
||||
<NavRailItem Href="/design/connections" Text="Connections" />
|
||||
<NavRailItem Href="/design/external-systems" Text="External Systems" />
|
||||
<NavRailItem Href="/design/transport/export" Text="Export Bundle" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployment role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<NavSection Title="Deployment"
|
||||
Expanded="@_expanded.Contains("deployment")"
|
||||
OnToggle="@(() => ToggleAsync("deployment"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@* Deployment section — Deployer role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<NavRailSection Title="Deployment" Key="deployment">
|
||||
<NavRailItem Href="/deployment/topology" Text="Topology" />
|
||||
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
|
||||
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Notifications — mixed-role section; each item gated by its own policy.
|
||||
The section is ungated: every authenticated user holds at least one of
|
||||
Admin/Design/Deployment, so it always has a visible child. *@
|
||||
<NavSection Title="Notifications"
|
||||
Expanded="@_expanded.Contains("notifications")"
|
||||
OnToggle="@(() => ToggleAsync("notifications"))">
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="notifAdminContext">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="notifDesignContext">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="notifDeploymentContext">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavSection>
|
||||
|
||||
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
||||
matching the Notification Report page's gate; the whole
|
||||
section sits inside the policy block so a non-Deployment
|
||||
user does not see the heading. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="siteCallsContext">
|
||||
<NavSection Title="Site Calls"
|
||||
Expanded="@_expanded.Contains("sitecalls")"
|
||||
OnToggle="@(() => ToggleAsync("sitecalls"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||
Parked Messages are Deployment-role only (Component-CentralUI).
|
||||
The section is ungated because Health Dashboard is always
|
||||
a visible child. *@
|
||||
<NavSection Title="Monitoring"
|
||||
Expanded="@_expanded.Contains("monitoring")"
|
||||
OnToggle="@(() => ToggleAsync("monitoring"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
||||
</li>
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="monitoringContext">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavSection>
|
||||
|
||||
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
|
||||
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
|
||||
Configuration Audit Log (IAuditService config-change
|
||||
viewer). The whole section sits inside the policy block:
|
||||
a non-audit user does not even see the heading.
|
||||
OperationalAudit is satisfied by the Admin, Audit, and
|
||||
AuditReadOnly roles. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
||||
<Authorized Context="auditContext">
|
||||
<NavSection Title="Audit"
|
||||
Expanded="@_expanded.Contains("audit")"
|
||||
OnToggle="@(() => ToggleAsync("audit"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@* Notifications — mixed-role section; each item gated by its own policy.
|
||||
The section is ungated: every authenticated user holds at least one of
|
||||
Admin/Design/Deployment, so it always has a visible child. *@
|
||||
<NavRailSection Title="Notifications" Key="notifications">
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="notifAdminContext">
|
||||
<NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
</div>
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="notifDesignContext">
|
||||
<NavRailItem Href="/notifications/lists" Text="Notification Lists" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="notifDeploymentContext">
|
||||
<NavRailItem Href="/notifications/report" Text="Notification Report" />
|
||||
<NavRailItem Href="/notifications/kpis" Text="Notification KPIs" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavRailSection>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="border-top px-3 py-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||
<span class="text-body-secondary small">@context.User.GetDisplayName()</span>
|
||||
<form method="post" action="/auth/logout" data-enhance="false">
|
||||
@* CentralUI-017: logout is a state-changing POST and is
|
||||
CSRF-protected — the antiforgery token is required. *@
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
@* Site Calls — Site Call Audit (#22). Deployer-role only,
|
||||
matching the Notification Report page's gate; the whole
|
||||
section sits inside the policy block so a non-Deployer
|
||||
user does not see the heading. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="siteCallsContext">
|
||||
<NavRailSection Title="Site Calls" Key="sitecalls">
|
||||
<NavRailItem Href="/site-calls/report" Text="Site Calls" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@code {
|
||||
// Expanded-section state persists in the "scadabridge_nav" cookie, written
|
||||
// by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a
|
||||
// comma-separated list of section ids.
|
||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||
Parked Messages are Deployer-role only (Component-CentralUI).
|
||||
The section is ungated because Health Dashboard is always
|
||||
a visible child. *@
|
||||
<NavRailSection Title="Monitoring" Key="monitoring">
|
||||
<NavRailItem Href="/monitoring/health" Text="Health Dashboard" />
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="monitoringContext">
|
||||
<NavRailItem Href="/monitoring/event-logs" Text="Event Logs" />
|
||||
<NavRailItem Href="/monitoring/parked-messages" Text="Parked Messages" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavRailSection>
|
||||
|
||||
// Every collapsible section id. Also the allow-list for parsing the cookie.
|
||||
private static readonly string[] SectionIds =
|
||||
{ "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" };
|
||||
|
||||
// The currently-expanded sections. Populated from the cookie on first
|
||||
// render; mutated by ToggleAsync and by navigating into a section.
|
||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Hydrate from the cookie. Until this completes the sidebar paints
|
||||
// collapsed (the "collapsed by default" state) — matching how TreeView
|
||||
// hydrates its expand state in OnAfterRenderAsync(firstRender).
|
||||
string saved;
|
||||
try
|
||||
{
|
||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in saved.Split(
|
||||
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||
{
|
||||
_expanded.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// The section of the page we loaded on is always expanded.
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
{
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
// Navigating into a collapsed section expands it (and remembers it).
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
{
|
||||
_ = PersistAsync();
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAsync(string id)
|
||||
{
|
||||
if (!_expanded.Remove(id))
|
||||
{
|
||||
_expanded.Add(id);
|
||||
}
|
||||
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
// Adds the current page's section to _expanded; returns true if it changed.
|
||||
private bool EnsureCurrentSectionExpanded()
|
||||
{
|
||||
var section = CurrentSection();
|
||||
return section is not null && _expanded.Add(section);
|
||||
}
|
||||
|
||||
// Maps the current URL's first path segment to a section id, or null for
|
||||
// sectionless pages (Dashboard, Login).
|
||||
private string? CurrentSection()
|
||||
{
|
||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
var firstSegment = relative.Split('?', '#')[0]
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault();
|
||||
|
||||
return firstSegment switch
|
||||
{
|
||||
"admin" => "admin",
|
||||
"design" => "design",
|
||||
"deployment" => "deployment",
|
||||
"notifications" => "notifications",
|
||||
"site-calls" => "sitecalls",
|
||||
"monitoring" => "monitoring",
|
||||
"audit" => "audit",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PersistAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// The circuit is gone — nothing to persist to.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
|
||||
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
|
||||
Configuration Audit Log (IAuditService config-change
|
||||
viewer). The whole section sits inside the policy block:
|
||||
a non-audit user does not even see the heading.
|
||||
OperationalAudit is satisfied by the Administrator and
|
||||
Viewer roles (post-Task-1.7 canonical collapse: former
|
||||
Audit→Administrator, AuditReadOnly→Viewer). *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
||||
<Authorized Context="auditContext">
|
||||
<NavRailSection Title="Audit" Key="audit">
|
||||
<NavRailItem Href="/audit/log" Text="Audit Log" />
|
||||
<NavRailItem Href="/audit/configuration" Text="Configuration Audit Log" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
@* A collapsible sidebar nav section: an uppercase-eyebrow header button that
|
||||
toggles the visibility of its child nav items. The header <li> and the item
|
||||
<li>s (ChildContent) render as siblings inside NavMenu's <ul>. *@
|
||||
|
||||
<li class="nav-item">
|
||||
<button type="button"
|
||||
class="nav-section-toggle"
|
||||
@onclick="OnToggle"
|
||||
aria-expanded="@(Expanded ? "true" : "false")">
|
||||
<i class="bi @(Expanded ? "bi-chevron-down" : "bi-chevron-right")" aria-hidden="true"></i>
|
||||
<span>@Title</span>
|
||||
</button>
|
||||
</li>
|
||||
@if (Expanded)
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Section label shown in the header (e.g. "Deployment").</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the section is expanded — its items rendered.</summary>
|
||||
[Parameter]
|
||||
public bool Expanded { get; set; }
|
||||
|
||||
/// <summary>Raised when the header button is clicked.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnToggle { get; set; }
|
||||
|
||||
/// <summary>The section's nav items, rendered only while expanded.</summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
@page "/admin/api-keys/create"
|
||||
@page "/admin/api-keys/{Id:int}/edit"
|
||||
@page "/admin/api-keys/{KeyId}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@@ -46,15 +48,16 @@
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_saved && _newlyCreatedKeyValue != null)
|
||||
else if (_saved && _newlyCreatedToken != null)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<strong>New API Key Created</strong>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<code class="me-2">@_newlyCreatedKeyValue</code>
|
||||
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
|
||||
<div class="d-flex align-items-center mt-2">
|
||||
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
|
||||
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
|
||||
</div>
|
||||
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
|
||||
}
|
||||
@@ -66,39 +69,37 @@
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
@* Name is fixed on edit — the seam has no rename. *@
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" disabled="@IsEditMode" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodNames.Contains(method.Name)"
|
||||
@onchange="e => ToggleMethod(method.Name, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method. At least one is required.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodIds.Contains(method.Id)"
|
||||
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
@@ -111,21 +112,26 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam.
|
||||
// Keys are identified by an opaque string KeyId; method access is a set of method
|
||||
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
|
||||
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
|
||||
[Parameter] public string? KeyId { get; set; }
|
||||
|
||||
private bool IsEditMode => _editingKey != null;
|
||||
|
||||
private ApiKey? _editingKey;
|
||||
private InboundApiKeyInfo? _editingKey;
|
||||
private string _formName = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private string? _newlyCreatedKeyValue;
|
||||
private string? _newlyCreatedToken;
|
||||
private string? _newlyCreatedKeyId;
|
||||
private bool _loading = true;
|
||||
private bool _saved;
|
||||
|
||||
private List<ApiMethod> _allMethods = new();
|
||||
private HashSet<int> _initialMethodIds = new();
|
||||
private HashSet<int> _selectedMethodIds = new();
|
||||
// Selection set is method NAMES (scopes), not method ids.
|
||||
private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
@@ -133,22 +139,23 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Id.HasValue)
|
||||
// Methods always come from SQL Server (methods stay on the repository).
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(KeyId))
|
||||
{
|
||||
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
|
||||
// No single-key getter on the seam — locate this key in the full list.
|
||||
var all = await ApiKeyAdmin.ListAsync();
|
||||
_editingKey = all.FirstOrDefault(k => string.Equals(k.KeyId, KeyId, StringComparison.Ordinal));
|
||||
if (_editingKey == null)
|
||||
{
|
||||
_errorMessage = $"API key with ID {Id.Value} not found.";
|
||||
_errorMessage = $"API key '{KeyId}' not found.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_formName = _editingKey.Name;
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
_initialMethodIds = _allMethods
|
||||
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
|
||||
.Select(m => m.Id)
|
||||
.ToHashSet();
|
||||
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
|
||||
var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
|
||||
_selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,40 +169,38 @@
|
||||
private async Task SaveKey()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
if (!IsEditMode && string.IsNullOrWhiteSpace(_formName))
|
||||
{
|
||||
_formError = "Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
// The seam/server reject empty scope sets; validate in the UI for a clear message.
|
||||
if (_selectedMethodNames.Count == 0)
|
||||
{
|
||||
_formError = "Select at least one API method for this key.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingKey != null)
|
||||
{
|
||||
_editingKey.Name = _formName.Trim();
|
||||
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
|
||||
|
||||
var changedIds = _selectedMethodIds
|
||||
.Except(_initialMethodIds)
|
||||
.Concat(_initialMethodIds.Except(_selectedMethodIds))
|
||||
.ToHashSet();
|
||||
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
|
||||
// Edit: name is fixed; only the method-scope set is mutable.
|
||||
var ok = await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
|
||||
if (!ok)
|
||||
{
|
||||
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
|
||||
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
|
||||
else ids.Remove(_editingKey.Id);
|
||||
method.ApprovedApiKeyIds = ids.Count == 0
|
||||
? null
|
||||
: string.Join(",", ids.OrderBy(x => x));
|
||||
await InboundApiRepository.UpdateApiMethodAsync(method);
|
||||
_formError = $"API key '{_editingKey.Name}' was not found. Reload and retry.";
|
||||
return;
|
||||
}
|
||||
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/api-keys");
|
||||
}
|
||||
else
|
||||
{
|
||||
var keyValue = GenerateApiKey();
|
||||
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
|
||||
await InboundApiRepository.AddApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_newlyCreatedKeyValue = keyValue;
|
||||
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
|
||||
_newlyCreatedKeyId = created.KeyId;
|
||||
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
|
||||
_saved = true;
|
||||
}
|
||||
}
|
||||
@@ -207,28 +212,18 @@
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
|
||||
|
||||
private void ToggleMethod(int methodId, bool isChecked)
|
||||
private void ToggleMethod(string methodName, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedMethodIds.Add(methodId);
|
||||
else _selectedMethodIds.Remove(methodId);
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
if (isChecked) _selectedMethodNames.Add(methodName);
|
||||
else _selectedMethodNames.Remove(methodName);
|
||||
}
|
||||
|
||||
private async Task CopyKeyToClipboard()
|
||||
{
|
||||
if (_newlyCreatedKeyValue == null) return;
|
||||
if (_newlyCreatedToken == null) return;
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch
|
||||
@@ -236,12 +231,4 @@
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@page "/admin/api-keys"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
@@ -44,29 +43,29 @@
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Key ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Hash</th>
|
||||
<th>Methods</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var key in FilteredKeys)
|
||||
{
|
||||
<tr @key="key.Id">
|
||||
<td>@key.Id</td>
|
||||
<tr @key="key.KeyId">
|
||||
<td><code>@TruncateKeyId(key.KeyId)</code></td>
|
||||
<td>
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
|
||||
<td>@key.Methods.Count</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||
data-bs-toggle="dropdown"
|
||||
@@ -75,7 +74,7 @@
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => ToggleKey(key)">
|
||||
@(key.IsEnabled ? "Disable" : "Enable")
|
||||
@(key.Enabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
@@ -98,14 +97,17 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<ApiKey> _keys = new();
|
||||
// Inbound-API key re-arch (C3): this page reads keys from the IInboundApiKeyAdmin seam
|
||||
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
|
||||
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
|
||||
private List<InboundApiKeyInfo> _keys = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private IEnumerable<ApiKey> FilteredKeys =>
|
||||
private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _keys
|
||||
: _keys.Where(k =>
|
||||
@@ -122,7 +124,7 @@
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
_keys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -131,20 +133,28 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static string MaskKeyValue(string keyValue)
|
||||
// Show a short, recognizable prefix of the opaque KeyId rather than the full 32-char value.
|
||||
private static string TruncateKeyId(string keyId)
|
||||
{
|
||||
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
|
||||
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
|
||||
if (string.IsNullOrEmpty(keyId)) return keyId;
|
||||
return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
|
||||
}
|
||||
|
||||
private async Task ToggleKey(ApiKey key)
|
||||
private async Task ToggleKey(InboundApiKeyInfo key)
|
||||
{
|
||||
try
|
||||
{
|
||||
key.IsEnabled = !key.IsEnabled;
|
||||
await InboundApiRepository.UpdateApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
|
||||
var newEnabled = !key.Enabled;
|
||||
// The seam persists; there is no separate SaveChangesAsync.
|
||||
var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
|
||||
if (!ok)
|
||||
{
|
||||
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
|
||||
await LoadDataAsync();
|
||||
return;
|
||||
}
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(newEnabled ? "enabled" : "disabled")}.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -152,7 +162,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteKey(ApiKey key)
|
||||
private async Task DeleteKey(InboundApiKeyInfo key)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete API Key",
|
||||
@@ -162,8 +172,13 @@
|
||||
|
||||
try
|
||||
{
|
||||
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
|
||||
if (!ok)
|
||||
{
|
||||
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
|
||||
await LoadDataAsync();
|
||||
return;
|
||||
}
|
||||
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@
|
||||
<label class="form-label small">Role</label>
|
||||
<select class="form-select form-select-sm" @bind="_formRole">
|
||||
<option value="">Select role...</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Design">Design</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
<option value="@Roles.Administrator">Administrator</option>
|
||||
<option value="@Roles.Designer">Designer</option>
|
||||
<option value="@Roles.Deployer">Deployer</option>
|
||||
<option value="@Roles.Viewer">Viewer</option>
|
||||
</select>
|
||||
<div class="form-text">Deployment role: configure site scope below after saving.</div>
|
||||
<div class="form-text">Deployer role: configure site scope below after saving.</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
private AuditLogQueryFilter? _currentFilter;
|
||||
private AuditEvent? _selectedEvent;
|
||||
private AuditEventView? _selectedEvent;
|
||||
private bool _drawerOpen;
|
||||
private string? _initialInstanceSearch;
|
||||
|
||||
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
|
||||
_currentFilter = filter;
|
||||
}
|
||||
|
||||
private void HandleRowSelected(AuditEvent row)
|
||||
private void HandleRowSelected(AuditEventView row)
|
||||
{
|
||||
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
|
||||
// the selected row and open the drilldown drawer — the drawer is fully
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
const string basePath = "/api/centralui/audit/export";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -108,7 +109,7 @@
|
||||
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
|
||||
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
|
||||
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
|
||||
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
|
||||
_apiKeyCount = (await ApiKeyAdmin.ListAsync()).Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -44,14 +47,14 @@
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var key in _allKeys)
|
||||
{
|
||||
var checkboxId = $"approved-key-{key.Id}";
|
||||
var checkboxId = $"approved-key-{key.KeyId}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedKeyIds.Contains(key.Id)"
|
||||
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
|
||||
checked="@_selectedKeyIds.Contains(key.KeyId)"
|
||||
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
@@ -195,9 +198,15 @@
|
||||
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
// Inbound-API key re-arch (C3): the approved-keys list is driven by the IInboundApiKeyAdmin
|
||||
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
|
||||
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to
|
||||
// per-key method-scopes. Keys are identified by an opaque string KeyId.
|
||||
private ApiMethod? _existing;
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
private List<InboundApiKeyInfo> _allKeys = new();
|
||||
private HashSet<string> _selectedKeyIds = new(StringComparer.Ordinal);
|
||||
// Keys approved for this method when the form loaded (empty on create), for diffing on save.
|
||||
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
@@ -209,7 +218,8 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
// All keys come from the seam (hash-free projection).
|
||||
_allKeys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
|
||||
@@ -225,7 +235,10 @@
|
||||
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||
_params = _existing.ParameterDefinitions;
|
||||
_returns = _existing.ReturnDefinition;
|
||||
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
|
||||
// Seed approved keys from the seam: which keys' scopes contain this method.
|
||||
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
|
||||
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
|
||||
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -233,25 +246,12 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private void ToggleKey(int keyId, bool isChecked)
|
||||
private void ToggleKey(string keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
else _selectedKeyIds.Remove(keyId);
|
||||
}
|
||||
|
||||
private string? SerializeApprovedKeyIds() =>
|
||||
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
@@ -263,15 +263,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
var approvedKeyIds = SerializeApprovedKeyIds();
|
||||
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
|
||||
// key scopes against it. The method entity stays in SQL Server; we leave the
|
||||
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
|
||||
string methodName;
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Script = _script;
|
||||
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||
_existing.ParameterDefinitions = _params?.Trim();
|
||||
_existing.ReturnDefinition = _returns?.Trim();
|
||||
_existing.ApprovedApiKeyIds = approvedKeyIds;
|
||||
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
||||
methodName = _existing.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -279,17 +282,81 @@
|
||||
{
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
ParameterDefinitions = _params?.Trim(),
|
||||
ReturnDefinition = _returns?.Trim(),
|
||||
ApprovedApiKeyIds = approvedKeyIds
|
||||
ReturnDefinition = _returns?.Trim()
|
||||
};
|
||||
await InboundApiRepository.AddApiMethodAsync(m);
|
||||
methodName = m.Name;
|
||||
}
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
|
||||
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
|
||||
if (!await ReconcileKeyScopesAsync(methodName)) return;
|
||||
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes this method's name into / out of each affected key's scope set. Returns false
|
||||
/// (leaving a form error) when a revoke would empty a key's scopes — the server rejects
|
||||
/// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected
|
||||
/// key so we never clobber unrelated method-scopes.
|
||||
/// </summary>
|
||||
private async Task<bool> ReconcileKeyScopesAsync(string methodName)
|
||||
{
|
||||
var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal)
|
||||
.Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Read each affected key's CURRENT full scope set so add/remove preserves other methods.
|
||||
var currentMethodsByKey = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
foreach (var keyId in affected)
|
||||
{
|
||||
currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId);
|
||||
}
|
||||
|
||||
var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal);
|
||||
|
||||
var plan = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById);
|
||||
|
||||
// Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it).
|
||||
if (plan.EmptyScopeKeyNames.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", plan.EmptyScopeKeyNames);
|
||||
_formError =
|
||||
$"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " +
|
||||
"Disable or delete the key instead, or grant it another method first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var update in plan.Updates)
|
||||
{
|
||||
var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
|
||||
if (!ok)
|
||||
throw new InvalidOperationException(
|
||||
$"Key '{NameFor(update.KeyId)}' was not found in the key store.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Method '{methodName}' was saved, but updating approved-key scopes failed partway: {ex.Message} " +
|
||||
"Some keys may be partially updated — review them on the API Keys page and retry.", ex);
|
||||
}
|
||||
|
||||
// Selection is now the baseline (matters if save is retried without reload).
|
||||
_initialKeyIds = new HashSet<string>(_selectedKeyIds, StringComparer.Ordinal);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns the display name for a keyId if available from the loaded key list, else the id itself.
|
||||
private string NameFor(string keyId) =>
|
||||
_allKeys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))?.Name ?? keyId;
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
@@ -138,14 +138,15 @@
|
||||
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-keys">
|
||||
<legend class="h6">API Keys</legend>
|
||||
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||
<legend class="h6">API Methods</legend>
|
||||
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
|
||||
<strong>API keys are not part of config transport.</strong> Inbound API keys
|
||||
live in each environment's own secret store and cannot be exported. After
|
||||
importing, re-create the keys on the destination and re-grant their method
|
||||
scopes via the admin UI/CLI.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
@@ -261,10 +262,7 @@
|
||||
{
|
||||
<li>SmtpConfig: @s.Host</li>
|
||||
}
|
||||
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiKey: @k.Name</li>
|
||||
}
|
||||
@* Inbound API keys are not transported (re-arch C4) — methods only. *@
|
||||
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiMethod: @m.Name</li>
|
||||
|
||||
+8
-6
@@ -69,7 +69,7 @@ public partial class TransportExport : ComponentBase
|
||||
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
||||
private List<NotificationList> _notificationLists = new();
|
||||
private List<SmtpConfiguration> _smtpConfigs = new();
|
||||
private List<ApiKey> _apiKeys = new();
|
||||
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
||||
private List<ApiMethod> _apiMethods = new();
|
||||
|
||||
// ---- Step 1: selection state ----
|
||||
@@ -82,7 +82,7 @@ public partial class TransportExport : ComponentBase
|
||||
private readonly HashSet<int> _selectedDbConnections = new();
|
||||
private readonly HashSet<int> _selectedNotificationLists = new();
|
||||
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
||||
private readonly HashSet<int> _selectedApiKeys = new();
|
||||
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
|
||||
private readonly HashSet<int> _selectedApiMethods = new();
|
||||
private string _filter = string.Empty;
|
||||
private bool _includeDependencies = true;
|
||||
@@ -124,7 +124,7 @@ public partial class TransportExport : ComponentBase
|
||||
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
||||
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
||||
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
||||
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
|
||||
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
|
||||
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -169,7 +169,6 @@ public partial class TransportExport : ComponentBase
|
||||
|| _selectedDbConnections.Count > 0
|
||||
|| _selectedNotificationLists.Count > 0
|
||||
|| _selectedSmtpConfigs.Count > 0
|
||||
|| _selectedApiKeys.Count > 0
|
||||
|| _selectedApiMethods.Count > 0;
|
||||
|
||||
private bool PassphraseValid =>
|
||||
@@ -183,6 +182,7 @@ public partial class TransportExport : ComponentBase
|
||||
/// importer enforces its own strength + lockout policies.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return 0;
|
||||
@@ -205,7 +205,7 @@ public partial class TransportExport : ComponentBase
|
||||
DatabaseConnectionIds: _selectedDbConnections.ToList(),
|
||||
NotificationListIds: _selectedNotificationLists.ToList(),
|
||||
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
||||
ApiKeyIds: _selectedApiKeys.ToList(),
|
||||
// Inbound API keys are not transported (re-arch C4) — methods only.
|
||||
ApiMethodIds: _selectedApiMethods.ToList(),
|
||||
IncludeDependencies: _includeDependencies);
|
||||
}
|
||||
@@ -262,6 +262,7 @@ public partial class TransportExport : ComponentBase
|
||||
/// knows exactly what an unencrypted export would leak.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
var count = 0;
|
||||
@@ -368,6 +369,7 @@ public partial class TransportExport : ComponentBase
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <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)
|
||||
{
|
||||
var safe = SanitizeForFilename(sourceEnvironment);
|
||||
@@ -393,7 +395,6 @@ public partial class TransportExport : ComponentBase
|
||||
_selectedDbConnections.Clear();
|
||||
_selectedNotificationLists.Clear();
|
||||
_selectedSmtpConfigs.Clear();
|
||||
_selectedApiKeys.Clear();
|
||||
_selectedApiMethods.Clear();
|
||||
_filter = string.Empty;
|
||||
_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="seed">The set of explicitly selected item ids.</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)
|
||||
{
|
||||
return all.Where(x => !seed.Contains(idOf(x))).ToList();
|
||||
|
||||
@@ -3,34 +3,10 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [AllowAnonymous]
|
||||
|
||||
<div class="d-flex align-items-center justify-content-center min-vh-100">
|
||||
<div class="card shadow-sm" style="max-width: 400px; width: 100%;">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title mb-4 text-center">ScadaBridge</h4>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
<form method="post" action="/auth/login" data-enhance="false">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
required autocomplete="username" autofocus />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
required autocomplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoginCard Product="ScadaBridge" Action="/auth/login" Error="@ErrorMessage">
|
||||
<AntiforgeryToken />
|
||||
</LoginCard>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "error")] public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ internal static class DurationInput
|
||||
/// <c>sec</c> unit.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
|
||||
@@ -34,6 +35,7 @@ internal static class DurationInput
|
||||
/// </summary>
|
||||
/// <param name="value">The numeric string entered by the user.</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)
|
||||
{
|
||||
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
|
||||
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
|
||||
/// "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);
|
||||
|
||||
/// <summary>
|
||||
@@ -28,5 +29,6 @@ public interface IDialogService
|
||||
/// <param name="label">Label rendered above 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>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ internal static class SchemaBuilderModel
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// <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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return fallback;
|
||||
@@ -66,15 +67,18 @@ internal static class SchemaBuilderModel
|
||||
}
|
||||
|
||||
/// <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" };
|
||||
|
||||
/// <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" };
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a <see cref="SchemaNode"/> tree to its canonical JSON Schema string.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
using var stream = new System.IO.MemoryStream();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user