Compare commits
33 Commits
145d2668e2
...
b3de8408fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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.
|
||||
@@ -80,6 +80,11 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -22,13 +22,15 @@
|
||||
"MachineDataDb": "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
},
|
||||
"Security": {
|
||||
"LdapServer": "scadabridge-ldap",
|
||||
"LdapPort": 3893,
|
||||
"LdapUseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"LdapSearchBase": "dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
|
||||
"LdapServiceAccountPassword": "password",
|
||||
"Ldap": {
|
||||
"Server": "scadabridge-ldap",
|
||||
"Port": 3893,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "password"
|
||||
},
|
||||
"JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||
"JwtExpiryMinutes": 15,
|
||||
"IdleTimeoutMinutes": 30,
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<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" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
<!--
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
@@ -13,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// Central-side singleton (per Bundle E wiring) that ingests batches of
|
||||
/// <see cref="AuditEvent"/> rows pushed from sites via the
|
||||
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
|
||||
/// <see cref="AuditEvent.IngestedAtUtc"/> and inserted idempotently via
|
||||
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
|
||||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
|
||||
/// silently swallowed (first-write-wins per Bundle A's hardening).
|
||||
/// </summary>
|
||||
@@ -116,10 +117,10 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// Resolve the repository for the whole batch — one DbContext per
|
||||
// message, mirroring NotificationOutboxActor. The injected-repository
|
||||
// mode (Bundle D tests) skips the scope entirely.
|
||||
// Bundle C (M5-T6): the IAuditPayloadFilter is also resolved from the
|
||||
// Bundle C (M5-T6): the IAuditRedactor is also resolved from the
|
||||
// per-message scope when one is available so the row is truncated +
|
||||
// redacted before InsertIfNotExistsAsync. The single-repository test
|
||||
// ctor has no service provider — it falls through with no filter,
|
||||
// ctor has no service provider — it falls through with no redactor,
|
||||
// which preserves the small-payload assumptions baked into the
|
||||
// existing D2 fixtures.
|
||||
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
|
||||
@@ -127,19 +128,19 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// without blocking on sync Dispose() of pending connection cleanup.
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
await IngestWithRepositoryAsync(_injectedRepository, filter: null, failureCounter: null, cmd, nowUtc, accepted)
|
||||
await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var scope = _serviceProvider!.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
||||
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||
// M6 Bundle E (T8): central health counter is best-effort —
|
||||
// unregistered (test composition roots) means the per-row catch
|
||||
// simply logs without surfacing on the health dashboard.
|
||||
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
|
||||
await IngestWithRepositoryAsync(repository, filter, failureCounter, cmd, nowUtc, accepted)
|
||||
await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
|
||||
private async Task IngestWithRepositoryAsync(
|
||||
IAuditLogRepository repository,
|
||||
IAuditPayloadFilter? filter,
|
||||
IAuditRedactor? redactor,
|
||||
ICentralAuditWriteFailureCounter? failureCounter,
|
||||
IngestAuditEventsCommand cmd,
|
||||
DateTime nowUtc,
|
||||
@@ -162,15 +163,17 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// repository hardening already swallows duplicate-key races,
|
||||
// so the same id arriving twice (site retry, reconciliation)
|
||||
// is a silent no-op.
|
||||
// Filter BEFORE the IngestedAtUtc stamp so the redacted
|
||||
// copy carries the central-side ingest timestamp. Filter
|
||||
// Redact BEFORE the IngestedAtUtc stamp so the redacted
|
||||
// copy carries the central-side ingest timestamp. The redactor
|
||||
// is contract-bound to never throw. AuditLog-008: a null
|
||||
// filter (test composition root, no IAuditPayloadFilter
|
||||
// redactor (test composition root, no IAuditRedactor
|
||||
// registered) now falls back to the SafeDefault rather than
|
||||
// pass-through, so HTTP header redaction always runs.
|
||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
var filtered = safeFilter.Apply(evt);
|
||||
var ingested = filtered with { IngestedAtUtc = nowUtc };
|
||||
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on
|
||||
// the canonical record, so stamp it via the projection helper.
|
||||
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
var filtered = safeRedactor.Apply(evt);
|
||||
var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
|
||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||
accepted.Add(evt.EventId);
|
||||
}
|
||||
@@ -216,12 +219,12 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
// Bundle C (M5-T6): resolve the filter for the whole batch from
|
||||
// the scope; null = pass-through for test composition roots that
|
||||
// skip the filter registration. The filter is contract-bound to
|
||||
// Bundle C (M5-T6): resolve the redactor for the whole batch from
|
||||
// the scope; null = SafeDefault for test composition roots that
|
||||
// skip the redactor registration. The redactor is contract-bound to
|
||||
// never throw, so we can apply it inside the per-entry try
|
||||
// without risking an unbounded blast radius.
|
||||
var filter = scope.ServiceProvider.GetService<IAuditPayloadFilter>();
|
||||
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
|
||||
// M6 Bundle E (T8): same best-effort central health counter as
|
||||
// the OnIngestAsync path — null on test composition roots that
|
||||
// skip the registration.
|
||||
@@ -240,14 +243,16 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// matching timestamps (debugging convenience, not a
|
||||
// correctness invariant).
|
||||
var ingestedAt = DateTime.UtcNow;
|
||||
// Filter the audit half BEFORE the dual-write — only the
|
||||
// AuditLog row's payload columns are filterable; SiteCalls
|
||||
// Redact the audit half BEFORE the dual-write — only the
|
||||
// AuditLog row's payload columns are redactable; SiteCalls
|
||||
// carries operational state only (status, retry count) and
|
||||
// is left untouched. AuditLog-008: null filter falls back
|
||||
// is left untouched. AuditLog-008: null redactor falls back
|
||||
// to SafeDefault so header redaction always runs.
|
||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
var filteredAudit = safeFilter.Apply(entry.Audit);
|
||||
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
|
||||
// C3 transitional shim: IngestedAtUtc is a DetailsJson field
|
||||
// on the canonical record, so stamp it via the projection helper.
|
||||
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
var filteredAudit = safeRedactor.Apply(entry.Audit);
|
||||
var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
|
||||
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
|
||||
|
||||
await auditRepo.InsertIfNotExistsAsync(auditStamped)
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M6 Bundle E (T9) — bridges
|
||||
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
|
||||
/// <see cref="DefaultAuditPayloadFilter"/> every time a header / body / SQL
|
||||
/// parameter redactor stage throws and the filter has to over-redact the
|
||||
/// offending field) into <see cref="AuditCentralHealthSnapshot"/> so the
|
||||
/// failure surfaces on the central health surface as
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
|
||||
/// a header / body / SQL parameter redactor stage throws and the redactor has
|
||||
/// to over-redact the offending field) into <see cref="AuditCentralHealthSnapshot"/>
|
||||
/// so the failure surfaces on the central health surface as
|
||||
/// <c>AuditCentralHealthSnapshot.AuditRedactionFailure</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<CentralAuditWriter> _logger;
|
||||
private readonly IAuditPayloadFilter _filter;
|
||||
private readonly IAuditRedactor _redactor;
|
||||
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
||||
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||
|
||||
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
/// </summary>
|
||||
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
|
||||
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
|
||||
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
|
||||
/// <param name="redactor">Optional canonical redactor for truncation and redaction; defaults to the always-safe default.</param>
|
||||
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
|
||||
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
|
||||
public CentralAuditWriter(
|
||||
IServiceProvider services,
|
||||
ILogger<CentralAuditWriter> logger,
|
||||
IAuditPayloadFilter? filter = null,
|
||||
IAuditRedactor? redactor = null,
|
||||
ICentralAuditWriteFailureCounter? failureCounter = null,
|
||||
INodeIdentityProvider? nodeIdentity = null)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
// AuditLog-008: never default to null — over-redact instead.
|
||||
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
|
||||
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
|
||||
// SafeDefaultAuditRedactor applies HTTP header redaction with
|
||||
// hard-coded sensitive defaults so a composition root that omits the
|
||||
// real filter still scrubs Authorization / X-Api-Key / Cookie /
|
||||
// real redactor still scrubs Authorization / X-Api-Key / Cookie /
|
||||
// Set-Cookie before persistence.
|
||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
_redactor = redactor ?? SafeDefaultAuditRedactor.Instance;
|
||||
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
||||
_nodeIdentity = nodeIdentity;
|
||||
}
|
||||
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
|
||||
try
|
||||
{
|
||||
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
|
||||
// filter contract is "never throws". AuditLog-008: _filter is now
|
||||
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
|
||||
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
|
||||
// redactor contract is "never throws". AuditLog-008: _redactor is
|
||||
// now non-null (SafeDefaultAuditRedactor fallback) so header
|
||||
// redaction always runs even in composition roots that omit the
|
||||
// real filter.
|
||||
var filtered = _filter.Apply(evt);
|
||||
// real redactor.
|
||||
var filtered = _redactor.Apply(evt);
|
||||
|
||||
// SourceNode-stamping (Task 12): caller-provided value wins
|
||||
// (supports any future direct-write callsite that already has its
|
||||
@@ -124,7 +126,9 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
|
||||
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the
|
||||
// canonical record, so stamp it via the projection helper.
|
||||
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
|
||||
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
// misbehaving custom counter does, swallowing here keeps the
|
||||
// best-effort contract intact.
|
||||
}
|
||||
// Log the input event's identifying fields. These three (EventId,
|
||||
// Kind, Status) are immutable across the filter+stamp chain — the
|
||||
// `with` clones above touch only SourceNode and IngestedAtUtc — so
|
||||
// referencing `evt` here is intentional and equivalent to the
|
||||
// stamped record for diagnostics. If you add a field here that the
|
||||
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
|
||||
// post-stamp record name instead, not `evt`.
|
||||
// Log the input event's identifying fields. EventId + Action are
|
||||
// immutable across the redact+stamp chain — the `with` clones above
|
||||
// touch only SourceNode and DetailsJson — so referencing `evt` here
|
||||
// is intentional and equivalent to the stamped record for
|
||||
// diagnostics. Action = "{Channel}.{Kind}" carries the kind; the
|
||||
// canonical Outcome carries the coarse status (fine-grained Status
|
||||
// lives in DetailsJson).
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
||||
evt.EventId, evt.Kind, evt.Status);
|
||||
"CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
|
||||
evt.EventId, evt.Action, evt.Outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
|
||||
@@ -258,7 +258,9 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
||||
// concurrent push, or a retry of this very pull) collapse to
|
||||
// a no-op courtesy of M2 Bundle A's race-fix on
|
||||
// InsertIfNotExistsAsync.
|
||||
var ingested = evt with { IngestedAtUtc = nowUtc };
|
||||
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
|
||||
// stamp it via the projection helper.
|
||||
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
|
||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||
_failedInsertAttempts.Remove(evt.EventId);
|
||||
advanceForThisRow = true;
|
||||
@@ -299,9 +301,11 @@ public class SiteAuditReconciliationActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
if (advanceForThisRow && evt.OccurredAtUtc > maxOccurred)
|
||||
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
|
||||
var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
|
||||
if (advanceForThisRow && occurredUtc > maxOccurred)
|
||||
{
|
||||
maxOccurred = evt.OccurredAtUtc;
|
||||
maxOccurred = occurredUtc;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, stateless redaction + truncation primitives used by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
||||
/// (which operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> + its <c>DetailsJson</c>).
|
||||
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) so the
|
||||
/// byte-exact redaction logic lives in ONE place.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each stage method is a pure function of its inputs (no instance state). The
|
||||
/// only side effects are diagnostics-only: a warning log line and an
|
||||
/// <paramref name="onFailure"/> callback invocation when a redactor faults, so
|
||||
/// the caller can bump its redaction-failure health counter. The callbacks are
|
||||
/// passed in (rather than the counter interface) to keep this helper free of
|
||||
/// any DI / health-metric coupling.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The regex CACHE and per-call options resolution live in
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.AuditRegexCache"/> /
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
|
||||
/// — they carry per-instance state (lazy compile, 100 ms compile budget,
|
||||
/// sentinel entries). This helper only holds the stateless stages that
|
||||
/// operate once the compiled regex set / redact list / cap has already been
|
||||
/// resolved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class AuditRedactionPrimitives
|
||||
{
|
||||
/// <summary>Marker replacing redacted header values, body matches, and SQL parameter values.</summary>
|
||||
public const string RedactedMarker = "<redacted>";
|
||||
|
||||
/// <summary>Over-redaction marker emitted when a redactor stage itself faults.</summary>
|
||||
public const string RedactorErrorMarker = "<redacted: redactor error>";
|
||||
|
||||
/// <summary>
|
||||
/// Marker used by the outer never-throws safety net when the entire redaction
|
||||
/// pipeline fails catastrophically — all potentially-sensitive string fields are
|
||||
/// set to this value so no raw payload leaks on an unexpected fault.
|
||||
/// Deliberately equal to <see cref="RedactorErrorMarker"/>: both represent a
|
||||
/// defensive scrub-everything fallback.
|
||||
/// </summary>
|
||||
public const string OverRedactedEventMarker = RedactorErrorMarker;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options used to re-emit redacted summaries. The
|
||||
/// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
|
||||
/// (which contains <c><</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>
|
||||
public static string? RedactHeaders(
|
||||
string? json,
|
||||
IList<string> redactList,
|
||||
ILogger logger,
|
||||
Action onFailure)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cheap structural pre-check: only attempt JSON parsing when the input
|
||||
// actually looks like a JSON object. Saves the JsonDocument allocation
|
||||
// on the (very common) non-JSON ErrorDetail / Extra fields.
|
||||
var trimmed = json.AsSpan().TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] != '{')
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not parseable JSON — leave the field alone (no error, no
|
||||
// redaction). Emitters not yet using the documented shape get
|
||||
// a transparent pass.
|
||||
return json;
|
||||
}
|
||||
|
||||
if (root is not JsonObject obj || obj["headers"] is not JsonObject headers)
|
||||
{
|
||||
// No "headers" object at the top level — nothing to redact.
|
||||
return json;
|
||||
}
|
||||
|
||||
// Build a case-insensitive lookup of the redact list so we can do
|
||||
// one O(1) check per header name without an inner Any() loop.
|
||||
var redactSet = new HashSet<string>(redactList, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Take a snapshot of names first — we cannot mutate while
|
||||
// enumerating the JsonObject.
|
||||
var names = new List<string>(headers.Count);
|
||||
foreach (var kvp in headers)
|
||||
{
|
||||
names.Add(kvp.Key);
|
||||
}
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (redactSet.Contains(name))
|
||||
{
|
||||
headers[name] = JsonValue.Create(RedactedMarker);
|
||||
}
|
||||
}
|
||||
|
||||
return obj.ToJsonString(RedactedSummaryJsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Header redactor faulted; over-redacting field with '{Marker}'",
|
||||
RedactorErrorMarker);
|
||||
try { onFailure(); } catch { /* swallow per §7 */ }
|
||||
return RedactorErrorMarker;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply each compiled body-redactor regex to <paramref name="value"/> in
|
||||
/// turn, replacing every match with <see cref="RedactedMarker"/>. If any
|
||||
/// single regex match throws (most commonly
|
||||
/// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted
|
||||
/// with <see cref="RedactorErrorMarker"/> and <paramref name="onFailure"/>
|
||||
/// is invoked — the user-facing action is never aborted.
|
||||
/// </summary>
|
||||
public static string? RedactBody(
|
||||
string? value,
|
||||
IReadOnlyList<Regex> regexes,
|
||||
ILogger logger,
|
||||
Action onFailure)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var current = value;
|
||||
foreach (var rx in regexes)
|
||||
{
|
||||
try
|
||||
{
|
||||
current = rx.Replace(current, RedactedMarker);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Body redactor '{Pattern}' faulted; over-redacting field with '{Marker}'",
|
||||
rx.ToString(), RedactorErrorMarker);
|
||||
try { onFailure(); } catch { /* swallow per §7 */ }
|
||||
return RedactorErrorMarker;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
|
||||
/// shape; for each parameter whose NAME matches
|
||||
/// <paramref name="paramNameRegex"/>, replace its value with
|
||||
/// <see cref="RedactedMarker"/>. Re-serialise. No-op pass-through when the
|
||||
/// input is not parseable JSON, is not a JSON object, or does not carry a
|
||||
/// top-level <c>"parameters"</c> object. On any unexpected fault the field
|
||||
/// is over-redacted with <see cref="RedactorErrorMarker"/> and
|
||||
/// <paramref name="onFailure"/> is invoked.
|
||||
/// </summary>
|
||||
public static string? RedactSqlParameters(
|
||||
string? json,
|
||||
Regex paramNameRegex,
|
||||
ILogger logger,
|
||||
Action onFailure)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = json.AsSpan().TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] != '{')
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
if (root is not JsonObject obj || obj["parameters"] is not JsonObject parameters)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
// Snapshot the names — mutating during enumeration is unsupported.
|
||||
var names = new List<string>(parameters.Count);
|
||||
foreach (var kvp in parameters)
|
||||
{
|
||||
names.Add(kvp.Key);
|
||||
}
|
||||
var anyChanged = false;
|
||||
foreach (var name in names)
|
||||
{
|
||||
bool matched;
|
||||
try
|
||||
{
|
||||
matched = paramNameRegex.IsMatch(name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
|
||||
RedactorErrorMarker);
|
||||
try { onFailure(); } catch { /* swallow per §7 */ }
|
||||
return RedactorErrorMarker;
|
||||
}
|
||||
if (matched)
|
||||
{
|
||||
parameters[name] = JsonValue.Create(RedactedMarker);
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid re-serialising (which would normalise whitespace / order)
|
||||
// when no parameter matched — keeps the on-disk row byte-identical
|
||||
// to the emitter's output on the no-match path.
|
||||
return anyChanged ? obj.ToJsonString(RedactedSummaryJsonOptions) : json;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
|
||||
RedactorErrorMarker);
|
||||
try { onFailure(); } catch { /* swallow per §7 */ }
|
||||
return RedactorErrorMarker;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncate <paramref name="value"/> to <paramref name="cap"/> UTF-8 bytes,
|
||||
/// setting <paramref name="truncated"/> to <c>true</c> when the value was
|
||||
/// shortened. Null passes through as null.
|
||||
/// </summary>
|
||||
public static string? TruncateField(string? value, int cap, ref bool truncated)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var result = TruncateUtf8(value, cap);
|
||||
// Char-count comparison is sufficient: TruncateUtf8 only ever shortens the
|
||||
// string, so result.Length < value.Length iff bytes were removed.
|
||||
if (result.Length != value.Length)
|
||||
{
|
||||
truncated = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
|
||||
/// the cap position until the byte is NOT a continuation byte
|
||||
/// (<c>byte & 0xC0 == 0x80</c>), and decodes the resulting prefix —
|
||||
/// guaranteeing the returned string never splits a multi-byte sequence.
|
||||
/// </summary>
|
||||
public static string TruncateUtf8(string value, int capBytes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
if (bytes.Length <= capBytes)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var boundary = capBytes;
|
||||
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
|
||||
{
|
||||
boundary--;
|
||||
}
|
||||
return Encoding.UTF8.GetString(bytes, 0, boundary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance compiled-regex cache for audit body / SQL-parameter redactors
|
||||
/// used by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>.
|
||||
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) to
|
||||
/// centralize compile rules (50 ms per-match timeout, 100 ms compile budget,
|
||||
/// invalid-pattern sentinel).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Lazy population keyed by pattern string: each pattern is compiled on first
|
||||
/// use and cached forever. A failed compile (or a compile slower than 100 ms)
|
||||
/// caches a sentinel so the failing compile is not retried on every event. The
|
||||
/// failure is logged once on first encounter. <see cref="ConcurrentDictionary{TKey,TValue}"/>
|
||||
/// is the right primitive because the owning redactor is a DI singleton on the
|
||||
/// audit hot-path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditRegexCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-match regex timeout. Catastrophic-backtracking patterns trip a
|
||||
/// <see cref="RegexMatchTimeoutException"/> when a single match takes longer
|
||||
/// than this; the caller then over-redacts the offending field. 50 ms is
|
||||
/// generous for normal patterns yet short enough that the audit hot-path is
|
||||
/// not held up by a misconfigured regex.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private readonly ConcurrentDictionary<string, CompiledRegex> _cache = new();
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public AuditRegexCache(ILogger logger) => _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a compiled regex from the cache, compiling it on first use.
|
||||
/// Returns <c>false</c> for patterns that are invalid OR whose compile took
|
||||
/// longer than 100 ms (the spec calls catastrophic-backtracking guesses at
|
||||
/// compile time "invalid"); the failure is logged once and the sentinel
|
||||
/// cache entry prevents repeat compile attempts.
|
||||
/// </summary>
|
||||
public bool TryGet(string pattern, out Regex? regex)
|
||||
{
|
||||
var entry = _cache.GetOrAdd(pattern, Compile);
|
||||
regex = entry.Regex;
|
||||
return entry.Regex != null;
|
||||
}
|
||||
|
||||
private CompiledRegex Compile(string pattern)
|
||||
{
|
||||
try
|
||||
{
|
||||
var swStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var rx = new Regex(pattern, RegexOptions.Compiled, RegexMatchTimeout);
|
||||
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - swStart)
|
||||
* 1000d / System.Diagnostics.Stopwatch.Frequency;
|
||||
if (elapsedMs > 100)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Body redactor pattern compiled in {Elapsed}ms (> 100ms cap); rejecting '{Pattern}'",
|
||||
elapsedMs, pattern);
|
||||
return CompiledRegex.Invalid;
|
||||
}
|
||||
return new CompiledRegex(rx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Body redactor pattern '{Pattern}' failed to compile; skipping",
|
||||
pattern);
|
||||
return CompiledRegex.Invalid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache entry for a body-redactor pattern. Carries the working
|
||||
/// <see cref="Regex"/> on the success path, or the <see cref="Invalid"/>
|
||||
/// sentinel for patterns that failed to compile (or exceeded the 100 ms
|
||||
/// compile budget).
|
||||
/// </summary>
|
||||
private readonly struct CompiledRegex
|
||||
{
|
||||
public static readonly CompiledRegex Invalid = new(null);
|
||||
|
||||
public Regex? Regex { get; }
|
||||
|
||||
public CompiledRegex(Regex? regex) => Regex = regex;
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAuditPayloadFilter"/>. Bundle A established the
|
||||
/// truncation backbone; Bundle B chains HTTP header redaction (M5-T3) BEFORE
|
||||
/// truncation so redactors operate on the full payload and the cap then trims
|
||||
/// the redacted result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Uses <see cref="IOptionsMonitor{TOptions}"/> (not <see cref="IOptions{TOptions}"/>)
|
||||
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
|
||||
/// singleton. <see cref="Apply"/> reads <see cref="IOptionsMonitor{T}.CurrentValue"/>
|
||||
/// on every call, and the regex cache is keyed by pattern string — patterns
|
||||
/// added via a live config change compile on first use of the next event;
|
||||
/// patterns removed simply stop being looked up. No <c>OnChange</c> subscription
|
||||
/// or explicit cache invalidation is required (the
|
||||
/// <c>AuditLogOptionsBindingTests</c> fixture in <c>ZB.MOM.WW.ScadaBridge.AuditLog.Tests</c>
|
||||
/// pins this behaviour).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// "Error row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
|
||||
/// <c>Submitted</c>, <c>Forwarded</c>) — every other status, including the
|
||||
/// non-terminal <c>Attempted</c>, the parked/discarded terminals, and the
|
||||
/// short-circuit <c>Skipped</c>, receives the larger error cap so a verbose
|
||||
/// error body survives.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
|
||||
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
|
||||
/// increments the <c>AuditRedactionFailure</c> health metric via the injected
|
||||
/// <see cref="IAuditRedactionFailureCounter"/>. Each redactor stage runs in
|
||||
/// its own try/catch — a failure in (say) the header redactor still lets the
|
||||
/// SQL parameter redactor and the truncator run on the remaining fields.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Stage order (each runs on every applicable field):
|
||||
/// header redaction → body regex redaction → truncation. The SQL-parameter
|
||||
/// stage piggybacks on the body-redactor path; both run BEFORE truncation so
|
||||
/// the cap trims the redacted result, never bytes the redactor intended to
|
||||
/// hide.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
|
||||
{
|
||||
private const string RedactedMarker = "<redacted>";
|
||||
private const string RedactorErrorMarker = "<redacted: redactor error>";
|
||||
|
||||
/// <summary>
|
||||
/// Per-match regex timeout. Catastrophic-backtracking patterns trip a
|
||||
/// <see cref="RegexMatchTimeoutException"/> when a single match takes
|
||||
/// longer than this; the offending field is then over-redacted with
|
||||
/// <see cref="RedactorErrorMarker"/> and the failure counter is bumped.
|
||||
/// 50 ms is generous for normal patterns yet short enough that the
|
||||
/// audit hot-path isn't held up by a misconfigured regex.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options used to re-emit redacted summaries. The
|
||||
/// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
|
||||
/// (which contains <c><</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,99 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using static ZB.MOM.WW.ScadaBridge.AuditLog.Payload.AuditRedactionPrimitives;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal always-safe <see cref="IAuditRedactor"/> fallback for composition
|
||||
/// roots that bypass the full <see cref="ScadaBridgeAuditRedactor"/>.
|
||||
/// Performs line-oriented HTTP header
|
||||
/// redaction for the always-sensitive defaults (Authorization, X-Api-Key,
|
||||
/// Cookie, Set-Cookie) on the <c>RequestSummary</c> / <c>ResponseSummary</c>
|
||||
/// fields carried inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>. Does NOT
|
||||
/// perform body-regex redaction, SQL-parameter redaction, or truncation — those
|
||||
/// need <see cref="ScadaBridgeAuditRedactor"/> with live options. Contract:
|
||||
/// over-redact safely, never throw, never miss a header on the default
|
||||
/// sensitive list.
|
||||
/// </summary>
|
||||
public sealed class SafeDefaultAuditRedactor : IAuditRedactor
|
||||
{
|
||||
/// <summary>Singleton instance — the redactor is stateless and side-effect-free.</summary>
|
||||
public static SafeDefaultAuditRedactor Instance { get; } = new SafeDefaultAuditRedactor();
|
||||
|
||||
private static readonly string[] DefaultHeaderRedactList =
|
||||
{
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
};
|
||||
|
||||
private static readonly Regex HeaderRegex = new(
|
||||
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private SafeDefaultAuditRedactor() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rawEvent);
|
||||
|
||||
// Fast path: no DetailsJson means no summaries to scrub.
|
||||
if (string.IsNullOrEmpty(rawEvent.DetailsJson))
|
||||
{
|
||||
return rawEvent;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);
|
||||
var scrubbed = d with
|
||||
{
|
||||
RequestSummary = RedactHeaders(d.RequestSummary),
|
||||
ResponseSummary = RedactHeaders(d.ResponseSummary),
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(scrubbed) };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Over-redact: suppress ALL sensitive free-text fields so a failure
|
||||
// on any internal path never leaks the original. The contract is
|
||||
// "never throw." Uses the shared OverRedactedEventMarker so all
|
||||
// redactor safety-nets emit the same sentinel string.
|
||||
var safe = new AuditDetails
|
||||
{
|
||||
RequestSummary = OverRedactedEventMarker,
|
||||
ResponseSummary = OverRedactedEventMarker,
|
||||
ErrorDetail = OverRedactedEventMarker,
|
||||
ErrorMessage = OverRedactedEventMarker,
|
||||
Extra = OverRedactedEventMarker,
|
||||
PayloadTruncated = true,
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
|
||||
}
|
||||
}
|
||||
|
||||
private static string? RedactHeaders(string? summary)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary)) return summary;
|
||||
|
||||
return HeaderRegex.Replace(summary, m =>
|
||||
{
|
||||
var name = m.Groups["name"].Value;
|
||||
foreach (var sensitive in DefaultHeaderRedactList)
|
||||
{
|
||||
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Use the shared RedactedMarker so line-format and JSON-format
|
||||
// header redaction emit the same sentinel string.
|
||||
return $"{name}: {RedactedMarker}";
|
||||
}
|
||||
}
|
||||
return m.Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical <see cref="IAuditRedactor"/> implementation for ScadaBridge —
|
||||
/// operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> and its <see cref="AuditEvent.DetailsJson"/>
|
||||
/// payload bag. The ScadaBridge request/response/error/extra summaries travel
|
||||
/// inside <c>DetailsJson</c> as a <see cref="AuditDetails"/> record (serialized
|
||||
/// by <see cref="AuditDetailsCodec"/>); this redactor deserializes them, applies
|
||||
/// the header → body-regex → SQL-parameter → byte-safe truncation pipeline,
|
||||
/// re-serializes, and returns a filtered COPY.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Cap selection is faithful to the original pipeline, translated onto canonical
|
||||
/// fields:
|
||||
/// <list type="bullet">
|
||||
/// <item>The <c>ApiInbound</c> branch keys on <see cref="AuditEvent.Category"/>
|
||||
/// (= <c>AuditChannel.ToString()</c> per <see cref="AuditFieldBuilders.BuildCategory"/>)
|
||||
/// → <see cref="AuditLogOptions.InboundMaxBytes"/>.</item>
|
||||
/// <item>The "error row" branch reproduces the legacy
|
||||
/// <c>IsErrorStatus(Status)</c> rule — Status NOT IN (<c>Delivered</c>,
|
||||
/// <c>Submitted</c>, <c>Forwarded</c>) → <see cref="AuditLogOptions.ErrorCapBytes"/>.
|
||||
/// The fine-grained status is read from <see cref="AuditDetails.Status"/>
|
||||
/// when present (it must be — <see cref="AuditOutcome"/> alone cannot
|
||||
/// reproduce <c>IsErrorStatus</c>, since <c>Attempted</c>/<c>Skipped</c>
|
||||
/// project to <see cref="AuditOutcome.Success"/> yet take the error cap).
|
||||
/// When <see cref="AuditDetails.Status"/> is absent/unparseable the
|
||||
/// canonical <see cref="AuditEvent.Outcome"/> is the fallback:
|
||||
/// <see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
|
||||
/// → error cap.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// MUST NOT throw — wrapped in try/catch; over-redacts (drops ALL sensitive free-text
|
||||
/// fields to a safe marker) on any internal failure, mirroring
|
||||
/// <see cref="SafeDefaultAuditRedactor"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
||||
{
|
||||
private const string OverRedactedMarker = AuditRedactionPrimitives.OverRedactedEventMarker;
|
||||
|
||||
private readonly IOptionsMonitor<AuditLogOptions> _options;
|
||||
private readonly ILogger<ScadaBridgeAuditRedactor> _logger;
|
||||
private readonly IAuditRedactionFailureCounter _failureCounter;
|
||||
private readonly AuditRegexCache _regexCache;
|
||||
|
||||
/// <summary>
|
||||
/// Primary constructor used by DI — pulls the optional redaction-failure
|
||||
/// counter from the container; a NoOp default is used when none is supplied.
|
||||
/// </summary>
|
||||
/// <param name="options">Live-reloadable audit log options.</param>
|
||||
/// <param name="logger">Logger for redaction diagnostics.</param>
|
||||
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
|
||||
public ScadaBridgeAuditRedactor(
|
||||
IOptionsMonitor<AuditLogOptions> options,
|
||||
ILogger<ScadaBridgeAuditRedactor> logger,
|
||||
IAuditRedactionFailureCounter? failureCounter = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
|
||||
_regexCache = new AuditRegexCache(_logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
// --- Fast path -------------------------------------------------
|
||||
// Mirror the legacy filter's non-JSON pre-check: when there is no
|
||||
// DetailsJson payload to scrub AND the Target is within the cap,
|
||||
// there is nothing to redact or truncate. Return the input
|
||||
// unchanged so the common case stays cheap (no Deserialize, no
|
||||
// re-Serialize, same instance back).
|
||||
var detailsEmpty = string.IsNullOrEmpty(rawEvent.DetailsJson);
|
||||
var targetWithinCap = rawEvent.Target is null
|
||||
|| Encoding.UTF8.GetByteCount(rawEvent.Target) <= opts.DefaultCapBytes;
|
||||
if (detailsEmpty && targetWithinCap)
|
||||
{
|
||||
return rawEvent;
|
||||
}
|
||||
|
||||
// --- Slow path -------------------------------------------------
|
||||
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);
|
||||
|
||||
// Cap selection. Channel = canonical Category (the ApiInbound
|
||||
// branch); error-cap selection reproduces the legacy
|
||||
// IsErrorStatus(Status) — read from d.Status when present, else
|
||||
// fall back to the canonical Outcome.
|
||||
var cap = SelectCap(opts, rawEvent.Category, d.Status, rawEvent.Outcome);
|
||||
|
||||
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
||||
var request = RedactHeaders(d.RequestSummary, opts.HeaderRedactList);
|
||||
var response = RedactHeaders(d.ResponseSummary, opts.HeaderRedactList);
|
||||
var errorDetail = d.ErrorDetail;
|
||||
var extra = d.Extra;
|
||||
|
||||
// --- Body-regex stage (also runs BEFORE truncation) -----------
|
||||
// Per-target additions key on the canonical Target.
|
||||
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
|
||||
if (bodyRegexes.Count > 0)
|
||||
{
|
||||
request = RedactBody(request, bodyRegexes);
|
||||
response = RedactBody(response, bodyRegexes);
|
||||
errorDetail = RedactBody(errorDetail, bodyRegexes);
|
||||
extra = RedactBody(extra, bodyRegexes);
|
||||
}
|
||||
|
||||
// --- SQL parameter redaction stage (DbOutbound only) ----------
|
||||
// Channel-guarded on the canonical Category; connection key is the
|
||||
// Target prefix before the first '.'.
|
||||
if (string.Equals(rawEvent.Category, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
|
||||
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
|
||||
{
|
||||
request = RedactSqlParameters(request, sqlParamRegex!);
|
||||
}
|
||||
|
||||
// --- Truncation stage -----------------------------------------
|
||||
var truncated = false;
|
||||
request = TruncateField(request, cap, ref truncated);
|
||||
response = TruncateField(response, cap, ref truncated);
|
||||
errorDetail = TruncateField(errorDetail, cap, ref truncated);
|
||||
extra = TruncateField(extra, cap, ref truncated);
|
||||
|
||||
var rewritten = d with
|
||||
{
|
||||
RequestSummary = request,
|
||||
ResponseSummary = response,
|
||||
ErrorDetail = errorDetail,
|
||||
Extra = extra,
|
||||
PayloadTruncated = d.PayloadTruncated || truncated,
|
||||
};
|
||||
|
||||
// Target length cap (canonical top-level field). Cap at the default
|
||||
// byte ceiling so an absurd Target cannot blow the storage column.
|
||||
var cappedTarget = TruncateTarget(rawEvent.Target, opts.DefaultCapBytes);
|
||||
|
||||
return rawEvent with
|
||||
{
|
||||
DetailsJson = AuditDetailsCodec.Serialize(rewritten),
|
||||
Target = cappedTarget,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Audit is best-effort: over-redact rather than fail the caller.
|
||||
// Drop the summaries entirely (mirroring SafeDefault's catch path)
|
||||
// and flag PayloadTruncated so downstream readers know the row was
|
||||
// scrubbed defensively.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Canonical audit redactor failed; over-redacting DetailsJson and flagging PayloadTruncated");
|
||||
IncrementFailureCounter();
|
||||
return OverRedact(rawEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the truncation cap. <paramref name="category"/> = canonical Category
|
||||
/// (= channel name): <c>ApiInbound</c> → <see cref="AuditLogOptions.InboundMaxBytes"/>.
|
||||
/// Otherwise the legacy <c>IsErrorStatus</c> rule decides between the error
|
||||
/// and default caps, preferring the fine-grained <paramref name="detailsStatus"/>
|
||||
/// (from <c>DetailsJson</c>) and falling back to the canonical
|
||||
/// <paramref name="outcome"/> when status is absent/unparseable.
|
||||
/// </summary>
|
||||
private static int SelectCap(
|
||||
AuditLogOptions opts,
|
||||
string? category,
|
||||
string? detailsStatus,
|
||||
AuditOutcome outcome)
|
||||
{
|
||||
if (string.Equals(category, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal))
|
||||
{
|
||||
return opts.InboundMaxBytes;
|
||||
}
|
||||
return IsErrorRow(detailsStatus, outcome) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduce the legacy <c>IsErrorStatus(Status)</c> error-cap predicate on
|
||||
/// the canonical record: Status NOT IN (<c>Delivered</c>, <c>Submitted</c>,
|
||||
/// <c>Forwarded</c>) → error row. When the fine-grained status is present in
|
||||
/// <c>DetailsJson</c> it is authoritative; otherwise the canonical
|
||||
/// <see cref="AuditOutcome"/> is the fallback
|
||||
/// (<see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
|
||||
/// → error row).
|
||||
/// </summary>
|
||||
private static bool IsErrorRow(string? detailsStatus, AuditOutcome outcome)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(detailsStatus)
|
||||
&& Enum.TryParse<AuditStatus>(detailsStatus, ignoreCase: false, out var status))
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
// No usable status — fall back to the canonical outcome.
|
||||
return outcome != AuditOutcome.Success;
|
||||
}
|
||||
|
||||
private string? RedactHeaders(string? json, IList<string> redactList)
|
||||
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
|
||||
|
||||
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
|
||||
=> AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
|
||||
|
||||
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
|
||||
=> AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);
|
||||
|
||||
private static string? TruncateField(string? value, int cap, ref bool truncated)
|
||||
=> AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);
|
||||
|
||||
private static string? TruncateTarget(string? target, int cap)
|
||||
=> target is null ? null : AuditRedactionPrimitives.TruncateUtf8(target, cap);
|
||||
|
||||
/// <summary>
|
||||
/// Combine the global and per-target body-redactor lists, returning the
|
||||
/// compiled-regex set to apply. Patterns that failed compilation are
|
||||
/// silently skipped.
|
||||
/// </summary>
|
||||
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
|
||||
{
|
||||
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
|
||||
var perTargetAdditions = (target != null
|
||||
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
|
||||
&& over.AdditionalBodyRedactors is { Count: > 0 })
|
||||
? over.AdditionalBodyRedactors
|
||||
: null;
|
||||
|
||||
if (!hasGlobal && perTargetAdditions == null)
|
||||
{
|
||||
return Array.Empty<Regex>();
|
||||
}
|
||||
|
||||
var result = new List<Regex>();
|
||||
if (hasGlobal)
|
||||
{
|
||||
foreach (var pattern in opts.GlobalBodyRedactors)
|
||||
{
|
||||
if (_regexCache.TryGet(pattern, out var rx))
|
||||
{
|
||||
result.Add(rx!);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (perTargetAdditions != null)
|
||||
{
|
||||
foreach (var pattern in perTargetAdditions)
|
||||
{
|
||||
if (_regexCache.TryGet(pattern, out var rx))
|
||||
{
|
||||
result.Add(rx!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the per-connection SQL parameter redaction regex for the given
|
||||
/// target. Connection key = everything before the first <c>.</c> in
|
||||
/// <paramref name="target"/>. Patterns are forced case-insensitive.
|
||||
/// </summary>
|
||||
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
|
||||
{
|
||||
regex = null;
|
||||
if (string.IsNullOrEmpty(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dot = target.IndexOf('.');
|
||||
var connectionKey = dot < 0 ? target : target[..dot];
|
||||
|
||||
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|
||||
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
||||
return _regexCache.TryGet(cacheKey, out regex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Over-redaction copy returned from the never-throws catch: suppress ALL
|
||||
/// potentially-sensitive string fields inside <c>DetailsJson</c> to a safe
|
||||
/// marker and flag <see cref="AuditDetails.PayloadTruncated"/>. "All sensitive
|
||||
/// fields" = <c>RequestSummary</c>, <c>ResponseSummary</c>, <c>ErrorDetail</c>,
|
||||
/// <c>ErrorMessage</c>, and <c>Extra</c> — all body-regex redaction targets
|
||||
/// that can carry sensitive values. Best-effort re-serialise; if even that
|
||||
/// fails, return the input with no sensitive fields via a minimal details bag.
|
||||
/// </summary>
|
||||
private static AuditEvent OverRedact(AuditEvent rawEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson) with
|
||||
{
|
||||
RequestSummary = OverRedactedMarker,
|
||||
ResponseSummary = OverRedactedMarker,
|
||||
ErrorDetail = OverRedactedMarker,
|
||||
ErrorMessage = OverRedactedMarker,
|
||||
Extra = OverRedactedMarker,
|
||||
PayloadTruncated = true,
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(d) };
|
||||
}
|
||||
catch
|
||||
{
|
||||
var safe = new AuditDetails
|
||||
{
|
||||
RequestSummary = OverRedactedMarker,
|
||||
ResponseSummary = OverRedactedMarker,
|
||||
ErrorDetail = OverRedactedMarker,
|
||||
ErrorMessage = OverRedactedMarker,
|
||||
Extra = OverRedactedMarker,
|
||||
PayloadTruncated = true,
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bumps the injected redaction-failure counter, swallowing any fault per
|
||||
/// alog.md §7. Passed as the <c>onFailure</c> callback to the shared
|
||||
/// primitives and called from the top-level catch.
|
||||
/// </summary>
|
||||
private void IncrementFailureCounter()
|
||||
{
|
||||
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,16 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using 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;
|
||||
|
||||
@@ -69,14 +72,12 @@ public static class ServiceCollectionExtensions
|
||||
// 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
|
||||
@@ -115,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).
|
||||
@@ -124,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.
|
||||
@@ -202,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
|
||||
@@ -210,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)
|
||||
@@ -230,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;
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ using System.Threading.Channels;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
|
||||
@@ -18,15 +20,27 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The schema is bootstrapped in the constructor (Bundle B-T1). The
|
||||
/// Channel-based <see cref="WriteAsync"/> hot-path + Bundle D
|
||||
/// <see cref="ReadPendingAsync"/> / <see cref="MarkForwardedAsync"/> support
|
||||
/// surface are wired in Bundle B-T2.
|
||||
/// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables:
|
||||
/// the append-only canonical <c>audit_event</c> (the 10 canonical
|
||||
/// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
|
||||
/// the mutable operational <c>audit_forward_state</c> sidecar that carries the
|
||||
/// forwarding lifecycle (<see cref="AuditForwardState"/>), a duplicated
|
||||
/// <c>OccurredAtUtc</c> for the drain index range-scan, a precomputed
|
||||
/// <c>IsCachedKind</c> flag that drives the cached/non-cached drain split without
|
||||
/// re-parsing <c>DetailsJson</c> on the read hot-path, plus attempt bookkeeping.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention,
|
||||
/// recreated per deployment), so C4's schema change is an in-place RESET: the new
|
||||
/// tables are created and the old single 24-column <c>AuditLog</c> table is
|
||||
/// DROP-ped if present. No SQLite data migration is performed (and none is
|
||||
/// needed) — any rows in a pre-C4 <c>AuditLog</c> table are within the retention
|
||||
/// window and are discarded by the drop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Site rows always carry <see cref="AuditForwardState.Pending"/> on first
|
||||
/// insert; the central row-shape's <c>IngestedAtUtc</c> column does NOT live in
|
||||
/// the site SQLite schema — central stamps it on ingest.
|
||||
/// insert; the central row-shape's <c>IngestedAtUtc</c> is a DetailsJson field
|
||||
/// stamped by central on ingest, not a site column.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable, IDisposable
|
||||
@@ -35,8 +49,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// on a PRIMARY KEY violation; the extended subcode 1555 (SQLITE_CONSTRAINT_PRIMARYKEY)
|
||||
// is exposed via SqliteException.SqliteExtendedErrorCode but isn't reliably
|
||||
// surfaced across all SQLite builds. We treat any constraint error on insert
|
||||
// as a duplicate-eventid race and swallow it (first-write-wins) — the index
|
||||
// on EventId is the only constraint on this table, so this scope is precise.
|
||||
// as a duplicate-eventid race and swallow it (first-write-wins) — the PRIMARY
|
||||
// KEY on audit_event.EventId is the constraint that fires first, so this scope
|
||||
// is precise (the sidecar insert for the same EventId is in the same
|
||||
// transaction and never reached once audit_event's insert throws).
|
||||
private const int SqliteErrorConstraint = 19;
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
@@ -97,6 +113,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
_readConnection = new SqliteConnection(connectionString);
|
||||
_readConnection.Open();
|
||||
|
||||
// PRAGMA foreign_keys is a per-connection setting. Set it on the read
|
||||
// connection as well so that any future read-path change (e.g. a
|
||||
// DELETE that may be added later) also benefits from FK enforcement.
|
||||
// Pure SELECT queries are unaffected — this is defensive belt-and-
|
||||
// suspenders for the read connection.
|
||||
using (var pragmaCmd = _readConnection.CreateCommand())
|
||||
{
|
||||
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
|
||||
pragmaCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
_writeQueue = Channel.CreateBounded<PendingAuditEvent>(
|
||||
new BoundedChannelOptions(_options.ChannelCapacity)
|
||||
{
|
||||
@@ -140,95 +167,81 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
pragmaCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS AuditLog (
|
||||
EventId TEXT NOT NULL,
|
||||
OccurredAtUtc TEXT NOT NULL,
|
||||
Channel TEXT NOT NULL,
|
||||
Kind TEXT NOT NULL,
|
||||
CorrelationId TEXT NULL,
|
||||
SourceSiteId TEXT NULL,
|
||||
SourceNode TEXT NULL,
|
||||
SourceInstanceId TEXT NULL,
|
||||
SourceScript TEXT NULL,
|
||||
Actor TEXT NULL,
|
||||
Target TEXT NULL,
|
||||
Status TEXT NOT NULL,
|
||||
HttpStatus INTEGER NULL,
|
||||
DurationMs INTEGER NULL,
|
||||
ErrorMessage TEXT NULL,
|
||||
ErrorDetail TEXT NULL,
|
||||
RequestSummary TEXT NULL,
|
||||
ResponseSummary TEXT NULL,
|
||||
PayloadTruncated INTEGER NOT NULL,
|
||||
Extra TEXT NULL,
|
||||
ForwardState TEXT NOT NULL,
|
||||
ExecutionId TEXT NULL,
|
||||
ParentExecutionId TEXT NULL,
|
||||
PRIMARY KEY (EventId)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
|
||||
ON AuditLog (ForwardState, OccurredAtUtc);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
|
||||
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
|
||||
// table that already exists from a pre-ExecutionId build, so an
|
||||
// auditlog.db created by an older build needs the column ALTER-ed in.
|
||||
// The file is durable across restart/failover by design (7-day
|
||||
// retention), so without this step every WriteAsync on an upgraded
|
||||
// deployment would bind $ExecutionId against a missing column and the
|
||||
// best-effort write path would silently drop every site audit row.
|
||||
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
|
||||
// probed first and the ALTER skipped when already there. The column is
|
||||
// nullable with no default, so any row written before this migration
|
||||
// reads back ExecutionId = null (back-compat).
|
||||
AddColumnIfMissing("ExecutionId", "TEXT NULL");
|
||||
|
||||
// Audit Log #23 (ParentExecutionId): same idempotent upgrade path as
|
||||
// ExecutionId above. A deployment that already ran the ExecutionId
|
||||
// branch has an auditlog.db with the 21-column schema and no
|
||||
// ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it,
|
||||
// so it is ALTER-ed in here. Nullable with no default — rows written
|
||||
// before this migration read back ParentExecutionId = null.
|
||||
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
|
||||
|
||||
// SourceNode stamping: same idempotent upgrade path as ExecutionId /
|
||||
// ParentExecutionId above. A deployment that already ran the
|
||||
// ParentExecutionId branch has an auditlog.db with the 22-column
|
||||
// schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot
|
||||
// add it, so it is ALTER-ed in here. Nullable with no default — rows
|
||||
// written before this migration read back SourceNode = null.
|
||||
AddColumnIfMissing("SourceNode", "TEXT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
|
||||
/// it is not already present (used for <c>ExecutionId</c> and
|
||||
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
|
||||
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
|
||||
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
|
||||
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
|
||||
/// here to match the rest of this writer's bootstrap DDL.
|
||||
/// </summary>
|
||||
private void AddColumnIfMissing(string columnName, string columnDefinition)
|
||||
{
|
||||
using var probe = _connection.CreateCommand();
|
||||
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
|
||||
probe.Parameters.AddWithValue("$name", columnName);
|
||||
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
|
||||
if (exists)
|
||||
// Enable FK enforcement on the WRITE connection. PRAGMA foreign_keys is
|
||||
// a per-connection, per-session setting in SQLite — it is NOT persisted
|
||||
// in the database file, so every new connection that may INSERT into
|
||||
// audit_forward_state must set it for the FK
|
||||
// audit_forward_state.EventId → audit_event.EventId
|
||||
// to be a real runtime guard rather than decorative DDL. The write
|
||||
// connection owns all INSERTs (and the MarkForwardedAsync /
|
||||
// MarkReconciledAsync UPDATEs), so setting it here — after WAL is
|
||||
// established, before the CREATE TABLEs — ensures the FK is live for
|
||||
// every insert that follows. The existing insert order (audit_event
|
||||
// first, then audit_forward_state, inside the same transaction) already
|
||||
// satisfies the FK, so no pre-existing rows can violate the constraint.
|
||||
using (var pragmaCmd = _connection.CreateCommand())
|
||||
{
|
||||
return;
|
||||
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON";
|
||||
pragmaCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
// Column name + definition are caller-controlled constants, never user
|
||||
// input — safe to interpolate (parameters are not permitted in DDL).
|
||||
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
|
||||
alter.ExecuteNonQuery();
|
||||
// C4 (Task 2.5) — in-place reset. The site store is EPHEMERAL (≈7-day
|
||||
// retention, recreated per deployment), so we do NOT migrate the old
|
||||
// single 24-column AuditLog table to the new two-table shape: any rows
|
||||
// it holds are within the retention window and discarded. DROP it if a
|
||||
// pre-C4 deployment left it behind, then CREATE the two new tables. This
|
||||
// is safe precisely BECAUSE the site store is ephemeral — never do this
|
||||
// on a durable store (the central SQL Server side keeps its shim until
|
||||
// C5 and is migrated, not reset).
|
||||
using (var dropCmd = _connection.CreateCommand())
|
||||
{
|
||||
dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;";
|
||||
dropCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
-- Canonical, append-only / write-once: the 10 fields of the canonical
|
||||
-- ZB.MOM.WW.Audit.AuditEvent stored directly (DetailsJson carries the
|
||||
-- ScadaBridge domain fields). No forwarding state lives here — that is
|
||||
-- the audit_forward_state sidecar's concern.
|
||||
CREATE TABLE IF NOT EXISTS audit_event (
|
||||
EventId TEXT NOT NULL,
|
||||
OccurredAtUtc TEXT NOT NULL,
|
||||
Actor TEXT NOT NULL,
|
||||
Action TEXT NOT NULL,
|
||||
Outcome TEXT NOT NULL,
|
||||
Category TEXT NULL,
|
||||
Target TEXT NULL,
|
||||
SourceNode TEXT NULL,
|
||||
CorrelationId TEXT NULL,
|
||||
DetailsJson TEXT NULL,
|
||||
PRIMARY KEY (EventId)
|
||||
);
|
||||
|
||||
-- Operational, mutable: the forwarding lifecycle for each canonical
|
||||
-- row. OccurredAtUtc is duplicated here so the drain range-scan stays
|
||||
-- on this one table's index; IsCachedKind is precomputed at insert so
|
||||
-- the cached/non-cached drain split never re-parses DetailsJson on the
|
||||
-- read hot-path.
|
||||
CREATE TABLE IF NOT EXISTS audit_forward_state (
|
||||
EventId TEXT NOT NULL,
|
||||
ForwardState TEXT NOT NULL,
|
||||
OccurredAtUtc TEXT NOT NULL,
|
||||
IsCachedKind INTEGER NOT NULL,
|
||||
AttemptCount INTEGER NOT NULL DEFAULT 0,
|
||||
LastAttemptUtc TEXT NULL,
|
||||
PRIMARY KEY (EventId),
|
||||
FOREIGN KEY (EventId) REFERENCES audit_event(EventId)
|
||||
);
|
||||
|
||||
-- Drain index: every read filters on (ForwardState, IsCachedKind) and
|
||||
-- range-scans/orders by OccurredAtUtc, so this composite covers the
|
||||
-- four reads + the backlog COUNT/MIN.
|
||||
CREATE INDEX IF NOT EXISTS IX_fwd
|
||||
ON audit_forward_state (ForwardState, IsCachedKind, OccurredAtUtc);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -236,14 +249,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
// Site rows always carry a non-null ForwardState; central rows leave it
|
||||
// null. Force Pending on enqueue so callers can pass a bare AuditEvent
|
||||
// without thinking about site-vs-central provenance.
|
||||
var siteEvt = evt.ForwardState is null
|
||||
? evt with { ForwardState = AuditForwardState.Pending }
|
||||
: evt;
|
||||
|
||||
var pending = new PendingAuditEvent(siteEvt);
|
||||
// The canonical record carries no ForwardState (a site-storage-only
|
||||
// concern). Site rows always start Pending; the sidecar row is written
|
||||
// alongside the canonical row in the same transaction.
|
||||
var pending = new PendingAuditEvent(evt, AuditForwardState.Pending);
|
||||
|
||||
// CreateBounded(FullMode=Wait) means WriteAsync will await room rather
|
||||
// than throw when full — exactly the hot-path back-pressure semantics
|
||||
@@ -316,96 +325,99 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.Transaction = transaction;
|
||||
cmd.CommandText = """
|
||||
INSERT INTO AuditLog (
|
||||
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
// INSERT 1: the canonical row, stored DIRECTLY (the 10 canonical
|
||||
// fields straight off the AuditEvent — no Decompose; audit_event
|
||||
// holds canonical shape, not the legacy 24-column shape).
|
||||
using var eventCmd = _connection.CreateCommand();
|
||||
eventCmd.Transaction = transaction;
|
||||
eventCmd.CommandText = """
|
||||
INSERT INTO audit_event (
|
||||
EventId, OccurredAtUtc, Actor, Action, Outcome,
|
||||
Category, Target, SourceNode, CorrelationId, DetailsJson
|
||||
) VALUES (
|
||||
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
|
||||
$SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target,
|
||||
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
|
||||
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
|
||||
$ExecutionId, $ParentExecutionId
|
||||
$EventId, $OccurredAtUtc, $Actor, $Action, $Outcome,
|
||||
$Category, $Target, $SourceNode, $CorrelationId, $DetailsJson
|
||||
);
|
||||
""";
|
||||
var eEventId = eventCmd.Parameters.Add("$EventId", SqliteType.Text);
|
||||
var eOccurredAt = eventCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
|
||||
var eActor = eventCmd.Parameters.Add("$Actor", SqliteType.Text);
|
||||
var eAction = eventCmd.Parameters.Add("$Action", SqliteType.Text);
|
||||
var eOutcome = eventCmd.Parameters.Add("$Outcome", SqliteType.Text);
|
||||
var eCategory = eventCmd.Parameters.Add("$Category", SqliteType.Text);
|
||||
var eTarget = eventCmd.Parameters.Add("$Target", SqliteType.Text);
|
||||
var eSourceNode = eventCmd.Parameters.Add("$SourceNode", SqliteType.Text);
|
||||
var eCorrelationId = eventCmd.Parameters.Add("$CorrelationId", SqliteType.Text);
|
||||
var eDetailsJson = eventCmd.Parameters.Add("$DetailsJson", SqliteType.Text);
|
||||
|
||||
var pEventId = cmd.Parameters.Add("$EventId", SqliteType.Text);
|
||||
var pOccurredAt = cmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
|
||||
var pChannel = cmd.Parameters.Add("$Channel", SqliteType.Text);
|
||||
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text);
|
||||
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text);
|
||||
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text);
|
||||
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text);
|
||||
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text);
|
||||
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text);
|
||||
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text);
|
||||
var pTarget = cmd.Parameters.Add("$Target", SqliteType.Text);
|
||||
var pStatus = cmd.Parameters.Add("$Status", SqliteType.Text);
|
||||
var pHttpStatus = cmd.Parameters.Add("$HttpStatus", SqliteType.Integer);
|
||||
var pDurationMs = cmd.Parameters.Add("$DurationMs", SqliteType.Integer);
|
||||
var pErrorMessage = cmd.Parameters.Add("$ErrorMessage", SqliteType.Text);
|
||||
var pErrorDetail = cmd.Parameters.Add("$ErrorDetail", SqliteType.Text);
|
||||
var pRequestSummary = cmd.Parameters.Add("$RequestSummary", SqliteType.Text);
|
||||
var pResponseSummary = cmd.Parameters.Add("$ResponseSummary", SqliteType.Text);
|
||||
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
|
||||
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
|
||||
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
||||
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
|
||||
var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text);
|
||||
// INSERT 2: the operational sidecar row. ForwardState=Pending,
|
||||
// OccurredAtUtc duplicated for the drain index, IsCachedKind
|
||||
// precomputed (so the read split never parses DetailsJson),
|
||||
// AttemptCount=0, LastAttemptUtc=NULL.
|
||||
using var fwdCmd = _connection.CreateCommand();
|
||||
fwdCmd.Transaction = transaction;
|
||||
fwdCmd.CommandText = """
|
||||
INSERT INTO audit_forward_state (
|
||||
EventId, ForwardState, OccurredAtUtc, IsCachedKind, AttemptCount, LastAttemptUtc
|
||||
) VALUES (
|
||||
$EventId, $ForwardState, $OccurredAtUtc, $IsCachedKind, 0, NULL
|
||||
);
|
||||
""";
|
||||
var fEventId = fwdCmd.Parameters.Add("$EventId", SqliteType.Text);
|
||||
var fForwardState = fwdCmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
||||
var fOccurredAt = fwdCmd.Parameters.Add("$OccurredAtUtc", SqliteType.Text);
|
||||
var fIsCachedKind = fwdCmd.Parameters.Add("$IsCachedKind", SqliteType.Integer);
|
||||
|
||||
foreach (var pending in batch)
|
||||
{
|
||||
var e = pending.Event;
|
||||
pEventId.Value = e.EventId.ToString();
|
||||
pOccurredAt.Value = e.OccurredAtUtc.ToString("o");
|
||||
pChannel.Value = e.Channel.ToString();
|
||||
pKind.Value = e.Kind.ToString();
|
||||
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
|
||||
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
|
||||
var evt = pending.Event;
|
||||
// Canonical OccurredAtUtc is UTC by construction; store the
|
||||
// round-trip "o" form so string comparison stays monotonic
|
||||
// (the drain range-scan and ORDER BY rely on it).
|
||||
var occurredText = evt.OccurredAtUtc.UtcDateTime.ToString(
|
||||
"o", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
eEventId.Value = evt.EventId.ToString();
|
||||
eOccurredAt.Value = occurredText;
|
||||
// Canonical Actor is a required non-null string.
|
||||
eActor.Value = evt.Actor ?? string.Empty;
|
||||
eAction.Value = evt.Action;
|
||||
eOutcome.Value = evt.Outcome.ToString();
|
||||
eCategory.Value = (object?)evt.Category ?? DBNull.Value;
|
||||
eTarget.Value = (object?)evt.Target ?? DBNull.Value;
|
||||
// SourceNode-stamping: caller-provided value wins (preserves
|
||||
// rows reconciled in from other nodes via the same writer);
|
||||
// otherwise stamp from the local INodeIdentityProvider. The
|
||||
// event record itself is NOT mutated — stamping is at write
|
||||
// time only. If the provider also returns null (unconfigured
|
||||
// node), the row's SourceNode stays NULL — operators see
|
||||
// "needs config" via the schema, not a magic fallback string.
|
||||
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
|
||||
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
||||
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
|
||||
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
|
||||
pActor.Value = (object?)e.Actor ?? DBNull.Value;
|
||||
pTarget.Value = (object?)e.Target ?? DBNull.Value;
|
||||
pStatus.Value = e.Status.ToString();
|
||||
pHttpStatus.Value = (object?)e.HttpStatus ?? DBNull.Value;
|
||||
pDurationMs.Value = (object?)e.DurationMs ?? DBNull.Value;
|
||||
pErrorMessage.Value = (object?)e.ErrorMessage ?? DBNull.Value;
|
||||
pErrorDetail.Value = (object?)e.ErrorDetail ?? DBNull.Value;
|
||||
pRequestSummary.Value = (object?)e.RequestSummary ?? DBNull.Value;
|
||||
pResponseSummary.Value = (object?)e.ResponseSummary ?? DBNull.Value;
|
||||
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
||||
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
||||
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
||||
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
|
||||
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
|
||||
// node), the column stays NULL — operators see "needs config"
|
||||
// via the schema, not a magic fallback string.
|
||||
var sourceNode = evt.SourceNode ?? _nodeIdentity.NodeName;
|
||||
eSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
||||
eCorrelationId.Value = (object?)evt.CorrelationId?.ToString() ?? DBNull.Value;
|
||||
eDetailsJson.Value = (object?)evt.DetailsJson ?? DBNull.Value;
|
||||
|
||||
fEventId.Value = evt.EventId.ToString();
|
||||
fForwardState.Value = pending.ForwardState.ToString();
|
||||
fOccurredAt.Value = occurredText;
|
||||
fIsCachedKind.Value = IsCachedKind(evt.DetailsJson) ? 1 : 0;
|
||||
|
||||
try
|
||||
{
|
||||
cmd.ExecuteNonQuery();
|
||||
eventCmd.ExecuteNonQuery();
|
||||
fwdCmd.ExecuteNonQuery();
|
||||
pending.Completion.TrySetResult();
|
||||
}
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == SqliteErrorConstraint)
|
||||
{
|
||||
// Duplicate EventId — first-write-wins (alog.md §11).
|
||||
// Treat as success: the lifecycle event is durably
|
||||
// recorded under the first writer's payload.
|
||||
// Duplicate EventId — first-write-wins (alog.md §11). The
|
||||
// audit_event PRIMARY KEY throws before the sidecar insert
|
||||
// runs, so neither table gains a second row. Treat as
|
||||
// success: the lifecycle event is durably recorded under
|
||||
// the first writer's payload.
|
||||
_logger.LogDebug(ex,
|
||||
"Duplicate EventId {EventId} swallowed by SqliteAuditWriter",
|
||||
e.EventId);
|
||||
evt.EventId);
|
||||
pending.Completion.TrySetResult();
|
||||
}
|
||||
}
|
||||
@@ -427,17 +439,36 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry
|
||||
// drain (joined with the operational tracking row + pushed via
|
||||
// IngestCachedTelemetryAsync into the central dual-write transaction).
|
||||
// ReadPendingAsync EXCLUDES these so the audit-only drain doesn't double-emit
|
||||
// them; ReadPendingCachedTelemetryAsync below is the dedicated read surface
|
||||
// the new SiteAuditTelemetryActor cached-drain uses.
|
||||
private static readonly string[] CachedTelemetryKindNames =
|
||||
// C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query
|
||||
// filtered on (Kind IN (...)); it is now precomputed into the sidecar's
|
||||
// IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
|
||||
// integer predicate, not a JSON parse. ReadPendingAsync drains everything
|
||||
// with IsCachedKind=0; ReadPendingCachedTelemetryAsync drains IsCachedKind=1.
|
||||
private static readonly HashSet<AuditKind> CachedTelemetryKinds = new()
|
||||
{
|
||||
nameof(AuditKind.CachedSubmit),
|
||||
nameof(AuditKind.ApiCallCached),
|
||||
nameof(AuditKind.DbWriteCached),
|
||||
nameof(AuditKind.CachedResolve),
|
||||
AuditKind.CachedSubmit,
|
||||
AuditKind.ApiCallCached,
|
||||
AuditKind.DbWriteCached,
|
||||
AuditKind.CachedResolve,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// C4: precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical
|
||||
/// row's <c>DetailsJson</c>. Parses the <see cref="AuditDetails.Kind"/>
|
||||
/// discriminator via <see cref="AuditDetailsCodec"/> and returns <c>true</c>
|
||||
/// iff it is one of the cached-lifecycle kinds
|
||||
/// (<see cref="AuditKind.CachedSubmit"/>, <see cref="AuditKind.ApiCallCached"/>,
|
||||
/// <see cref="AuditKind.DbWriteCached"/>, <see cref="AuditKind.CachedResolve"/>).
|
||||
/// Runs once per event at INSERT time so the cached/non-cached drain split is
|
||||
/// a cheap integer predicate on read, never a JSON parse on the hot path.
|
||||
/// </summary>
|
||||
private static bool IsCachedKind(string? detailsJson)
|
||||
{
|
||||
var details = AuditDetailsCodec.Deserialize(detailsJson);
|
||||
var kind = AuditRowProjection.ParseEnum(details.Kind, AuditKind.InboundRequest);
|
||||
return CachedTelemetryKinds.Contains(kind);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
|
||||
{
|
||||
@@ -449,47 +480,35 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// AuditLog-005: read via the dedicated _readConnection so this scan
|
||||
// (which can be expensive when the backlog grows under a central
|
||||
// outage) does not block the batched writer on _writeLock. WAL mode
|
||||
// gives us a stable snapshot of the table while writes proceed on the
|
||||
// gives us a stable snapshot of the tables while writes proceed on the
|
||||
// writer connection. _readLock serialises this connection across
|
||||
// multiple concurrent read callers since SqliteConnection itself is
|
||||
// not thread-safe.
|
||||
// AuditLog-001: NOT IN ($cached1,$cached2,$cached3,$cached4) excludes the
|
||||
// cached-lifecycle kinds — they flow through ReadPendingCachedTelemetryAsync
|
||||
// + the combined-telemetry drain. Kind is stored as the enum's name (see
|
||||
// FlushBatch's pKind.Value), so a string-IN against the constant kind
|
||||
// names matches the on-disk shape exactly.
|
||||
// C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached-
|
||||
// lifecycle kinds (IsCachedKind=1) flow through
|
||||
// ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
|
||||
// split is a precomputed integer predicate on the indexed sidecar, not
|
||||
// a DetailsJson parse. Ordering is by the sidecar's OccurredAtUtc with
|
||||
// EventId as the deterministic tiebreaker.
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $pending
|
||||
AND Kind NOT IN ($k0, $k1, $k2, $k3)
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState = $pending
|
||||
AND fs.IsCachedKind = 0
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
|
||||
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
|
||||
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
|
||||
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,42 +521,29 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
|
||||
}
|
||||
|
||||
// AuditLog-001: dedicated read surface for the cached-call lifecycle
|
||||
// drain — symmetric to ReadPendingAsync but filtered to the four
|
||||
// cached AuditKinds. Same _readConnection + _readLock pattern so the
|
||||
// hot-path writer is not contended.
|
||||
// AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle
|
||||
// drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
|
||||
// Same _readConnection + _readLock pattern so the hot-path writer is not
|
||||
// contended.
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $pending
|
||||
AND Kind IN ($k0, $k1, $k2, $k3)
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState = $pending
|
||||
AND fs.IsCachedKind = 1
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
cmd.Parameters.AddWithValue("$k0", CachedTelemetryKindNames[0]);
|
||||
cmd.Parameters.AddWithValue("$k1", CachedTelemetryKindNames[1]);
|
||||
cmd.Parameters.AddWithValue("$k2", CachedTelemetryKindNames[2]);
|
||||
cmd.Parameters.AddWithValue("$k3", CachedTelemetryKindNames[3]);
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,34 +569,27 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
|
||||
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
|
||||
// _readLock so this query never contends with the batched writer on
|
||||
// _writeLock.
|
||||
// _writeLock. C4: JOIN the sidecar and filter on ForwardState='Forwarded'
|
||||
// (no IsCachedKind split — both cached and non-cached Forwarded rows are
|
||||
// returned, as before).
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $forwarded
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState = $forwarded
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,11 +607,25 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
// Build a single IN (...) parameter list so we issue one UPDATE per
|
||||
// batch regardless of size. Each id is bound as its own parameter,
|
||||
// so no string concatenation of user data ever enters the SQL.
|
||||
// C4: flip the sidecar — UPDATE audit_forward_state, not the canonical
|
||||
// audit_event (which is append-only / write-once). Bump AttemptCount +
|
||||
// stamp LastAttemptUtc so operators can see how many drain passes a row
|
||||
// took to forward. Build a single IN (...) parameter list so we issue
|
||||
// one UPDATE per batch regardless of size. Each id is bound as its own
|
||||
// parameter, so no string concatenation of user data ever enters the SQL.
|
||||
//
|
||||
// Defensive state guard: only transition rows that are still Pending or
|
||||
// Forwarded (i.e. not yet Reconciled). Without this guard a mis-called
|
||||
// batch that includes a Reconciled EventId would silently demote it back
|
||||
// to Forwarded — a state regression that would cause duplicate central
|
||||
// ingestion. Symmetric with MarkReconciledAsync's
|
||||
// WHERE ForwardState IN ($pending, $forwarded)
|
||||
// guard. Current callers only pass Pending IDs, so normal-path behaviour
|
||||
// is unchanged; the guard is purely defensive.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("UPDATE AuditLog SET ForwardState = $forwarded WHERE EventId IN (");
|
||||
sb.Append("UPDATE audit_forward_state SET ForwardState = $forwarded, ")
|
||||
.Append("AttemptCount = AttemptCount + 1, LastAttemptUtc = $now ")
|
||||
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
||||
for (int i = 0; i < eventIds.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
@@ -623,6 +636,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
sb.Append(");");
|
||||
cmd.CommandText = sb.ToString();
|
||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString(
|
||||
"o", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
return Task.CompletedTask;
|
||||
@@ -639,22 +655,24 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
}
|
||||
|
||||
// AuditLog-005: read via _readConnection / _readLock — same lock-
|
||||
// decoupling as ReadPendingAsync.
|
||||
// decoupling as ReadPendingAsync. C4: JOIN the sidecar; the range scan
|
||||
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
|
||||
// Both Pending and Forwarded rows are returned (the central reconciliation
|
||||
// puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested
|
||||
// row is safe).
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId, ParentExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState IN ($pending, $forwarded)
|
||||
AND OccurredAtUtc >= $since
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
SELECT ae.EventId, ae.OccurredAtUtc, ae.Actor, ae.Action, ae.Outcome,
|
||||
ae.Category, ae.Target, ae.SourceNode, ae.CorrelationId, ae.DetailsJson
|
||||
FROM audit_event ae
|
||||
JOIN audit_forward_state fs ON fs.EventId = ae.EventId
|
||||
WHERE fs.ForwardState IN ($pending, $forwarded)
|
||||
AND fs.OccurredAtUtc >= $since
|
||||
ORDER BY fs.OccurredAtUtc ASC, ae.EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
@@ -666,14 +684,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
"o", System.Globalization.CultureInfo.InvariantCulture));
|
||||
cmd.Parameters.AddWithValue("$limit", batchSize);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(batchSize, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
return Task.FromResult(ReadRows(cmd, batchSize));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,8 +702,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
// C4: flip the sidecar from Pending/Forwarded → Reconciled. Rows
|
||||
// already Reconciled are left untouched (idempotent re-call), and the
|
||||
// canonical audit_event row is never modified.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("UPDATE AuditLog SET ForwardState = $reconciled ")
|
||||
sb.Append("UPDATE audit_forward_state SET ForwardState = $reconciled ")
|
||||
.Append("WHERE ForwardState IN ($pending, $forwarded) AND EventId IN (");
|
||||
for (int i = 0; i < eventIds.Count; i++)
|
||||
{
|
||||
@@ -724,18 +738,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
// central outage the Pending backlog can grow to hundreds of thousands
|
||||
// of rows and the COUNT(*) scan correspondingly stretches; that no
|
||||
// longer adds tail latency to user-facing audit writes.
|
||||
// C4: count over the sidecar (audit_forward_state) — the canonical
|
||||
// audit_event table carries no ForwardState. The IX_fwd index makes both
|
||||
// aggregates cheap (count is a covering scan, min is the first key).
|
||||
lock (_readLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Single round-trip — COUNT(*) + MIN(OccurredAtUtc) over the same
|
||||
// index range avoids a second scan. The IX_SiteAuditLog_ForwardState_Occurred
|
||||
// index makes both aggregates cheap (count is a covering scan, min
|
||||
// is the first key).
|
||||
using var cmd = _readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT COUNT(*), MIN(OccurredAtUtc)
|
||||
FROM AuditLog
|
||||
FROM audit_forward_state
|
||||
WHERE ForwardState = $pending;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$pending", AuditForwardState.Pending.ToString());
|
||||
@@ -786,35 +799,48 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
? value
|
||||
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// Executes <paramref name="cmd"/> (one of the four reads, each already
|
||||
/// projecting the 10 <c>audit_event</c> columns in canonical order) and
|
||||
/// materialises the rows via <see cref="MapRow"/>.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<AuditEvent> ReadRows(SqliteCommand cmd, int capacityHint)
|
||||
{
|
||||
var rows = new List<AuditEvent>(Math.Min(capacityHint, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4: builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
|
||||
/// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because
|
||||
/// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>.
|
||||
/// <c>Outcome</c> is stored as the enum's name; the safe
|
||||
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> degrades an unknown/renamed
|
||||
/// value gracefully rather than throwing.
|
||||
/// </summary>
|
||||
private static AuditEvent MapRow(SqliteDataReader reader)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse(reader.GetString(0)),
|
||||
OccurredAtUtc = DateTime.Parse(reader.GetString(1),
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind),
|
||||
Channel = Enum.Parse<AuditChannel>(reader.GetString(2)),
|
||||
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
|
||||
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
|
||||
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
|
||||
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
|
||||
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
|
||||
PayloadTruncated = reader.GetInt32(18) != 0,
|
||||
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
|
||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
|
||||
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
||||
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
|
||||
OccurredAtUtc = new DateTimeOffset(DateTime.SpecifyKind(
|
||||
DateTime.Parse(reader.GetString(1),
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind),
|
||||
DateTimeKind.Utc)),
|
||||
Actor = reader.GetString(2),
|
||||
Action = reader.GetString(3),
|
||||
Outcome = AuditRowProjection.ParseEnum(reader.GetString(4), AuditOutcome.Success),
|
||||
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
|
||||
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -898,15 +924,19 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
private sealed class PendingAuditEvent
|
||||
{
|
||||
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
|
||||
/// <param name="evt">The audit event to persist.</param>
|
||||
public PendingAuditEvent(AuditEvent evt)
|
||||
/// <param name="evt">The canonical audit event to persist.</param>
|
||||
/// <param name="forwardState">Initial site-local forwarding state written to the sidecar row (always Pending for fresh events).</param>
|
||||
public PendingAuditEvent(AuditEvent evt, AuditForwardState forwardState)
|
||||
{
|
||||
Event = evt;
|
||||
ForwardState = forwardState;
|
||||
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
/// <summary>The audit event to persist.</summary>
|
||||
/// <summary>The canonical audit event to persist.</summary>
|
||||
public AuditEvent Event { get; }
|
||||
/// <summary>Initial forwarding state for this row's sidecar (bound to audit_forward_state.ForwardState).</summary>
|
||||
public AuditForwardState ForwardState { get; }
|
||||
/// <summary>Task completion source for write completion signaling.</summary>
|
||||
public TaskCompletionSource Completion { get; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
@@ -141,37 +141,33 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
||||
var channel = ChannelStringToEnum(context.Channel);
|
||||
|
||||
return new CachedCallTelemetry(
|
||||
Audit: new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||
Channel = channel,
|
||||
Kind = kind,
|
||||
CorrelationId = context.TrackedOperationId.Value,
|
||||
Audit: ScadaBridgeAuditEventFactory.Create(
|
||||
channel: channel,
|
||||
kind: kind,
|
||||
status: status,
|
||||
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
|
||||
target: context.Target,
|
||||
correlationId: context.TrackedOperationId.Value,
|
||||
// Audit Log #23 (ExecutionId Task 4): the originating script
|
||||
// execution's per-run correlation id, threaded through the S&F
|
||||
// buffer; null on rows buffered before Task 4 (back-compat).
|
||||
ExecutionId = context.ExecutionId,
|
||||
executionId: context.ExecutionId,
|
||||
// Audit Log #23 (ParentExecutionId Task 6): the spawning
|
||||
// inbound-API request's ExecutionId, threaded through the S&F
|
||||
// buffer alongside ExecutionId so the retry-loop cached rows
|
||||
// correlate back to the cross-execution chain. Null for a
|
||||
// non-routed run and on rows buffered before Task 6.
|
||||
ParentExecutionId = context.ParentExecutionId,
|
||||
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||
SourceInstanceId = context.SourceInstanceId,
|
||||
parentExecutionId: context.ParentExecutionId,
|
||||
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||
sourceInstanceId: context.SourceInstanceId,
|
||||
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
|
||||
// threaded through the S&F buffer alongside ExecutionId — the
|
||||
// retry-loop cached rows carry the same provenance the
|
||||
// script-side cached rows do. Null on pre-Task-4 buffered rows.
|
||||
SourceScript = context.SourceScript,
|
||||
Target = context.Target,
|
||||
Status = status,
|
||||
HttpStatus = httpStatus,
|
||||
DurationMs = context.DurationMs,
|
||||
ErrorMessage = lastError,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
},
|
||||
sourceScript: context.SourceScript,
|
||||
httpStatus: httpStatus,
|
||||
durationMs: context.DurationMs,
|
||||
errorMessage: lastError),
|
||||
Operational: new SiteCallOperational(
|
||||
TrackedOperationId: context.TrackedOperationId,
|
||||
Channel: context.Channel,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
@@ -111,9 +111,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
// FallbackAuditWriter) handles transient writer failures upstream;
|
||||
// a throw bubbling up here means the writer's own swallow contract
|
||||
// failed, which is itself best-effort-handled.
|
||||
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them.
|
||||
var d = AuditRowProjection.Decompose(telemetry.Audit);
|
||||
_logger.LogWarning(ex,
|
||||
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
|
||||
telemetry.Audit.EventId, telemetry.Audit.Kind, telemetry.Audit.Status);
|
||||
d.EventId, d.Kind, d.Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +130,12 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
return;
|
||||
}
|
||||
|
||||
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript)
|
||||
// ride inside DetailsJson — decompose once for this packet.
|
||||
var audit = AuditRowProjection.Decompose(telemetry.Audit);
|
||||
try
|
||||
{
|
||||
switch (telemetry.Audit.Kind)
|
||||
switch (audit.Kind)
|
||||
{
|
||||
case AuditKind.CachedSubmit:
|
||||
// Enqueue — insert-if-not-exists with the operational
|
||||
@@ -144,8 +149,8 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
telemetry.Operational.TrackedOperationId,
|
||||
telemetry.Operational.Channel,
|
||||
telemetry.Operational.Target,
|
||||
telemetry.Audit.SourceInstanceId,
|
||||
telemetry.Audit.SourceScript,
|
||||
audit.SourceInstanceId,
|
||||
audit.SourceScript,
|
||||
sourceNode: _nodeIdentity?.NodeName,
|
||||
ct).ConfigureAwait(false);
|
||||
break;
|
||||
@@ -180,7 +185,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||
// forwarder.
|
||||
_logger.LogWarning(
|
||||
"CachedCallTelemetryForwarder: unexpected audit kind {Kind} on tracking emission for EventId {EventId}",
|
||||
telemetry.Audit.Kind, telemetry.Audit.EventId);
|
||||
audit.Kind, audit.EventId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ using Akka.Actor;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
|
||||
@@ -259,8 +260,8 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
// row stays Pending (still not in emittedEventIds) and
|
||||
// central reconciliation will pick it up.
|
||||
_logger.LogWarning(
|
||||
"Cached-telemetry drain: audit row {EventId} ({Kind}) has no CorrelationId; skipping.",
|
||||
auditRow.EventId, auditRow.Kind);
|
||||
"Cached-telemetry drain: audit row {EventId} ({Action}) has no CorrelationId; skipping.",
|
||||
auditRow.EventId, auditRow.Action);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -363,10 +364,13 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
private static CachedTelemetryPacket BuildCachedPacket(
|
||||
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
|
||||
{
|
||||
var sourceSite = auditRow.SourceSiteId ?? string.Empty;
|
||||
// C3: SourceSiteId + Channel ride inside the canonical record's
|
||||
// DetailsJson — decompose to read them.
|
||||
var audit = AuditRowProjection.Decompose(auditRow);
|
||||
var sourceSite = audit.SourceSiteId ?? string.Empty;
|
||||
// Channel string form mirrors the AuditChannel-to-string convention used
|
||||
// by SiteCallOperational + CachedCallLifecycleBridge.BuildPacket.
|
||||
var channelString = auditRow.Channel.ToString();
|
||||
var channelString = audit.Channel.ToString();
|
||||
var target = auditRow.Target ?? snapshot.TargetSummary ?? string.Empty;
|
||||
|
||||
var operationalDto = new SiteCallOperationalDto
|
||||
|
||||
@@ -61,7 +61,9 @@ public static class BundleCommands
|
||||
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
|
||||
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
|
||||
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
|
||||
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names");
|
||||
// Inbound API keys are not transported between environments (re-arch C4) — no
|
||||
// --api-keys option. Re-create keys and re-grant their method scopes on the
|
||||
// destination via the admin UI/CLI.
|
||||
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
|
||||
var includeDepsOption = new Option<bool>("--include-dependencies")
|
||||
{
|
||||
@@ -85,7 +87,6 @@ public static class BundleCommands
|
||||
cmd.Add(dbConnectionsOption);
|
||||
cmd.Add(notificationListsOption);
|
||||
cmd.Add(smtpConfigsOption);
|
||||
cmd.Add(apiKeysOption);
|
||||
cmd.Add(apiMethodsOption);
|
||||
cmd.Add(includeDepsOption);
|
||||
cmd.Add(sourceEnvOption);
|
||||
@@ -106,7 +107,6 @@ public static class BundleCommands
|
||||
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
|
||||
NotificationListNames: result.GetValue(notificationListsOption),
|
||||
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
|
||||
ApiKeyNames: result.GetValue(apiKeysOption),
|
||||
ApiMethodNames: result.GetValue(apiMethodsOption),
|
||||
IncludeDependencies: includeDeps,
|
||||
Passphrase: passphrase,
|
||||
|
||||
@@ -37,44 +37,108 @@ public static class SecurityCommands
|
||||
group.Add(listCmd);
|
||||
|
||||
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true };
|
||||
var createMethodsOption = new Option<string>("--methods")
|
||||
{
|
||||
Description = "Comma-separated API method names this key may call (e.g. \"MethodA,MethodB\")",
|
||||
Required = true
|
||||
};
|
||||
var createCmd = new Command("create") { Description = "Create an API key" };
|
||||
createCmd.Add(nameOption);
|
||||
createCmd.Add(createMethodsOption);
|
||||
createCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var methods = ParseMethods(result.GetValue(createMethodsOption));
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name));
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateApiKeyCommand(name, methods),
|
||||
onSuccess: PrintCreatedKey);
|
||||
});
|
||||
group.Add(createCmd);
|
||||
|
||||
var idOption = new Option<int>("--id") { Description = "API key ID", Required = true };
|
||||
var deleteKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
|
||||
var deleteCmd = new Command("delete") { Description = "Delete an API key" };
|
||||
deleteCmd.Add(idOption);
|
||||
deleteCmd.Add(deleteKeyIdOption);
|
||||
deleteCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var keyId = result.GetValue(deleteKeyIdOption)!;
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id));
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(keyId));
|
||||
});
|
||||
group.Add(deleteCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "API key ID", Required = true };
|
||||
var updateKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
|
||||
var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
|
||||
var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateKeyIdOption);
|
||||
updateCmd.Add(enabledOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(updateIdOption);
|
||||
var keyId = result.GetValue(updateKeyIdOption)!;
|
||||
var enabled = result.GetValue(enabledOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled));
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(keyId, enabled));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
var setMethodsKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
|
||||
var setMethodsOption = new Option<string>("--methods")
|
||||
{
|
||||
Description = "Comma-separated API method names this key may call (replaces the existing set)",
|
||||
Required = true
|
||||
};
|
||||
var setMethodsCmd = new Command("set-methods") { Description = "Replace the method-scopes on an API key" };
|
||||
setMethodsCmd.Add(setMethodsKeyIdOption);
|
||||
setMethodsCmd.Add(setMethodsOption);
|
||||
setMethodsCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var keyId = result.GetValue(setMethodsKeyIdOption)!;
|
||||
var methods = ParseMethods(result.GetValue(setMethodsOption));
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetApiKeyMethodsCommand(keyId, methods));
|
||||
});
|
||||
group.Add(setMethodsCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated <c>--methods</c> value into a trimmed, non-empty list of
|
||||
/// method names. A null/empty value yields an empty list (the server rejects an empty
|
||||
/// scope set if its rules require one).
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw delimited option value.</param>
|
||||
private static IReadOnlyList<string> ParseMethods(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return Array.Empty<string>();
|
||||
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the create-key response, surfacing the one-time bearer token prominently —
|
||||
/// it is the only moment the secret is available and cannot be retrieved afterwards.
|
||||
/// The advisory line is written to stderr so that piping stdout captures only the token.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON success body returned by the management API.</param>
|
||||
internal static int PrintCreatedKey(string json)
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var keyId = root.TryGetProperty("keyId", out var k) ? k.GetString() : null;
|
||||
var token = root.TryGetProperty("token", out var t) ? t.GetString() : null;
|
||||
|
||||
Console.WriteLine($"API key created. KeyId: {keyId}");
|
||||
Console.WriteLine();
|
||||
Console.Error.WriteLine("Save this token now — it will not be shown again:");
|
||||
Console.WriteLine($" {token}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Command BuildRoleMapping(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
|
||||
|
||||
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
@@ -31,20 +33,35 @@ public static class AuthEndpoints
|
||||
return;
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
|
||||
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
|
||||
context.Response.Redirect($"/login?error={errorMsg}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Map LDAP groups to roles
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
|
||||
// (Task 1.1 ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
|
||||
// The full RoleMappingResult — including PermittedSiteIds and the
|
||||
// system-wide flag — is carried in the mapping's opaque Scope so the
|
||||
// site-scope→SiteId claims below are built exactly as before.
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// The ScadaBridge mapper carries the full RoleMappingResult in the seam's
|
||||
// opaque Scope (see ScadaBridgeGroupRoleMapper). Guard the unwrap (review I4):
|
||||
// a future/alternate IGroupRoleMapper<string> could leave Scope null or set a
|
||||
// different type. Rather than throw InvalidCastException mid-login, fall back to
|
||||
// the most restrictive interpretation — not a system-wide deployment and no
|
||||
// permitted sites — so no SiteId claims are stamped (deny-by-omission). The real
|
||||
// ScadaBridge mapper always supplies a RoleMappingResult, so behaviour is unchanged.
|
||||
var scope = roleMapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
// Build claims from LDAP auth + role mapping.
|
||||
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
|
||||
@@ -52,27 +69,40 @@ public static class AuthEndpoints
|
||||
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
|
||||
// SlidingExpiration = true). A frozen absolute claim would contradict
|
||||
// the documented sliding-refresh policy.
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, authResult.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
|
||||
new(ClaimTypes.Name, resolvedUsername),
|
||||
new(JwtTokenService.DisplayNameClaimType, displayName),
|
||||
new(JwtTokenService.UsernameClaimType, resolvedUsername),
|
||||
};
|
||||
|
||||
foreach (var role in roleMappingResult.Roles)
|
||||
foreach (var role in roleMapping.Roles)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
}
|
||||
|
||||
if (!roleMappingResult.IsSystemWideDeployment)
|
||||
if (!scope.IsSystemWideDeployment)
|
||||
{
|
||||
foreach (var siteId in roleMappingResult.PermittedSiteIds)
|
||||
foreach (var siteId in scope.PermittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
// Task 1.5: name the role/name claim types explicitly so the cookie
|
||||
// principal's IsInRole / [Authorize(Roles=…)] resolve against the same
|
||||
// canonical types we mint (JwtTokenService.RoleClaimType = ZbClaimTypes.Role,
|
||||
// ClaimTypes.Name = ZbClaimTypes.Name). The policies use
|
||||
// RequireClaim(RoleClaimType, …) which checks type+value directly, but
|
||||
// pinning roleType keeps IsInRole-style checks consistent and survives the
|
||||
// cookie serialize/round-trip.
|
||||
var identity = new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: JwtTokenService.RoleClaimType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await context.SignInAsync(
|
||||
@@ -94,33 +124,43 @@ public static class AuthEndpoints
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = authResult.ErrorMessage ?? "Authentication failed." },
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// Guard the opaque-Scope unwrap (review I4); see the matching note on
|
||||
// /auth/login. Fall back to no site-scope rather than throwing if a future
|
||||
// mapper leaves Scope null or sets a different type.
|
||||
var scope = roleMapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
|
||||
|
||||
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
|
||||
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
authResult.DisplayName ?? username,
|
||||
authResult.Username ?? username,
|
||||
roleMappingResult.Roles,
|
||||
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
|
||||
displayName,
|
||||
resolvedUsername,
|
||||
roleMapping.Roles,
|
||||
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
username = authResult.Username ?? username,
|
||||
display_name = authResult.DisplayName ?? username,
|
||||
roles = roleMappingResult.Roles,
|
||||
username = resolvedUsername,
|
||||
display_name = displayName,
|
||||
roles = roleMapping.Roles,
|
||||
});
|
||||
}).DisableAntiforgery();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
|
||||
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
||||
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
|
||||
/// Renders one <see cref="AuditEventView"/> in a right-side off-canvas drawer.
|
||||
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
|
||||
/// Close buttons; the single-row detail body (read-only fields, conditional
|
||||
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
|
||||
@@ -20,7 +20,7 @@ public partial class AuditDrilldownDrawer
|
||||
/// The row to render. When null the drawer renders nothing — the host
|
||||
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
||||
/// </summary>
|
||||
[Parameter] public AuditEvent? Event { get; set; }
|
||||
[Parameter] public AuditEventView? Event { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the host wants the drawer visible. We deliberately keep
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
|
||||
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
|
||||
@@ -66,7 +66,7 @@ public partial class AuditEventDetail
|
||||
/// The row to render. Required and non-null — the host (drawer or modal)
|
||||
/// only mounts this component once it has a row to show.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public AuditEventView Event { get; set; } = null!;
|
||||
|
||||
private const string RedactionSentinel = "<redacted>";
|
||||
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
||||
@@ -303,7 +303,7 @@ public partial class AuditEventDetail
|
||||
/// outbound audit rows — the audit pipeline does not always capture
|
||||
/// the verb explicitly.
|
||||
/// </summary>
|
||||
private static string BuildCurlCommand(AuditEvent ev)
|
||||
private static string BuildCurlCommand(AuditEventView ev)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("curl");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
@inject IAuditLogQueryService QueryService
|
||||
@@ -103,7 +102,7 @@
|
||||
return n.Length >= 8 ? n[..8] : n;
|
||||
}
|
||||
|
||||
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
|
||||
private RenderFragment RenderCell(string key, AuditEventView row) => __builder =>
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -61,7 +61,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
private const string ColumnOrderStorageKey = "columnOrder";
|
||||
private const string ColumnWidthsStorageKey = "columnWidths";
|
||||
|
||||
private readonly List<AuditEvent> _rows = new();
|
||||
private readonly List<AuditEventView> _rows = new();
|
||||
private int _pageNumber = 1;
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
@@ -109,9 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
|
||||
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
|
||||
/// drawer. The event payload is the full <see cref="AuditEventView"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
|
||||
[Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }
|
||||
|
||||
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
|
||||
private int _pageSize => Math.Max(1, PageSize);
|
||||
@@ -289,7 +289,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRowClick(AuditEvent row)
|
||||
private async Task HandleRowClick(AuditEventView row)
|
||||
{
|
||||
if (OnRowSelected.HasDelegate)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
|
||||
@* Execution-Tree Node Detail Modal (Task 3).
|
||||
Opened from an execution-tree node double-click. Given an ExecutionId it
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -61,10 +60,10 @@ public partial class ExecutionDetailModal
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
// The loaded rows for the current execution; empty until a load completes.
|
||||
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
|
||||
private IReadOnlyList<AuditEventView> _rows = Array.Empty<AuditEventView>();
|
||||
|
||||
// The row whose detail is shown; null = list view.
|
||||
private AuditEvent? _selectedRow;
|
||||
private AuditEventView? _selectedRow;
|
||||
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
@@ -103,7 +102,7 @@ public partial class ExecutionDetailModal
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_selectedRow = null;
|
||||
_rows = Array.Empty<AuditEvent>();
|
||||
_rows = Array.Empty<AuditEventView>();
|
||||
|
||||
if (ExecutionId is null)
|
||||
{
|
||||
@@ -135,7 +134,7 @@ public partial class ExecutionDetailModal
|
||||
// degrades the modal to an inline error banner rather than killing
|
||||
// the SignalR circuit. Never rethrow.
|
||||
_error = $"Could not load this execution's audit rows: {ex.Message}";
|
||||
_rows = Array.Empty<AuditEvent>();
|
||||
_rows = Array.Empty<AuditEventView>();
|
||||
_selectedRow = null;
|
||||
}
|
||||
finally
|
||||
@@ -144,7 +143,7 @@ public partial class ExecutionDetailModal
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectRow(AuditEvent row) => _selectedRow = row;
|
||||
private void SelectRow(AuditEventView row) => _selectedRow = row;
|
||||
|
||||
private void BackToList() => _selectedRow = null;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Admin role only *@
|
||||
@* Admin section — Administrator role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<NavSection Title="Admin"
|
||||
@@ -32,7 +32,7 @@
|
||||
<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.
|
||||
@* Import Bundle requires Administrator only — Designer 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>
|
||||
@@ -41,7 +41,7 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Design section — Design role *@
|
||||
@* Design section — Designer role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<NavSection Title="Design"
|
||||
@@ -66,7 +66,7 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployment role *@
|
||||
@* Deployment section — Deployer role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<NavSection Title="Deployment"
|
||||
@@ -117,9 +117,9 @@
|
||||
</AuthorizeView>
|
||||
</NavSection>
|
||||
|
||||
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
||||
@* 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-Deployment
|
||||
section sits inside the policy block so a non-Deployer
|
||||
user does not see the heading. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="siteCallsContext">
|
||||
@@ -134,7 +134,7 @@
|
||||
</AuthorizeView>
|
||||
|
||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||
Parked Messages are Deployment-role only (Component-CentralUI).
|
||||
Parked Messages are Deployer-role only (Component-CentralUI).
|
||||
The section is ungated because Health Dashboard is always
|
||||
a visible child. *@
|
||||
<NavSection Title="Monitoring"
|
||||
@@ -160,8 +160,9 @@
|
||||
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. *@
|
||||
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">
|
||||
<NavSection Title="Audit"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
@page "/admin/api-keys/create"
|
||||
@page "/admin/api-keys/{Id:int}/edit"
|
||||
@page "/admin/api-keys/{KeyId}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@@ -46,15 +48,16 @@
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_saved && _newlyCreatedKeyValue != null)
|
||||
else if (_saved && _newlyCreatedToken != null)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<strong>New API Key Created</strong>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<code class="me-2">@_newlyCreatedKeyValue</code>
|
||||
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
|
||||
<div class="d-flex align-items-center mt-2">
|
||||
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
|
||||
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
|
||||
</div>
|
||||
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
|
||||
}
|
||||
@@ -66,39 +69,37 @@
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
@* Name is fixed on edit — the seam has no rename. *@
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" disabled="@IsEditMode" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodNames.Contains(method.Name)"
|
||||
@onchange="e => ToggleMethod(method.Name, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method. At least one is required.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodIds.Contains(method.Id)"
|
||||
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
@@ -111,21 +112,26 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam.
|
||||
// Keys are identified by an opaque string KeyId; method access is a set of method
|
||||
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
|
||||
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
|
||||
[Parameter] public string? KeyId { get; set; }
|
||||
|
||||
private bool IsEditMode => _editingKey != null;
|
||||
|
||||
private ApiKey? _editingKey;
|
||||
private InboundApiKeyInfo? _editingKey;
|
||||
private string _formName = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private string? _newlyCreatedKeyValue;
|
||||
private string? _newlyCreatedToken;
|
||||
private string? _newlyCreatedKeyId;
|
||||
private bool _loading = true;
|
||||
private bool _saved;
|
||||
|
||||
private List<ApiMethod> _allMethods = new();
|
||||
private HashSet<int> _initialMethodIds = new();
|
||||
private HashSet<int> _selectedMethodIds = new();
|
||||
// Selection set is method NAMES (scopes), not method ids.
|
||||
private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
@@ -133,22 +139,23 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Id.HasValue)
|
||||
// Methods always come from SQL Server (methods stay on the repository).
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(KeyId))
|
||||
{
|
||||
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
|
||||
// No single-key getter on the seam — locate this key in the full list.
|
||||
var all = await ApiKeyAdmin.ListAsync();
|
||||
_editingKey = all.FirstOrDefault(k => string.Equals(k.KeyId, KeyId, StringComparison.Ordinal));
|
||||
if (_editingKey == null)
|
||||
{
|
||||
_errorMessage = $"API key with ID {Id.Value} not found.";
|
||||
_errorMessage = $"API key '{KeyId}' not found.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_formName = _editingKey.Name;
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
_initialMethodIds = _allMethods
|
||||
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
|
||||
.Select(m => m.Id)
|
||||
.ToHashSet();
|
||||
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
|
||||
var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
|
||||
_selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,40 +169,38 @@
|
||||
private async Task SaveKey()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
if (!IsEditMode && string.IsNullOrWhiteSpace(_formName))
|
||||
{
|
||||
_formError = "Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
// The seam/server reject empty scope sets; validate in the UI for a clear message.
|
||||
if (_selectedMethodNames.Count == 0)
|
||||
{
|
||||
_formError = "Select at least one API method for this key.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingKey != null)
|
||||
{
|
||||
_editingKey.Name = _formName.Trim();
|
||||
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
|
||||
|
||||
var changedIds = _selectedMethodIds
|
||||
.Except(_initialMethodIds)
|
||||
.Concat(_initialMethodIds.Except(_selectedMethodIds))
|
||||
.ToHashSet();
|
||||
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
|
||||
// Edit: name is fixed; only the method-scope set is mutable.
|
||||
var ok = await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
|
||||
if (!ok)
|
||||
{
|
||||
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
|
||||
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
|
||||
else ids.Remove(_editingKey.Id);
|
||||
method.ApprovedApiKeyIds = ids.Count == 0
|
||||
? null
|
||||
: string.Join(",", ids.OrderBy(x => x));
|
||||
await InboundApiRepository.UpdateApiMethodAsync(method);
|
||||
_formError = $"API key '{_editingKey.Name}' was not found. Reload and retry.";
|
||||
return;
|
||||
}
|
||||
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/api-keys");
|
||||
}
|
||||
else
|
||||
{
|
||||
var keyValue = GenerateApiKey();
|
||||
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
|
||||
await InboundApiRepository.AddApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_newlyCreatedKeyValue = keyValue;
|
||||
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
|
||||
_newlyCreatedKeyId = created.KeyId;
|
||||
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
|
||||
_saved = true;
|
||||
}
|
||||
}
|
||||
@@ -207,28 +212,18 @@
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
|
||||
|
||||
private void ToggleMethod(int methodId, bool isChecked)
|
||||
private void ToggleMethod(string methodName, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedMethodIds.Add(methodId);
|
||||
else _selectedMethodIds.Remove(methodId);
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
if (isChecked) _selectedMethodNames.Add(methodName);
|
||||
else _selectedMethodNames.Remove(methodName);
|
||||
}
|
||||
|
||||
private async Task CopyKeyToClipboard()
|
||||
{
|
||||
if (_newlyCreatedKeyValue == null) return;
|
||||
if (_newlyCreatedToken == null) return;
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch
|
||||
@@ -236,12 +231,4 @@
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@page "/admin/api-keys"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
@@ -44,29 +43,29 @@
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Key ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Hash</th>
|
||||
<th>Methods</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var key in FilteredKeys)
|
||||
{
|
||||
<tr @key="key.Id">
|
||||
<td>@key.Id</td>
|
||||
<tr @key="key.KeyId">
|
||||
<td><code>@TruncateKeyId(key.KeyId)</code></td>
|
||||
<td>
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
|
||||
<td>@key.Methods.Count</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||
data-bs-toggle="dropdown"
|
||||
@@ -75,7 +74,7 @@
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => ToggleKey(key)">
|
||||
@(key.IsEnabled ? "Disable" : "Enable")
|
||||
@(key.Enabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
@@ -98,14 +97,17 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<ApiKey> _keys = new();
|
||||
// Inbound-API key re-arch (C3): this page reads keys from the IInboundApiKeyAdmin seam
|
||||
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
|
||||
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
|
||||
private List<InboundApiKeyInfo> _keys = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private IEnumerable<ApiKey> FilteredKeys =>
|
||||
private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _keys
|
||||
: _keys.Where(k =>
|
||||
@@ -122,7 +124,7 @@
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
_keys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -131,20 +133,28 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static string MaskKeyValue(string keyValue)
|
||||
// Show a short, recognizable prefix of the opaque KeyId rather than the full 32-char value.
|
||||
private static string TruncateKeyId(string keyId)
|
||||
{
|
||||
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
|
||||
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
|
||||
if (string.IsNullOrEmpty(keyId)) return keyId;
|
||||
return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
|
||||
}
|
||||
|
||||
private async Task ToggleKey(ApiKey key)
|
||||
private async Task ToggleKey(InboundApiKeyInfo key)
|
||||
{
|
||||
try
|
||||
{
|
||||
key.IsEnabled = !key.IsEnabled;
|
||||
await InboundApiRepository.UpdateApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
|
||||
var newEnabled = !key.Enabled;
|
||||
// The seam persists; there is no separate SaveChangesAsync.
|
||||
var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
|
||||
if (!ok)
|
||||
{
|
||||
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
|
||||
await LoadDataAsync();
|
||||
return;
|
||||
}
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(newEnabled ? "enabled" : "disabled")}.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -152,7 +162,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteKey(ApiKey key)
|
||||
private async Task DeleteKey(InboundApiKeyInfo key)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete API Key",
|
||||
@@ -162,8 +172,13 @@
|
||||
|
||||
try
|
||||
{
|
||||
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
|
||||
if (!ok)
|
||||
{
|
||||
_toast.ShowError($"API key '{key.Name}' was not found — it may have been removed. Refreshing.");
|
||||
await LoadDataAsync();
|
||||
return;
|
||||
}
|
||||
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@
|
||||
<label class="form-label small">Role</label>
|
||||
<select class="form-select form-select-sm" @bind="_formRole">
|
||||
<option value="">Select role...</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Design">Design</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
<option value="@Roles.Administrator">Administrator</option>
|
||||
<option value="@Roles.Designer">Designer</option>
|
||||
<option value="@Roles.Deployer">Deployer</option>
|
||||
<option value="@Roles.Viewer">Viewer</option>
|
||||
</select>
|
||||
<div class="form-text">Deployment role: configure site scope below after saving.</div>
|
||||
<div class="form-text">Deployer role: configure site scope below after saving.</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
@@ -50,7 +50,7 @@ public partial class AuditLogPage : IDisposable
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
private AuditLogQueryFilter? _currentFilter;
|
||||
private AuditEvent? _selectedEvent;
|
||||
private AuditEventView? _selectedEvent;
|
||||
private bool _drawerOpen;
|
||||
private string? _initialInstanceSearch;
|
||||
|
||||
@@ -222,7 +222,7 @@ public partial class AuditLogPage : IDisposable
|
||||
_currentFilter = filter;
|
||||
}
|
||||
|
||||
private void HandleRowSelected(AuditEvent row)
|
||||
private void HandleRowSelected(AuditEventView row)
|
||||
{
|
||||
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
|
||||
// the selected row and open the drilldown drawer — the drawer is fully
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -108,7 +109,7 @@
|
||||
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
|
||||
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
|
||||
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
|
||||
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
|
||||
_apiKeyCount = (await ApiKeyAdmin.ListAsync()).Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -44,14 +47,14 @@
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var key in _allKeys)
|
||||
{
|
||||
var checkboxId = $"approved-key-{key.Id}";
|
||||
var checkboxId = $"approved-key-{key.KeyId}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedKeyIds.Contains(key.Id)"
|
||||
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
|
||||
checked="@_selectedKeyIds.Contains(key.KeyId)"
|
||||
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
@@ -195,9 +198,15 @@
|
||||
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
// Inbound-API key re-arch (C3): the approved-keys list is driven by the IInboundApiKeyAdmin
|
||||
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
|
||||
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to
|
||||
// per-key method-scopes. Keys are identified by an opaque string KeyId.
|
||||
private ApiMethod? _existing;
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
private List<InboundApiKeyInfo> _allKeys = new();
|
||||
private HashSet<string> _selectedKeyIds = new(StringComparer.Ordinal);
|
||||
// Keys approved for this method when the form loaded (empty on create), for diffing on save.
|
||||
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
@@ -209,7 +218,8 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
// All keys come from the seam (hash-free projection).
|
||||
_allKeys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
|
||||
@@ -225,7 +235,10 @@
|
||||
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||
_params = _existing.ParameterDefinitions;
|
||||
_returns = _existing.ReturnDefinition;
|
||||
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
|
||||
// Seed approved keys from the seam: which keys' scopes contain this method.
|
||||
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
|
||||
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
|
||||
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -233,25 +246,12 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private void ToggleKey(int keyId, bool isChecked)
|
||||
private void ToggleKey(string keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
else _selectedKeyIds.Remove(keyId);
|
||||
}
|
||||
|
||||
private string? SerializeApprovedKeyIds() =>
|
||||
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
@@ -263,15 +263,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
var approvedKeyIds = SerializeApprovedKeyIds();
|
||||
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
|
||||
// key scopes against it. The method entity stays in SQL Server; we leave the
|
||||
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
|
||||
string methodName;
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Script = _script;
|
||||
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||
_existing.ParameterDefinitions = _params?.Trim();
|
||||
_existing.ReturnDefinition = _returns?.Trim();
|
||||
_existing.ApprovedApiKeyIds = approvedKeyIds;
|
||||
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
||||
methodName = _existing.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -279,17 +282,81 @@
|
||||
{
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
ParameterDefinitions = _params?.Trim(),
|
||||
ReturnDefinition = _returns?.Trim(),
|
||||
ApprovedApiKeyIds = approvedKeyIds
|
||||
ReturnDefinition = _returns?.Trim()
|
||||
};
|
||||
await InboundApiRepository.AddApiMethodAsync(m);
|
||||
methodName = m.Name;
|
||||
}
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
|
||||
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
|
||||
if (!await ReconcileKeyScopesAsync(methodName)) return;
|
||||
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes this method's name into / out of each affected key's scope set. Returns false
|
||||
/// (leaving a form error) when a revoke would empty a key's scopes — the server rejects
|
||||
/// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected
|
||||
/// key so we never clobber unrelated method-scopes.
|
||||
/// </summary>
|
||||
private async Task<bool> ReconcileKeyScopesAsync(string methodName)
|
||||
{
|
||||
var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal)
|
||||
.Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Read each affected key's CURRENT full scope set so add/remove preserves other methods.
|
||||
var currentMethodsByKey = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
foreach (var keyId in affected)
|
||||
{
|
||||
currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId);
|
||||
}
|
||||
|
||||
var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal);
|
||||
|
||||
var plan = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById);
|
||||
|
||||
// Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it).
|
||||
if (plan.EmptyScopeKeyNames.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", plan.EmptyScopeKeyNames);
|
||||
_formError =
|
||||
$"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " +
|
||||
"Disable or delete the key instead, or grant it another method first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var update in plan.Updates)
|
||||
{
|
||||
var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
|
||||
if (!ok)
|
||||
throw new InvalidOperationException(
|
||||
$"Key '{NameFor(update.KeyId)}' was not found in the key store.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Method '{methodName}' was saved, but updating approved-key scopes failed partway: {ex.Message} " +
|
||||
"Some keys may be partially updated — review them on the API Keys page and retry.", ex);
|
||||
}
|
||||
|
||||
// Selection is now the baseline (matters if save is retried without reload).
|
||||
_initialKeyIds = new HashSet<string>(_selectedKeyIds, StringComparer.Ordinal);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns the display name for a keyId if available from the loaded key list, else the id itself.
|
||||
private string NameFor(string keyId) =>
|
||||
_allKeys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))?.Name ?? keyId;
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
@@ -138,14 +138,15 @@
|
||||
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-keys">
|
||||
<legend class="h6">API Keys</legend>
|
||||
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||
<legend class="h6">API Methods</legend>
|
||||
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
|
||||
<strong>API keys are not part of config transport.</strong> Inbound API keys
|
||||
live in each environment's own secret store and cannot be exported. After
|
||||
importing, re-create the keys on the destination and re-grant their method
|
||||
scopes via the admin UI/CLI.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
@@ -261,10 +262,7 @@
|
||||
{
|
||||
<li>SmtpConfig: @s.Host</li>
|
||||
}
|
||||
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiKey: @k.Name</li>
|
||||
}
|
||||
@* Inbound API keys are not transported (re-arch C4) — methods only. *@
|
||||
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiMethod: @m.Name</li>
|
||||
|
||||
+4
-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 =>
|
||||
@@ -205,7 +204,7 @@ public partial class TransportExport : ComponentBase
|
||||
DatabaseConnectionIds: _selectedDbConnections.ToList(),
|
||||
NotificationListIds: _selectedNotificationLists.ToList(),
|
||||
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
||||
ApiKeyIds: _selectedApiKeys.ToList(),
|
||||
// Inbound API keys are not transported (re-arch C4) — methods only.
|
||||
ApiMethodIds: _selectedApiMethods.ToList(),
|
||||
IncludeDependencies: _includeDependencies);
|
||||
}
|
||||
@@ -393,7 +392,6 @@ public partial class TransportExport : ComponentBase
|
||||
_selectedDbConnections.Clear();
|
||||
_selectedNotificationLists.Clear();
|
||||
_selectedSmtpConfigs.Clear();
|
||||
_selectedApiKeys.Clear();
|
||||
_selectedApiMethods.Clear();
|
||||
_filter = string.Empty;
|
||||
_includeDependencies = true;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helper for the inbound-API-key re-arch (C3) on the API-method form. The approval
|
||||
/// relationship moved from <c>ApiMethod.ApprovedApiKeyIds</c> (a CSV on the method) onto
|
||||
/// per-key method-scopes managed through <c>IInboundApiKeyAdmin</c>. When an operator edits
|
||||
/// the "Approved API Keys" list for one method, we must reconcile that method's NAME into (or
|
||||
/// out of) each affected key's scope set — without touching keys whose membership did not change.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Factored out of the Blazor component so the add/remove/empty-guard logic is unit-testable
|
||||
/// independent of bUnit rendering.
|
||||
/// </remarks>
|
||||
public static class ApiMethodKeyScopeReconciler
|
||||
{
|
||||
/// <summary>
|
||||
/// The recomputed scope set for one key that must be persisted via
|
||||
/// <c>IInboundApiKeyAdmin.SetMethodsAsync</c>.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">The key whose scopes changed.</param>
|
||||
/// <param name="NewMethods">The full new method-scope set for that key (always non-empty —
|
||||
/// the empty case is reported as a conflict instead).</param>
|
||||
public sealed record KeyScopeUpdate(string KeyId, IReadOnlyList<string> NewMethods);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of reconciling one method's approved-key selection against the keys' current scopes.
|
||||
/// </summary>
|
||||
/// <param name="Updates">Per-affected-key new scope sets to push. Empty when nothing changed.</param>
|
||||
/// <param name="EmptyScopeKeyNames">Display names of keys for which revoking this method would
|
||||
/// leave zero scopes. When this is non-empty the caller MUST NOT apply <see cref="Updates"/> —
|
||||
/// it should surface a validation error and abort, because the server rejects empty scope sets.</param>
|
||||
public sealed record ReconcileResult(
|
||||
IReadOnlyList<KeyScopeUpdate> Updates,
|
||||
IReadOnlyList<string> EmptyScopeKeyNames);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the scope edits needed so that exactly the keys in <paramref name="selectedKeyIds"/>
|
||||
/// are scoped to <paramref name="methodName"/>.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The method whose approval set is being edited. Must already exist
|
||||
/// in the store (save the ApiMethod entity FIRST so its name resolves).</param>
|
||||
/// <param name="selectedKeyIds">Keys the operator wants approved for this method (after edit).</param>
|
||||
/// <param name="initialKeyIds">Keys that were approved for this method when the form loaded
|
||||
/// (empty on create).</param>
|
||||
/// <param name="currentMethodsByKey">Each affected key's CURRENT full scope set, keyed by KeyId.
|
||||
/// Read fresh from the seam right before reconciling so concurrent edits do not get clobbered.</param>
|
||||
/// <param name="keyNamesById">Display names by KeyId, for human-readable empty-scope messages.</param>
|
||||
public static ReconcileResult Reconcile(
|
||||
string methodName,
|
||||
IReadOnlySet<string> selectedKeyIds,
|
||||
IReadOnlySet<string> initialKeyIds,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> currentMethodsByKey,
|
||||
IReadOnlyDictionary<string, string> keyNamesById)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
|
||||
ArgumentNullException.ThrowIfNull(selectedKeyIds);
|
||||
ArgumentNullException.ThrowIfNull(initialKeyIds);
|
||||
ArgumentNullException.ThrowIfNull(currentMethodsByKey);
|
||||
ArgumentNullException.ThrowIfNull(keyNamesById);
|
||||
|
||||
var added = selectedKeyIds.Except(initialKeyIds, StringComparer.Ordinal);
|
||||
var removed = initialKeyIds.Except(selectedKeyIds, StringComparer.Ordinal);
|
||||
|
||||
var updates = new List<KeyScopeUpdate>();
|
||||
var emptyScopeKeyNames = new List<string>();
|
||||
|
||||
// Approving: add this method's name to the key's current scope set (idempotent).
|
||||
foreach (var keyId in added)
|
||||
{
|
||||
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
|
||||
? m
|
||||
: (IReadOnlyList<string>)Array.Empty<string>();
|
||||
var next = new HashSet<string>(current, StringComparer.Ordinal) { methodName };
|
||||
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
|
||||
}
|
||||
|
||||
// Revoking: remove this method's name. If that empties the key, it is a conflict —
|
||||
// the server rejects empty scope sets, so we report it and the caller must abort.
|
||||
foreach (var keyId in removed)
|
||||
{
|
||||
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
|
||||
? m
|
||||
: (IReadOnlyList<string>)Array.Empty<string>();
|
||||
var next = new HashSet<string>(current, StringComparer.Ordinal);
|
||||
next.Remove(methodName);
|
||||
|
||||
if (next.Count == 0)
|
||||
{
|
||||
emptyScopeKeyNames.Add(
|
||||
keyNamesById.TryGetValue(keyId, out var name) ? name : keyId);
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
|
||||
}
|
||||
|
||||
return new ReconcileResult(updates, emptyScopeKeyNames);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Flattened, typed view of a canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for the
|
||||
/// Central UI audit pages. C3 (Task 2.5) made the canonical record the seam type — the
|
||||
/// query service decomposes it into this view (via <see cref="AuditRowProjection"/>) so the
|
||||
/// existing razor bindings (<c>row.Channel</c>, <c>Event.Status</c>, <c>evt.RequestSummary</c>,
|
||||
/// …) keep working against typed properties rather than parsing <c>DetailsJson</c> inline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is presentation-only: it carries the same field surface the bespoke
|
||||
/// <c>Commons.Entities.Audit.AuditEvent</c> exposed before C3. <c>ForwardState</c> is always
|
||||
/// null on the central read path (it is site-storage-only and not carried on canonical rows).
|
||||
/// </remarks>
|
||||
public sealed record AuditEventView
|
||||
{
|
||||
/// <summary>Idempotency key.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
/// <summary>UTC timestamp when the audited action occurred.</summary>
|
||||
public DateTime OccurredAtUtc { get; init; }
|
||||
/// <summary>UTC ingest timestamp (central-set); null until ingest.</summary>
|
||||
public DateTime? IngestedAtUtc { get; init; }
|
||||
/// <summary>Trust-boundary channel.</summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
/// <summary>Specific event kind.</summary>
|
||||
public AuditKind Kind { get; init; }
|
||||
/// <summary>Per-operation correlation id.</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
/// <summary>Originating execution id.</summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
/// <summary>Spawning execution id; null for top-level runs.</summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
/// <summary>Site id where the action originated.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
/// <summary>Cluster node that emitted the event.</summary>
|
||||
public string? SourceNode { get; init; }
|
||||
/// <summary>Instance id where the action originated.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
/// <summary>Script that initiated the action.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
/// <summary>Authenticated actor.</summary>
|
||||
public string? Actor { get; init; }
|
||||
/// <summary>Target of the action.</summary>
|
||||
public string? Target { get; init; }
|
||||
/// <summary>Lifecycle status.</summary>
|
||||
public AuditStatus Status { get; init; }
|
||||
/// <summary>HTTP status code where applicable.</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
/// <summary>Duration of the action in ms.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
/// <summary>Human-readable error summary.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
/// <summary>Verbose error detail.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
/// <summary>Truncated/redacted request summary.</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
/// <summary>Truncated/redacted response summary.</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
/// <summary>True when summaries were truncated.</summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
/// <summary>Free-form JSON extension.</summary>
|
||||
public string? Extra { get; init; }
|
||||
/// <summary>Site-local forwarding state; always null on the central read path.</summary>
|
||||
public AuditForwardState? ForwardState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Decomposes a canonical <see cref="AuditEvent"/> into a flat view for the UI.
|
||||
/// </summary>
|
||||
public static AuditEventView From(AuditEvent evt)
|
||||
{
|
||||
var r = AuditRowProjection.Decompose(evt);
|
||||
return new AuditEventView
|
||||
{
|
||||
EventId = r.EventId,
|
||||
OccurredAtUtc = r.OccurredAtUtc,
|
||||
IngestedAtUtc = r.IngestedAtUtc,
|
||||
Channel = r.Channel,
|
||||
Kind = r.Kind,
|
||||
CorrelationId = r.CorrelationId,
|
||||
ExecutionId = r.ExecutionId,
|
||||
ParentExecutionId = r.ParentExecutionId,
|
||||
SourceSiteId = r.SourceSiteId,
|
||||
SourceNode = r.SourceNode,
|
||||
SourceInstanceId = r.SourceInstanceId,
|
||||
SourceScript = r.SourceScript,
|
||||
Actor = r.Actor,
|
||||
Target = r.Target,
|
||||
Status = r.Status,
|
||||
HttpStatus = r.HttpStatus,
|
||||
DurationMs = r.DurationMs,
|
||||
ErrorMessage = r.ErrorMessage,
|
||||
ErrorDetail = r.ErrorDetail,
|
||||
RequestSummary = r.RequestSummary,
|
||||
ResponseSummary = r.ResponseSummary,
|
||||
PayloadTruncated = r.PayloadTruncated,
|
||||
Extra = r.Extra,
|
||||
ForwardState = null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
@@ -121,7 +120,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
{
|
||||
break;
|
||||
}
|
||||
await writer.WriteLineAsync(FormatCsvRow(evt));
|
||||
await writer.WriteLineAsync(FormatCsvRow(AuditEventView.From(evt)));
|
||||
written++;
|
||||
}
|
||||
|
||||
@@ -140,7 +139,9 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
var last = page[^1];
|
||||
cursor = new AuditLogPaging(
|
||||
PageSize: pageSize,
|
||||
AfterOccurredAtUtc: last.OccurredAtUtc,
|
||||
// C3: canonical OccurredAtUtc is a DateTimeOffset; the keyset
|
||||
// cursor column is a UTC DateTime.
|
||||
AfterOccurredAtUtc: last.OccurredAtUtc.UtcDateTime,
|
||||
AfterEventId: last.EventId);
|
||||
}
|
||||
|
||||
@@ -169,13 +170,13 @@ public sealed class AuditLogExportService : IAuditLogExportService
|
||||
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises one <see cref="AuditEvent"/> as a CSV row (no trailing newline).
|
||||
/// Serialises one <see cref="AuditEventView"/> as a CSV row (no trailing newline).
|
||||
/// Each nullable column renders as the empty string when null; non-null
|
||||
/// scalars use invariant culture so an export taken on one locale parses
|
||||
/// cleanly on another.
|
||||
/// </summary>
|
||||
/// <param name="evt">The audit event to format as a CSV row.</param>
|
||||
internal static string FormatCsvRow(AuditEvent evt)
|
||||
/// <param name="evt">The audit event view to format as a CSV row.</param>
|
||||
internal static string FormatCsvRow(AuditEventView evt)
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
AppendField(sb, evt.EventId.ToString(), first: true);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
@@ -93,7 +92,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
public int DefaultPageSize => 100;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
public async Task<IReadOnlyList<AuditEventView>> QueryAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
AuditLogPaging? paging = null,
|
||||
CancellationToken ct = default)
|
||||
@@ -101,17 +100,22 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
|
||||
|
||||
// C3 (Task 2.5): the repository seam returns canonical records; decompose
|
||||
// each into a flat AuditEventView so the audit pages keep binding to typed
|
||||
// properties.
|
||||
// Test-seam ctor: use the injected repository directly.
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
return await _injectedRepository.QueryAsync(filter, effective, ct);
|
||||
var rows = await _injectedRepository.QueryAsync(filter, effective, ct);
|
||||
return rows.Select(AuditEventView.From).ToList();
|
||||
}
|
||||
|
||||
// Production: a fresh scope (and thus a fresh DbContext) per query so the
|
||||
// page's auto-load never shares the circuit-scoped context.
|
||||
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
return await repository.QueryAsync(filter, effective, ct);
|
||||
var result = await repository.QueryAsync(filter, effective, ct);
|
||||
return result.Select(AuditEventView.From).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -36,11 +36,11 @@ public sealed class BindingTester : IBindingTester
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level
|
||||
// roles, so the Design check must happen here before any cross-cluster
|
||||
// roles, so the Designer check must happen here before any cross-cluster
|
||||
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
|
||||
// IsInRole, per c1e16cf).
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
|
||||
@@ -43,9 +43,9 @@ public sealed class BrowseService : IBrowseService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level roles,
|
||||
// so the Design check must happen here before any cross-cluster traffic.
|
||||
// so the Designer check must happen here before any cross-cluster traffic.
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
|
||||
{
|
||||
return new BrowseNodeResult(
|
||||
Array.Empty<BrowseNode>(),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
@@ -18,13 +17,18 @@ public interface IAuditLogQueryService
|
||||
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
|
||||
/// rows with no cursor (first page). The repository orders by
|
||||
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
|
||||
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
|
||||
/// <see cref="AuditEventView.OccurredAtUtc"/> + <see cref="AuditEventView.EventId"/>
|
||||
/// back as the cursor for the next page.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C3 (Task 2.5): the repository seam returns the canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c>; this facade decomposes each row into a flat
|
||||
/// <see cref="AuditEventView"/> so the audit pages keep binding to typed properties.
|
||||
/// </remarks>
|
||||
/// <param name="filter">Filter criteria applied to the audit log query.</param>
|
||||
/// <param name="paging">Optional paging cursor; defaults to first page when null.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
Task<IReadOnlyList<AuditEventView>> QueryAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
AuditLogPaging? paging = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
|
||||
/// site rows leave IngestedAtUtc null until ingest. Append-only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
|
||||
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
||||
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
|
||||
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
|
||||
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
|
||||
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
|
||||
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
|
||||
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
|
||||
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
|
||||
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
|
||||
/// <c>datetime2</c> column shape required by the AuditLog table.
|
||||
/// </remarks>
|
||||
public sealed record AuditEvent
|
||||
{
|
||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the audited action occurred at its source. The value
|
||||
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
||||
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
|
||||
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
|
||||
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
|
||||
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
|
||||
/// <c>datetime2</c> read where the value bypassed the EF converter) is
|
||||
/// re-tagged as UTC rather than treated as local time downstream. Producers
|
||||
/// are still expected to supply values that ARE genuinely UTC — the setter
|
||||
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
|
||||
/// </summary>
|
||||
public DateTime OccurredAtUtc
|
||||
{
|
||||
get => _occurredAtUtc;
|
||||
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
}
|
||||
private readonly DateTime _occurredAtUtc;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
|
||||
/// The value MUST be in UTC when non-null; the init-setter forces
|
||||
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
|
||||
/// <see cref="OccurredAtUtc"/>'s contract.
|
||||
/// </summary>
|
||||
public DateTime? IngestedAtUtc
|
||||
{
|
||||
get => _ingestedAtUtc;
|
||||
init => _ingestedAtUtc = value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
|
||||
: null;
|
||||
}
|
||||
private readonly DateTime? _ingestedAtUtc;
|
||||
|
||||
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
|
||||
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
|
||||
public AuditKind Kind { get; init; }
|
||||
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the originating script execution / inbound request — the universal
|
||||
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
|
||||
/// is the per-operation lifecycle id).
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
|
||||
/// run was spawned by another; null for top-level runs. Lets a spawned
|
||||
/// execution point back at its spawner for cross-run correlation.
|
||||
/// </summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
|
||||
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
|
||||
/// for central-originated rows. Stamped by the writing node from
|
||||
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
|
||||
/// has since been retired don't block ingest.
|
||||
/// </summary>
|
||||
public string? SourceNode { get; init; }
|
||||
|
||||
/// <summary>Instance id where the action originated, when applicable.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
|
||||
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
|
||||
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>Lifecycle status of this row.</summary>
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Human-readable error summary on failure rows.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
|
||||
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
|
||||
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
|
||||
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
|
||||
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
|
||||
public string? Extra { get; init; }
|
||||
|
||||
/// <summary>Site-local forwarding state; null on central rows.</summary>
|
||||
public AuditForwardState? ForwardState { get; init; }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
|
||||
/// <summary>
|
||||
/// An inbound-API bearer credential. Per ConfigurationDatabase-012 the plaintext key
|
||||
/// is never persisted: the entity stores only <see cref="KeyHash"/>, a deterministic
|
||||
/// keyed hash of the key (HMAC-SHA256 with a server-side pepper). The plaintext is
|
||||
/// generated at creation, shown to the operator exactly once, and then discarded.
|
||||
/// </summary>
|
||||
public class ApiKey
|
||||
{
|
||||
/// <summary>Database primary key.</summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>Display name for the API key.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic keyed hash of the API key value. This is the only form of the
|
||||
/// credential persisted; the plaintext key is never stored. Authentication hashes
|
||||
/// the presented candidate with the same scheme and compares against this value.
|
||||
/// </summary>
|
||||
public string KeyHash { get; set; }
|
||||
|
||||
/// <summary>When false, the key is rejected even if the hash matches.</summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an API key from a plaintext value, immediately hashing it with the
|
||||
/// unpeppered default hasher (<see cref="ApiKeyHasher.Default"/>) so the entity
|
||||
/// never holds the plaintext. Production code paths that have a configured pepper
|
||||
/// should use <see cref="FromHash(string, string)"/> with a peppered hash instead.
|
||||
/// </summary>
|
||||
/// <param name="name">Display name for the API key.</param>
|
||||
/// <param name="keyValue">Plaintext key value; hashed immediately and never stored.</param>
|
||||
public ApiKey(string name, string keyValue)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
if (keyValue is null) throw new ArgumentNullException(nameof(keyValue));
|
||||
KeyHash = ApiKeyHasher.Default.Hash(keyValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameterless constructor for the EF Core materializer. Application code uses
|
||||
/// <see cref="ApiKey(string, string)"/> or <see cref="FromHash(string, string)"/>.
|
||||
/// </summary>
|
||||
private ApiKey()
|
||||
{
|
||||
Name = string.Empty;
|
||||
KeyHash = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an API key from an already-computed key hash. Used by the creation
|
||||
/// path, which generates a random key, hashes it with the configured (peppered)
|
||||
/// <see cref="IApiKeyHasher"/>, and stores only the resulting hash.
|
||||
/// </summary>
|
||||
/// <param name="name">Display name for the API key.</param>
|
||||
/// <param name="keyHash">Pre-computed keyed hash of the API key value.</param>
|
||||
public static ApiKey FromHash(string name, string keyHash)
|
||||
{
|
||||
return new ApiKey
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name)),
|
||||
KeyHash = keyHash ?? throw new ArgumentNullException(nameof(keyHash)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ public class ApiMethod
|
||||
public string Name { get; set; }
|
||||
/// <summary>Gets or sets the C# script body executed when the method is invoked.</summary>
|
||||
public string Script { get; set; }
|
||||
/// <summary>Gets or sets the JSON-serialised list of API key IDs approved for this method, or <c>null</c> for unrestricted.</summary>
|
||||
public string? ApprovedApiKeyIds { get; set; }
|
||||
/// <summary>Gets or sets the JSON Schema describing the accepted parameters, or <c>null</c> if the method takes no parameters.</summary>
|
||||
public string? ParameterDefinitions { get; set; }
|
||||
/// <summary>Gets or sets the JSON Schema describing the return type, or <c>null</c> if the method returns nothing.</summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
|
||||
@@ -4,30 +4,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
public interface IInboundApiRepository
|
||||
{
|
||||
// ApiKey
|
||||
/// <summary>Retrieves an API key by ID.</summary>
|
||||
/// <param name="id">The API key ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
/// <summary>Retrieves all API keys.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default);
|
||||
/// <summary>Retrieves an API key by value.</summary>
|
||||
/// <param name="keyValue">The API key value.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default);
|
||||
/// <summary>Adds a new API key.</summary>
|
||||
/// <param name="apiKey">The API key to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
|
||||
/// <summary>Updates an existing API key.</summary>
|
||||
/// <param name="apiKey">The API key to update.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
|
||||
/// <summary>Deletes an API key by ID.</summary>
|
||||
/// <param name="id">The API key ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default);
|
||||
// ApiKey persistence retired (re-arch C5): inbound API keys live in the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys SQLite store, not the SQL Server configuration DB. The
|
||||
// former GetApiKeyByIdAsync / GetAllApiKeysAsync / GetApiKeyByValueAsync /
|
||||
// AddApiKeyAsync / UpdateApiKeyAsync / DeleteApiKeyAsync / GetApprovedKeysForMethodAsync
|
||||
// methods were removed with the SQL Server ApiKey entity.
|
||||
|
||||
// ApiMethod
|
||||
/// <summary>Retrieves an API method by ID.</summary>
|
||||
@@ -41,10 +22,6 @@ public interface IInboundApiRepository
|
||||
/// <param name="name">The API method name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
/// <summary>Retrieves API keys approved for a method.</summary>
|
||||
/// <param name="methodId">The API method ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default);
|
||||
/// <summary>Adds a new API method.</summary>
|
||||
/// <param name="method">The API method to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Read-side projection of one inbound API key, as surfaced by the management seam.
|
||||
/// Hash-free by construction — the secret is never carried here; it is shown ONCE at
|
||||
/// creation via <see cref="InboundApiKeyCreated"/>.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">Stable key identifier (the middle segment of the token).</param>
|
||||
/// <param name="Name">Operator-facing display name.</param>
|
||||
/// <param name="Enabled">True while the key is active (not revoked/disabled).</param>
|
||||
/// <param name="Methods">The API-method names this key is scoped to call, sorted ordinally.</param>
|
||||
/// <param name="CreatedUtc">When the key was created.</param>
|
||||
/// <param name="LastUsedUtc">When the key last authenticated a request, if ever.</param>
|
||||
public sealed record InboundApiKeyInfo(
|
||||
string KeyId,
|
||||
string Name,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> Methods,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a key. <see cref="Token"/> is the assembled bearer token
|
||||
/// (<c>sbk_<keyId>_<secret></c>) and is the ONLY moment the secret is available —
|
||||
/// it is never retrievable afterwards.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">The new key's identifier.</param>
|
||||
/// <param name="Token">The bearer token, shown once.</param>
|
||||
public sealed record InboundApiKeyCreated(string KeyId, string Token);
|
||||
|
||||
/// <summary>
|
||||
/// App-facing management seam for inbound API keys. This is the single shared path CLI
|
||||
/// and CentralUI use to create / list / enable / disable / delete inbound keys and edit
|
||||
/// their method-scopes. The interface lives in Commons and is deliberately free of any
|
||||
/// dependency on the underlying auth library, so consumers depend only on this contract.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mutating operations (<see cref="CreateAsync"/>, <see cref="SetEnabledAsync"/>,
|
||||
/// <see cref="SetMethodsAsync"/>, <see cref="DeleteAsync"/>) may <b>throw</b> on
|
||||
/// store-level or configuration failures (e.g. an unavailable pepper) rather than
|
||||
/// exclusively signalling failure via their <c>bool</c> return — callers must handle
|
||||
/// exceptions in addition to checking the return value.
|
||||
/// </remarks>
|
||||
public interface IInboundApiKeyAdmin
|
||||
{
|
||||
/// <summary>Creates a new key scoped to <paramref name="methods"/> and returns its
|
||||
/// identifier plus the bearer token (shown once).</summary>
|
||||
Task<InboundApiKeyCreated> CreateAsync(
|
||||
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Lists all inbound keys (hash-free projection).</summary>
|
||||
Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Enables or disables a key without changing its secret. Returns false if
|
||||
/// the key does not exist.</summary>
|
||||
Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Replaces the method-scope set on a key without changing its secret.
|
||||
/// Returns false if the key does not exist.</summary>
|
||||
Task<bool> SetMethodsAsync(
|
||||
string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Removes a key (revoke-then-delete). Returns false if the key could not be
|
||||
/// deleted.</summary>
|
||||
Task<bool> DeleteAsync(string keyId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns the method-scope set for a key, or an empty list if not found.</summary>
|
||||
/// <remarks>Enumerates the full key list (O(n)); intended for admin-scale use, not hot paths.</remarks>
|
||||
Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns the identifiers of all keys whose scopes contain
|
||||
/// <paramref name="methodName"/>.</summary>
|
||||
/// <remarks>Enumerates the full key list (O(n)); intended for admin-scale use, not hot paths.</remarks>
|
||||
Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>Actor</c> for an audit row from the current authenticated
|
||||
/// principal (Phase 3 of the audit re-architecture). User-facing emit sites
|
||||
/// (the inbound API middleware on a cookie/LDAP-authenticated request) read
|
||||
/// <see cref="CurrentActor"/> so the canonical <c>AuditEvent.Actor</c> records
|
||||
/// the real authenticated user, rather than a generic system/identity fallback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The seam is deliberately ASP.NET-free (a plain <c>string?</c>) so it can
|
||||
/// live in Commons and be consumed by any project without pulling an HTTP
|
||||
/// dependency. The HTTP-backed implementation
|
||||
/// (<c>ZB.MOM.WW.ScadaBridge.Security.HttpAuditActorAccessor</c>) reads the
|
||||
/// authenticated principal off <c>IHttpContextAccessor.HttpContext?.User</c>.</para>
|
||||
/// <para>This seam is for the <em>authenticated, interactive</em> actor only.
|
||||
/// System-originated emitters (script/notification/db-outbound) keep their own
|
||||
/// system actor/fallback and do NOT consult this accessor — there is no
|
||||
/// interactive principal to read in those flows.</para>
|
||||
/// </remarks>
|
||||
public interface IAuditActorAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// The actor string for the currently authenticated principal, or
|
||||
/// <c>null</c> when there is no authenticated interactive user (no ambient
|
||||
/// request, or an unauthenticated / auth-failure request). A null result
|
||||
/// signals the caller to fall back to its existing actor (API-key name,
|
||||
/// "system", etc.) — an unauthenticated principal is never echoed back.
|
||||
/// </summary>
|
||||
string? CurrentActor { get; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
@@ -7,6 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
|
||||
/// Failures must NEVER abort the user-facing action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C3 (Task 2.5): the event type is the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>.
|
||||
/// The local seam is retained (rather than collapsed onto <c>ZB.MOM.WW.Audit.IAuditWriter</c>)
|
||||
/// so it stays a distinct DI binding from <see cref="ICentralAuditWriter"/> and so the many
|
||||
/// existing site/central implementations and test fakes keep their identity.
|
||||
/// </remarks>
|
||||
public interface IAuditWriter
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
|
||||
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
|
||||
/// AuditLog-001: cached-lifecycle audit kinds
|
||||
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
||||
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
|
||||
/// <summary>
|
||||
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
|
||||
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
|
||||
/// whose audit kind belongs to the cached-call lifecycle
|
||||
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
public record ListApiKeysCommand;
|
||||
public record CreateApiKeyCommand(string Name);
|
||||
public record DeleteApiKeyCommand(int ApiKeyId);
|
||||
public record CreateApiKeyCommand(string Name, IReadOnlyList<string> Methods);
|
||||
public record DeleteApiKeyCommand(string KeyId);
|
||||
public record ListRoleMappingsCommand;
|
||||
public record CreateRoleMappingCommand(string LdapGroupName, string Role);
|
||||
public record UpdateRoleMappingCommand(int MappingId, string LdapGroupName, string Role);
|
||||
public record DeleteRoleMappingCommand(int MappingId);
|
||||
public record UpdateApiKeyCommand(int ApiKeyId, bool IsEnabled);
|
||||
public record UpdateApiKeyCommand(string KeyId, bool IsEnabled);
|
||||
public record SetApiKeyMethodsCommand(string KeyId, IReadOnlyList<string> Methods);
|
||||
public record ListScopeRulesCommand(int MappingId);
|
||||
public record AddScopeRuleCommand(int MappingId, int SiteId);
|
||||
public record DeleteScopeRuleCommand(int ScopeRuleId);
|
||||
|
||||
@@ -6,6 +6,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
/// Exports a bundle. Names rather than IDs in the selection so test scripts can
|
||||
/// be written without an ID lookup step. <c>All=true</c> overrides the per-type
|
||||
/// name lists and exports every entity of every supported type.
|
||||
/// <para>
|
||||
/// Inbound API keys are intentionally not selectable: per the inbound-API-key
|
||||
/// re-architecture (C4) keys are not transported between environments; only API
|
||||
/// methods travel. Re-create keys and re-grant their method scopes on the
|
||||
/// destination via the admin UI/CLI.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ExportBundleCommand(
|
||||
bool All,
|
||||
@@ -15,7 +21,6 @@ public sealed record ExportBundleCommand(
|
||||
IReadOnlyList<string>? DatabaseConnectionNames,
|
||||
IReadOnlyList<string>? NotificationListNames,
|
||||
IReadOnlyList<string>? SmtpConfigurationNames,
|
||||
IReadOnlyList<string>? ApiKeyNames,
|
||||
IReadOnlyList<string>? ApiMethodNames,
|
||||
bool IncludeDependencies,
|
||||
string? Passphrase,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge domain fields that travel inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
|
||||
/// All optional. <c>Channel</c>, <c>Kind</c>, and <c>Status</c> carry the string
|
||||
/// representations of the corresponding ScadaBridge enums for stable JSON serialization.
|
||||
/// <c>ForwardState</c> is deliberately absent — it remains a site-sidecar concern.
|
||||
/// Serialized and deserialized by <see cref="AuditDetailsCodec"/>.
|
||||
/// Property declaration order is load-bearing: <see cref="AuditDetailsCodec"/> relies on it for
|
||||
/// byte-deterministic output (System.Text.Json respects declared order).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only.
|
||||
/// </remarks>
|
||||
public sealed record AuditDetails
|
||||
{
|
||||
/// <summary>Top-level audit channel (<c>AuditChannel.ToString()</c>).</summary>
|
||||
public string? Channel { get; init; }
|
||||
|
||||
/// <summary>Specific event kind (<c>AuditKind.ToString()</c>).</summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
/// <summary>Lifecycle status (<c>AuditStatus.ToString()</c>).</summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>Execution / operation identifier for the audited call.</summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>Parent execution identifier for nested / cached operations.</summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site identifier where the event originated.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>Instance (Galaxy instance / target resource) identifier.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
|
||||
/// <summary>Script name or endpoint that initiated the audited action.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
|
||||
/// <summary>HTTP status code returned by the outbound or inbound call.</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>Elapsed duration of the audited action in milliseconds.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Short error message when the action failed.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Full error detail / stack excerpt when the action failed.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
|
||||
/// <summary>Condensed request representation (URL, method, or payload summary).</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
|
||||
/// <summary>Condensed response representation (status + body summary).</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> if any string field in this record was truncated before serialization.
|
||||
/// Included in JSON even when <c>false</c> (non-nullable bool, never null-omitted).
|
||||
/// </summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
|
||||
/// <summary>Freeform extension JSON for fields not covered above.</summary>
|
||||
public string? Extra { get; init; }
|
||||
|
||||
/// <summary>UTC instant when the central AuditLog store ingested this event.</summary>
|
||||
public DateTimeOffset? IngestedAtUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes and deserializes <see cref="AuditDetails"/> to/from the JSON string that
|
||||
/// occupies <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The options are DETERMINISTIC by design — this is a load-bearing contract:
|
||||
/// canonical <c>AuditEvent</c> uses value-equality (record) and consumers dedup on it, so
|
||||
/// two independent emitters must produce byte-identical <c>DetailsJson</c> for equal inputs.
|
||||
/// The options that make this deterministic:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — stable camelCase key names.</item>
|
||||
/// <item><c>WriteIndented = false</c> — no whitespace variation.</item>
|
||||
/// <item><see cref="JsonIgnoreCondition.WhenWritingNull"/> — null fields absent; non-nullable
|
||||
/// <c>bool</c> fields (like <see cref="AuditDetails.PayloadTruncated"/>) always present.</item>
|
||||
/// <item><see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> — angle brackets and other
|
||||
/// extended characters (including <c><redacted></c> markers) are NOT percent-escaped,
|
||||
/// preserving the exact byte value of any existing redaction strings.</item>
|
||||
/// <item>Property declaration order on <see cref="AuditDetails"/> fixes key order —
|
||||
/// System.Text.Json honours declared order, so serialization is stable across calls.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditDetailsCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// Single, cached, immutable options instance. Re-using one instance avoids repeated
|
||||
/// reflection overhead and guarantees the same encoder is used across all serialization calls.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes <paramref name="details"/> to a compact, deterministic JSON string
|
||||
/// suitable for storage in <c>AuditEvent.DetailsJson</c>.
|
||||
/// </summary>
|
||||
public static string Serialize(AuditDetails details)
|
||||
=> JsonSerializer.Serialize(details, Options);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a <see cref="AuditDetails"/> from <paramref name="json"/>.
|
||||
/// Returns an empty (all-null) <see cref="AuditDetails"/> when <paramref name="json"/>
|
||||
/// is <c>null</c>, empty, or whitespace — never throws.
|
||||
/// </summary>
|
||||
public static AuditDetails Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new AuditDetails();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditDetails>(json, Options)
|
||||
?? new AuditDetails();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new AuditDetails();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical <c>Action</c> and <c>Category</c> fields for
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> from ScadaBridge's channel + kind enums.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildAction"/> produces <c>"{channel}.{kind}"</c> — a stable dot-separated
|
||||
/// identifier that maps directly onto the canonical <c>Action</c> field.</item>
|
||||
/// <item><see cref="BuildCategory"/> produces <c>channel.ToString()</c> — a grouping token
|
||||
/// that maps onto the canonical <c>Category</c> field.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditFieldBuilders
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>Action</c> string: <c>"{channel}.{kind}"</c>.
|
||||
/// </summary>
|
||||
public static string BuildAction(AuditChannel channel, AuditKind kind)
|
||||
=> $"{channel}.{kind}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>Category</c> string: the channel name.
|
||||
/// </summary>
|
||||
public static string BuildCategory(AuditChannel channel)
|
||||
=> channel.ToString();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the ScadaBridge (<see cref="AuditStatus"/>, <see cref="AuditKind"/>) pair onto
|
||||
/// the canonical <see cref="AuditOutcome"/> required by <c>ZB.MOM.WW.Audit.AuditEvent</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Projection table:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AuditKind.InboundAuthFailure"/> → <see cref="AuditOutcome.Denied"/>
|
||||
/// (checked <b>first</b>, overrides any status).</item>
|
||||
/// <item><see cref="AuditStatus.Delivered"/> → <see cref="AuditOutcome.Success"/>.</item>
|
||||
/// <item><see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
|
||||
/// <see cref="AuditStatus.Discarded"/> → <see cref="AuditOutcome.Failure"/>.</item>
|
||||
/// <item>All other statuses (<c>Submitted</c>, <c>Forwarded</c>, <c>Attempted</c>,
|
||||
/// <c>Skipped</c>) → <see cref="AuditOutcome.Success"/>.</item>
|
||||
/// </list>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditOutcomeProjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects <paramref name="status"/> + <paramref name="kind"/> onto the canonical
|
||||
/// <see cref="AuditOutcome"/>.
|
||||
/// </summary>
|
||||
public static AuditOutcome Project(AuditStatus status, AuditKind kind)
|
||||
{
|
||||
// Auth-failure kind takes absolute precedence — checked before any status rule.
|
||||
if (kind == AuditKind.InboundAuthFailure)
|
||||
return AuditOutcome.Denied;
|
||||
|
||||
return status switch
|
||||
{
|
||||
AuditStatus.Delivered => AuditOutcome.Success,
|
||||
AuditStatus.Failed => AuditOutcome.Failure,
|
||||
AuditStatus.Parked => AuditOutcome.Failure,
|
||||
AuditStatus.Discarded => AuditOutcome.Failure,
|
||||
// Submitted / Forwarded / Attempted / Skipped → in-progress or short-circuit → Success
|
||||
_ => AuditOutcome.Success,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Transitional canonical ⇄ 24-column shim for the two AuditLog storage
|
||||
/// implementations (site SQLite, central SQL Server). C3 keeps the existing
|
||||
/// 24-column tables UNCHANGED; this helper decomposes a canonical
|
||||
/// <see cref="ZB.MOM.WW.Audit.AuditEvent"/> into the typed domain values the
|
||||
/// columns expect (Channel/Kind/Status enums + the <see cref="AuditDetails"/>
|
||||
/// fields) and recomposes a canonical record from those column values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical record
|
||||
/// only carries Action/Category/Outcome at the top level and stashes every
|
||||
/// ScadaBridge domain field inside <c>DetailsJson</c>; the legacy storage rows
|
||||
/// carry the domain fields as typed columns. This shim bridges the two without
|
||||
/// any schema change. C4 replaces the site shim with the real DetailsJson
|
||||
/// schema; C5 the central one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>ForwardState</c> is deliberately NOT part of this projection — it is a
|
||||
/// site-storage-only concern carried alongside the canonical record by the site
|
||||
/// SQLite writer, never inside <c>DetailsJson</c> and never on a central row.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AuditRowProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// The decomposed domain view of a canonical <see cref="AuditEvent"/> — the
|
||||
/// values the 24 storage columns expect. Built by <see cref="Decompose"/> from
|
||||
/// the canonical top-level fields plus the <see cref="AuditDetails"/> bag.
|
||||
/// </summary>
|
||||
public readonly record struct AuditRowValues(
|
||||
Guid EventId,
|
||||
DateTime OccurredAtUtc,
|
||||
DateTime? IngestedAtUtc,
|
||||
AuditChannel Channel,
|
||||
AuditKind Kind,
|
||||
AuditStatus Status,
|
||||
Guid? CorrelationId,
|
||||
Guid? ExecutionId,
|
||||
Guid? ParentExecutionId,
|
||||
string? SourceSiteId,
|
||||
string? SourceNode,
|
||||
string? SourceInstanceId,
|
||||
string? SourceScript,
|
||||
string? Actor,
|
||||
string? Target,
|
||||
int? HttpStatus,
|
||||
int? DurationMs,
|
||||
string? ErrorMessage,
|
||||
string? ErrorDetail,
|
||||
string? RequestSummary,
|
||||
string? ResponseSummary,
|
||||
bool PayloadTruncated,
|
||||
string? Extra);
|
||||
|
||||
/// <summary>
|
||||
/// Decomposes a canonical record into the typed column values. Channel/Kind/Status
|
||||
/// come from <c>DetailsJson</c> (the strings written by
|
||||
/// <see cref="ScadaBridgeAuditEventFactory"/>); a missing/unparseable discriminator
|
||||
/// falls back to the first enum member (defensive — production rows always carry them).
|
||||
/// </summary>
|
||||
public static AuditRowValues Decompose(AuditEvent evt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||
|
||||
var channel = ParseEnum(d.Channel, AuditChannel.ApiInbound);
|
||||
var kind = ParseEnum(d.Kind, AuditKind.InboundRequest);
|
||||
var status = ParseEnum(d.Status, AuditStatus.Submitted);
|
||||
|
||||
// The canonical OccurredAtUtc is UTC by construction; columns store a
|
||||
// Kind=Utc DateTime so downstream UTC/local conversions are safe
|
||||
// (CLAUDE.md: "All timestamps are UTC throughout the system.").
|
||||
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
|
||||
DateTime? ingested = d.IngestedAtUtc.HasValue
|
||||
? DateTime.SpecifyKind(d.IngestedAtUtc.Value.UtcDateTime, DateTimeKind.Utc)
|
||||
: null;
|
||||
|
||||
return new AuditRowValues(
|
||||
EventId: evt.EventId,
|
||||
OccurredAtUtc: occurred,
|
||||
IngestedAtUtc: ingested,
|
||||
Channel: channel,
|
||||
Kind: kind,
|
||||
Status: status,
|
||||
CorrelationId: evt.CorrelationId,
|
||||
ExecutionId: d.ExecutionId,
|
||||
ParentExecutionId: d.ParentExecutionId,
|
||||
SourceSiteId: d.SourceSiteId,
|
||||
SourceNode: evt.SourceNode,
|
||||
SourceInstanceId: d.SourceInstanceId,
|
||||
SourceScript: d.SourceScript,
|
||||
// Canonical Actor is a required non-null string; an empty Actor maps
|
||||
// back to a NULL column (legacy rows stored null for system/anon).
|
||||
Actor: string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
|
||||
Target: evt.Target,
|
||||
HttpStatus: d.HttpStatus,
|
||||
DurationMs: d.DurationMs,
|
||||
ErrorMessage: d.ErrorMessage,
|
||||
ErrorDetail: d.ErrorDetail,
|
||||
RequestSummary: d.RequestSummary,
|
||||
ResponseSummary: d.ResponseSummary,
|
||||
PayloadTruncated: d.PayloadTruncated,
|
||||
Extra: d.Extra);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomposes a canonical <see cref="AuditEvent"/> from the typed column values read
|
||||
/// back from storage. The inverse of <see cref="Decompose"/>: Action/Category/Outcome
|
||||
/// are rebuilt via the field builders / outcome projector, and every domain field is
|
||||
/// re-serialized into <c>DetailsJson</c> via <see cref="AuditDetailsCodec"/>.
|
||||
/// </summary>
|
||||
public static AuditEvent Recompose(in AuditRowValues v)
|
||||
{
|
||||
var details = new AuditDetails
|
||||
{
|
||||
Channel = v.Channel.ToString(),
|
||||
Kind = v.Kind.ToString(),
|
||||
Status = v.Status.ToString(),
|
||||
ExecutionId = v.ExecutionId,
|
||||
ParentExecutionId = v.ParentExecutionId,
|
||||
SourceSiteId = v.SourceSiteId,
|
||||
SourceInstanceId = v.SourceInstanceId,
|
||||
SourceScript = v.SourceScript,
|
||||
HttpStatus = v.HttpStatus,
|
||||
DurationMs = v.DurationMs,
|
||||
ErrorMessage = v.ErrorMessage,
|
||||
ErrorDetail = v.ErrorDetail,
|
||||
RequestSummary = v.RequestSummary,
|
||||
ResponseSummary = v.ResponseSummary,
|
||||
PayloadTruncated = v.PayloadTruncated,
|
||||
Extra = v.Extra,
|
||||
IngestedAtUtc = v.IngestedAtUtc.HasValue
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(v.IngestedAtUtc.Value, DateTimeKind.Utc))
|
||||
: null,
|
||||
};
|
||||
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = v.EventId,
|
||||
OccurredAtUtc = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(v.OccurredAtUtc, DateTimeKind.Utc)),
|
||||
Actor = v.Actor ?? string.Empty,
|
||||
Action = AuditFieldBuilders.BuildAction(v.Channel, v.Kind),
|
||||
Category = AuditFieldBuilders.BuildCategory(v.Channel),
|
||||
Outcome = AuditOutcomeProjector.Project(v.Status, v.Kind),
|
||||
Target = v.Target,
|
||||
SourceNode = v.SourceNode,
|
||||
CorrelationId = v.CorrelationId,
|
||||
DetailsJson = AuditDetailsCodec.Serialize(details),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="evt"/> with the central-side ingest timestamp
|
||||
/// stamped into its <c>DetailsJson</c> (<see cref="AuditDetails.IngestedAtUtc"/>).
|
||||
/// C3 transitional shim: <c>IngestedAtUtc</c> is a DetailsJson field on the canonical
|
||||
/// record, so the central ingest paths stamp it here rather than on a top-level
|
||||
/// property as the legacy bespoke record allowed.
|
||||
/// </summary>
|
||||
public static AuditEvent WithIngestedAtUtc(AuditEvent evt, DateTimeOffset ingestedAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson) with
|
||||
{
|
||||
IngestedAtUtc = ingestedAtUtc.ToUniversalTime(),
|
||||
};
|
||||
return evt with { DetailsJson = AuditDetailsCodec.Serialize(d) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case-sensitive <see cref="Enum.TryParse{TEnum}"/> with a caller-supplied fallback.
|
||||
/// Returns <paramref name="fallback"/> when <paramref name="value"/> is null, empty,
|
||||
/// or does not match any declared member name — so callers never throw on an
|
||||
/// unknown/renamed enum string (legacy or corrupt rows degrade gracefully).
|
||||
/// </summary>
|
||||
public static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct, Enum
|
||||
=> !string.IsNullOrEmpty(value) && Enum.TryParse<TEnum>(value, ignoreCase: false, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience extension that decomposes a canonical <see cref="AuditEvent"/> into its
|
||||
/// typed 24-field <see cref="AuditRowProjection.AuditRowValues"/> view. Lets callers
|
||||
/// (and tests) read the ScadaBridge domain fields — Channel/Kind/Status + the DetailsJson
|
||||
/// fields — as typed properties off a canonical row.
|
||||
/// </summary>
|
||||
public static class AuditEventRowExtensions
|
||||
{
|
||||
/// <summary>Decomposes this canonical record into its typed 24-field view.</summary>
|
||||
public static AuditRowProjection.AuditRowValues AsRow(this AuditEvent evt)
|
||||
=> AuditRowProjection.Decompose(evt);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>
|
||||
/// from ScadaBridge's domain vocabulary. Every emit site builds its row through
|
||||
/// <see cref="Create"/> so the canonical-field mapping (Channel/Kind/Status →
|
||||
/// Action/Category/Outcome, every other domain field → <see cref="AuditDetails"/>
|
||||
/// inside <see cref="ZB.MOM.WW.Audit.AuditEvent.DetailsJson"/>) is applied
|
||||
/// identically everywhere — no per-site drift.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical
|
||||
/// record is the type at every seam, emit site, DTO boundary, and redactor; the
|
||||
/// ScadaBridge domain fields ride in <c>DetailsJson</c> via
|
||||
/// <see cref="AuditDetailsCodec"/>.</para>
|
||||
/// <para>Mapping (see Task 2.5 spec):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Action</c> = <see cref="AuditFieldBuilders.BuildAction"/>(channel, kind).</item>
|
||||
/// <item><c>Category</c> = <see cref="AuditFieldBuilders.BuildCategory"/>(channel) (= channel name).</item>
|
||||
/// <item><c>Outcome</c> = <see cref="AuditOutcomeProjector.Project"/>(status, kind).</item>
|
||||
/// <item><c>DetailsJson</c> carries Channel/Kind/Status (as strings) + every other
|
||||
/// ScadaBridge domain field. <c>ForwardState</c> is NOT a DetailsJson field — it is
|
||||
/// a site-storage-only concern handled by the site SQLite shim.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ScadaBridgeAuditEventFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for one ScadaBridge
|
||||
/// audit row. <paramref name="channel"/>/<paramref name="kind"/>/<paramref name="status"/>
|
||||
/// drive the canonical Action/Category/Outcome and are also recorded (as strings) in
|
||||
/// <c>DetailsJson</c>; all remaining ScadaBridge domain fields are carried in
|
||||
/// <c>DetailsJson</c> too.
|
||||
/// </summary>
|
||||
/// <param name="channel">Trust-boundary channel the audited action crossed.</param>
|
||||
/// <param name="kind">Specific event kind within the channel.</param>
|
||||
/// <param name="status">Lifecycle status of this row.</param>
|
||||
/// <param name="eventId">Idempotency key. Defaults to a fresh <see cref="Guid"/> when omitted.</param>
|
||||
/// <param name="occurredAtUtc">When the action occurred (UTC). Defaults to <see cref="DateTime.UtcNow"/> when omitted.</param>
|
||||
/// <param name="actor">Authenticated actor for inbound paths (API key name, user, "system", etc.).</param>
|
||||
/// <param name="target">Target of the action (external system, db connection, list name, inbound method).</param>
|
||||
/// <param name="sourceNode">Cluster node that emitted the event (top-level canonical field).</param>
|
||||
/// <param name="correlationId">Per-operation lifecycle correlation id (top-level canonical field).</param>
|
||||
/// <param name="executionId">Originating script-execution / inbound-request id (DetailsJson).</param>
|
||||
/// <param name="parentExecutionId">Spawning execution's id (DetailsJson).</param>
|
||||
/// <param name="sourceSiteId">Site id where the action originated (DetailsJson).</param>
|
||||
/// <param name="sourceInstanceId">Instance id where the action originated (DetailsJson).</param>
|
||||
/// <param name="sourceScript">Script that initiated the action (DetailsJson).</param>
|
||||
/// <param name="httpStatus">HTTP status code where applicable (DetailsJson).</param>
|
||||
/// <param name="durationMs">Duration of the audited action in ms (DetailsJson).</param>
|
||||
/// <param name="errorMessage">Human-readable error summary on failure rows (DetailsJson).</param>
|
||||
/// <param name="errorDetail">Verbose error detail (stack/exception) on failure rows (DetailsJson).</param>
|
||||
/// <param name="requestSummary">Truncated/redacted request summary (DetailsJson).</param>
|
||||
/// <param name="responseSummary">Truncated/redacted response summary (DetailsJson).</param>
|
||||
/// <param name="payloadTruncated">True when summaries were truncated to the payload cap (DetailsJson).</param>
|
||||
/// <param name="extra">Free-form JSON extension for channel-specific extras (DetailsJson).</param>
|
||||
/// <param name="ingestedAtUtc">UTC ingest timestamp (central-set; DetailsJson).</param>
|
||||
public static AuditEvent Create(
|
||||
AuditChannel channel,
|
||||
AuditKind kind,
|
||||
AuditStatus status,
|
||||
Guid? eventId = null,
|
||||
DateTime? occurredAtUtc = null,
|
||||
string? actor = null,
|
||||
string? target = null,
|
||||
string? sourceNode = null,
|
||||
Guid? correlationId = null,
|
||||
Guid? executionId = null,
|
||||
Guid? parentExecutionId = null,
|
||||
string? sourceSiteId = null,
|
||||
string? sourceInstanceId = null,
|
||||
string? sourceScript = null,
|
||||
int? httpStatus = null,
|
||||
int? durationMs = null,
|
||||
string? errorMessage = null,
|
||||
string? errorDetail = null,
|
||||
string? requestSummary = null,
|
||||
string? responseSummary = null,
|
||||
bool payloadTruncated = false,
|
||||
string? extra = null,
|
||||
DateTimeOffset? ingestedAtUtc = null)
|
||||
{
|
||||
var details = new AuditDetails
|
||||
{
|
||||
Channel = channel.ToString(),
|
||||
Kind = kind.ToString(),
|
||||
Status = status.ToString(),
|
||||
ExecutionId = executionId,
|
||||
ParentExecutionId = parentExecutionId,
|
||||
SourceSiteId = sourceSiteId,
|
||||
SourceInstanceId = sourceInstanceId,
|
||||
SourceScript = sourceScript,
|
||||
HttpStatus = httpStatus,
|
||||
DurationMs = durationMs,
|
||||
ErrorMessage = errorMessage,
|
||||
ErrorDetail = errorDetail,
|
||||
RequestSummary = requestSummary,
|
||||
ResponseSummary = responseSummary,
|
||||
PayloadTruncated = payloadTruncated,
|
||||
Extra = extra,
|
||||
IngestedAtUtc = ingestedAtUtc,
|
||||
};
|
||||
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
// DateTimeOffset assumes UTC when the source DateTime is Unspecified/Utc;
|
||||
// every ScadaBridge OccurredAt value is UTC by contract.
|
||||
OccurredAtUtc = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(occurredAtUtc ?? DateTime.UtcNow, DateTimeKind.Utc)),
|
||||
Actor = actor ?? string.Empty,
|
||||
Action = AuditFieldBuilders.BuildAction(channel, kind),
|
||||
Category = AuditFieldBuilders.BuildCategory(channel),
|
||||
Outcome = AuditOutcomeProjector.Project(status, kind),
|
||||
Target = target,
|
||||
SourceNode = sourceNode,
|
||||
CorrelationId = correlationId,
|
||||
DetailsJson = AuditDetailsCodec.Serialize(details),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic, keyed hash of an inbound-API key value
|
||||
/// (ConfigurationDatabase-012). API keys are persisted as this hash, never as
|
||||
/// plaintext, so a configuration-database dump does not yield usable credentials.
|
||||
/// The hash is deterministic so authentication can still resolve a key by value.
|
||||
/// </summary>
|
||||
public interface IApiKeyHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the keyed hash of <paramref name="apiKey"/> as a Base64 string.
|
||||
/// The same input always produces the same output (deterministic), which keeps
|
||||
/// the by-value lookup working.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The raw API key to hash.</param>
|
||||
/// <returns>A Base64-encoded HMAC-SHA256 hash of the key.</returns>
|
||||
string Hash(string apiKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC-SHA256 implementation of <see cref="IApiKeyHasher"/>. The HMAC key is a
|
||||
/// server-side <em>pepper</em> bound from configuration. A per-row random salt is
|
||||
/// intentionally NOT used: an API key is already a high-entropy random token, and a
|
||||
/// random salt would break the deterministic by-value lookup the authentication
|
||||
/// path relies on. The pepper instead binds every hash to this deployment, so a
|
||||
/// stolen database is useless without it.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyHasher : IApiKeyHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum accepted pepper length. A pepper shorter than this is treated as a
|
||||
/// deployment misconfiguration and rejected — see <see cref="ApiKeyHasher(string)"/>.
|
||||
/// </summary>
|
||||
public const int MinimumPepperLength = 16;
|
||||
|
||||
private readonly byte[] _pepper;
|
||||
|
||||
/// <summary>
|
||||
/// An unpeppered hasher (HMAC-SHA256 keyed with a fixed, empty-equivalent value).
|
||||
/// It is still a one-way hash, but carries no deployment-specific binding. It
|
||||
/// exists for tests and non-production wiring; production must construct an
|
||||
/// <see cref="ApiKeyHasher"/> with a real pepper.
|
||||
/// </summary>
|
||||
public static ApiKeyHasher Default { get; } = new ApiKeyHasher();
|
||||
|
||||
private ApiKeyHasher()
|
||||
{
|
||||
// Fixed, deployment-independent key for the unpeppered default.
|
||||
_pepper = Encoding.UTF8.GetBytes("ZB.MOM.WW.ScadaBridge.InboundApi.DefaultApiKeyHasher");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a hasher keyed with the given server-side pepper.
|
||||
/// </summary>
|
||||
/// <param name="pepper">Server-side HMAC key; must be at least <see cref="MinimumPepperLength"/> characters.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown if <paramref name="pepper"/> is null, blank, or shorter than
|
||||
/// <see cref="MinimumPepperLength"/> — a missing or weak pepper is a deployment
|
||||
/// misconfiguration and must fail loudly rather than degrade silently.
|
||||
/// </exception>
|
||||
public ApiKeyHasher(string pepper)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The API-key HMAC pepper must be configured. Set a strong, random value " +
|
||||
"in configuration (ScadaBridge:InboundApi:ApiKeyPepper).",
|
||||
nameof(pepper));
|
||||
}
|
||||
|
||||
if (pepper.Trim().Length < MinimumPepperLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The API-key HMAC pepper is too weak: it must be at least {MinimumPepperLength} " +
|
||||
"characters. Use a strong, random value.",
|
||||
nameof(pepper));
|
||||
}
|
||||
|
||||
_pepper = Encoding.UTF8.GetBytes(pepper);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Hash(string apiKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiKey);
|
||||
|
||||
using var hmac = new HMACSHA256(_pepper);
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
// ApiKeys is intentionally absent: inbound API keys are not transported between
|
||||
// environments (re-arch C4). Only API methods are summarised.
|
||||
public sealed record BundleSummary(
|
||||
int Templates,
|
||||
int TemplateFolders,
|
||||
@@ -8,5 +10,4 @@ public sealed record BundleSummary(
|
||||
int DbConnections,
|
||||
int NotificationLists,
|
||||
int SmtpConfigs,
|
||||
int ApiKeys,
|
||||
int ApiMethods);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
// Inbound API keys are intentionally absent from the transport selection: per the
|
||||
// inbound-API-key re-architecture (commit C4) keys are NOT carried between
|
||||
// environments. They live in the per-environment SQLite store (per-env pepper +
|
||||
// secret-shown-once) and are re-created/re-granted via the admin UI/CLI on the
|
||||
// destination. Only API *methods* travel in a bundle.
|
||||
public sealed record ExportSelection(
|
||||
IReadOnlyList<int> TemplateIds,
|
||||
IReadOnlyList<int> SharedScriptIds,
|
||||
@@ -7,6 +12,5 @@ public sealed record ExportSelection(
|
||||
IReadOnlyList<int> DatabaseConnectionIds,
|
||||
IReadOnlyList<int> NotificationListIds,
|
||||
IReadOnlyList<int> SmtpConfigurationIds,
|
||||
IReadOnlyList<int> ApiKeyIds,
|
||||
IReadOnlyList<int> ApiMethodIds,
|
||||
bool IncludeDependencies);
|
||||
|
||||
@@ -7,4 +7,8 @@ public sealed record ImportResult(
|
||||
int Skipped,
|
||||
int Renamed,
|
||||
IReadOnlyList<int> StaleInstanceIds,
|
||||
string AuditEventCorrelation);
|
||||
string AuditEventCorrelation,
|
||||
// Number of legacy inbound API keys found in the bundle that were ignored
|
||||
// (re-arch C4 — keys are not transported; re-create them on this environment).
|
||||
// Defaults to 0 so existing positional construction sites stay source-compatible.
|
||||
int ApiKeysIgnored = 0);
|
||||
|
||||
@@ -7,4 +7,8 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||
|
||||
@@ -41,38 +42,44 @@ public static class AuditEventDtoMapper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
// C3 (Task 2.5): the proto contract is the UNCHANGED 24-field wire. The
|
||||
// canonical record carries the ScadaBridge domain fields inside
|
||||
// DetailsJson — decompose them so the DTO's typed domain fields are
|
||||
// populated exactly as before.
|
||||
var r = AuditRowProjection.Decompose(evt);
|
||||
|
||||
var dto = new AuditEventDto
|
||||
{
|
||||
EventId = evt.EventId.ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
|
||||
Channel = evt.Channel.ToString(),
|
||||
Kind = evt.Kind.ToString(),
|
||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
||||
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
|
||||
ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
|
||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
||||
SourceNode = evt.SourceNode ?? string.Empty,
|
||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
||||
SourceScript = evt.SourceScript ?? string.Empty,
|
||||
Actor = evt.Actor ?? string.Empty,
|
||||
Target = evt.Target ?? string.Empty,
|
||||
Status = evt.Status.ToString(),
|
||||
ErrorMessage = evt.ErrorMessage ?? string.Empty,
|
||||
ErrorDetail = evt.ErrorDetail ?? string.Empty,
|
||||
RequestSummary = evt.RequestSummary ?? string.Empty,
|
||||
ResponseSummary = evt.ResponseSummary ?? string.Empty,
|
||||
PayloadTruncated = evt.PayloadTruncated,
|
||||
Extra = evt.Extra ?? string.Empty
|
||||
EventId = r.EventId.ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTime(EnsureUtc(r.OccurredAtUtc)),
|
||||
Channel = r.Channel.ToString(),
|
||||
Kind = r.Kind.ToString(),
|
||||
CorrelationId = r.CorrelationId?.ToString() ?? string.Empty,
|
||||
ExecutionId = r.ExecutionId?.ToString() ?? string.Empty,
|
||||
ParentExecutionId = r.ParentExecutionId?.ToString() ?? string.Empty,
|
||||
SourceSiteId = r.SourceSiteId ?? string.Empty,
|
||||
SourceNode = r.SourceNode ?? string.Empty,
|
||||
SourceInstanceId = r.SourceInstanceId ?? string.Empty,
|
||||
SourceScript = r.SourceScript ?? string.Empty,
|
||||
Actor = r.Actor ?? string.Empty,
|
||||
Target = r.Target ?? string.Empty,
|
||||
Status = r.Status.ToString(),
|
||||
ErrorMessage = r.ErrorMessage ?? string.Empty,
|
||||
ErrorDetail = r.ErrorDetail ?? string.Empty,
|
||||
RequestSummary = r.RequestSummary ?? string.Empty,
|
||||
ResponseSummary = r.ResponseSummary ?? string.Empty,
|
||||
PayloadTruncated = r.PayloadTruncated,
|
||||
Extra = r.Extra ?? string.Empty
|
||||
};
|
||||
|
||||
if (evt.HttpStatus.HasValue)
|
||||
if (r.HttpStatus.HasValue)
|
||||
{
|
||||
dto.HttpStatus = evt.HttpStatus.Value;
|
||||
dto.HttpStatus = r.HttpStatus.Value;
|
||||
}
|
||||
|
||||
if (evt.DurationMs.HasValue)
|
||||
if (r.DurationMs.HasValue)
|
||||
{
|
||||
dto.DurationMs = evt.DurationMs.Value;
|
||||
dto.DurationMs = r.DurationMs.Value;
|
||||
}
|
||||
|
||||
return dto;
|
||||
@@ -89,33 +96,35 @@ public static class AuditEventDtoMapper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse(dto.EventId),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
||||
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
||||
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
|
||||
ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
|
||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
||||
SourceNode = NullIfEmpty(dto.SourceNode),
|
||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
||||
Actor = NullIfEmpty(dto.Actor),
|
||||
Target = NullIfEmpty(dto.Target),
|
||||
Status = Enum.Parse<AuditStatus>(dto.Status),
|
||||
HttpStatus = dto.HttpStatus,
|
||||
DurationMs = dto.DurationMs,
|
||||
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
||||
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
||||
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
||||
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
||||
PayloadTruncated = dto.PayloadTruncated,
|
||||
Extra = NullIfEmpty(dto.Extra),
|
||||
ForwardState = null
|
||||
};
|
||||
// C3 (Task 2.5): recompose the canonical record from the 24-field wire
|
||||
// DTO. The domain fields are re-serialized into DetailsJson via the
|
||||
// projection helper; IngestedAtUtc is left null (central sets it at
|
||||
// ingest) and ForwardState is dropped (site-storage-only, never on the
|
||||
// wire).
|
||||
return AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
|
||||
EventId: Guid.Parse(dto.EventId),
|
||||
OccurredAtUtc: DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc: null,
|
||||
Channel: AuditRowProjection.ParseEnum<AuditChannel>(dto.Channel, AuditChannel.ApiInbound),
|
||||
Kind: AuditRowProjection.ParseEnum<AuditKind>(dto.Kind, AuditKind.InboundRequest),
|
||||
Status: AuditRowProjection.ParseEnum<AuditStatus>(dto.Status, AuditStatus.Submitted),
|
||||
CorrelationId: NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
||||
ExecutionId: NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
|
||||
ParentExecutionId: NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
|
||||
SourceSiteId: NullIfEmpty(dto.SourceSiteId),
|
||||
SourceNode: NullIfEmpty(dto.SourceNode),
|
||||
SourceInstanceId: NullIfEmpty(dto.SourceInstanceId),
|
||||
SourceScript: NullIfEmpty(dto.SourceScript),
|
||||
Actor: NullIfEmpty(dto.Actor),
|
||||
Target: NullIfEmpty(dto.Target),
|
||||
HttpStatus: dto.HttpStatus,
|
||||
DurationMs: dto.DurationMs,
|
||||
ErrorMessage: NullIfEmpty(dto.ErrorMessage),
|
||||
ErrorDetail: NullIfEmpty(dto.ErrorDetail),
|
||||
RequestSummary: NullIfEmpty(dto.RequestSummary),
|
||||
ResponseSummary: NullIfEmpty(dto.ResponseSummary),
|
||||
PayloadTruncated: dto.PayloadTruncated,
|
||||
Extra: NullIfEmpty(dto.Extra)));
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ using Akka.Actor;
|
||||
using Grpc.Core;
|
||||
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.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
|
||||
+138
-74
@@ -1,16 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
|
||||
/// described in alog.md §4. Column lengths/types and the five named indexes are
|
||||
/// fixed by that specification — keep this in sync with the doc.
|
||||
/// Maps the C5 (Task 2.5) <see cref="AuditLogRow"/> persistence shape to the central
|
||||
/// <c>dbo.AuditLog</c> table: the 10 canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns
|
||||
/// (writable) plus six read-only, server-side <b>computed columns</b> derived from
|
||||
/// <c>DetailsJson</c> via <c>JSON_VALUE</c> — five PERSISTED (<c>Kind</c>/<c>Status</c>/
|
||||
/// <c>SourceSiteId</c>/<c>ExecutionId</c>/<c>ParentExecutionId</c>) plus <c>IngestedAtUtc</c>
|
||||
/// which is computed but NOT persisted (SQL Server forbids PERSISTED on its non-deterministic
|
||||
/// <c>SWITCHOFFSET</c> cast). The computed-column SQL and the index
|
||||
/// set here mirror the <c>CollapseAuditLogToCanonical</c> migration's
|
||||
/// <c>dbo.AuditLog_v2</c> DDL byte-for-byte — keep them in sync.
|
||||
/// </summary>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
|
||||
{
|
||||
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
|
||||
// (a column hydrated from the database always surfaces as
|
||||
@@ -33,88 +39,146 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
||||
: null,
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
|
||||
// The computed columns derive enum-named strings from DetailsJson (e.g.
|
||||
// JSON_VALUE(...,'$.kind') == "CachedResolve"), exactly the value
|
||||
// HasConversion<string>() expects on read. The repository never writes these
|
||||
// (they are server-computed), but the string<->enum converter is still
|
||||
// required so EF materialises them as the strongly-typed enum a LINQ
|
||||
// predicate like `e.Kind == AuditKind.CachedResolve` translates against.
|
||||
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<AuditEvent> builder)
|
||||
public void Configure(EntityTypeBuilder<AuditLogRow> builder)
|
||||
{
|
||||
builder.ToTable("AuditLog");
|
||||
|
||||
// Enforce DateTimeKind.Utc on every *Utc-suffixed DateTime column. See
|
||||
// the UtcConverter remarks above for the rationale.
|
||||
builder.Property(e => e.OccurredAtUtc).HasConversion(UtcConverter);
|
||||
builder.Property(e => e.IngestedAtUtc).HasConversion(NullableUtcConverter);
|
||||
// ── Canonical columns (writable) ─────────────────────────────────────
|
||||
//
|
||||
// Column SQL TYPES are intentionally left to EF's relational conventions
|
||||
// (driven by HasMaxLength / IsUnicode / the CLR type) rather than pinned
|
||||
// with HasColumnType, so the SAME configuration maps to SQL Server
|
||||
// (varchar(n) / nvarchar(n) / uniqueidentifier / datetime2) in production
|
||||
// AND to the SQLite test provider (TEXT) without a `(max)`/`uniqueidentifier`
|
||||
// literal leaking into SQLite DDL. The migration's raw DDL pins the exact
|
||||
// SQL Server types; EF's conventions agree with them (verified clean via
|
||||
// `has-pending-model-changes`).
|
||||
|
||||
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
|
||||
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
|
||||
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
|
||||
// separate unique index is declared on EventId alone.
|
||||
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
|
||||
|
||||
builder.HasIndex(e => e.EventId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AuditLog_EventId");
|
||||
|
||||
// Enum-as-string columns: bounded varchar(32) ASCII.
|
||||
builder.Property(e => e.Channel)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Kind)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.ForwardState)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Ascii identifier columns — never carry user-supplied unicode.
|
||||
builder.Property(e => e.SourceSiteId)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(e => e.SourceInstanceId)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(e => e.SourceScript)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
// Enforce DateTimeKind.Utc on the OccurredAtUtc column. See the
|
||||
// UtcConverter remarks above for the rationale.
|
||||
builder.Property(e => e.OccurredAtUtc)
|
||||
.HasConversion(UtcConverter);
|
||||
|
||||
builder.Property(e => e.Actor)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
.HasMaxLength(256);
|
||||
|
||||
builder.Property(e => e.Action)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Outcome)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(16)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Channel rides in the canonical Category column (Category = channel name
|
||||
// for ScadaBridge). Stored as the enum's name in varchar(32); the
|
||||
// string<->enum converter lets `e.Channel == AuditChannel.X` translate to
|
||||
// `[Category] = 'X'` server-side.
|
||||
builder.Property(e => e.Channel)
|
||||
.HasColumnName("Category")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Target)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false);
|
||||
.HasMaxLength(256);
|
||||
|
||||
// SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the
|
||||
// cluster member that produced the row (e.g. "node-a", "central-a"). NULL is
|
||||
// valid for reconciled rows from a retired node and for direct-write rows
|
||||
// produced before this feature shipped. ASCII — varchar(64), no unicode.
|
||||
builder.Property(e => e.SourceNode)
|
||||
.HasColumnType("varchar(64)")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Bounded unicode message column.
|
||||
builder.Property(e => e.ErrorMessage)
|
||||
.HasMaxLength(1024);
|
||||
// DetailsJson: unbounded → nvarchar(max) on SQL Server, TEXT on SQLite.
|
||||
// (No HasMaxLength / HasColumnType — let conventions pick per provider.)
|
||||
|
||||
// ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
|
||||
// ── Persisted computed columns (read-only; derived from DetailsJson) ──
|
||||
//
|
||||
// Each is `… AS <expr> PERSISTED`; EF must never attempt to write them
|
||||
// (ValueGeneratedOnAddOrUpdate + metadata-only mapping). The SQL strings
|
||||
// here MUST match the migration's dbo.AuditLog_v2 DDL exactly so
|
||||
// `dotnet ef migrations has-pending-model-changes` stays clean. The SQLite
|
||||
// test context strips the computed-column SQL (JSON_VALUE is unknown to
|
||||
// SQLite) so EnsureCreated still works.
|
||||
|
||||
// Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
|
||||
builder.Property(e => e.Kind)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.SourceSiteId)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
builder.Property(e => e.ExecutionId)
|
||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
builder.Property(e => e.ParentExecutionId)
|
||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
// IngestedAtUtc rides in DetailsJson as an ISO-8601-with-offset string
|
||||
// (always +00:00 — the codec normalises to UTC). SWITCHOFFSET(...,0)
|
||||
// normalises any offset to UTC before the datetime2 cast, so the column
|
||||
// is the UTC wall-clock regardless. The datetimeoffset cast / SWITCHOFFSET
|
||||
// is NON-DETERMINISTic to SQL Server, so this computed column is NOT
|
||||
// persisted (stored:false) — a PERSISTED non-deterministic column is
|
||||
// rejected at CREATE. It is not indexed, so non-persistence costs nothing.
|
||||
// Routed through the nullable UTC converter so the materialised value
|
||||
// carries Kind=Utc.
|
||||
builder.Property(e => e.IngestedAtUtc)
|
||||
.HasColumnType("datetime2(7)")
|
||||
.HasConversion(NullableUtcConverter)
|
||||
.HasComputedColumnSql(
|
||||
"CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))",
|
||||
stored: false)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
// ── Keys + indexes ───────────────────────────────────────────────────
|
||||
|
||||
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
|
||||
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
|
||||
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
|
||||
// separate (non-aligned) unique index is declared on EventId alone.
|
||||
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
|
||||
|
||||
builder.HasIndex(e => e.EventId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AuditLog_EventId");
|
||||
|
||||
// Index names are locked for reconciliation/migration discoverability. The
|
||||
// column SETS migrate to the canonical/computed shape (alog.md §4 semantics
|
||||
// preserved): Channel→Category, Site/Node/Execution/ParentExecution now read
|
||||
// off the computed columns.
|
||||
builder.HasIndex(e => e.OccurredAtUtc)
|
||||
.IsDescending(true)
|
||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||
@@ -127,22 +191,22 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
||||
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||
|
||||
// ExecutionId / ParentExecutionId are persisted computed columns: SQL Server
|
||||
// forbids a filtered-index WHERE predicate referencing a computed column, so
|
||||
// these two indexes are UNFILTERED (they also index NULL rows; equality
|
||||
// lookups are unaffected). Keep HasFilter(null) so the model matches the
|
||||
// migration DDL exactly.
|
||||
builder.HasIndex(e => e.ExecutionId)
|
||||
.HasFilter("[ExecutionId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_Execution");
|
||||
|
||||
builder.HasIndex(e => e.ParentExecutionId)
|
||||
.HasFilter("[ParentExecutionId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_ParentExecution");
|
||||
|
||||
// SourceNode composite index (Audit Log #23, SourceNode-stamping): backs
|
||||
// per-node Central UI / health-dashboard queries (e.g. "rows produced by
|
||||
// central-a, newest first"). Created via raw SQL in the migration so it lands
|
||||
// on the ps_AuditLog_Month(OccurredAtUtc) partition scheme like every other
|
||||
// IX_AuditLog_* index — keeps the partition-switch purge path intact.
|
||||
builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc })
|
||||
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
||||
|
||||
// IX_AuditLog_Channel_Status_Occurred name preserved; columns are now the
|
||||
// canonical Category (= channel) + computed Status + OccurredAtUtc.
|
||||
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||
|
||||
+5
-26
@@ -4,29 +4,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for the <see cref="ApiKey"/> entity.</summary>
|
||||
/// <param name="builder">Entity type builder used to apply the configuration.</param>
|
||||
public void Configure(EntityTypeBuilder<ApiKey> builder)
|
||||
{
|
||||
builder.HasKey(k => k.Id);
|
||||
|
||||
builder.Property(k => k.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// ConfigurationDatabase-012: the bearer credential is persisted only as a
|
||||
// deterministic HMAC-SHA256 hash, never as plaintext. Base64 of a 32-byte
|
||||
// HMAC-SHA256 digest is 44 characters; 256 leaves generous headroom.
|
||||
builder.Property(k => k.KeyHash)
|
||||
.IsRequired()
|
||||
.HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(k => k.Name).IsUnique();
|
||||
builder.HasIndex(k => k.KeyHash).IsUnique();
|
||||
}
|
||||
}
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity was retired — inbound API keys now
|
||||
// live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store. The former
|
||||
// ApiKeyConfiguration (and the ApiMethod.ApprovedApiKeyIds mapping) were removed; the
|
||||
// ApiKeys table + ApprovedApiKeyIds column are dropped by the RetireInboundApiKeyStore
|
||||
// migration.
|
||||
|
||||
public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
|
||||
{
|
||||
@@ -43,9 +25,6 @@ public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
|
||||
builder.Property(m => m.Script)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.ApprovedApiKeyIds)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
|
||||
+8
-5
@@ -25,12 +25,15 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
|
||||
|
||||
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
||||
|
||||
// Seed default group mappings matching GLAuth test users
|
||||
// Seed default group mappings matching GLAuth test users.
|
||||
// Role VALUES are the canonical six (Task 1.7): Administrator/Designer/
|
||||
// Deployer. The LDAP group NAMES (SCADA-Admins etc.) are unchanged —
|
||||
// only the role each group maps to was canonicalized.
|
||||
builder.HasData(
|
||||
new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 });
|
||||
new LdapGroupMapping("SCADA-Admins", "Administrator") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Designer") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployer") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployer") { Id = 4 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core persistence shape for the central <c>dbo.AuditLog</c> table after the
|
||||
/// C5 collapse (Audit Log #23, Task 2.5). The table is now the 10 canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> fields stored DIRECTLY plus a set of
|
||||
/// read-only, server-side <b>persisted computed columns</b> derived from
|
||||
/// <see cref="DetailsJson"/> (<c>JSON_VALUE</c> … <c>PERSISTED</c>) so the
|
||||
/// reporting queries stay indexable without re-parsing JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>C5 (Task 2.5).</b> The transitional 24-typed-column shim is retired. The
|
||||
/// repository writes the 10 canonical columns directly (no <c>Decompose</c>) and
|
||||
/// the computed columns auto-derive at INSERT; reads build the canonical
|
||||
/// <c>AuditEvent</c> straight off the canonical columns (no <c>Recompose</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Canonical columns (writable):</b> <see cref="EventId"/>,
|
||||
/// <see cref="OccurredAtUtc"/>, <see cref="Actor"/>, <see cref="Action"/>,
|
||||
/// <see cref="Outcome"/>, <see cref="Channel"/> (the canonical <c>Category</c>
|
||||
/// column — for ScadaBridge, Category = channel name), <see cref="Target"/>,
|
||||
/// <see cref="SourceNode"/>, <see cref="CorrelationId"/>,
|
||||
/// <see cref="DetailsJson"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Persisted computed columns (read-only):</b> <see cref="Kind"/>,
|
||||
/// <see cref="Status"/>, <see cref="SourceSiteId"/>, <see cref="ExecutionId"/>,
|
||||
/// <see cref="ParentExecutionId"/> (the five spec'd queryability columns), plus
|
||||
/// <see cref="IngestedAtUtc"/> (central ingest timestamp, also a DetailsJson
|
||||
/// field). These are populated by SQL Server from <see cref="DetailsJson"/>; EF
|
||||
/// never writes them. Their getters expose them as typed
|
||||
/// (enum / <see cref="Guid"/> / <see cref="DateTime"/>) properties so the
|
||||
/// existing LINQ filter/aggregate queries keep their meaning; the value
|
||||
/// converters that turn enum names ⇄ varchar match the JSON_VALUE string output.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
|
||||
/// (CLAUDE.md: "All timestamps are UTC throughout the system."). The init-setters
|
||||
/// force <see cref="DateTimeKind.Utc"/> on assignment so a value re-hydrated from a
|
||||
/// SQL Server <c>datetime2</c> column (which strips the <c>Kind</c> flag on the wire)
|
||||
/// cannot leak downstream as <see cref="DateTimeKind.Unspecified"/> or be silently
|
||||
/// re-interpreted as local time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record AuditLogRow
|
||||
{
|
||||
// ── Canonical columns (the 10 ZB.MOM.WW.Audit.AuditEvent fields) ──────────
|
||||
|
||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
|
||||
public DateTime OccurredAtUtc
|
||||
{
|
||||
get => _occurredAtUtc;
|
||||
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
}
|
||||
private readonly DateTime _occurredAtUtc;
|
||||
|
||||
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.); null/empty for system/anon.</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Canonical action verb — <c>"{channel}.{kind}"</c> (e.g. <c>ApiOutbound.ApiCall</c>).</summary>
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Normalized canonical outcome (Success / Failure / Denied).</summary>
|
||||
public AuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust-boundary channel the audited action crossed. Stored in the canonical
|
||||
/// <c>Category</c> column (for ScadaBridge the canonical Category IS the channel
|
||||
/// name); exposed here as the strongly-typed <see cref="AuditChannel"/> enum.
|
||||
/// </summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
|
||||
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>The cluster node on which the event was emitted.</summary>
|
||||
public string? SourceNode { get; init; }
|
||||
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Canonical JSON extension bag carrying every ScadaBridge domain field.</summary>
|
||||
public string? DetailsJson { get; init; }
|
||||
|
||||
// ── Persisted computed columns (read-only; derived from DetailsJson) ──────
|
||||
|
||||
/// <summary>
|
||||
/// Specific event kind. Computed column <c>JSON_VALUE(DetailsJson,'$.kind')</c>
|
||||
/// PERSISTED; read-only (the DB derives it on INSERT).
|
||||
/// </summary>
|
||||
public AuditKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status. Computed column <c>JSON_VALUE(DetailsJson,'$.status')</c>
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Site id where the action originated; null for central-direct events. Computed
|
||||
/// column <c>JSON_VALUE(DetailsJson,'$.sourceSiteId')</c> PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the originating script execution / inbound request. Computed column
|
||||
/// <c>CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)</c>
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ExecutionId of the execution that spawned this run; null for top-level runs.
|
||||
/// Computed column
|
||||
/// <c>CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)</c>
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the central AuditLog store ingested this row; null until
|
||||
/// central stamps it. Computed column over
|
||||
/// <c>JSON_VALUE(DetailsJson,'$.ingestedAtUtc')</c> (normalized to UTC datetime2)
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public DateTime? IngestedAtUtc
|
||||
{
|
||||
get => _ingestedAtUtc;
|
||||
init => _ingestedAtUtc = value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
|
||||
: null;
|
||||
}
|
||||
private readonly DateTime? _ingestedAtUtc;
|
||||
}
|
||||
+1740
File diff suppressed because it is too large
Load Diff
+59
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RetireInboundApiKeyStore : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedApiKeyIds",
|
||||
table: "ApiMethods");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ApprovedApiKeyIds",
|
||||
table: "ApiMethods",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
KeyHash = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyHash",
|
||||
table: "ApiKeys",
|
||||
column: "KeyHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_Name",
|
||||
table: "ApiKeys",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1740
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user