docs(components): normalize Secrets component + index the shared lib

Add components/secrets/ (SPEC, realized shared-contract, code-verified
current-state for OtOpcUa/mxaccessgw/ScadaBridge, GAPS adoption backlog) and
register the Secrets row in CLAUDE.md + components/README.md. The
ZB.MOM.WW.Secrets lib is built + published 0.1.2 + reference-consumer-proven
(HistorianGateway, live vs the wonder historian); per-app adoption is the
tracked follow-on.
This commit is contained in:
Joseph Doherty
2026-07-16 04:49:57 -04:00
parent de77bc30e3
commit e347286f28
9 changed files with 474 additions and 0 deletions
@@ -0,0 +1,113 @@
# Shared contract — `ZB.MOM.WW.Secrets`
The public API of the shared secrets library. Unlike most `shared-contract/` docs (which
propose an API to extract), this component's library is **already built and published**
this records the **realized** surface (v0.1.2 on the `dohertj2-gitea` NuGet feed). Source
lives in-repo at [`ZB.MOM.WW.Secrets/`](../../../ZB.MOM.WW.Secrets/).
## Packages (mirrors `ZB.MOM.WW.Auth`'s shape)
| Package | Role | Depends on |
|---|---|---|
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only — interfaces, records, enums, exceptions | BCL + `DI.Abstractions` |
| `ZB.MOM.WW.Secrets` | Impl — cipher, master-key providers, SQLite store + migrator, resolver, `${secret:}` expander, `AddZbSecrets` | Abstractions, `Microsoft.Data.Sqlite`, `ZB.MOM.WW.Audit` |
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme``/admin/secrets` (list / add / rotate / delete / gated reveal) | Abstractions, `ZB.MOM.WW.Theme`, `Auth.AspNetCore` |
| `ZB.MOM.WW.Secrets.Cli` *(tool, not packed)* | `secret set / get / list / rm / rotate` | Secrets |
| `ZB.MOM.WW.Secrets.Akka` *(deferred — not built)* | Cluster replication of encrypted rows | Secrets, Akka.Cluster |
## Abstractions (contracts)
Interfaces: `IMasterKeyProvider`, `ISecretCipher`, `ISecretStore`, `ISecretResolver`,
`ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`.
```csharp
public interface IMasterKeyProvider // 32-byte KEK; KekId identifies the active key
{
ReadOnlyMemory<byte> GetMasterKey();
string KekId { get; }
}
public interface ISecretStore // pluggable storage (SQLite default)
{
Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct);
Task UpsertAsync(StoredSecret row, CancellationToken ct); // overwrite-in-place
Task<bool> DeleteAsync(SecretName name, CancellationToken ct); // soft delete (tombstone)
Task<IReadOnlyList<SecretMetadata>> ListAsync(CancellationToken ct); // metadata only
Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct); // (name → revision)
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct); // LWW upsert (cluster)
}
public interface ISecretResolver // app-runtime read (cached, audited)
{
Task<string?> GetAsync(SecretName name, CancellationToken ct);
}
public interface ISecretCacheInvalidator { /* invalidate on rotate/delete */ }
public interface ISecretReplicator { /* no-op default; Akka impl deferred */ }
public interface ISecretActorAccessor { /* supplies the audit actor from the host */ }
```
Types: `SecretName` (validated `readonly record struct` — rejects `..` path-traversal,
rooted/`//` segments, empty; fail-fast on `default`), `SecretContentType` enum
(`Text` | `ConnectionString` | `Json` | `BinaryBase64`), `StoredSecret` (the ciphertext
row), `SecretMetadata` (**no** `byte[]` members — safe to list/log), `SecretManifestEntry`.
Exceptions (all fail-closed): `MasterKeyUnavailableException`, `SecretDecryptionException`
(GCM tag mismatch / tamper / wrong KEK), `SecretNotFoundException`,
`SecretStoreMigrationException`.
## Implementation (core)
- **`AesGcmEnvelopeCipher`** (`…Crypto`) — per-write DEK, AES-256-GCM, AAD name-binding,
DEK-wrap under the KEK, `CryptographicOperations.ZeroMemory` on DEK material.
- **Master-key providers** (`…MasterKey`) — `Environment` / `File` / `Dpapi` selected by
`MasterKeyProviderFactory` from `MasterKeyOptions`; 32-byte validated; fail-closed.
- **`SqliteSecretStore`** + `SqliteSecretsStoreMigrator` + `SecretsSqliteConnectionFactory`
(`…Sqlite`) — schema v1, WAL + `busy_timeout`, idempotent migration.
- **`DefaultSecretResolver`** — TTL cache; audits every resolve (Success/Failure) via
`ZB.MOM.WW.Audit`; **fail-loud** on decrypt failure (audit Failure, then rethrow).
- **`SecretReferenceExpander`** (`…Configuration`) — `${secret:name}` expansion pre-host,
fail-closed, skips `_`-prefixed comment keys.
- **`AddZbSecrets(config, sectionPath)`** (`…DependencyInjection`) — one call wires the
provider + cipher + store + resolver (also exposed as `ISecretCacheInvalidator`) + no-op
replicator + a startup migration hosted service. Binds `SecretsOptions`
(`SqlitePath`, `MasterKey`, `RunMigrationsOnStartup`, `ResolveCacheTtl`).
## UI + authz
- `ZB.MOM.WW.Secrets.Ui``SecretsPage` (`@page "/admin/secrets"`) mounted via the host's
`AddAdditionalAssemblies(SecretsPage.Assembly)`; `SecretsList`, `SecretEditor`,
`ConfirmDeleteModal` (in-page Theme modal), `RevealButton`.
- `SecretsAuthorization.AddSecretsAuthorization()` adds two named policies:
**`secrets:manage`** (`ManageRole`/`RevealRole`/`Administrator`) for CRUD and
**`secrets:reveal`** (`RevealRole`/`Administrator`) for plaintext reveal. Role names are
compared case-sensitively (Ordinal) by ASP.NET — the admin role is
`nameof(CanonicalRole.Administrator)` to match the family's canonical vocabulary.
## Configuration shape
```json
"Secrets": {
"SqlitePath": "app-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
}
```
`MasterKey.Source``Environment` | `File` | `Dpapi`. The master key itself is **never**
committed — env var (containers), mounted key file (clustered pairs), or DPAPI (Windows).
## Consumer wiring (reference: HistorianGateway)
1. Pre-host: a throwaway `ServiceCollection().AddZbSecrets(...)` provider →
`SqliteSecretsStoreMigrator.MigrateAsync``SecretReferenceExpander.ExpandConfigurationAsync`
over `builder.Configuration`, **before** options bind/validation (scoped
`#pragma warning disable ASP0000` for the deliberate throwaway container).
2. Runtime: `builder.Services.AddZbSecrets(...)` for the app's `ISecretResolver`.
3. UI: `Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())` +
`AddAdditionalAssemblies(SecretsPage.Assembly)`.
4. A config value becomes `"${secret:sql/<app>/<name>}"`; the value is seeded via the CLI or
the `/admin/secrets` UI. **Live-proven** end-to-end 2026-07-16: a resolved `${secret:}`
password authenticated a real read against the wonder historian (wrong secret →
`HistorianAuthException`; correct → real samples).