Compare commits
33 Commits
c3ab37523a
...
4b90ebb588
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b90ebb588 | |||
| 4de61d29f5 | |||
| 1ec057a32a | |||
| a591a9fb47 | |||
| e9100d0b74 | |||
| 672ac5ff04 | |||
| f073241f52 | |||
| 98e957903f | |||
| ca2a9ac507 | |||
| abe06a2163 | |||
| 95681ac0b2 | |||
| d73762bf76 | |||
| 02a84b074a | |||
| 9b5535ea47 | |||
| 406ede19dd | |||
| ba7b38a654 | |||
| e69e9c635b | |||
| a4f9968917 | |||
| 290e85cb38 | |||
| 468959ca8a | |||
| 30c60f9d5f | |||
| d30cdea487 | |||
| f2b73367d5 | |||
| da669bfc9b | |||
| 2d50d5dcf0 | |||
| aecc106657 | |||
| 0586e64f64 | |||
| 37c03e5fc2 | |||
| bea08f9673 | |||
| 32fd953969 | |||
| c715565bd2 | |||
| f98fa84e4a | |||
| 6ec1ea7d65 |
@@ -120,12 +120,12 @@ each project's **code-verified current state**, and the **gaps** between. See
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| Auth (login / identity / authz) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master, **not pushed**) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
||||
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
|
||||
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
||||
| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master, **not pushed**) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
|
||||
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
|
||||
@@ -137,7 +137,14 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB
|
||||
(its own nested git repo; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
|
||||
172 tests; `dotnet pack` → 4 nupkgs @ 0.1.0). The implementation plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md).
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
|
||||
**Adopted across all three apps on 2026-06-02** (auth GAPS #1–#8) on each repo's `feat/adopt-zb-auth` branch —
|
||||
committed + reviewed, then **fast-forward-merged into the repo's local default (main/master) on 2026-06-03; NOT pushed**
|
||||
(origin untouched; the `feat/*` branches were kept as history). Cutover: shared `Auth.Ldap`,
|
||||
`Auth.ApiKeys` (ScadaBridge inbound fully re-architected to the keyId/Bearer model), `IGroupRoleMapper<TRole>` seam,
|
||||
`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
|
||||
canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: OtOpcUa `0.1.1`,
|
||||
MxGateway `0.1.2`, ScadaBridge `0.1.3`. Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) +
|
||||
`docs/plans/2026-06-02-auth-audit-normalization*.md`.
|
||||
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
|
||||
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
|
||||
|
||||
@@ -231,10 +238,20 @@ principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam conv
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/)
|
||||
(.NET 10; 1 package — `ZB.MOM.WW.Audit`; only non-BCL dependency `Microsoft.Extensions.DependencyInjection.Abstractions`;
|
||||
19 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Repo: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/audit/GAPS.md`](components/audit/GAPS.md).
|
||||
**Adopted across all three apps on 2026-06-02** (audit GAPS #1–#6) on each repo's `feat/adopt-zb-audit` branch
|
||||
(stacked on `feat/adopt-zb-auth`) — committed + reviewed, then **merged into the repo's local default (main/master) on
|
||||
2026-06-03; NOT pushed** (origin untouched). Depth =
|
||||
**DEEP adopt** (the canonical 9-field `AuditEvent` is the record everywhere; domain fields ride in `DetailsJson`).
|
||||
OtOpcUa: canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` column/migration + `ClusterAudit` fix.
|
||||
MxGateway: new canonical SQLite `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter.
|
||||
**ScadaBridge: a full audit-subsystem re-architecture** (the program's largest task) — canonical record everywhere via a
|
||||
deterministic codec; site SQLite split into `audit_event` + an `audit_forward_state` forwarding sidecar; central
|
||||
partitioned `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical`
|
||||
migration, MSSQL-verified). Phase 3 wires `Actor` from the Auth principal at authenticated emit sites (per-app
|
||||
`IAuditActorAccessor`). Per-repo detail in [`components/audit/GAPS.md`](components/audit/GAPS.md) +
|
||||
`docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.md`.
|
||||
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
|
||||
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge each map their own audit record/seam
|
||||
onto the canonical type at the emit boundary).
|
||||
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
|
||||
|
||||
## Per-project primary commands
|
||||
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# push.sh — pack and push the ZB.MOM.WW.Audit NuGet package to the Gitea feed.
|
||||
#
|
||||
# Required environment variables:
|
||||
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
|
||||
# e.g. https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json
|
||||
# GITEA_NUGET_KEY — Gitea access token with package:write permission
|
||||
#
|
||||
# Usage:
|
||||
# export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
|
||||
# export GITEA_NUGET_KEY="your-gitea-token"
|
||||
# ./build/push.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<Version>0.1.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ public interface IApiKeyAdminStore
|
||||
Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string keyId, CancellationToken ct);
|
||||
|
||||
/// <summary>Replaces the scope set on an existing key. Does not touch the secret. Returns false if the key does not exist.</summary>
|
||||
Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct);
|
||||
|
||||
/// <summary>Enables (clears revoked_utc) or disables (sets revoked_utc) a key WITHOUT changing its secret. Returns false if the key does not exist.</summary>
|
||||
Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all API keys as hash-free <see cref="ApiKeyListItem"/> projections, newest first.
|
||||
/// The secret hash is never selected, so callers cannot use this to recover secret material.
|
||||
|
||||
@@ -187,6 +187,53 @@ public sealed class ApiKeyAdminCommands
|
||||
return new KeyActionResult(deleted, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// set-scopes: replaces the scope set on an existing key WITHOUT touching its secret, and
|
||||
/// appends a <c>set-scopes</c> audit entry. Only the scope count is recorded in the audit
|
||||
/// details — the scope values themselves are not logged verbatim.
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> SetScopesAsync(
|
||||
string keyId, IReadOnlySet<string> scopes, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
|
||||
bool updated = await _adminStore.SetScopesAsync(keyId, scopes, ct).ConfigureAwait(false);
|
||||
|
||||
string status = updated ? "scopes-set" : "not-found";
|
||||
// Record only the count, never the scope contents, to avoid leaking authority detail into audit.
|
||||
await AppendAuditAsync(keyId, "set-scopes", remoteAddress, $"{status}; count={scopes.Count}", ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(updated, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// enable-key / disable-key: reversibly toggles a key's active state WITHOUT changing its
|
||||
/// secret, and appends an <c>enable-key</c> (when enabling) or <c>disable-key</c> (when
|
||||
/// disabling) audit entry.
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> SetEnabledAsync(
|
||||
string keyId, bool enabled, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
DateTimeOffset now = _clock.GetUtcNow();
|
||||
bool updated = await _adminStore.SetEnabledAsync(keyId, enabled, now, ct).ConfigureAwait(false);
|
||||
|
||||
string eventType = enabled ? "enable-key" : "disable-key";
|
||||
string status = updated
|
||||
? (enabled ? "enabled" : "disabled")
|
||||
: "not-found";
|
||||
await AppendAuditAsync(keyId, eventType, remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(updated, status);
|
||||
}
|
||||
|
||||
private string RequirePepper()
|
||||
{
|
||||
string? pepper = _pepperProvider.GetPepper();
|
||||
|
||||
@@ -4,7 +4,8 @@ using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
|
||||
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete,
|
||||
/// set-scopes, enable/disable).
|
||||
/// </summary>
|
||||
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
||||
{
|
||||
@@ -85,6 +86,67 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET scopes = $scopes
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(scopes));
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
|
||||
// Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works
|
||||
// regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched
|
||||
// — that is what distinguishes re-enable from RotateAsync.
|
||||
if (enabled)
|
||||
{
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET revoked_utc = NULL
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET revoked_utc = $revoked_utc
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
|
||||
}
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -5,8 +5,15 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
/// </summary>
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
/// <summary>The schema version this build creates and supports.</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
/// <summary>
|
||||
/// The schema version this build creates and supports. This is <c>2</c>, not <c>1</c>,
|
||||
/// to match the deployed databases of the donor (MxAccessGateway) this store was
|
||||
/// extracted from: that store reached its final shape via a v1→v2 history and stamps
|
||||
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a
|
||||
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c>
|
||||
/// files (the migrator only refuses an on-disk version <em>newer</em> than this).
|
||||
/// </summary>
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -78,7 +78,10 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionOneAsync(
|
||||
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is
|
||||
// idempotent against an already-provisioned database). The applied version is stamped
|
||||
// separately by WriteSchemaVersionAsync.
|
||||
private static async Task ApplySchemaAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// low-level error on the first real login attempt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four conditions are enforced:
|
||||
/// Validation is skipped entirely when <see cref="LdapOptions.Enabled"/> is <c>false</c>
|
||||
/// (a disabled provider's connection fields are inert). When enabled, four conditions
|
||||
/// are enforced:
|
||||
/// <list type="bullet">
|
||||
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
|
||||
@@ -27,6 +29,14 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// When LDAP is disabled, its connection fields are inert — do not require them.
|
||||
// A consumer that turns LDAP off should not have to supply a server/search-base/
|
||||
// service-account just to satisfy startup validation.
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
|
||||
@@ -292,6 +292,59 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
|
||||
Assert.Equal(auditCountBefore, auditCountAfter);
|
||||
}
|
||||
|
||||
// --- set-scopes / enable-disable ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Disable, then re-enable, then replace scopes.
|
||||
KeyActionResult disabled =
|
||||
await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None);
|
||||
Assert.True(disabled.Succeeded);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
KeyActionResult enabled =
|
||||
await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None);
|
||||
Assert.True(enabled.Succeeded);
|
||||
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
KeyActionResult scoped = await commands.SetScopesAsync(
|
||||
"key-1",
|
||||
new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
||||
"10.0.0.1",
|
||||
CancellationToken.None);
|
||||
Assert.True(scoped.Succeeded);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Single(recent, e => e.EventType == "disable-key");
|
||||
Assert.Single(recent, e => e.EventType == "enable-key");
|
||||
Assert.Single(recent, e => e.EventType == "set-scopes");
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["read", "write"], StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_NullScopes_Throws()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
commands.SetScopesAsync("key-1", null!, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- delete-key ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -105,6 +105,87 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- SetScopes ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
|
||||
{
|
||||
await _admin.CreateAsync(
|
||||
SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
|
||||
CancellationToken.None);
|
||||
|
||||
bool result = await _admin.SetScopesAsync(
|
||||
"key-1",
|
||||
new HashSet<string>(["b", "c"], StringComparer.Ordinal),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.SetScopesAsync(
|
||||
"missing",
|
||||
new HashSet<string>(["b"], StringComparer.Ordinal),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- SetEnabled ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_False_DisablesKey()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(when, found!.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
|
||||
{
|
||||
ApiKeyRecord original = SampleRecord("key-1");
|
||||
await _admin.CreateAsync(original, CancellationToken.None);
|
||||
// Record some usage so we can prove last_used_utc is left untouched on re-enable.
|
||||
var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
|
||||
await _read.MarkUsedAsync("key-1", used, CancellationToken.None);
|
||||
|
||||
// Disable, then re-enable.
|
||||
await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
// Active again, and the secret hash + last-used timestamp are unchanged.
|
||||
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(active);
|
||||
Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
|
||||
Assert.Null(active.RevokedUtc);
|
||||
Assert.Equal(used, active.LastUsedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
[Fact]
|
||||
@@ -172,6 +253,73 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
|
||||
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.SetScopesAsync(
|
||||
keyId!,
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- SetEnabled idempotence ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(active);
|
||||
Assert.Null(active!.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Disable at t1.
|
||||
await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);
|
||||
|
||||
// Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
|
||||
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.Equal(t2, item.RevokedUtc);
|
||||
}
|
||||
|
||||
// --- Audit ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -34,6 +34,27 @@ public sealed class SqliteMigratorTests : IDisposable
|
||||
Assert.Equal(1, await CountSchemaVersionRowsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() =>
|
||||
// The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is
|
||||
// stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse
|
||||
// those existing databases on first boot. Locking this invariant.
|
||||
Assert.Equal(2, SqliteAuthSchema.CurrentVersion);
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndStaysAt2()
|
||||
{
|
||||
// The deployed-gateway scenario: a database already provisioned at version 2.
|
||||
var migrator = new SqliteAuthStoreMigrator(Factory);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
await SetVersionAsync(2);
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None); // must not throw
|
||||
|
||||
Assert.Equal(2, await ReadVersionAsync());
|
||||
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_FutureSchemaVersion_Throws()
|
||||
{
|
||||
|
||||
@@ -72,4 +72,20 @@ public class LdapOptionsValidatorTests
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts())
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_Skips_AllChecks_WhenDisabled() =>
|
||||
// When LDAP is disabled its connection fields are inert; an otherwise-invalid
|
||||
// config (plaintext + blank Server/SearchBase/ServiceAccountDn) must still pass.
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = false,
|
||||
Server = "",
|
||||
SearchBase = "",
|
||||
ServiceAccountDn = "",
|
||||
})
|
||||
.Failed);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,19 @@
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
> **Adoption is deferred this round.** The library is being designed (shared contract in
|
||||
> [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)) but is not yet
|
||||
> wired into any app — exactly where `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today.
|
||||
> The items below are the follow-on work; each lands as a separate PR per project.
|
||||
> **✅ ADOPTED 2026-06-02 (local-only) — DEEP.** The backlog (#1–#6) was implemented across all three apps on each repo's
|
||||
> **`feat/adopt-zb-audit`** branch (stacked on `feat/adopt-zb-auth`) — committed + spec/code-reviewed, then **merged to
|
||||
> each repo's local default (main/master) on 2026-06-03; NOT pushed** (origin untouched). The user chose **DEEP adopt**:
|
||||
> the canonical 9-field `AuditEvent` is the record EVERYWHERE
|
||||
> (domain fields ride in `DetailsJson`), so the §1 "keep own record" framing below was superseded. OtOpcUa: canonical
|
||||
> record + `AuditWriterActor : IAuditWriter` + `Outcome` col/migration + `ClusterAudit` fix. MxGateway: canonical SQLite
|
||||
> `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter. **ScadaBridge: a full audit-subsystem
|
||||
> re-architecture** (codec + site `audit_event`/`audit_forward_state` sidecar + central partitioned-table collapse to
|
||||
> 10 canonical + persisted computed cols, MSSQL-verified). §5 (Actor→Auth principal) wired via per-app
|
||||
> `IAuditActorAccessor` (Phase 3). The Task 2.0 gate found this doc's pre-adoption framing was partly stale (MxGateway's
|
||||
> store had moved into the lib; OtOpcUa's structured path was dormant; ScadaBridge's filter was typed to its own record).
|
||||
> Detail: `docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.md`. The
|
||||
> ⛔/🟡 cells below describe the PRE-adoption divergence (kept for history).
|
||||
|
||||
## Divergence vs spec
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
> **✅ ADOPTED 2026-06-02 (local-only).** The full backlog (#1–#8) was implemented across all three apps on each repo's
|
||||
> **`feat/adopt-zb-auth`** branch — committed + spec/code-reviewed, then **merged to each repo's local default
|
||||
> (main/master) on 2026-06-03; NOT pushed** (origin untouched; `feat/*` kept). Shared
|
||||
> `Auth.Ldap` + `Auth.ApiKeys` (ScadaBridge inbound re-architected to keyId/Bearer), `IGroupRoleMapper<TRole>`,
|
||||
> `Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
|
||||
> canonical-six roles (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: OtOpcUa `0.1.1`,
|
||||
> MxGateway `0.1.2`, ScadaBridge `0.1.3`. Detail: `docs/plans/2026-06-02-auth-audit-normalization*.md`. The ⛔/🟡 cells
|
||||
> below describe the PRE-adoption divergence (kept for history).
|
||||
|
||||
## Divergence vs spec
|
||||
|
||||
### §1 LDAP config schema
|
||||
|
||||
@@ -99,7 +99,10 @@ public interface IApiKeyStore { // default: SQLite (hash, scope
|
||||
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
|
||||
Task MarkUsedAsync(string keyId, CancellationToken ct);
|
||||
}
|
||||
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */ }
|
||||
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */
|
||||
Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct); // 0.1.3: replace scope set; secret untouched
|
||||
Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct); // 0.1.3: reversible enable/disable toggle; secret untouched
|
||||
}
|
||||
```
|
||||
|
||||
- Constraints are carried as an **opaque `object`** (project supplies the policy: mxaccessgw
|
||||
@@ -107,6 +110,22 @@ public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audi
|
||||
parse→lookup→peppered-HMAC→constant-time-compare→audit pipeline; it does **not** interpret constraints.
|
||||
- Ships the `apikey` admin verbs as a reusable command set.
|
||||
|
||||
### 0.1.3 admin additions
|
||||
|
||||
`0.1.3` adds **editable scopes** and a **reversible enable/disable toggle** with **no schema
|
||||
change** (still `CurrentVersion = 2`). Both land on `IApiKeyAdminStore` and the
|
||||
`ApiKeyAdminCommands` facade:
|
||||
|
||||
- `IApiKeyAdminStore.SetScopesAsync(keyId, scopes, ct)` — replaces a key's scope set; never
|
||||
touches the secret. Returns `false` if the key is unknown.
|
||||
- `IApiKeyAdminStore.SetEnabledAsync(keyId, enabled, whenUtc, ct)` — clears (`enabled: true`) or
|
||||
sets (`enabled: false`) `revoked_utc` regardless of current state; leaves `secret_hash` and
|
||||
`last_used_utc` untouched (the distinction from rotate). Returns `false` if the key is unknown.
|
||||
- `ApiKeyAdminCommands.SetScopesAsync(...)` — audited `set-scopes` verb (records scope **count**,
|
||||
not contents); returns `KeyActionResult`.
|
||||
- `ApiKeyAdminCommands.SetEnabledAsync(...)` — audited `enable-key` / `disable-key` verb;
|
||||
returns `KeyActionResult`.
|
||||
|
||||
## `ZB.MOM.WW.Auth.AspNetCore`
|
||||
|
||||
- Canonical `ClaimTypes` constants (name, display, username, role, scope-id).
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Design — Auth + Audit normalization across all three sister projects
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Approved (brainstorming complete) — handing off to writing-plans.
|
||||
**Scope owner decision:** full two-library normalization (see [Scope decisions](#scope-decisions)).
|
||||
|
||||
## Summary
|
||||
|
||||
Bring two shared libraries that already live in `scadaproj` but are **unpublished and
|
||||
adopted by no app** — `ZB.MOM.WW.Auth` (4 packages) and `ZB.MOM.WW.Audit` (1 package) —
|
||||
to **full adoption across OtOpcUa, MxAccessGateway, and ScadaBridge**, ending with every
|
||||
audit emit site carrying the genuine Auth-resolved principal as `AuditEvent.Actor`.
|
||||
|
||||
The original request was "implement the audit component in all sister projects." Because
|
||||
audit GAPS #4 (Actor = the `ZB.MOM.WW.Auth` principal) requires an authenticated principal
|
||||
at every emit site, and because the owner chose the maximal scope at every fork, the job
|
||||
expands to a **two-library program**: full Auth adoption (auth GAPS #1–#8) first, then full
|
||||
Audit adoption (audit GAPS #1–#6) with #4 wiring `Actor` from the now-live principal.
|
||||
|
||||
## Verified starting state (source-checked 2026-06-02)
|
||||
|
||||
- **Both libraries exist and are pack-ready** in `scadaproj/ZB.MOM.WW.Auth/` (4 csproj +
|
||||
`build/pack.sh` + `build/push.sh`, 172 tests) and `scadaproj/ZB.MOM.WW.Audit/`
|
||||
(`build/pack.sh`, 19 tests). Both at version `0.1.0`, both central-package-management.
|
||||
- **Neither is on the Gitea feed.** All five package registration endpoints return
|
||||
**HTTP 404**. No `.nupkg` is built locally.
|
||||
- **Adopted by zero apps.** No sibling repo references `ZB.MOM.WW.Auth*` or `ZB.MOM.WW.Audit`.
|
||||
- **Feed source-mapping is missing in all three repos.** Each `NuGet.config`
|
||||
`packageSourceMapping` lists Health/Telemetry/Configuration but **not** Auth or Audit, so
|
||||
each repo needs mapping lines added (mirror MxGateway commit `437ab65`, which did this for
|
||||
Configuration).
|
||||
- **The MxGateway audit coordination gate (audit GAPS #2) is CLEAR.** `MxGateway.Server`
|
||||
already references `ZB.MOM.WW.Telemetry.Serilog 0.1.0`; the Serilog/Telemetry/Configuration
|
||||
work is merged to `main`. MxGateway audit adoption is unblocked.
|
||||
- Established adoption rhythm (Telemetry, Configuration): publish lib to feed → add feed
|
||||
mapping + version pin → behaviour-preserving consumer cutover → land on the repo's local
|
||||
default branch (not pushed to remote).
|
||||
|
||||
> Per repo memory, prior "published"/"adopted" claims in this workspace have repeatedly been
|
||||
> optimistic; every claim above was re-verified against the feed and source on 2026-06-02.
|
||||
|
||||
## Scope decisions
|
||||
|
||||
| Fork | Decision |
|
||||
|---|---|
|
||||
| How deep into the audit GAPS backlog? | **Everything incl. #4 Actor→Auth** (all of #1–#6). |
|
||||
| How to satisfy #4 given Auth is unadopted? | **Adopt Auth first, then audit** (two-library program). |
|
||||
| How much of the Auth backlog? | **Full Auth normalization** (auth GAPS #1–#8, all 3 repos). |
|
||||
| How to walk the work matrix? | **Library-major waterfall** (Phase 1 Auth → Phase 2 Audit → Phase 3 wiring). |
|
||||
| Remote integration model | **Local-only**; no `git push`, no PRs (safest for production auth paths; flip per repo later if desired). |
|
||||
|
||||
## Architecture — four phases
|
||||
|
||||
```
|
||||
Phase 0 Publish & feed-map pack + push both libs to Gitea feed (fix the 404s);
|
||||
(foundation) add NuGet.config source-mappings + version pins in all 3 repos.
|
||||
|
||||
Phase 1 Auth adoption auth GAPS #1–#8 across all 3 repos, in GAPS sequence:
|
||||
(largest, sec-sensitive) #3 IGroupRoleMapper seam → #1 Ldap + #2 ApiKeys cutover →
|
||||
#4 config schema (A1/A2) + #5 claims/cookies → #6 dev base DN →
|
||||
#8 canonical roles. Each lands behind tests.
|
||||
|
||||
Phase 2 Audit adoption audit GAPS #1–#3 core + #5/#6 cleanups across all 3 repos.
|
||||
(behaviour-preserving)
|
||||
|
||||
Phase 3 Actor→Auth wiring audit GAPS #4: route the now-live Auth principal into Actor
|
||||
(the payoff) at every emit site. Closes the loop Audit.Actor == Auth principal.
|
||||
```
|
||||
|
||||
The waterfall is enforced by task dependencies (Phase 0 → 1 → 2 → 3). Phase 1 must fully
|
||||
land before Phase 3 can wire a *stable* principal; Phase 2 sits after Phase 1 so emit sites
|
||||
aren't touched twice.
|
||||
|
||||
### Delivery model
|
||||
|
||||
- One **feature branch per repo per library phase** (`feat/adopt-zb-auth`, then
|
||||
`feat/adopt-zb-audit`), behaviour-preserving except where a GAPS item is explicitly net-new.
|
||||
- **Publish-first**: both packages on the feed and verified resolvable before any consumer edit.
|
||||
- **Land on each repo's local default branch**, gated by that repo's tests + new contract tests.
|
||||
- **Local-only** (no push). Each phase is a revertable branch merge.
|
||||
- The libraries themselves are plain files in `scadaproj` (not nested git repos) — publishing
|
||||
is `pack` + `push` only; no commits to the libs unless a parity gap forces a fix.
|
||||
|
||||
## Phase 0 — publish & feed-map *(task #7)*
|
||||
|
||||
1. `dotnet pack -c Release` both libraries; `push.sh` to the Gitea feed
|
||||
(`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`).
|
||||
2. Verify all five packages return HTTP 200 from the registration endpoint.
|
||||
3. In each repo: add `packageSourceMapping` patterns (`ZB.MOM.WW.Auth`, `ZB.MOM.WW.Auth.*`,
|
||||
`ZB.MOM.WW.Audit`) to the gitea source, and version pins (`Directory.Packages.props` for
|
||||
OtOpcUa/ScadaBridge; inline `Version="0.1.0"` for MxGateway).
|
||||
4. `dotnet restore` resolves the new patterns in all three repos.
|
||||
|
||||
## Phase 1 — Auth adoption *(task #8, blocked by #7)*
|
||||
|
||||
Consumer cutover (libs are already extracted). GAPS order: #3 seam → #1 Ldap + #2 ApiKeys →
|
||||
#4 config schema + #5 claims/cookies → #6 dev base DN → #8 canonical roles.
|
||||
|
||||
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| Packages | Abstractions + Ldap + AspNetCore (no ApiKeys — OPC UA transport security) | all 4 (**source** for ApiKeys — cuts over first) | all 4 (**source** for Ldap; ApiKeys consumer after gw) |
|
||||
| Role mapper (#3) | config-backed (`GroupToRole`) | config-backed | **DB-backed** (`LdapGroupMapping`) |
|
||||
| Config migration (#4) | A1: `UseTls`→`Transport` enum (section already nested) | A1: `UseTls`→`Transport` enum | **A2 (biggest)**: flat `Security:Ldap*`→nested; rename `LdapUserIdAttribute`→`UserNameAttribute`, `LdapGroupAttribute`→`GroupAttribute` |
|
||||
| Cookies/claims (#5) | Blazor Admin control-plane cookie | keep `MxGatewayDashboard` name, share claims | keep `ZB.MOM.WW.ScadaBridge.Auth` name, share claims |
|
||||
| Canonical roles (#8) | no first-class `Deployer` (publish ⊂ `FleetAdmin`) | no `Designer`/`Deployer` | **roles collapse**: `AuditReadOnly`→Viewer, `Audit`→Administrator (auditor/admin SoD loss — GAPS-accepted) |
|
||||
|
||||
**Two deliberate behaviour changes (accepted):**
|
||||
1. **ScadaBridge API-key token format** (D2): raw `X-API-Key` → structured
|
||||
`<prefix>_<id>_<secret>`. A genuine wire change for inbound API clients — acceptable
|
||||
pre-prod, requires an interop check.
|
||||
2. **Canonical-roles collapse** in ScadaBridge removes auditor/admin separation-of-duties.
|
||||
|
||||
**Known live issue to fix during OtOpcUa cutover:** `LdapAuthService` `Enabled`/double-singleton
|
||||
wiring is still open even though the `Security:Ldap` section binding was fixed — fold the fix
|
||||
into the OtOpcUa LDAP cutover.
|
||||
|
||||
**Risk gate:** parity tests reproducing each app's current authn decisions (bind-then-search,
|
||||
fail-closed group lookup, RFC-4514 + filter escaping, constant-time compare, peppered
|
||||
HMAC-SHA256) must be green before any cutover merges.
|
||||
|
||||
## Phase 2 — Audit adoption *(task #9, blocked by #8)*
|
||||
|
||||
Behaviour-preserving seam/record/enum adoption.
|
||||
|
||||
| Repo | Core work (GAPS #1–#3) | Keep bespoke |
|
||||
|---|---|---|
|
||||
| **OtOpcUa** (#1, #5) | Replace `Commons/.../AuditEvent.cs` with canonical record; `AuditWriterActor : IAuditWriter`; derive `Outcome` at emit sites (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt`→Denied, config verbs→Success); bridge `NodeId`/`CorrelationId` value-types | Akka singleton transport, 500/5s batching, two-layer dedup, `ConfigAuditLog` EF entity + idempotency index |
|
||||
| **MxGateway** (#2, #6) | Map `IApiKeyAuditStore`/`ApiKeyAuditEntry`→`IAuditWriter`/`AuditEvent`; generate `EventId`; `"system"`/`"cli"` Actor fallback; `Category="ApiKey"`; `constraint-denied`→Denied | SQLite store, 3 producer call sites (only injected type changes), append-only table |
|
||||
| **ScadaBridge** (#3) | Outright rename `IAuditPayloadFilter`→`IAuditRedactor`; adopt canonical `AuditOutcome` enum; confirm writer contract (byte-identical) — keep bespoke ~25-field record as storage shape | Entire Site/Central pipeline, 4 domain enums, CLI export/verify, Blazor UI, redaction policy |
|
||||
|
||||
**Resolved open GAPS decisions:**
|
||||
1. **ScadaBridge rename vs. alias** → **outright rename** (compiler-verified across the HIGH blast radius).
|
||||
2. **MxGateway `Details`→`DetailsJson`** → **wrap as a small JSON object** (keeps the field valid JSON).
|
||||
3. **OtOpcUa `Outcome` storage** → **new nullable `Outcome` column + EF migration** (first-class, queryable).
|
||||
4. **OtOpcUa SP path** → **leave bespoke + document**; *do* fix the `ClusterId`-filter/actor
|
||||
mismatch in `ClusterAudit.razor` so structured rows are visible.
|
||||
|
||||
**Cleanups in scope:** #5 (OtOpcUa SP reconcile + `ClusterId` visibility fix), #6 (MxGateway
|
||||
`CorrelationId` capture + structured `Target`).
|
||||
|
||||
**Behaviour fix:** MxGateway's `AppendAsync` currently may propagate; wrap it so the adopted
|
||||
`IAuditWriter` never throws (honors the best-effort contract).
|
||||
|
||||
## Phase 3 — Actor→Auth wiring *(task #10, blocked by #8 + #9)*
|
||||
|
||||
With Auth live (Phase 1) and the canonical record adopted (Phase 2), route the resolved
|
||||
principal into `AuditEvent.Actor` everywhere:
|
||||
|
||||
- **Seam:** one small `IAuditActorAccessor` — HTTP paths read `HttpContext.User`; non-HTTP
|
||||
paths (Akka actors, CLI) thread the operation principal or fall back. The single place that
|
||||
changes if the principal source ever changes again.
|
||||
- OtOpcUa → LDAP-resolved user. MxGateway → API-key name (system/cli fallback retained for
|
||||
keyless CLI events). ScadaBridge → principal at `ManagementActor`/inbound boundary.
|
||||
|
||||
## Contracts, testing & risk gates
|
||||
|
||||
**Hard seam contracts:**
|
||||
- `IAuditWriter` — best-effort, MUST NOT throw, swallow internal failures. OtOpcUa actor ✅,
|
||||
ScadaBridge ✅; MxGateway needs the never-throw wrap (above).
|
||||
- `IAuditRedactor` — pure, never throws, over-redacts on failure. ScadaBridge's
|
||||
`SafeDefaultAuditPayloadFilter` is the reference; rename preserves it.
|
||||
|
||||
**Cross-boundary surface:** Auth/Audit adoption is in-process and does **not** touch the
|
||||
cross-repo wire contracts (gateway `.proto` files, OPC UA address-space shape) — **except** the
|
||||
ScadaBridge API-key token-format change, the one item needing an interop check rather than just
|
||||
a green unit build. A green build in one repo does not prove interop.
|
||||
|
||||
**Per-phase verification (evidence before "done"):**
|
||||
- **Phase 0:** all 5 packages HTTP 200; `dotnet restore` green in all 3 repos.
|
||||
- **Phase 1:** existing auth tests + new parity tests green per repo before merge; SB
|
||||
token-format integration check.
|
||||
- **Phase 2:** existing audit tests + new `Outcome`/`EventId`/rename tests; OtOpcUa `Outcome`
|
||||
migration applies forward.
|
||||
- **Phase 3:** `Actor == authenticated principal` on authenticated paths; fallback retained on
|
||||
keyless/system paths.
|
||||
- **Library suites** (Audit 19, Auth 172) re-run if any lib is touched. If a parity gap forces
|
||||
a lib fix, bump `0.1.0`→`0.1.1` and re-publish rather than editing a published version.
|
||||
|
||||
## Tasks
|
||||
|
||||
| Task | Item | Blocked by |
|
||||
|---|---|---|
|
||||
| #7 | Phase 0 — publish both libs + feed-map all 3 repos | — |
|
||||
| #8 | Phase 1 — adopt ZB.MOM.WW.Auth across all 3 repos (auth GAPS #1–#8) | #7 |
|
||||
| #9 | Phase 2 — adopt ZB.MOM.WW.Audit across all 3 repos (audit GAPS #1–#3, #5, #6) | #8 |
|
||||
| #10 | Phase 3 — wire Actor from the Auth principal (audit GAPS #4) | #8, #9 |
|
||||
|
||||
## References
|
||||
|
||||
- `components/auth/GAPS.md`, `components/auth/spec/`, `components/auth/current-state/*`
|
||||
- `components/audit/GAPS.md`, `components/audit/shared-contract/ZB.MOM.WW.Audit.md`,
|
||||
`components/audit/current-state/*`
|
||||
- Libraries: `ZB.MOM.WW.Auth/`, `ZB.MOM.WW.Audit/`
|
||||
- Prior adoption precedent: `components/configuration/GAPS.md`,
|
||||
`components/observability/GAPS.md`
|
||||
@@ -0,0 +1,366 @@
|
||||
# Phase 1 (Auth adoption) — elaborated steps + Task 1.0 findings
|
||||
|
||||
Companion to `2026-06-02-auth-audit-normalization.md`. Produced by the Task 1.0 read-only
|
||||
exploration gate (4 parallel explorers: library surface + 3 repos). All paths verified
|
||||
2026-06-02 against source.
|
||||
|
||||
## Cutover target — `ZB.MOM.WW.Auth` public surface
|
||||
|
||||
| Package | Consumer entry points |
|
||||
|---|---|
|
||||
| `.Abstractions` | **NB: `IGroupRoleMapper<TRole>`/`GroupRoleMapping<TRole>`/`CanonicalRole` live in namespace `ZB.MOM.WW.Auth.Abstractions.Roles`** (verified during Task 1.1). `ILdapAuthService`, `LdapOptions` (`Transport: LdapTransport{Ldaps,StartTls,None}`, `AllowInsecure`, `UserNameAttribute`, `GroupAttribute`, `ServiceAccountDn/Password`, `SearchBase`, `ConnectionTimeoutMs`, `ServerCertificateValidationCallback`), `LdapAuthResult(Succeeded,Username,DisplayName,Groups,Failure)`, `LdapAuthFailure`, `CanonicalRole{Viewer,Operator,Engineer,Designer,Deployer,Administrator}`, `IGroupRoleMapper<TRole>` (**no default impl — consumer writes it**) → `GroupRoleMapping<TRole>(Roles, Scope:object?)`, plus API-key abstractions (`IApiKeyVerifier`, `ApiKeyVerification`, `ApiKeyIdentity`, `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore`, `ApiKeyOptions{TokenPrefix,PepperSecretName,SqlitePath,RunMigrationsOnStartup}`) |
|
||||
| `.Ldap` | `LdapAuthService(LdapOptions)` : `ILdapAuthService`. Bind-then-search, fail-closed, never throws. `LdapOptionsValidator` (TLS-or-AllowInsecure) auto-registered. |
|
||||
| `.ApiKeys` | `ApiKeyVerifier(ApiKeyOptions, IApiKeyStore, IApiKeyPepperProvider, TimeProvider?)`, `ApiKeyParser.TryParse` (`<prefix>_<keyId>_<secret>`), `ApiKeySecretGenerator.NewSecret()`, default SQLite stores, `ConfigurationApiKeyPepperProvider`. **Extracted from MxGateway — near-1:1 with its pipeline.** |
|
||||
| `.AspNetCore` | `ZbClaimTypes{Name,Role,DisplayName,Username,ScopeId}`, `ZbCookieDefaults.Apply(opts, requireHttps, idleTimeout)`, DI: `AddZbLdapAuth(services, config, sectionPath)`, `AddZbApiKeyAuth(services, config, sectionPath)`. |
|
||||
|
||||
## Per-app current state (verified) and elaborated cutover
|
||||
|
||||
### OtOpcUa — packages: Abstractions + Ldap + AspNetCore (no ApiKeys)
|
||||
|
||||
Current LDAP: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs` (impl), `ILdapAuthService.cs`,
|
||||
`LdapOptions.cs` (**section `Security:Ldap`**, `UseTls` bool, `Enabled`, `DevStubMode`, embedded `GroupToRole` dict),
|
||||
`LdapAuthResult.cs` (already carries `Roles`). Role mapping is **config + DB**: `RoleMapper.Map` (config
|
||||
`GroupToRole`) + `RoleMapper.Merge` with DB `LdapGroupRoleMappingService`/`LdapGroupRoleMapping` (system-wide rows).
|
||||
Native roles `AdminRole{ConfigViewer,ConfigEditor,FleetAdmin}` (control-plane only; data-plane is a separate
|
||||
`NodePermissions` bitmask). DI: two `TryAddSingleton<ILdapAuthService,LdapAuthService>` sites
|
||||
(`Security/ServiceCollectionExtensions.cs:42` + `Host/Program.cs:106`). Cookie `ZB.MOM.WW.OtOpcUa.Auth`,
|
||||
single Cookie scheme (JWT inside cookie). **Second LDAP consumer:** OPC UA data-plane
|
||||
`LdapOpcUaUserAuthenticator` + `OpcUaApplicationHost.HandleImpersonation` call the LDAP service too.
|
||||
|
||||
- **1.1 mapper:** implement `IGroupRoleMapper<AdminRole>` (or `<string>`) wrapping `RoleMapper.Map` + DB `Merge`.
|
||||
- **1.2 Ldap:** replace `LdapAuthService` with `Auth.Ldap`; restructure flow to `ILdapAuthService → Groups → IGroupRoleMapper → roles → claims`; **preserve `DevStubMode` app-side** (library has no stub); wire BOTH consumers (login endpoint + OPC UA impersonation).
|
||||
- **1.4 config:** `UseTls`→`Transport` enum (section already `Security:Ldap` — see Finding #1).
|
||||
- **1.5 cookie/claims:** use `ZbClaimTypes` + `ZbCookieDefaults.Apply`; keep cookie name.
|
||||
- **1.7 roles:** `ConfigViewer→Viewer`, `ConfigEditor→Designer`, `FleetAdmin→Administrator(+Deployer; publish⊂FleetAdmin)`. Data-plane `NodePermissions` unaffected.
|
||||
|
||||
### MxAccessGateway — packages: all 4 (ApiKeys **source**, cuts over first)
|
||||
|
||||
Current API keys (`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`): `ApiKeyParser` (`mxgw_<id>_<secret>`),
|
||||
`ApiKeySecretHasher` (HMAC-SHA256 + pepper `MxGateway:ApiKeyPepper`), `ApiKeySecretGenerator`, `ApiKeyVerifier`
|
||||
(`FixedTimeEquals`), SQLite stores, `ConstraintEnforcer` + rich `ApiKeyConstraints`, gRPC
|
||||
`GatewayGrpcAuthorizationInterceptor` + `GatewayScopes`. DI `AddSqliteAuthStore()`. → **near-1:1 with `Auth.ApiKeys`.**
|
||||
LDAP: `Dashboard/DashboardAuthenticator.cs` (`MxGateway:Ldap`, `UseTls`), `GroupToRole` under `MxGateway:Dashboard`,
|
||||
roles `Admin`/`Viewer`, cookie `MxGatewayDashboard`.
|
||||
|
||||
- **1.1 mapper:** `IGroupRoleMapper<string>` wrapping `DashboardAuthenticator.MapGroupsToRoles`.
|
||||
- **1.2 Ldap:** replace `DashboardAuthenticator`'s LDAP internals with `Auth.Ldap` (keep dashboard claims/principal build).
|
||||
- **1.3 ApiKeys:** delete the local parser/hasher/generator/verifier/stores; re-point to `Auth.ApiKeys`; **keep** `ConstraintEnforcer` + gRPC interceptor + scopes on top (constraints carried as the opaque blob). Lowest-risk ApiKeys cutover (it's the donor).
|
||||
- **1.4 config:** `UseTls`→`Transport`.
|
||||
- **1.5/1.7:** `ZbClaimTypes`/cookie defaults; `Viewer→Viewer`, `Admin→Administrator`.
|
||||
|
||||
### ScadaBridge — packages: all 4 (Ldap **source**; ApiKeys consumer)
|
||||
|
||||
Current LDAP (`src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs`): the hardened reference (RFC-4514 DN escape,
|
||||
filter escape, per-op timeout, fail-closed group lookup, username trim, service-account-bind distinction). Config is
|
||||
**flat** `ScadaBridge:Security:Ldap*` in `SecurityOptions.cs` with **`LdapTransport` enum already** (`Ldaps/StartTls/None`),
|
||||
`AllowInsecureLdap`, `LdapUserIdAttribute`, `LdapGroupAttribute`, validated by `SecurityOptionsValidator : OptionsValidatorBase`.
|
||||
Role mapping **DB-backed** with **site-scoping**: `RoleMapper.MapGroupsToRolesAsync` → `RoleMappingResult(Roles, PermittedSiteIds, IsSystemWideDeployment)` over `LdapGroupMapping` + `SiteScopeRule` (SQL Server). Roles
|
||||
`Admin/Design/Deployment/Audit/AuditReadOnly`; SoD via `OperationalAudit{Admin,Audit,AuditReadOnly}` + `AuditExport{Admin,Audit}`.
|
||||
Cookie `ZB.MOM.WW.ScadaBridge.Auth`; JWT-in-cookie via `JwtTokenService`.
|
||||
**Inbound API keys** (`InboundAPI/ApiKeyValidator.cs`): **raw `X-API-Key`**, **deterministic** HMAC (`ApiKeyHasher`, no per-row salt, by-value lookup), `ApiKey{Name,KeyHash,IsEnabled}` in **SQL Server**, **per-method approval** via `ApiMethod.ApprovedApiKeyIds` — **architecturally different from the library's keyId/scope/SQLite model.**
|
||||
|
||||
- **1.1 mapper:** `IGroupRoleMapper<string>` wrapping `RoleMapper.MapGroupsToRolesAsync`, carrying `PermittedSiteIds`/`IsSystemWideDeployment` in `GroupRoleMapping.Scope`.
|
||||
- **1.2 Ldap:** ScadaBridge is the donor — confirm `Auth.Ldap` behaviour-matches, then re-point `LdapAuthService` usages to the library type. Lowest-risk Ldap cutover.
|
||||
- **1.3 ApiKeys:** **see Finding #3 — bigger than a token reformat; needs a scope decision.**
|
||||
- **1.4 config:** nest flat `Security:Ldap*` under a sub-section + rename `LdapUserIdAttribute→UserNameAttribute`, `LdapGroupAttribute→GroupAttribute`, `LdapTransport→Transport` (+ `SecurityOptionsValidator` + appsettings). Enum already matches.
|
||||
- **1.7 roles:** `Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, `Audit→Administrator` (collapse), `AuditReadOnly→Viewer` (collapse) — removes the `OperationalAudit`/`AuditExport` SoD (accepted).
|
||||
|
||||
## Key findings that change the plan
|
||||
|
||||
1. **OtOpcUa LDAP section is `Security:Ldap`, not `Authentication:Ldap`.** Both `components/auth/GAPS.md §1`
|
||||
and the auth current-state doc are wrong; the code (and the prior fix in memory) use `Security:Ldap`.
|
||||
→ Task 1.4 for OtOpcUa is only `UseTls`→`Transport`, not a section move.
|
||||
2. **OtOpcUa "double-singleton bug" is already mitigated.** Both registration sites use `TryAddSingleton`
|
||||
(dedupes); the `Enabled` flag is an intentional fail-closed master switch. → Not a blocking fix; verify and
|
||||
keep `Enabled`. Removes a risk the plan flagged.
|
||||
3. **ScadaBridge inbound API keys are a re-architecture, not a token reformat.** The library's ApiKeys model
|
||||
(`<prefix>_<keyId>_<secret>` Bearer, keyId lookup + constant-time compare, SQLite store, scopes + opaque
|
||||
constraints) is fundamentally different from ScadaBridge's (raw `X-API-Key`, deterministic by-value HMAC
|
||||
lookup, SQL Server `ApiKey{Name,KeyHash}`, per-method approval list). Wholesale adoption means re-architecting
|
||||
inbound-API auth AND resolving a SQLite-vs-SQL-Server storage mismatch. **Needs a scope decision (Decision A).**
|
||||
4. **OtOpcUa role mapping is config + DB**, not just config (`RoleMapper.Map` baseline + DB `Merge`). The
|
||||
`IGroupRoleMapper` impl must combine both. OtOpcUa also has `DevStubMode` (no library equivalent — keep app-side)
|
||||
and a **second LDAP consumer** (OPC UA data-plane impersonation) that must be re-wired too.
|
||||
5. **MxGateway ApiKeys cutover is the donor path — lowest risk** (delete locals, re-point to library; keep
|
||||
`ConstraintEnforcer`/gRPC/scopes on top). Confirms the GAPS sequencing (gateway first).
|
||||
|
||||
## Task 1.2 (LDAP cutover) — implemented + reviewed (2026-06-02)
|
||||
|
||||
Commits: OtOpcUa `257caa7`, MxGateway `c3b466e`, ScadaBridge `ac34dac`. All targeted tests green.
|
||||
Security review verdict: **sound, no credential-leak regression** in any repo (insecure-transport
|
||||
guards fire correctly; DevStubMode cannot leak to prod; claim shapes preserved). All three returned
|
||||
CHANGES-REQUESTED for fixable issues:
|
||||
|
||||
- **OtOpcUa** (no Critical): (I1) insecure-transport guard is login-time only — add startup
|
||||
validation gated on `Enabled` for defense-in-depth, verify prod overlays still boot; (I2) integration
|
||||
stub pre-populates `Roles` so the Groups→mapper path isn't actually exercised — fix the stub; (I3)
|
||||
document/test the zero-role fail-closed fallback.
|
||||
- **MxGateway** (2 Critical): (C1) library strips group DNs to short RDN names before the
|
||||
`LdapGroupClaimType` claim → verify prior behaviour, document, drop the now-dead full-DN branch in the
|
||||
mapper, add a claim-value assertion; (C2) gateway's local `LdapOptions` is now a shadow copy (validated
|
||||
but unused at runtime) → fold to the shared type or document the drift. (I1) shared `LdapOptionsValidator`
|
||||
has **no `Enabled=false` guard** → validates even when LDAP is disabled (real for MxGateway, which can
|
||||
disable dashboard LDAP).
|
||||
- **ScadaBridge** (2 Critical): (C1) `ConfigSecretsTests` still checks the OLD flat key → passes
|
||||
vacuously, no longer guards secret-in-config — repoint to nested key; (C2) `production-checklist.md`
|
||||
still lists deleted flat keys → update; (I) unsafe `(RoleMappingResult)Scope!` cast → null-guard.
|
||||
|
||||
**Cross-cutting decision — shared library `LdapOptionsValidator` `Enabled` guard:** the validator runs
|
||||
regardless of `Enabled`, requiring Server/SearchBase/ServiceAccountDn even when LDAP is off. Correct fix =
|
||||
add an `if (!Enabled) return Success` guard to the shared validator and republish `0.1.1`, re-pinning all
|
||||
consumers. (Alternative: each consumer always supplies those fields. The library fix is the principled one.)
|
||||
|
||||
## Task 1.2/1.4 — DONE (reviewed + fixed, 2026-06-02)
|
||||
|
||||
Library hardened to **`0.1.1`** (`LdapOptionsValidator` skips when `Enabled=false`), republished, re-pinned in all 3 repos.
|
||||
Fix commits: OtOpcUa `c4f315e` (startup insecure-transport guard gated on Enabled/DevStub + `Transport: Ldaps`
|
||||
declared in the 3 prod overlays + test fidelity), MxGateway `f4dc11b` (group-claim shape documented as
|
||||
non-breaking — claim read nowhere in prod; shadow `LdapOptions` kept with a drift-warning doc), ScadaBridge
|
||||
`4db8c37` (secret-test repointed to nested key, prod checklist updated, `Scope` cast guarded). All targeted
|
||||
suites green. **1.2 (LDAP) + 1.4 (config) complete across all 3 repos.**
|
||||
|
||||
Remaining Phase 1: **1.3 ApiKeys** (MxGateway donor cutover — low risk; ScadaBridge full re-architecture —
|
||||
largest single item: SQLite store + Bearer format + scopes + key re-issuance), **1.5** claims/cookies,
|
||||
**1.6** dev base DN, **1.7** canonical roles.
|
||||
|
||||
## Task 1.3 ApiKeys — MxGateway DONE; ScadaBridge pending (2026-06-02)
|
||||
|
||||
**Library bumped to `0.1.2`**: `Auth.ApiKeys` SQLite migrator now stamps schema version **2** (was 1) to
|
||||
match the donor gateway's deployed `gateway-auth.db` — without it the gateway would fail to boot (migrator
|
||||
threw on a newer on-disk version). Final schema byte-identical since v1; no key re-issuance. Republished,
|
||||
re-pinned in MxGateway. (+2 migrator tests.)
|
||||
|
||||
**MxGateway 1.3 — DONE + APPROVED** (commit `05009d7`): deleted 28 local pipeline files, adopted
|
||||
`Auth.ApiKeys 0.1.2` via `AddZbApiKeyAuth`; kept `ConstraintEnforcer`/gRPC interceptor/scopes/CLI/dashboard
|
||||
on top via a `GatewayApiKeyIdentityMapper` (library identity → gateway identity-with-EffectiveConstraints).
|
||||
Review: no Critical; no auth bypass, schema compat + crypto parity + gRPC status mapping verified. Non-blocking
|
||||
follow-ups: (a) dashboard mutations now write two audit rows (library + `dashboard-*`) — fine, note for Phase 2
|
||||
audit bridging; (b) nit: `GatewayApiKeyIdentityMapper` uses `Constraints as string` (opaque coupling) — consider
|
||||
a guard/contract test.
|
||||
|
||||
**ScadaBridge 1.3 — PENDING**: the full inbound-API re-architecture (SQL Server → SQLite store, `X-API-Key`
|
||||
→ Bearer, per-method-approval → scopes/constraints, **all inbound keys re-issued**). Largest/highest-risk
|
||||
single item in the program; warrants its own focused pass (likely decomposed).
|
||||
|
||||
## ScadaBridge ApiKeys re-architecture — spec (FULL ADOPT, 2026-06-02)
|
||||
|
||||
Decision: **full adopt** the library SQLite store + scopes model. Single consistent contract all layers build to:
|
||||
|
||||
- **Token format**: `Authorization: Bearer sbk_<keyId>_<secret>` (prefix `sbk`). Replaces the raw `X-API-Key` header.
|
||||
- **Scope model = method name.** A key's `Scopes` set = the API-method names it may call. `ApiMethod.ApprovedApiKeyIds`
|
||||
(CSV of key int IDs) is **retired**; per-method approval moves to the key's scopes. Auth check at the endpoint:
|
||||
`identity.Scopes.Contains(methodName)`.
|
||||
- **Storage**: inbound keys move to the library's SQLite store (new `ScadaBridge:InboundApi:ApiKeyStore` sqlite path
|
||||
+ pepper via `ApiKeyOptions.PepperSecretName`, `RunMigrationsOnStartup`). The SQL Server `ApiKey` entity is retired;
|
||||
`ApiMethod` is KEPT minus `ApprovedApiKeyIds` (EF migration drops the column). `InboundApiRepository` loses its ApiKey
|
||||
methods + `GetApprovedKeysForMethodAsync`.
|
||||
- **Auth path** (`InboundAPI`): endpoint reads Bearer, calls library `IApiKeyVerifier.VerifyAsync`, then the scope check.
|
||||
PRESERVE the security invariants: 401 (missing/invalid/disabled), **403 identical message for both "method not found"
|
||||
and "not in scope"** (enumeration-safety, InboundAPI-011), constant-time compare (library does it), active-node 503 +
|
||||
body-cap 413 filters unchanged, audit actor = key DisplayName. Delete `ApiKeyValidator` hashing + `ApiKeyHasher`.
|
||||
- **Management** (`ManagementActor` + CLI `security api-key` + Commons messages): drive the library `IApiKeyAdminStore` +
|
||||
`ApiKeySecretGenerator`. `create` returns `sbk_<keyId>_<secret>` once (plaintext-once preserved); methods a key may call
|
||||
= its scopes, set on create/update (e.g. `--methods a,b` or grant/revoke-method commands). `list` returns id/name/enabled
|
||||
(no secret), `update --enabled`, `delete`/revoke. Audit preserved.
|
||||
- **CentralUI**: `ApiKeys.razor` (list/create/toggle/delete via admin store; show token once), `ApiKeyForm.razor` (edit the
|
||||
key's method-scopes), `ApiMethodForm.razor` (method-side "approved keys" now reads/writes key scopes across keys).
|
||||
- **Breaking change**: all inbound keys re-issued (new format); clients switch `X-API-Key` → `Authorization: Bearer`.
|
||||
Needs a runbook + CHANGELOG. Re-pin ScadaBridge Auth packages to **0.1.2**.
|
||||
|
||||
Sub-tasks (sequential where files overlap): **(A)** storage retire + EF migration + library wiring/options;
|
||||
**(B)** auth-path rewrite (Bearer + verifier + scope check); **(C)** management (ManagementActor + CLI + messages);
|
||||
**(D)** CentralUI pages; **(E)** runbook/CHANGELOG + integration test sweep. A→(B,C)→D→E.
|
||||
Sequencing note: doing it **additively** (add library path, switch auth, rewire mgmt/UI, retire SQL Server entity LAST)
|
||||
keeps the build green at each step.
|
||||
|
||||
### Re-arch progress
|
||||
- **A+B foundation — DONE + reviewed+fixed** (commits `a94558c`, `1fcc4f5`; re-pinned to 0.1.2). Library `AddZbApiKeyAuth`
|
||||
wired additively (`ScadaBridge:InboundApi:ApiKeyStore`, prefix `sbk`, reuses inbound pepper); inbound endpoint now uses
|
||||
the library verifier + Bearer + `Scopes.Contains(methodName)`. Security invariants preserved: 401 generic / 403 identical
|
||||
body for not-found AND not-in-scope (enumeration-safe, pinned to a literal in tests), scope-check-before-DB (no timing
|
||||
oracle), fail-fast pepper preflight (Central), audit actor = DisplayName. Old SQL Server path still compiles (retired in E).
|
||||
163/163 InboundAPI tests green. **NOTE for E:** the library's `ApiKeySecretGenerator.NewSecret()` is `internal` — seed/create
|
||||
keys via the public `ApiKeyAdminCommands.CreateKeyAsync` seam (returns the assembled `sbk_…` token).
|
||||
- **Library 0.1.3 — DONE + reviewed + PUBLISHED** (scadaproj commits `468959c` impl, `290e85c` tests; pushed to Gitea,
|
||||
ApiKeys 0.1.3 nupkg verified HTTP 200). Added `IApiKeyAdminStore.SetScopesAsync(keyId, scopes, ct)` + `SetEnabledAsync(keyId,
|
||||
enabled, whenUtc, ct)` (+ audited facade verbs `ApiKeyAdminCommands.SetScopesAsync`/`SetEnabledAsync` → eventTypes
|
||||
`set-scopes`/`enable-key`/`disable-key`). **No schema change** (`CurrentVersion` stays 2): scopes column already exists;
|
||||
`revoked_utc` doubles as the enabled flag (null = enabled), so enable/disable is a reversible toggle that preserves the
|
||||
secret (proven by test asserting `SecretHash.SequenceEqual` + unchanged `last_used_utc`). This is what lets C/D edit a key's
|
||||
method-scopes and toggle enabled WITHOUT re-issuing the token. **ScadaBridge must re-pin Auth packages 0.1.2 → 0.1.3.**
|
||||
- **C (management), D (CentralUI), E (retire SQL Server ApiKey + ApiMethod.ApprovedApiKeyIds migration + runbook/CHANGELOG)
|
||||
— IN PROGRESS.** Mapping: `CreateApiKeyCommand` → `CreateKeyAsync` (keyId = `Guid.NewGuid().ToString("N")`,
|
||||
DisplayName = name, scopes = `--methods`); `ListApiKeysCommand` → `ListKeysAsync` (enabled = `RevokedUtc is null`);
|
||||
`UpdateApiKeyCommand(IsEnabled)` → `SetEnabledAsync`; new set-scopes path → `SetScopesAsync`; `DeleteApiKeyCommand` →
|
||||
revoke-then-`DeleteKeyAsync`. All management message keys switch `int ApiKeyId` → `string KeyId`.
|
||||
|
||||
### Discovered architecture (CentralUI Explore, 2026-06-02) — expands C/D/E
|
||||
Two facts the original A–E spec missed:
|
||||
1. **CentralUI bypasses the ManagementActor.** `Components/Pages/Admin/ApiKeys.razor`, `ApiKeyForm.razor`, and
|
||||
`Components/Pages/Design/ApiMethodForm.razor` call `IInboundApiRepository` (SQL Server EF) **directly** — they do NOT
|
||||
send the `CreateApiKeyCommand`/etc. management messages. So there are **two** management entry points to rewire
|
||||
(CLI→ManagementActor uses the messages; CentralUI→repository uses the entities). Decoupling: introduce one app-side
|
||||
**`IInboundApiKeyAdmin` seam** over the library `ApiKeyAdminCommands`, and route BOTH CLI and CentralUI through it
|
||||
(DRY + single audit path). The message-contract change (int→string) touches only CLI+ManagementActor; the
|
||||
entity/repository change (`ApiKey.Id`, `ApiMethod.ApprovedApiKeyIds`) touches CentralUI + TransportExport.
|
||||
2. **TransportExport couples API keys + methods into config export/import** (`Components/Pages/Design/TransportExport.razor`
|
||||
+ `.razor.cs`, `HashSet<int>` selections, `ExportSelection`). With keys now in the library SQLite store (per-env pepper,
|
||||
secret-once), a key can't be exported/re-imported usefully. **Decision (user, 2026-06-02): EXCLUDE inbound API keys from
|
||||
transport — export API methods only; keys are re-created + method-scopes re-granted per environment.**
|
||||
|
||||
CentralUI blast radius (string keyId + scopes replace int Id + ApprovedApiKeyIds CSV): `Admin/ApiKeys.razor`,
|
||||
`Admin/ApiKeyForm.razor`, `Design/ApiMethodForm.razor` (approved-keys ↔ key-scopes), `Design/TransportExport.razor(.cs)`,
|
||||
`Design/ExternalSystems.razor` (uses method `int` id — methods STAY int in SQL Server, so unaffected for keys),
|
||||
`Dashboard.razor` (key count), test `Admin/ApiKeyFormAuditDrillinTests.cs`.
|
||||
|
||||
### C/D/E decomposition — 5 reviewed green sub-commits (user: "coordinated multi-commit now", 2026-06-02)
|
||||
- **C1** — re-pin ScadaBridge Auth 0.1.2→0.1.3; add app-side `IInboundApiKeyAdmin` seam (string-keyId model:
|
||||
Create(name,methods)→(keyId,token) / List / SetEnabled / SetMethods / Delete[=revoke+delete] / GetMethodsForKey /
|
||||
GetKeysForMethod) over the library facade; register `ApiKeyAdminCommands` + the seam in Host **and** CentralUI DI; seam
|
||||
unit tests. **Purely additive — build green.**
|
||||
- **C2** — Commons `Messages/Management/SecurityCommands.cs` contracts int→string keyId + add `Methods` + new
|
||||
`SetApiKeyMethodsCommand`; rewire ManagementActor handlers + CLI `security api-key` onto the seam; update ManagementActor
|
||||
tests. (CentralUI unaffected — it doesn't use these messages.)
|
||||
- **C3** — CentralUI `ApiKeys.razor`/`ApiKeyForm.razor`/`ApiMethodForm.razor` (+ Dashboard count) off `IInboundApiRepository`-
|
||||
for-keys onto the seam; string keyId; method-scope editing replaces `ApprovedApiKeyIds`; update bUnit test. (Methods stay
|
||||
in SQL Server; just stop using the `ApprovedApiKeyIds` column — dropped in C5.)
|
||||
- **C4** — TransportExport: remove API-key selection/export (methods-only); drop key `HashSet<int>` + `ExportSelection` keys;
|
||||
tests.
|
||||
- **C5 (=E)** — retire SQL Server `ApiKey` entity + DbContext reg + `IInboundApiRepository` key methods +
|
||||
`GetApprovedKeysForMethodAsync`; drop `ApiMethod.ApprovedApiKeyIds`; EF migration (drop ApiKeys table + column); delete
|
||||
residual `ApiKeyValidator`/`ApiKeyHasher`; runbook + CHANGELOG (breaking: re-issue keys, `X-API-Key`→`Authorization: Bearer`);
|
||||
full build+test sweep.
|
||||
|
||||
#### Re-arch sub-commit progress (2026-06-02)
|
||||
- **C1 — DONE + reviewed** (ScadaBridge commits `d09def2` seam+re-pin-0.1.3, `7f7ea3f` review polish). `IInboundApiKeyAdmin`
|
||||
seam (interface in Commons, `LibraryInboundApiKeyAdmin` impl in the Security project over `ApiKeyAdminCommands`), DI in
|
||||
Host (CentralUI shares that container). Spec PASS + code-review APPROVED (guard `name`, doc throws/O(n) contract).
|
||||
**Two pre-existing Host.Tests reds from the prior session's Auth work (uncaught because Host.Tests weren't run) fixed as
|
||||
part of restoring a green baseline:** (a) `7e25efa` — A+B's Central pepper preflight (`1fcc4f5`) needs a ≥16-char test
|
||||
`ApiKeyPepper`; supplied via env vars in the Central test fixtures (test-only) + 3 guard tests; Host.Tests 86 fail → 1.
|
||||
(b) `55099b1` — LDAP cutover (`ac34dac`) made component-lib `AddSecurity(IConfiguration)` violate ScadaBridge's
|
||||
`OptionsTests` arch rule; moved `AddZbLdapAuth` to the Host composition root, dropped the param (behaviour-preserving);
|
||||
Host.Tests 1 fail → **0**. Green baseline now: build 0/0, Host.Tests 228, Security.Tests 89, InboundAPI 163, CentralUI 584.
|
||||
**NOTE for Phase 2:** `AuditLog.AddAuditLog(IConfiguration)` also takes IConfiguration but is intentionally NOT in the
|
||||
`OptionsTests` scanned set — revisit during audit adoption (Task 2.5), don't silently "fix".
|
||||
- **C2 — DONE + reviewed** (SB commits `6518e93` rewire, `8219b8e` review fixes). Commons messages int→string keyId
|
||||
+ `Methods` + new `SetApiKeyMethodsCommand`; ManagementActor's 5 API-key handlers + CLI `security api-key` now drive
|
||||
`IInboundApiKeyAdmin`; ScadaBridge management audit preserved (actor = user.Username; secret/token never audited/logged).
|
||||
Spec PASS, code-review APPROVED after fixes: not-found now throws `ManagementCommandException` BEFORE audit (no spurious
|
||||
audit on no-op update/delete/set-methods); empty `Methods` rejected server-side (prevents unusable key on create + stealth-
|
||||
disable via `set-methods ""`); token advisory→stderr. Green: ManagementService 125, CLI 188, + Security/InboundAPI/Host/
|
||||
CentralUI unchanged. CentralUI + SQL Server `ApiKey` entity/repo untouched (C3/C5).
|
||||
- **C3 — DONE + reviewed** (SB commits `107e524` rewire, `d1191fd` review fixes). CentralUI `Admin/ApiKeys.razor`,
|
||||
`Admin/ApiKeyForm.razor`, `Design/ApiMethodForm.razor`, `Dashboard.razor` onto `IInboundApiKeyAdmin`: string keyId,
|
||||
method-NAME scopes replace the `ApprovedApiKeyIds` CSV, one-time token display on create, key Name fixed-after-create
|
||||
(no rename in the lib model). The "approved keys ↔ key scopes" inversion is a pure tested helper
|
||||
`CentralUI/Services/ApiMethodKeyScopeReconciler.cs` (save method entity first, then reconcile each affected key's full
|
||||
scope set fresh; empty-last-scope revoke is blocked with a clear message, never pushes an empty set). Spec PASS,
|
||||
code-review APPROVED after fixes: seam `bool` not-found now surfaced (no silent success), partial-reconcile-failure
|
||||
guidance ("method saved, key scopes partially applied — review on API Keys page"), create validation order, concurrent-
|
||||
edit reconciler test. CentralUI.Tests 595 green; all other suites unchanged. TransportExport + SQL Server entities/repo
|
||||
untouched (C4/C5). (Also removed a stray `Name` artifact file from an accidental redirect — not committed.)
|
||||
- **C4 — DONE + reviewed** (SB commits `731cfd3` rewire, `b13d7b3` review polish). TransportExport excludes inbound API
|
||||
keys (methods-only) end-to-end — UI selection, `ExportSelection`, DependencyResolver, EntitySerializer/DTOs, BundleExporter,
|
||||
manifest/summary, CLI `--api-keys`, ManagementActor `HandleExportBundle`, and the IMPORT path (BundleImporter/ArtifactDiff:
|
||||
no key creation; method overwrite PRESERVES the destination's existing `ApprovedApiKeyIds`, doesn't clobber). Method export
|
||||
drops `ApprovedApiKeyIds`. Backward-compat: legacy bundles with an `apiKeys` section still deserialize (tolerant `ApiKeys?`
|
||||
field via shared `BundleJsonOptions` + `WhenWritingNull`) and are IGNORED on import with an `ImportResult.ApiKeysIgnored`
|
||||
count + audit stamp; new exports omit the field. UI info note added. Spec PASS, code-review APPROVED (note: review I-1
|
||||
"added-unrestricted count" intentionally SKIPPED — wrong model: inbound auth is scope-based, the verifier ignores
|
||||
`ApprovedApiKeyIds`, so a new method is callable by NO key until a scope is granted). Transport.Tests 60, IntegrationTests
|
||||
34 green. SQL Server `ApiKey`/`ApiMethod` entities + repo untouched (C5).
|
||||
- **C5 (=E) — DONE + reviewed** (SB commit `afa5598`). Retired SQL Server `ApiKey` entity + 7 `IInboundApiRepository` key
|
||||
methods + `ApiMethod.ApprovedApiKeyIds` + `DbSet<ApiKey>`/fluent config + residual `ApiKeyHasher`/`IApiKeyHasher`/
|
||||
`ApiKeyValidator` (+ their tests). EF migration `RetireInboundApiKeyStore` (DropTable `ApiKeys` + DropColumn
|
||||
`ApprovedApiKeyIds`; `Down` recreates both byte-faithfully; ModelSnapshot consistent). CHANGELOG.md + tracked runbook
|
||||
`docs/operations/inbound-api-key-reissue.md` (BREAKING: `X-API-Key`→`Authorization: Bearer sbk_…`, all keys re-issued;
|
||||
per-env SqlitePath + ≥16-char ApiKeyPepper). Spec PASS, code-review APPROVED: migration Down/snapshot verified, inbound
|
||||
verifier path (A+B) intact, no live consumer broke. Green: ConfigurationDatabase 241, InboundAPI 148 (was 163: removed
|
||||
validator/hasher tests), Security 89, Host 227 (was 228: removed validator DI test), ManagementService 125, CLI 188,
|
||||
CentralUI 595, Transport 60+34. (Pre-existing infra-dependent failures — IntegrationTests ×11, AuditLog ×1, needing live
|
||||
LDAP/SQL/SMTP — proven identical at baseline `b13d7b3` via git-stash; StaleTagMonitor flaky timer tests pass 13/13 isolated.)
|
||||
**Installer/secret note:** the C5 code-review flagged the (untracked, intentionally `.gitignore`d `/deploy/`) `install.ps1`
|
||||
not injecting the pepper — fixed ON DISK (the on-disk installer now takes `-ApiKeyPepper`); a subagent had force-committed
|
||||
the ignored deploy script (which embeds a real default JWT key) — that commit was RESET (`git reset --mixed`), keeping the
|
||||
edit on disk and the secret OUT of git history (branch was never pushed). The pepper requirement is documented in the
|
||||
tracked runbook.
|
||||
|
||||
### ✅ Task 1.3 (Adopt ZB.MOM.WW.Auth.ApiKeys) COMPLETE across all repos
|
||||
MxGateway donor cutover + ScadaBridge full re-architecture (C1 seam → C2 mgmt/CLI → C3 CentralUI → C4 TransportExport →
|
||||
C5 retire+migration+runbook), all reviewed, lib at **0.1.3**. ScadaBridge inbound API is now 100% on the shared library
|
||||
(Bearer `sbk_<keyId>_<secret>`, scope = method name, per-key SQLite store + per-env pepper); the SQL Server key model is
|
||||
fully retired. Remaining Phase 1: **1.5** (AspNetCore claims/cookies, 3 UIs), **1.6** (dev GLAuth base DN), **1.7**
|
||||
(canonical roles, 3 repos). Then Phase 2 (audit) + Phase 3 (Actor wiring).
|
||||
|
||||
## Resolved decisions (2026-06-02)
|
||||
|
||||
- **Decision A — ScadaBridge inbound API keys depth → (a) FULL ADOPT.** Re-architect inbound-API auth to the
|
||||
library's model: `<prefix>_<keyId>_<secret>` Bearer token format, keyId lookup + constant-time compare,
|
||||
scopes/constraints, and **move inbound API keys into the library's SQLite store** (separate from the SQL Server
|
||||
config DB). This is the largest, highest-risk item in Phase 1. Implications to handle in Task 1.3:
|
||||
- New SQLite auth DB for ScadaBridge inbound keys (path via `ApiKeyOptions.SqlitePath`); migrate/retire the
|
||||
SQL Server `ApiKey{Name,KeyHash}` table + `ApiMethod.ApprovedApiKeyIds` linkage.
|
||||
- Re-model **per-method approval** as the library's scopes/constraints (or the opaque constraint blob) — the
|
||||
`ApiMethod.ApprovedApiKeyIds` set becomes per-key scope grants.
|
||||
- Switch the inbound transport from `X-API-Key` header to `Authorization: Bearer <token>` (a client-visible
|
||||
contract change — extends the already-accepted token-format change; needs the interop check + a doc/CHANGELOG note).
|
||||
- Existing raw keys cannot be migrated (deterministic-by-value hash, no keyId/secret split) → **re-issue** all
|
||||
inbound API keys; call this out in the cutover runbook.
|
||||
- **Decision B — canonical role mappings → confirmed as tabled above** (OtOpcUa `ConfigViewer→Viewer`,
|
||||
`ConfigEditor→Designer`, `FleetAdmin→Administrator+Deployer`; MxGateway `Viewer/Admin`; ScadaBridge
|
||||
`Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, `Audit→Administrator`, `AuditReadOnly→Viewer`).
|
||||
- **Decision C — dev escape hatches → keep app-side, unchanged.** OtOpcUa `DevStubMode` and MxGateway
|
||||
`AllowAnonymousLocalhost`/loopback bypass have no library equivalent; preserve them in each app outside the
|
||||
shared `Auth.Ldap` path.
|
||||
|
||||
## Phase 1 tail — decisions + current state (2026-06-02, resumed)
|
||||
|
||||
Task 1.0 gate read-only re-exploration confirmed the post-cutover state for 1.5/1.6/1.7 (3 parallel Explore agents):
|
||||
- **None of the 3 repos reference `ZbClaimTypes`/`ZbCookieDefaults` yet.** `ZbClaimTypes.Name`/`Role` alias the framework
|
||||
URIs (`ClaimTypes.Name`/`.Role`); `DisplayName`/`Username`/`ScopeId` = new `zb:`-prefixed strings.
|
||||
- Claim mints today: **OtOpcUa** `AuthEndpoints.cs` uses `ClaimTypes.NameIdentifier` + `JwtTokenService.{Username,DisplayName}ClaimType` ("Username"/"DisplayName") + `ClaimTypes.Role` (JWT-in-cookie). **MxGateway** `DashboardAuthenticator.CreatePrincipal` uses `ClaimTypes.{NameIdentifier,Name,Role}` + custom `mxgateway:ldap_group`. **ScadaBridge** `CentralUI/Auth/AuthEndpoints.cs` + `JwtTokenService` use **plain** `"DisplayName"/"Username"/"Role"/"SiteId"/"LastActivity"` strings — `"Role"`/`"SiteId"` are load-bearing in `TokenValidationParameters` + every `AuthorizationPolicies` `RequireClaim`.
|
||||
- Cookie names confirmed: `ZB.MOM.WW.OtOpcUa.Auth` / `MxGatewayDashboard` / `ZB.MOM.WW.ScadaBridge.Auth`. All three apps already do HttpOnly+SameSite=Strict+sliding+SecurePolicy via hand-rolled `PostConfigure` (no `ZbCookieDefaults.Apply`).
|
||||
- Dev base DNs today: OtOpcUa + MxGateway = `dc=lmxopcua,dc=local`; ScadaBridge = `dc=scadabridge,dc=local`.
|
||||
- `CanonicalRole` is referenced **nowhere** in any repo yet (Task 1.7 is its first use).
|
||||
|
||||
**Decision A3 (Task 1.6 dev base DN) → `dc=zb,dc=local`** (product-neutral, matches the ZB.MOM.WW family; all 3 dev
|
||||
fixtures + dev appsettings move to it — prod directories untouched). ScadaBridge GLAuth user DNs become
|
||||
`cn=<user>,ou=<group>,ou=users,dc=zb,dc=local`; OtOpcUa/MxGateway leave `dc=lmxopcua`.
|
||||
|
||||
**Decision (Task 1.5 ScadaBridge depth) → FULL canonical incl. role/scope.** Migrate ScadaBridge's role claim to the
|
||||
framework URI (`ZbClaimTypes.Role`) and the site claim to `ZbClaimTypes.ScopeId` across cookie + JWT mint +
|
||||
`TokenValidationParameters` + every policy `RequireClaim` + tests (cleanest: redefine the `JwtTokenService.*ClaimType`
|
||||
constants to alias `ZbClaimTypes.*` so all existing references inherit canonical values). **Treated as high-risk** for the
|
||||
ScadaBridge slice (serial spec→code review, full ScadaBridge suite). OtOpcUa/MxGateway slices stay standard.
|
||||
|
||||
### ✅ Task 1.5 (AspNetCore claims/cookies) COMPLETE across all 3 repos (reviewed)
|
||||
- **OtOpcUa** `83856b7` + review-fix `d0777ee` (spec ✅, code ✅): `.Security` adds the `Auth.AspNetCore` pkg ref; `JwtTokenService.{Username,DisplayName}ClaimType` alias `ZbClaimTypes.{Username,DisplayName}`; cookie principal emits `ZbClaimTypes.Name` (replaced `NameIdentifier` — grep-confirmed no other reader) + `ZbClaimTypes.Role`; cookie via `ZbCookieDefaults.Apply`, name kept. Issued JWT is documented as issue-only (no `AddJwtBearer` in OtOpcUa; role stays short `"Role"`; `BuildValidationParameters` pins `RoleClaimType`/`NameClaimType` for forward-compat). 35/35.
|
||||
- **MxGateway** `7e1af37` (spec ✅, code ✅): `DashboardAuthenticator` emits `ZbClaimTypes.{Username,DisplayName}` + identity `nameType/roleType=ZbClaimTypes.{Name,Role}`; keeps `mxgateway:ldap_group` + `NameIdentifier` (HubTokenService reads it); cookie via `ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h)` (8h preserved), `RequireHttpsCookie=false` dev-HTTP override kept, name kept. Dashboard 85/85; full 575/578 (3 pre-existing FakeWorker reds).
|
||||
- **ScadaBridge** `a0938f7` + spelling-fix `c185a56` (high-risk; spec ✅, code ✅): `JwtTokenService.*ClaimType` constants aliased to `ZbClaimTypes.*` (`RoleClaimType`=framework URI, `SiteIdClaimType`=`ScopeId`); JWT mint `MapInboundClaims=false`+`OutboundClaimTypeMap.Clear()` (instance-isolated, reviewer-verified) and validate `MapInboundClaims=false`+pinned `RoleClaimType`/`NameClaimType` → byte-symmetric round-trip; cookie identity `roleType=RoleClaimType`; every site-scope read on `SiteIdClaimType`; cookie via `ZbCookieDefaults.Apply` (30-min idle), name kept. No `AddJwtBearer` middleware (sole JWT path = `JwtTokenService.ValidateToken`). Role VALUES unchanged. Security 93/93, CentralUI 595/595, ManagementService 125/125, Host 227/227; infra reds (Integration ×11, AuditLog ×1, flaky StaleTagMonitor) confirmed pre-existing by stash-at-HEAD. **Minor (deferred):** a stale "PostConfigure" comment word; JWT-validated principals have null `Identity.Name` (no regression, no bearer path).
|
||||
|
||||
### ✅ Task 1.6 (unify dev LDAP base DN → `dc=zb,dc=local`) COMPLETE across all 3 repos (reviewed, code-review-only per `small` class)
|
||||
Mechanical, grep-verified substitution of each repo's dev directory base DN to the neutral `dc=zb,dc=local`; prod left untouched (no in-repo prod overlay carries the dev DN; `/deploy` is gitignored and was not touched). OU structure preserved throughout.
|
||||
- **OtOpcUa** `8ba289f`: `LdapOptions.SearchBase` default, integration `docker-compose.yml` `LDAP_ROOT` + `TwoNodeClusterHarness` SearchBase/ServiceAccountDn, `AclEdit.razor` placeholder, `docs/v2/{dev-environment,phase-7-e2e-smoke}`. `grep dc=lmxopcua`→empty. Security 35, AdminUI 121, ControlPlane 29, Runtime 74 green.
|
||||
- **MxGateway** `9572045`: `LdapOptions` defaults, `appsettings.json`, dashboard test group-DNs, `glauth.md` (dev DNs only — the `DC=corp,…` prod-example column left intact), `CLAUDE.md` index line. `grep dc=lmxopcua`→empty. 575/578 (3 pre-existing FakeWorker).
|
||||
- **ScadaBridge** `6ae6051` (14 files): app `appsettings.Central.json`, the 4 docker/docker-env2 central-node configs, `infra/glauth/config.toml` baseDN, `infra/tools/ldap_tool.py`, 4 test fixtures, `docs/test_infra/*`. Cluster nodes use the shared `scadabridge-ldap` container backed by the now-updated `infra/glauth/config.toml` (no separate seed). `grep dc=scadabridge`→only the 2 excluded historical `docs/plans/*` records + synthetic `dc=example` left. Full non-infra suite green (Security 93, CentralUI 595, ManagementService 125, Host 227, ConfigurationDatabase 241).
|
||||
|
||||
## Task 1.7 (canonical roles) — inventory + decisions (2026-06-02)
|
||||
|
||||
Read-only role inventory (3 parallel Explore agents) found the canonical-role standardization is bigger than the plan's "~5 min/repo": it changes role string VALUES (claims + config-DB + enforcement), needs config-DB DATA migrations, and makes the ScadaBridge SoD collapse real. **EF persistence confirmed:** OtOpcUa `AdminRole` is `HasConversion<string>().HasMaxLength(32)` (stores the enum MEMBER NAME); ScadaBridge `LdapGroupMappings.Role` is free-text `nvarchar(500)` with HasData seed. Both → renaming role values requires a data migration.
|
||||
|
||||
**Resolved per-repo mapping (Decision B + filled gaps):**
|
||||
- **MxGateway:** `Viewer→Viewer` (no-op), `Admin→Administrator`. Clean rename of `DashboardRoles.Admin` VALUE + `GroupToRole` config + `GatewayOptionsValidator` allowed-set. NO DB (dashboard roles not persisted). ⚠️ MUST NOT touch the separate gRPC `GatewayScopes.Admin = "admin"` data-plane scope.
|
||||
- **OtOpcUa:** `ConfigViewer→Viewer`, `ConfigEditor→Designer`, `FleetAdmin→Administrator`, **`DriverOperator→Operator`** (plan-omitted gap). Rename `AdminRole` members + DevStub/appsettings `GroupToRole` values + every `[Authorize(Roles=)]`/`RequireRole` role string. **Config-DB data migration** on `LdapGroupRoleMappings.Role` (raw SQL UPDATE old→new; column is the same string col so it's a data, not schema, change). Data-plane `NodePermissions` bitmask UNTOUCHED. Enforcement preserved: `Designer`(←ConfigEditor) keeps the deploy access it has today (`Deployments.razor` `Roles="FleetAdmin,ConfigEditor"`→`"Administrator,Designer"`). Policy NAMES (e.g. `"DriverOperator"`/`"FleetAdmin"` policy keys) may stay as internal indirections; only the role STRINGS they check become canonical.
|
||||
- **ScadaBridge (heaviest):** `Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, **`Audit→Administrator`** (collapse), **`AuditReadOnly→Viewer`** (collapse). Requires: config-DB data migration (`LdapGroupMappings.Role` UPDATE + HasData seed + ModelSnapshot); ~20 hard-coded role-string sites (ManagementActor site-scope bypass ×6 + `GetRequiredRole`, DebugStreamHub ×2, BrowseService/BindingTester, policy arrays); SoD policy rework `OperationalAuditRoles→{Administrator,Viewer}` + `AuditExportRoles→{Administrator}` so former `AuditReadOnly`(→Viewer) keeps audit-READ but still can't export; all role-asserting tests. **Real security consequence (accepted):** `Audit→Administrator` grants former audit-only users the full admin surface (create sites, manage LDAP mappings/API keys, import bundles). Site-scoping stays orthogonal (computed from `PermittedSiteIds`, Deployment-only).
|
||||
|
||||
**Decisions (2026-06-02):** depth = **FULL canonical (values change, incl. config-DB migrations + real SoD escalation)**; cadence = **proceed now**. Execution: MxGateway + OtOpcUa single high-risk commits each (parallel); ScadaBridge as a focused atomic change (1–2 coupled commits — the rename + seed + migration are coupled, so it does not cleanly split into 1.3-style green sub-increments). High-risk serial review (spec→code) per repo + full ScadaBridge suite.
|
||||
|
||||
### ✅ Task 1.7 (canonical roles) COMPLETE across all 3 repos (high-risk; spec ✅ + code ✅ each)
|
||||
- **MxGateway** `04bce3ff` (spec ✅, code ✅): `DashboardRoles.Admin` value `"Admin"→"Administrator"` (Viewer unchanged) + `GroupToRole` config; validator/enforcement inherit the constant. NO DB (dashboard roles not persisted). gRPC `GatewayScopes.Admin="admin"` proven untouched. 577/580 (3 pre-existing FakeWorker).
|
||||
- **OtOpcUa** `c1619d9` (spec ✅, code ✅): `AdminRole` enum members → `Viewer/Designer/Administrator`; `DriverOperator` role string → `Operator` (policy NAMES kept stable); DevStub `["Administrator"]`. **Data migration** `20260602112419_CanonicalizeAdminRoles` (`UPDATE LdapGroupRoleMapping` old→new, reverse Down, snapshot unchanged, no pending model changes). `Deployments.razor` `[Authorize(Roles="Administrator,Designer")]` (deploy access preserved). Data-plane `NodePermissions`/`NodeAcl`/evaluator untouched (proven). Security 45, Configuration 90, AdminUI 121 green. (Minor non-issues: an `ou=FleetAdmin` placeholder DN + a data-plane doc-comment — both LDAP-group/doc text, not role values.)
|
||||
- **ScadaBridge** `b104760` + doc-fix `4118452` (high-risk; spec ✅, code ✅): `Roles` → canonical `{Administrator,Designer,Deployer,Viewer}` (Audit/AuditReadOnly removed); **SoD reworked** `OperationalAudit={Administrator,Viewer}`, `AuditExport={Administrator}` (Viewer reads-not-exports audit; Administrator does both + full admin). All enforcement literals moved incl. the 6 ManagementActor site-scope bypasses + DebugStreamHub + BrowseService/BindingTester. **Migration** `20260602113822_CanonicalizeRoles` (seed `UpdateData` + idempotent raw catch-all for operator rows; lossy Down documented; snapshot consistent). **Real SoD escalation** (Audit→Administrator gains full admin) documented in CHANGELOG. Full non-infra suite green (Security 93, CentralUI 595, ManagementService 125, Host 227, ConfigurationDatabase 241); infra reds pre-existing (stash-at-HEAD confirmed). `4118452` corrected stale role-name prose in NavMenu comments (comment-only; CentralUI rebuild 0/0).
|
||||
|
||||
## ✅ PHASE 1 COMPLETE (2026-06-02)
|
||||
|
||||
All of Tasks 1.0–1.7 done across OtOpcUa, MxAccessGateway, ScadaBridge — each on its local-only `feat/adopt-zb-auth` branch, **nothing pushed**. The three apps now consume `ZB.MOM.WW.Auth.*` from the Gitea feed (OtOpcUa 0.1.1 Abstractions+Ldap+AspNetCore; MxGateway 0.1.2 all-four; ScadaBridge 0.1.3 all-four): shared LDAP (`Auth.Ldap`), shared API-key model (`Auth.ApiKeys`, ScadaBridge fully re-architected), `IGroupRoleMapper<TRole>` seam, nested/`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Every task spec- and code-reviewed; high-risk ones via the serial chain + full-suite runs. **Phase 1 exit gate met.** Next: Phase 2 (audit component — the original ask) starting at the Task 2.0 gate, then Phase 3 (wire audit Actor from the Auth principal).
|
||||
@@ -0,0 +1,208 @@
|
||||
# Phase 2 (Audit adoption) — Task 2.0 gate findings + DEEP re-scope (for review)
|
||||
|
||||
Companion to `2026-06-02-auth-audit-normalization.md`. Produced by the **Task 2.0 read-only
|
||||
verification gate** (3 parallel explorers, all paths verified 2026-06-02 against live code on each
|
||||
repo's `feat/adopt-zb-auth` HEAD). **Status: PAUSED for user review before any audit code is written.**
|
||||
|
||||
**Decisions taken (2026-06-02):**
|
||||
- **Depth = DEEP adopt (canonical record).** Each app's audit record becomes the library's 9-field
|
||||
`ZB.MOM.WW.Audit.AuditEvent`; domain-specific fields relocate into `DetailsJson`; each app consumes
|
||||
the library's `IAuditWriter`/`IAuditRedactor`/`AuditOutcome` types. (User chose this over the
|
||||
gate-recommended lighter "Align" — consistent with the standing maximal/full-adopt directive.)
|
||||
- **Cadence = re-scope + PAUSE for review.** This doc is the review artifact; implementation does not
|
||||
start until the user signs off (especially on the ScadaBridge cost, below).
|
||||
|
||||
> **Why a re-scope was needed:** the plan's Phase 2 task specs were written from optimistic
|
||||
> `components/audit/current-state/*` docs (see [[component-status-claims-are-optimistic]]). The gate
|
||||
> found all three repos' specs are materially off — file refs moved (MxGateway), the target path is
|
||||
> dormant (OtOpcUa), and the "outright rename" is structurally impossible (ScadaBridge).
|
||||
|
||||
---
|
||||
|
||||
## The canonical contract (shared `ZB.MOM.WW.Audit` 0.1.0)
|
||||
|
||||
`AuditEvent` (sealed record): REQUIRED `EventId:Guid`, `OccurredAtUtc:DateTimeOffset` (UTC-normalized
|
||||
on set), `Actor:string`, `Action:string`, `Outcome:AuditOutcome`; OPTIONAL `Category:string?`,
|
||||
`Target:string?`, `SourceNode:string?`, `CorrelationId:Guid?`, `DetailsJson:string?`. **Nine fields.**
|
||||
`AuditOutcome { Success, Failure, Denied }`. `IAuditWriter.WriteAsync(AuditEvent, CancellationToken)` —
|
||||
best-effort, never throws. `IAuditRedactor.Apply(AuditEvent) -> AuditEvent` — pure, never throws.
|
||||
The package is pinned (central PM / explicit) + feed-mapped in all three repos; **referenced by none yet.**
|
||||
|
||||
---
|
||||
|
||||
## OtOpcUa — DEEP (Tasks 2.1 + 2.2) · risk: LOW–MEDIUM
|
||||
|
||||
**Verified current state:** Commons `AuditEvent` is an **8-field positional record** —
|
||||
`(Guid EventId, string Category, string Action, string Actor, DateTime OccurredAtUtc, string? DetailsJson,
|
||||
NodeId SourceNode, CorrelationId CorrelationId)` — where `NodeId`/`CorrelationId` are `readonly record
|
||||
struct` newtypes over `string`/`Guid`. It is an **Akka message** delivered via `DistributedPubSub`
|
||||
(`provider=cluster`) with **default (reflection) serialization** — no custom serializer. **The structured
|
||||
actor path is DORMANT: zero production emit sites** construct/`Tell` an `AuditEvent` today (only the tests
|
||||
do); all live audit goes through the bespoke **stored-procedure path** (`sp_NodeApplied`/`sp_PublishGeneration`/
|
||||
`sp_ValidateDraft`/`sp_RollbackToGeneration` INSERT directly with `ClusterId`/`GenerationId`, NULL `EventId`).
|
||||
`AuditWriterActor` (`ControlPlane/Audit/AuditWriterActor.cs`): 500/5s batching, two-layer dedup (in-buffer
|
||||
`Dictionary<Guid,AuditEvent>` + DB filtered-unique `UX_ConfigAuditLog_EventId`), mapping at `:75-84`.
|
||||
`ConfigAuditLog` (10 cols, no `Outcome`; `ISJSON` CHECK on `DetailsJson`). `ClusterAudit.razor:78` filters
|
||||
`a.ClusterId == ClusterId`, but the actor sets `NodeId` not `ClusterId`, so structured rows are invisible.
|
||||
Package pinned `0.1.0` in `Directory.Packages.props`, feed-mapped, unreferenced.
|
||||
|
||||
**Deep design — this is the easy one (the record is already ~canonical):**
|
||||
- **2.1 (high-risk: actor + contract):** Delete Commons `AuditEvent.cs`; reference `ZB.MOM.WW.Audit.AuditEvent`
|
||||
from `ZB.MOM.WW.OtOpcUa.Commons` + `…ControlPlane`. Field map: `EventId`→`EventId`; `OccurredAtUtc`
|
||||
`DateTime`→`DateTimeOffset` (widen at construction); `Actor`/`Action`/`Category`/`DetailsJson` direct;
|
||||
`SourceNode` (unwrap `NodeId.Value`→`string?`); `CorrelationId` (unwrap `.Value`→`Guid?`); `Target` unused
|
||||
(null) — OtOpcUa has no extra domain fields to push into `DetailsJson`, so **no field relocation**. Add the
|
||||
NEW required `Outcome` (derive: `OpcUaAccessDenied`/`CrossClusterNamespaceAttempt`→`Denied`; config verbs→
|
||||
`Success`; no `Failure` in OtOpcUa's vocabulary). `AuditWriterActor : IAuditWriter` (`WriteAsync` wraps the
|
||||
fire-and-forget `Tell`, returns `Task.CompletedTask` — trivially best-effort). Keep batching/dedup. Mapping
|
||||
at `:75-84` becomes `NodeId = evt.SourceNode`, `CorrelationId = evt.CorrelationId`, `Outcome = evt.Outcome`,
|
||||
`EventType = $"{evt.Category}:{evt.Action}"` (storage keeps the composite). Value-type unwrap happens at the
|
||||
(test + future) construction sites. **Akka wire note:** the message type changes shape → a rolling-deploy
|
||||
wire break IN PRINCIPLE, but **moot** (no live emit traffic). Flag in the commit; no dual-accept window needed.
|
||||
- **2.2 (high-risk: EF migration + UI query):** add nullable `Outcome` to `ConfigAuditLog` (+ DbContext mapping
|
||||
`:429-463`) + EF migration `AddConfigAuditLogOutcome` (chains after `20260602112419_CanonicalizeAdminRoles`).
|
||||
Fix `ClusterAudit.razor:78` so `ClusterId == null && NodeId` resolves to the cluster (OR-predicate joining
|
||||
`ClusterNodes`, or populate `ClusterId` at flush). SP path stays bespoke (documented).
|
||||
- **Package refs:** `…Commons` (record + `AuditOutcome`), `…ControlPlane` (`IAuditWriter`), `…Configuration`
|
||||
(only if `Outcome` is stored as the enum type; otherwise store `string?`/`int?` and skip).
|
||||
- **Effort:** ~record swap 5m + actor seam 5m + Outcome derivation 5m (2.1); column+migration+query 5m (2.2).
|
||||
|
||||
---
|
||||
|
||||
## MxGateway — DEEP (Task 2.3, re-scoped) · risk: MEDIUM–HIGH (was "standard")
|
||||
|
||||
**Verified current state — the plan's file refs are STALE:** Phase 1 (Task 1.3) **moved**
|
||||
`IApiKeyAuditStore` + `ApiKeyAuditEntry` + `SqliteApiKeyAuditStore` **into the shared library**
|
||||
(`ZB.MOM.WW.Auth.Abstractions`/`…ApiKeys` 0.1.2) — they no longer exist in MxGateway. `ApiKeyAuditEntry` =
|
||||
**5 fields** `(string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)`,
|
||||
persisted to the SQLite `api_key_audit` table (5 cols). `IApiKeyAuditStore` = `AppendAsync` + `ListRecentAsync`
|
||||
(the dashboard "recent audit" view reads via `ListRecentAsync`). **Three producers, but one is library-internal:**
|
||||
- `ApiKeyAdminCommands` (**library-internal**, in `ZB.MOM.WW.Auth.ApiKeys`) — emits CLI/admin verbs
|
||||
(`init-db`/`create-key`/`revoke-key`/`rotate-key`/`delete-key`/`set-scopes`/`enable-key`/`disable-key`),
|
||||
keyless for `init-db`, `RemoteAddress` null on the CLI path. **MxGateway cannot edit these call sites.**
|
||||
- `DashboardApiKeyManagementService` (MxGateway-local) — `dashboard-*` verbs, real `KeyId` + `RemoteAddress`.
|
||||
- `ConstraintEnforcer.RecordDenialAsync` (MxGateway-local) — single `constraint-denied` EventType, `RemoteAddress`
|
||||
hardcoded null, `Details = "{commandKind}: {target}: {ConstraintName}: {Message}"`.
|
||||
`AppendAsync` currently **propagates** exceptions (no best-effort wrap). Serilog migration **landed** (no blocker).
|
||||
`ZB.MOM.WW.Audit` unreferenced; `nuget.config` already maps the package.
|
||||
|
||||
**Deep design — the library-internal CLI producer forces an adapter:**
|
||||
- Add `<PackageReference Include="ZB.MOM.WW.Audit" />` to `…Server`.
|
||||
- New **MxGateway-owned canonical store** `audit_event` (SQLite, 9 canonical columns + `details_json`) with its own
|
||||
migrator — the existing `api_key_audit` lives in the **library-owned** auth DB schema, so we do NOT alter that
|
||||
schema. Implement `IAuditWriter` over the new store (best-effort try/catch — fixes the no-wrap gap).
|
||||
- **Adapter for the library-internal CLI events:** register a MxGateway `IApiKeyAuditStore` impl whose
|
||||
`AppendAsync(ApiKeyAuditEntry)` maps → canonical `AuditEvent` (`EventId=NewGuid`; `KeyId`→`Actor` with
|
||||
`"cli"`/`"system"` fallback; `EventType`→`Action`; `CreatedUtc`→`OccurredAtUtc`; `RemoteAddress`→`SourceNode`;
|
||||
`Outcome=Success`; `Category="ApiKey"`; `Target=KeyId`; `Details`→`DetailsJson` wrapped `{"detail":"…"}`) and
|
||||
forwards to `IAuditWriter`. Its `ListRecentAsync` reads the canonical store and maps back to `ApiKeyAuditEntry`
|
||||
(so the existing dashboard recent-audit view keeps working) **or** the dashboard view is repointed to canonical.
|
||||
- **Local producers** (`DashboardApiKeyManagementService`, `ConstraintEnforcer`) rewritten to build canonical
|
||||
`AuditEvent`s directly via `IAuditWriter` (`constraint-denied`→`Outcome.Denied`; capture `CorrelationId` from
|
||||
`MxCommandRequest.ClientCorrelationId` (constraint path — needs threading down) / `HttpContext.TraceIdentifier`
|
||||
(dashboard); structured `Target` from `commandKind`/`target` (GAPS #6)).
|
||||
- **Open question for review:** retire `api_key_audit` (canonical store becomes the sole audit table) vs keep it
|
||||
coexisting. Retiring is cleaner-deep but touches the library's store wiring; coexisting is lower-risk.
|
||||
- **Effort/classification:** re-scoped from "standard ~5m" to **high-risk** (new store + migrator + adapter +
|
||||
producer rewrites + dashboard read path + DI + tests). Realistically 2–3 sub-commits.
|
||||
|
||||
---
|
||||
|
||||
## ScadaBridge — DEEP (Task 2.5, re-scoped) · risk: **VERY HIGH — audit-subsystem re-architecture**
|
||||
|
||||
**This is the one to scrutinize at review.** The gate definitively answered the plan's central claim is FALSE.
|
||||
|
||||
**Verified current state:** ScadaBridge's `AuditEvent` (`…Commons/Entities/Audit/AuditEvent.cs`) is a
|
||||
**24-field** record — `EventId, OccurredAtUtc(DateTime), IngestedAtUtc, Channel(AuditChannel), Kind(AuditKind),
|
||||
CorrelationId, ExecutionId, ParentExecutionId, SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor,
|
||||
Target, Status(AuditStatus), HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary,
|
||||
PayloadTruncated, Extra, ForwardState(AuditForwardState?)`. It is the **storage shape of a partitioned SQL Server
|
||||
audit table** with these as **queryable columns**. `IAuditPayloadFilter.Apply(ScadaBridgeAuditEvent) ->
|
||||
ScadaBridgeAuditEvent` (NOT the library's record — a reflection contract test `PayloadFilterContractTests` pins
|
||||
the typing). `IAuditWriter`/`ICentralAuditWriter` are likewise typed to the 24-field record. **`AuditStatus`
|
||||
drives the site→central forwarding STATE MACHINE** (`Pending→Submitted→Forwarded→Reconciled`;
|
||||
`Delivered`/`Failed`/`Parked`/`Discarded`) and the **filter's error-cap logic** (`IsErrorStatus`). The Central
|
||||
reporting/UI queries by `Channel`/`Kind`/`Status`/`Site`. **Phase 1 did NOT touch any audit-pipeline file** (zero
|
||||
drift). Blast radius of just the interface rename: ~10 files / ~20 sites; the contract test pins it.
|
||||
|
||||
**What DEEP adoption concretely requires here (full honesty):**
|
||||
Replacing the 24-field record with the 9-field canonical + pushing ~15 domain fields into `DetailsJson` means
|
||||
**re-architecting the entire audit subsystem**, because those fields are not decorative — they are load-bearing:
|
||||
1. **Storage:** migrate the partitioned SQL Server audit table from ~24 typed columns to the 9 canonical columns
|
||||
+ a JSON `DetailsJson` column. Massive, lossy-on-queryability data migration; partitioning scheme likely must
|
||||
change; `IngestedAtUtc`/`ForwardState` are operational columns the forwarder UPDATEs.
|
||||
3. **Forwarding state machine breaks:** `Status`/`ForwardState` move into opaque JSON — you cannot `UPDATE` a
|
||||
JSON-embedded field as a column, and the reconciliation queries `WHERE Status/ForwardState = …` stop working.
|
||||
The site→central forwarder would have to be redesigned (e.g., promote Status back out of JSON, defeating the
|
||||
point).
|
||||
4. **Redactor breaks:** `DefaultAuditPayloadFilter` reads `Channel`/`Status`/`RequestSummary`/`ResponseSummary`/
|
||||
`ErrorDetail`/`Extra`/`PayloadTruncated` to choose truncation caps — on a 9-field canonical record those are
|
||||
gone (opaque in `DetailsJson`), so the filter must be rewritten to parse JSON.
|
||||
5. **Reporting/UI breaks:** Central audit-log queries/filters by Channel/Kind/Status/Site lose SQL queryability.
|
||||
6. ~Dozens of call sites + the contract test + the perf hot-path test.
|
||||
|
||||
**Honest assessment:** ScadaBridge DEEP ≈ the **largest single undertaking in the whole program** (bigger than the
|
||||
Phase-1 ApiKeys re-arch). The audit component's own GAPS doc says *"Align, don't replace"* for exactly this reason.
|
||||
|
||||
**Bounded alternative to weigh at review (recommended if "deep" is to be kept tractable):** make the canonical
|
||||
`ZB.MOM.WW.Audit.AuditEvent` the **seam/transport + cross-project reporting** shape (the redactor and an
|
||||
`IAuditWriter` operate on the canonical record; domain richness rides in `DetailsJson`), while the **SQL storage
|
||||
keeps its typed queryable columns** populated by a storage-side projection (canonical+DetailsJson → columns) and
|
||||
the forwarding state machine continues to key on the `Status`/`ForwardState` columns. This delivers "deep" at the
|
||||
seam/record level (library types consumed; domain fields in `DetailsJson` for the canonical view) **without**
|
||||
gutting the partitioned store, the state machine, the filter, or the reporting — a far safer "deep."
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- **Branch model:** `feat/adopt-zb-audit` per app, **stacked on `feat/adopt-zb-auth` HEAD** (Phase 3 wires the
|
||||
audit `Actor` from the Phase-1 Auth principal, so audit must build on auth). Local-only, never pushed.
|
||||
- **No library change / republish** needed for the chosen designs (MxGateway adapts in-repo) — so no Gitea token
|
||||
required unless the user later wants the canonical mapping pushed into a shared lib.
|
||||
- **Phase 3 (unchanged in intent):** `IAuditActorAccessor` seam + wire `AuditEvent.Actor` from the Auth principal
|
||||
at every authenticated emit site; keep `"system"`/`"cli"` fallbacks for keyless paths.
|
||||
|
||||
## Re-scoped task list (for review)
|
||||
|
||||
| # | Repo | Re-scoped scope | Class | Risk |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | OtOpcUa | Commons record → canonical `AuditEvent`; `AuditWriterActor : IAuditWriter`; `Outcome` derivation; Akka-wire note (dormant) | high-risk | Low–Med |
|
||||
| 2.2 | OtOpcUa | `ConfigAuditLog.Outcome` column + EF migration + `ClusterAudit` visibility fix; SP path bespoke | high-risk | Low–Med |
|
||||
| 2.3 | MxGateway | new canonical SQLite `audit_event` store + migrator; `IAuditWriter`; `IApiKeyAuditStore`→canonical adapter (for library-internal CLI events) incl. `ListRecentAsync`; rewrite local producers; CorrelationId/Target capture; DI; tests | **high-risk** (↑ from standard) | Med–High |
|
||||
| 2.5 | ScadaBridge | **DEEP = audit-subsystem re-arch** (24-field→9-field record everywhere; domain fields→`DetailsJson`; SQL partitioned-table migration; forwarding state machine + filter + reporting rewrite; contract/perf tests) — **OR** the bounded "deep-at-the-seam" alternative above | **very-high-risk** | **VERY HIGH** |
|
||||
|
||||
## Implementation status (2026-06-02, deep adoption underway)
|
||||
|
||||
- **✅ OtOpcUa 2.1 + 2.2 DONE** (`feat/adopt-zb-audit`, spec ✅ + code ✅): `933dd1a` — deleted bespoke Commons
|
||||
`AuditEvent`, adopted library `ZB.MOM.WW.Audit.AuditEvent`, `AuditWriterActor : IAuditWriter` (best-effort
|
||||
`WriteAsync` wraps `Self.Tell`), `AuditOutcomeMapper.FromAction` derivation, batching/dedup intact; `b7f5e88` —
|
||||
nullable `Outcome` column + migration `20260602135350_AddConfigAuditLogOutcome` (additive, chains after
|
||||
CanonicalizeAdminRoles, no pending model changes) + `ClusterAudit` fix via shared `ClusterAuditQuery` (OR-predicate
|
||||
joining `ClusterNode` membership). SP path untouched. ControlPlane 45/45, Configuration 80/80 (+3), AdminUI 121/121.
|
||||
Minor backlog: no `IX_ConfigAuditLog_NodeId` (irrelevant while structured path dormant).
|
||||
- **✅ MxGateway 2.3 DONE** (`feat/adopt-zb-audit`, spec ✅ + code ✅): `a5944bb` — new MxGateway-owned canonical
|
||||
SQLite `audit_event` store (same auth DB file via the library's `AuthSqliteConnectionFactory`; library tables
|
||||
untouched), `CanonicalAuditWriter : IAuditWriter` (best-effort, never throws — closes the library's no-wrap gap),
|
||||
`CanonicalForwardingApiKeyAuditStore : IApiKeyAuditStore` adapter (maps `ApiKeyAuditEntry`→canonical w/ system/cli
|
||||
fallback + constraint-denied→Denied + DetailsJson wrap; `ListRecent` round-trips for the dashboard view), DI
|
||||
overrides the library's `TryAddSingleton`'d store; `7ea8358` — Dashboard + ConstraintEnforcer rewritten to emit
|
||||
canonical `AuditEvent` directly via `IAuditWriter` with structured `Target` + (dashboard) `CorrelationId`. 587 pass,
|
||||
3 pre-existing FakeWorker reds, +10 tests. `api_key_audit` left unused (documented). Minor backlog: dup `WrapDetail`,
|
||||
per-op `EnsureTable`, a test temp-dir leak, unfiltered `ListRecent` category.
|
||||
- **✅ ScadaBridge 2.5 — DONE (FULL re-arch, user-chosen).** Decomposed into C1–C7 (design in
|
||||
`2026-06-02-scadabridge-audit-rearch.md`), all spec+code reviewed, MSSQL-verified, local-only on `feat/adopt-zb-audit`.
|
||||
Canonical record everywhere; site SQLite two-table (canonical + forwarding sidecar); central `dbo.AuditLog` collapsed to
|
||||
10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical` migration); redactor/outcome/UI/export/CLI all
|
||||
canonical. Forwarding state machine preserved (sidecar) + queryability preserved (persisted computed columns) — the design's
|
||||
key insight that central is append-only made pure-9-col central feasible without gutting forwarding.
|
||||
|
||||
## Open items to confirm at review
|
||||
1. **ScadaBridge:** full audit re-architecture (pure 9-col storage) vs the **bounded "deep-at-the-seam"** variant
|
||||
(canonical record at the seam/reporting boundary; keep typed storage columns + state machine). Strongly
|
||||
recommend the bounded variant.
|
||||
2. **MxGateway:** retire `api_key_audit` (canonical store is sole) vs keep it coexisting.
|
||||
3. **OtOpcUa:** confirm leaving the SP path bespoke (structured path is dormant; canonicalization is forward-looking
|
||||
prep) is acceptable, and the `ClusterAudit` fix approach (OR-predicate vs populate `ClusterId`).
|
||||
4. **Sequencing:** OtOpcUa (2.1→2.2) and MxGateway (2.3) are independent + tractable; ScadaBridge (2.5) is the
|
||||
gating risk — do it last, and as staged reviewed sub-commits regardless of variant.
|
||||
@@ -0,0 +1,347 @@
|
||||
# Auth + Audit Normalization Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Publish `ZB.MOM.WW.Auth` (4 pkgs) + `ZB.MOM.WW.Audit` (1 pkg) to the Gitea feed and adopt both across OtOpcUa, MxAccessGateway, and ScadaBridge, ending with every audit emit site carrying the Auth-resolved principal as `AuditEvent.Actor`.
|
||||
|
||||
**Architecture:** Library-major waterfall — Phase 0 publish/feed-map → Phase 1 full Auth adoption (auth GAPS #1–#8) → Phase 2 full Audit adoption (audit GAPS #1–#3,#5,#6) → Phase 3 wire `Actor` from the principal. Behaviour-preserving cutover except two accepted changes (ScadaBridge token format, canonical-roles collapse). One feature branch per repo per library phase; local-only delivery (no `git push`).
|
||||
|
||||
**Tech Stack:** .NET 10, NuGet (Gitea feed + central package management), Akka.NET (OtOpcUa/ScadaBridge), EF Core + SQL Server (OtOpcUa) / SQLite (MxGateway, ScadaBridge site), Blazor admin UIs, gRPC (gateway), LDAP/GLAuth, peppered HMAC API keys, xUnit.
|
||||
|
||||
**Design doc:** [`2026-06-02-auth-audit-normalization-design.md`](2026-06-02-auth-audit-normalization-design.md)
|
||||
|
||||
**Fidelity note:** Phase 0 tasks are command-exact and executable as written. Phase 1–3 cutover tasks name exact files-to-edit and acceptance criteria but their per-step diffs are elaborated **just-in-time** by the per-phase "explore + elaborate" gate task (the implementer reads the named source first) — these repos' auth source has not been opened during planning, only the normalized `components/*/current-state/` docs. Audit (Phase 2) tasks cite the exact paths/lines those docs provide.
|
||||
|
||||
**Prerequisite the executor must supply:** Phase 0 push needs `GITEA_NUGET_KEY` (Gitea token with `package:write`). The agent cannot mint this — the user exports it, or runs the push step via `!`.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0 — Publish & feed-map (executable now)
|
||||
|
||||
Branch: work on `docs/auth-audit-normalization` (current) or a fresh `chore/publish-auth-audit`. The library packs happen in `scadaproj`; the feed-map edits happen in the three sibling repos (each on its own `feat/adopt-zb-auth` branch — created here, reused in Phase 1).
|
||||
|
||||
### Task 0.1: Add a push script for ZB.MOM.WW.Audit
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** none (blocks 0.3)
|
||||
|
||||
**Files:**
|
||||
- Create: `ZB.MOM.WW.Audit/build/push.sh`
|
||||
|
||||
**Step 1: Create the script** (mirror `ZB.MOM.WW.Auth/build/push.sh`)
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# push.sh — pack and push the ZB.MOM.WW.Audit NuGet package to the Gitea feed.
|
||||
#
|
||||
# Required environment variables:
|
||||
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
|
||||
# GITEA_NUGET_KEY — Gitea access token with package:write permission
|
||||
set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
```
|
||||
|
||||
**Step 2:** `chmod +x ZB.MOM.WW.Audit/build/push.sh`
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add ZB.MOM.WW.Audit/build/push.sh && git commit -m "build(audit): add Gitea push.sh"
|
||||
```
|
||||
|
||||
### Task 0.2: Build + test both libraries green before publishing
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** 0.1
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Step 1:** `cd ZB.MOM.WW.Auth && dotnet test` — expect all 172 pass.
|
||||
**Step 2:** `cd ZB.MOM.WW.Audit && dotnet test` — expect all 19 pass.
|
||||
**Acceptance:** both suites green. If either fails, STOP — do not publish a red library.
|
||||
|
||||
### Task 0.3: Pack + push both libraries to the Gitea feed
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min (+ network)
|
||||
**Parallelizable with:** none (blocked by 0.1, 0.2)
|
||||
|
||||
**Files:** none (publishes artifacts)
|
||||
|
||||
**Step 1: Export credentials** (user-supplied token)
|
||||
```bash
|
||||
export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
|
||||
export GITEA_NUGET_KEY="<gitea token with package:write>"
|
||||
```
|
||||
**Step 2:** `cd ZB.MOM.WW.Auth && ./build/push.sh`
|
||||
**Step 3:** `cd ZB.MOM.WW.Audit && ./build/push.sh`
|
||||
**Step 4: Verify all 5 resolve (HTTP 200)**
|
||||
```bash
|
||||
for p in zb.mom.ww.auth.abstractions zb.mom.ww.auth.ldap zb.mom.ww.auth.apikeys \
|
||||
zb.mom.ww.auth.aspnetcore zb.mom.ww.audit; do
|
||||
printf '%s -> ' "$p"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||
"https://gitea.dohertylan.com/api/packages/dohertj2/nuget/registration/$p/index.json"
|
||||
done
|
||||
```
|
||||
**Acceptance:** all five print `200` (currently all `404`).
|
||||
|
||||
### Task 0.4: Feed-map + restore OtOpcUa
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** 0.5, 0.6 (different repos)
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/Desktop/OtOpcUa/NuGet.config` (add patterns under `dohertj2-gitea`)
|
||||
- Modify: `~/Desktop/OtOpcUa/Directory.Packages.props` (add `PackageVersion` entries)
|
||||
|
||||
**Step 1:** create branch `feat/adopt-zb-auth` in OtOpcUa.
|
||||
**Step 2:** under the `dohertj2-gitea` `packageSource`, add:
|
||||
```xml
|
||||
<package pattern="ZB.MOM.WW.Auth" />
|
||||
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||
<package pattern="ZB.MOM.WW.Audit" />
|
||||
```
|
||||
**Step 3:** in `Directory.Packages.props` add (version 0.1.0): `ZB.MOM.WW.Auth.Abstractions`, `ZB.MOM.WW.Auth.Ldap`, `ZB.MOM.WW.Auth.AspNetCore`, `ZB.MOM.WW.Audit`. (No `ZB.MOM.WW.Auth.ApiKeys` — OtOpcUa uses OPC UA transport security.)
|
||||
**Step 4:** `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` — expect success, the new packages download from gitea.
|
||||
**Step 5: Commit** `build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins`.
|
||||
**Acceptance:** restore succeeds; `obj/project.assets.json` lists the new packages from the gitea source.
|
||||
|
||||
### Task 0.5: Feed-map + restore MxAccessGateway
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** 0.4, 0.6
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/Desktop/MxAccessGateway/nuget.config`
|
||||
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` (inline `Version=` style — no CPM)
|
||||
|
||||
**Step 1:** branch `feat/adopt-zb-auth` in MxAccessGateway.
|
||||
**Step 2:** add the same three `<package pattern>` lines under `dohertj2-gitea`.
|
||||
**Step 3:** `dotnet restore src/MxGateway.sln` (PackageReferences added in Phase 1; this step only proves the feed resolves — optionally add a throwaway reference and remove, or defer restore-proof to Phase 1's first add).
|
||||
**Step 4: Commit** `build: add ZB.MOM.WW.Auth/Audit feed mapping`.
|
||||
**Acceptance:** `nuget.config` maps the new patterns; restore of an added Auth package succeeds.
|
||||
|
||||
### Task 0.6: Feed-map + restore ScadaBridge
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** 0.4, 0.5
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/Desktop/ScadaBridge/nuget.config`
|
||||
- Modify: `~/Desktop/ScadaBridge/Directory.Packages.props`
|
||||
|
||||
**Step 1:** branch `feat/adopt-zb-auth` in ScadaBridge.
|
||||
**Step 2:** add the three `<package pattern>` lines under `dohertj2-gitea`.
|
||||
**Step 3:** add `PackageVersion` entries @ 0.1.0 for all 4 Auth packages + `ZB.MOM.WW.Audit`.
|
||||
**Step 4:** `dotnet restore ZB.MOM.WW.ScadaBridge.slnx`.
|
||||
**Step 5: Commit** `build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins`.
|
||||
**Acceptance:** restore succeeds.
|
||||
|
||||
> **Phase 0 exit gate:** all 5 packages HTTP 200; all 3 repos restore green with the new feed mappings. Only then start Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Auth adoption (auth GAPS #1–#8) *[HIGH-RISK PHASE]*
|
||||
|
||||
Order within the phase (per `components/auth/GAPS.md` sequencing): **#3 seam → #1 Ldap + #2 ApiKeys → #4 config + #5 claims/cookies → #6 base DN → #8 canonical roles.** Every cutover is gated by parity tests before merge.
|
||||
|
||||
### Task 1.0: Explore auth source + elaborate Phase 1 steps *(GATE — do first)*
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (read-only)
|
||||
**Parallelizable with:** none (blocks all 1.x)
|
||||
|
||||
**Files (read-only):**
|
||||
- `components/auth/current-state/{otopcua,mxaccessgw,scadabridge}/CURRENT-STATE.md`
|
||||
- `components/auth/spec/SPEC.md`, `components/auth/spec/CANONICAL-ROLES.md`, `components/auth/shared-contract/ZB.MOM.WW.Auth.md`
|
||||
- `ZB.MOM.WW.Auth/src/**` (the public surface being adopted)
|
||||
- Each repo's LDAP auth service, API-key pipeline, role mapper, and auth DI wiring (paths surfaced by the current-state docs).
|
||||
|
||||
**Action:** read the above; for each task below fill in the concrete diff, exact file paths, and the parity-test assertions. Append the elaborated steps to this plan section (or a `…-phase1.md` companion). **No code changes in this task.** This gate exists because the per-repo auth source was not opened during planning.
|
||||
|
||||
### Task 1.1: `IGroupRoleMapper<TRole>` seam — config + DB mappers (GAPS #3, all 3 repos)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min/repo (split per repo if needed)
|
||||
**Parallelizable with:** 1.2 within a repo only after the seam type is referenced
|
||||
|
||||
**Files:** per-repo role-mapping call sites (config-backed for OtOpcUa + MxGateway; DB-backed `LdapGroupMapping` for ScadaBridge) — exact paths from Task 1.0.
|
||||
**Steps:** TDD — write a mapper test asserting current group→role outputs are preserved → wire the app to the library's `IGroupRoleMapper<TRole>` (config mapper for OtOpcUa/gw, DB/delegate mapper for SB) → green → commit. **Acceptance:** existing role-resolution behaviour byte-identical; #3 done (cheap, unblocks the rest).
|
||||
|
||||
### Task 1.2: Adopt `ZB.MOM.WW.Auth.Ldap` — cutover (GAPS #1, all 3 repos)
|
||||
|
||||
**Classification:** high-risk (security; LDAP)
|
||||
**Estimated implement time:** split per repo (~5 min each)
|
||||
**Parallelizable with:** 1.3 (different repos) — but within a repo, serial after 1.1
|
||||
|
||||
**Files:** each repo's LDAP authentication service + DI (ScadaBridge is the donor baseline; OtOpcUa/gw cut over to it). For OtOpcUa also fix the open `LdapAuthService` `Enabled`/double-singleton wiring (repo memory).
|
||||
**Steps (per repo):** write parity tests reproducing current authn decisions (bind-then-search, fail-closed-on-group-lookup, RFC-4514 + filter escaping, username trim, service-account-bind distinction) → run red against the library path → replace bespoke LDAP with `Auth.Ldap` → green → commit. **Acceptance:** parity tests green; bespoke LDAP code removed/delegated; OtOpcUa singleton bug fixed.
|
||||
|
||||
### Task 1.3: Adopt `ZB.MOM.WW.Auth.ApiKeys` — cutover (GAPS #2; MxGateway then ScadaBridge)
|
||||
|
||||
**Classification:** high-risk (security; API keys)
|
||||
**Estimated implement time:** ~5 min/repo
|
||||
**Parallelizable with:** 1.2 (different files) — MxGateway first (source), then ScadaBridge
|
||||
|
||||
**Files:** MxGateway `Security/Authentication/` API-key verifier/store DI; ScadaBridge Inbound API `X-API-Key` path.
|
||||
**Steps:** parity tests (peppered HMAC-SHA256, constant-time compare, scope/constraint enforcement) → cutover to `Auth.ApiKeys` → green → commit. **ScadaBridge behaviour change (accepted):** raw `X-API-Key` → structured `<prefix>_<id>_<secret>`; add an **interop check** that an inbound client using the new token format authenticates and the old format is rejected. **Acceptance:** parity + interop green; gateway is the proven source before SB cuts over.
|
||||
|
||||
### Task 1.4: Config schema migration (GAPS #4 / A1–A2, all 3 repos)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min/repo
|
||||
**Parallelizable with:** bundled with 1.2 per the GAPS note ("mechanical; do with #1")
|
||||
|
||||
**Files:** OtOpcUa + MxGateway: `UseTls`→`Transport` enum binding + appsettings. ScadaBridge: flat `Security:Ldap*`→nested section; rename `LdapUserIdAttribute`→`UserNameAttribute`, `LdapGroupAttribute`→`GroupAttribute` (+ appsettings + any validators).
|
||||
**Steps:** update options class + binding + appsettings + (ScadaBridge) `ConfigPreflight`/validator messages → run config-validation tests → commit. **Acceptance:** apps bind the new schema; no behaviour change beyond key names/enum.
|
||||
|
||||
### Task 1.5: `ZB.MOM.WW.Auth.AspNetCore` claims/cookie conventions (GAPS #5, all 3 UIs)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min/repo
|
||||
**Parallelizable with:** 1.4
|
||||
|
||||
**Files:** each UI's cookie/claims wiring (OtOpcUa Blazor Admin control-plane; MxGateway `MxGatewayDashboard`; ScadaBridge `ZB.MOM.WW.ScadaBridge.Auth`). Keep each cookie **name**; share canonical claim types + attributes.
|
||||
**Steps:** adopt the shared claim-type constants + cookie attribute defaults → auth-flow test (login sets canonical claims) → commit. **Acceptance:** each app keeps its cookie name but emits canonical claims.
|
||||
|
||||
### Task 1.6: Unify dev GLAuth base DN (GAPS #6, all 3 + fixtures)
|
||||
|
||||
**Classification:** small (dev-only)
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** 1.5
|
||||
|
||||
**Files:** dev appsettings + LDAP/GLAuth fixtures/infra in each repo. Pick one shared base DN (open decision A3 — resolve in Task 1.0).
|
||||
**Acceptance:** dev fixtures + all 3 apps share one base DN; dev login still works.
|
||||
|
||||
### Task 1.7: Canonical roles — `canonical → native` expansion (GAPS #8, all 3 repos)
|
||||
|
||||
**Classification:** high-risk (security policy)
|
||||
**Estimated implement time:** ~5 min/repo
|
||||
**Parallelizable with:** none (after 1.1)
|
||||
|
||||
**Files:** each repo's role-enforcement mapping. **ScadaBridge accepted collapse:** `AuditReadOnly`→Viewer, `Audit`→Administrator (auditor/admin SoD removed). OtOpcUa: publish ⊂ `FleetAdmin` (no first-class `Deployer`). MxGateway: assign applicable subset (no `Designer`/`Deployer`).
|
||||
**Steps:** map each canonical role to native enforcement; test that each LDAP group still authorizes its expected actions; document the SoD change → commit. **Acceptance:** canonical six standardized org-wide; per-project native enforcement unchanged except the documented ScadaBridge collapse.
|
||||
|
||||
> **Phase 1 exit gate:** all 3 repos consume `ZB.MOM.WW.Auth.*` from the feed; bespoke LDAP/ApiKey/role code removed or delegated; existing auth tests + new parity tests green per repo; SB token-format interop check green. Merge each `feat/adopt-zb-auth` to the repo's local default branch (no push).
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Audit adoption (audit GAPS #1–#3, #5, #6)
|
||||
|
||||
> ⚠️ **RE-SCOPED 2026-06-02 — the task specs below are SUPERSEDED.** The Task 2.0 gate (verified against
|
||||
> live code) found these specs materially wrong: MxGateway's audit files moved into the shared library
|
||||
> (Phase 1), OtOpcUa's structured audit path is dormant (zero emit sites), and the ScadaBridge
|
||||
> "outright rename" is structurally impossible (its filter is typed to its own 24-field record, not the
|
||||
> library's 9-field one). The user chose **DEEP adopt (canonical record)** + **pause for review**. The
|
||||
> corrected, gate-grounded deep design is in
|
||||
> [`2026-06-02-auth-audit-normalization-phase2-deep.md`](2026-06-02-auth-audit-normalization-phase2-deep.md)
|
||||
> — **implementation is PAUSED pending user review of that doc (esp. the ScadaBridge audit-subsystem
|
||||
> re-architecture cost).** The original specs below are kept for historical context only.
|
||||
|
||||
Branch `feat/adopt-zb-audit` per repo. Behaviour-preserving except the OtOpcUa `Outcome` column + `ClusterId` visibility fix. Concrete paths below come from `components/audit/current-state/*`.
|
||||
|
||||
### Task 2.0: Explore audit source + confirm elaboration *(GATE — light, paths already known)*
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min (read-only)
|
||||
**Parallelizable with:** none (blocks 2.x)
|
||||
|
||||
**Files (read-only):** the exact files cited in the tasks below (OtOpcUa `AuditWriterActor.cs`, `Commons/Messages/Audit/AuditEvent.cs`, `ConfigAuditLog.cs`, `OtOpcUaConfigDbContext.cs`, `ClusterAudit.razor`; MxGateway `IApiKeyAuditStore.cs`, `SqliteApiKeyAuditStore.cs`, `ApiKeyAuditEntry.cs`, `ConstraintEnforcer.cs`, the 3 producers; ScadaBridge `IAuditPayloadFilter.cs`, `IAuditWriter.cs`, `AuditEvent.cs`, the 4 enums). Confirm line refs still hold; adjust if drifted.
|
||||
|
||||
### Task 2.1: OtOpcUa — canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` (GAPS #1)
|
||||
|
||||
**Classification:** high-risk (actor model + data contract)
|
||||
**Estimated implement time:** split (record swap ~5 min; actor seam ~5 min; Outcome derivation ~5 min)
|
||||
**Parallelizable with:** 2.3, 2.5 (different repos)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs` (replace with canonical record usage; bridge `NodeId`/`CorrelationId` value-types at construction)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs` (implement `IAuditWriter`; map at `:75-84`)
|
||||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`
|
||||
|
||||
**Steps:** TDD — extend actor tests to assert `Outcome` derivation (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt`→Denied, config verbs→Success) and the canonical record mapping → red → swap record + implement seam + derive `Outcome` at emit sites → keep 500/5s batching + two-layer dedup → green → commit. **Acceptance:** existing tests + new `Outcome` tests green; transport/dedup unchanged.
|
||||
|
||||
### Task 2.2: OtOpcUa — `Outcome` column migration + `ClusterId` visibility fix (GAPS #1 storage, #5)
|
||||
|
||||
**Classification:** high-risk (EF migration + UI query)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (after 2.1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs` (add nullable `Outcome`)
|
||||
- Modify: `.../OtOpcUaConfigDbContext.cs` (mapping ~`:429-463`)
|
||||
- Create: `Migrations/<ts>_AddConfigAuditLogOutcome.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor:78` (so structured actor rows — which set `NodeId` not `ClusterId` — are discoverable)
|
||||
|
||||
**Steps:** add column + migration → `dotnet ef migrations add` + apply on a test DB → adjust the query so structured rows appear under a cluster → commit. Leave the SP path bespoke (documented). **Acceptance:** migration applies forward; structured `AuditEvent` rows now visible in `ClusterAudit.razor`.
|
||||
|
||||
### Task 2.3: MxGateway — `IApiKeyAuditStore` → `IAuditWriter` adapter (GAPS #2, #6)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** 2.1, 2.5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/` — `IApiKeyAuditStore.cs`, `SqliteApiKeyAuditStore.cs`, `ApiKeyAuditEntry.cs`, `AuthStoreServiceCollectionExtensions.cs:23`, and the 3 producers (`ApiKeyAdminCliRunner`, `DashboardApiKeyManagementService`, `ConstraintEnforcer.cs:117`)
|
||||
- Test: gateway audit tests (`SqliteAuthStoreTests`, `ApiKeyAdminCliRunnerTests`)
|
||||
|
||||
**Steps:** map to canonical `AuditEvent` — generate `EventId`; `KeyId→Actor` with `"system"`/`"cli"` fallback; `EventType→Action`; `CreatedUtc→OccurredAtUtc`; `RemoteAddress→SourceNode`; `constraint-denied→Outcome.Denied` else `Success`; `Category="ApiKey"`; `Details→DetailsJson` **wrapped as a JSON object**; add `CorrelationId` capture + structured `Target` (#6). **Wrap `AppendAsync` so it never throws** (best-effort contract). Producers keep call sites; only the injected type changes. → tests green → commit. **Acceptance:** writes produce canonical events; writer never propagates; tests green.
|
||||
|
||||
### Task 2.5: ScadaBridge — rename `IAuditPayloadFilter`→`IAuditRedactor` + adopt `AuditOutcome` (GAPS #3)
|
||||
|
||||
**Classification:** high-risk (HIGH blast radius rename across site/central/wiring)
|
||||
**Estimated implement time:** ~5 min (compiler-driven)
|
||||
**Parallelizable with:** 2.1, 2.3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs` → adopt `ZB.MOM.WW.Audit.IAuditRedactor` (outright rename; `DefaultAuditPayloadFilter`/`SafeDefaultAuditPayloadFilter` implement it unchanged)
|
||||
- Modify: all references across `AuditLog/Site`, `AuditLog/Central`, wiring, `Commons`
|
||||
- Adopt canonical `AuditOutcome` enum; confirm `IAuditWriter` signature is byte-identical (keep the bespoke ~25-field record as storage shape — option (a))
|
||||
|
||||
**Steps:** outright rename (let the compiler enumerate sites) → adopt `AuditOutcome` and the `Status→Outcome` projection (`Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`→Denied) for cross-project reporting → build + full audit test suite green → commit. **Acceptance:** compiles clean; no transport/storage/CLI/UI behaviour change; enum + interface names canonical.
|
||||
|
||||
> **Phase 2 exit gate:** all 3 repos consume `ZB.MOM.WW.Audit`; seams/record/enum canonical; existing audit suites green; OtOpcUa `Outcome` migration applies; ScadaBridge rename clean. Merge each `feat/adopt-zb-audit` locally (no push).
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — Wire `Actor` from the Auth principal (audit GAPS #4)
|
||||
|
||||
### Task 3.1: Introduce `IAuditActorAccessor` seam
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (blocks 3.2–3.4)
|
||||
|
||||
**Files:** a small accessor per app (HTTP impl reads `HttpContext.User`; non-HTTP returns a threaded/fallback principal). Exact location decided in Task 1.0/3.1 from the now-adopted `Auth.AspNetCore` principal plumbing.
|
||||
**Steps:** define the interface + an HTTP-backed impl + a fallback impl → unit test both → commit. **Acceptance:** accessor returns the Auth principal on authenticated paths, a fallback otherwise.
|
||||
|
||||
### Task 3.2 / 3.3 / 3.4: Wire emit sites — OtOpcUa / MxGateway / ScadaBridge
|
||||
|
||||
**Classification:** standard (each)
|
||||
**Estimated implement time:** ~4 min each
|
||||
**Parallelizable with:** each other (different repos), after 3.1
|
||||
|
||||
**Files:** each repo's audit emit sites (OtOpcUa config-write/authz emitters; MxGateway 3 producers — keep `"system"`/`"cli"` for keyless CLI; ScadaBridge `ManagementActor`/inbound boundary).
|
||||
**Steps:** inject `IAuditActorAccessor`; set `AuditEvent.Actor = accessor.CurrentPrincipal` at each emit site → test `Actor == authenticated principal` on authenticated paths, fallback retained otherwise → commit. **Acceptance:** every authenticated emit carries the real Auth principal; keyless/system paths retain explicit fallbacks.
|
||||
|
||||
> **Program exit gate:** `Audit.Actor == Auth principal` end-to-end across all 3 repos; all suites green; everything on local default branches (no push). Update `components/auth/GAPS.md` and `components/audit/GAPS.md` to mark the adopted items done, and refresh the relevant `CLAUDE.md` status rows.
|
||||
|
||||
---
|
||||
|
||||
## Risk gates (cross-cutting)
|
||||
|
||||
- **Never publish a red library** (Task 0.2 gates 0.3). If a parity gap forces a lib fix, bump `0.1.0`→`0.1.1` and re-publish; don't edit a published version.
|
||||
- **Phase 1 parity tests** must be green before any auth cutover merges — this is the security gate.
|
||||
- **A green build in one repo does not prove interop.** The ScadaBridge token-format change (Task 1.3) is the one cross-boundary contract change and needs the explicit interop check.
|
||||
- **Waterfall enforced by deps:** Phase 1 fully lands before Phase 2; Phase 3 after both.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-02-auth-audit-normalization.md",
|
||||
"designPath": "docs/plans/2026-06-02-auth-audit-normalization-design.md",
|
||||
"tasks": [
|
||||
{"id": 7, "subject": "Phase 0 umbrella — publish + feed-map", "status": "completed", "blockedBy": [11, 12, 13, 14, 15, 16]},
|
||||
{"id": 8, "subject": "Phase 1 umbrella — adopt ZB.MOM.WW.Auth — COMPLETE (all of 1.0-1.7 across 3 repos, reviewed, local-only)", "status": "completed", "blockedBy": [7, 17, 18, 19, 20, 21, 22, 23, 24]},
|
||||
{"id": 9, "subject": "Phase 2 umbrella — adopt ZB.MOM.WW.Audit — COMPLETE (OtOpcUa 2.1/2.2, MxGateway 2.3, ScadaBridge 2.5 full re-arch C1-C7; all reviewed, local-only)", "status": "completed", "blockedBy": [7, 8, 25, 26, 27, 28, 29]},
|
||||
{"id": 10, "subject": "Phase 3 umbrella — wire Actor from Auth principal — COMPLETE (IAuditActorAccessor per app + emit-site wiring; all reviewed, local-only)", "status": "completed", "blockedBy": [8, 9, 30, 31]},
|
||||
|
||||
{"id": 11, "subject": "Task 0.1: Add push.sh for ZB.MOM.WW.Audit", "status": "completed", "blockedBy": []},
|
||||
{"id": 12, "subject": "Task 0.2: Build+test both libs green", "status": "completed", "blockedBy": []},
|
||||
{"id": 13, "subject": "Task 0.3: Pack+push both libs; verify HTTP 200", "status": "completed", "blockedBy": [11, 12]},
|
||||
{"id": 14, "subject": "Task 0.4: Feed-map + restore OtOpcUa", "status": "completed", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 0.5: Feed-map MxAccessGateway", "status": "completed", "blockedBy": [13]},
|
||||
{"id": 16, "subject": "Task 0.6: Feed-map + restore ScadaBridge", "status": "completed", "blockedBy": [13]},
|
||||
|
||||
{"id": 17, "subject": "Task 1.0: GATE explore auth source + elaborate", "status": "completed", "blockedBy": [14, 15, 16]},
|
||||
{"id": 18, "subject": "Task 1.1: IGroupRoleMapper seam (#3)", "status": "completed", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 1.2: Adopt Auth.Ldap cutover (#1) [high-risk]", "status": "completed", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 1.3: Adopt Auth.ApiKeys (#2) [high-risk] — COMPLETE (MxGw donor + ScadaBridge re-arch C1-C5)", "status": "completed", "blockedBy": [18]},
|
||||
{"id": 21, "subject": "Task 1.4: Config schema migration A1/A2 (#4)", "status": "completed", "blockedBy": [17]},
|
||||
{"id": 22, "subject": "Task 1.5: AspNetCore claims/cookies (#5) — DONE all 3 (OtOpcUa 83856b7+d0777ee, MxGw 7e1af37, SB full-canonical a0938f7+c185a56)", "status": "completed", "blockedBy": [17]},
|
||||
{"id": 23, "subject": "Task 1.6: Unify dev base DN (#6) — DONE all 3 to dc=zb,dc=local (OtOpcUa 8ba289f, MxGw 9572045, SB 6ae6051)", "status": "completed", "blockedBy": [17]},
|
||||
{"id": 24, "subject": "Task 1.7: Canonical roles native expansion (#8) [high-risk] — DONE all 3, full-value canonical (MxGw 04bce3ff, OtOpcUa c1619d9 +DB-mig, SB b104760+4118452 +DB-mig +SoD collapse)", "status": "completed", "blockedBy": [18]},
|
||||
|
||||
{"id": 25, "subject": "Task 2.0: GATE confirm audit source refs — DONE; found plan specs materially off → DEEP re-scope in -phase2-deep.md; PAUSED for user review before 2.1/2.2/2.3/2.5", "status": "completed", "blockedBy": [8]},
|
||||
{"id": 26, "subject": "Task 2.1: OtOpcUa canonical record + IAuditWriter + Outcome (#1) [high-risk] — DONE 933dd1a (spec+code reviewed)", "status": "completed", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 2.2: OtOpcUa Outcome migration + ClusterId fix (#1,#5) [high-risk] — DONE b7f5e88 (spec+code reviewed)", "status": "completed", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 2.3: MxGateway store→IAuditWriter adapter (#2,#6) [re-scoped high-risk] — DONE a5944bb+7ea8358 (canonical SQLite store+adapter; spec+code reviewed)", "status": "completed", "blockedBy": [25]},
|
||||
{"id": 29, "subject": "Task 2.5: ScadaBridge audit DEEP full-rearch to 9-col canonical (#3) [high-risk] — DONE C1-C7 (3d77dc0,adfb4d3/5aaf9e2,db707bb/c27b2c3,946d3e2/1737d15,68a6bd1,C6-subsumed,635461c/bc0e5bf); all spec+code reviewed, MSSQL-verified", "status": "completed", "blockedBy": [25]},
|
||||
|
||||
{"id": 30, "subject": "Task 3.1: IAuditActorAccessor seam (per-app HTTP accessor) — DONE (OtOpcUa 075c0e6, MxGw 0859d47, SB b3de840)", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 31, "subject": "Task 3.2-3.4: Wire emit sites to Auth principal (#4) — DONE (MxGw dashboard Actor=operator/Target=keyId; SB inbound Actor from principal w/ auth-fail-null; OtOpcUa seam forward-looking) — reviewed", "status": "completed", "blockedBy": [30]},
|
||||
|
||||
{"id": 32, "subject": "Task 1.3-L: Extend Auth.ApiKeys admin store (SetScopes/SetEnabled) -> lib 0.1.3 (PUBLISHED)", "status": "completed", "blockedBy": []},
|
||||
{"id": 33, "subject": "Task 1.3-C1: ScadaBridge re-pin 0.1.3 + IInboundApiKeyAdmin seam (additive) + baseline reds fixed", "status": "completed", "blockedBy": [32]},
|
||||
{"id": 34, "subject": "Task 1.3-C2: ManagementActor + CLI + Commons messages onto seam", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 35, "subject": "Task 1.3-C3: CentralUI pages onto seam (string keyId + scopes)", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 36, "subject": "Task 1.3-C4: TransportExport exclude API keys (methods-only)", "status": "completed", "blockedBy": [33, 35]},
|
||||
{"id": 37, "subject": "Task 1.3-C5 (=E): retire SQL Server ApiKey entity + EF migration + runbook", "status": "completed", "blockedBy": [34, 35, 36]}
|
||||
],
|
||||
"lastUpdated": "2026-06-02 — PROGRAM COMPLETE (Phases 0-3 done across 3 repos: Auth + Audit normalized, Actor wired from principal). All local-only on feat/adopt-zb-auth + feat/adopt-zb-audit; NOTHING pushed/merged. Remaining = exit-gate doc updates + user merge/push decision."
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# ScadaBridge audit re-architecture (Task 2.5, DEEP full 9-col) — decomposition
|
||||
|
||||
Companion to `2026-06-02-auth-audit-normalization-phase2-deep.md`. User chose **Full re-arch (pure 9-col storage)**
|
||||
for ScadaBridge audit. Architect design pass (read-only, verified on `feat/adopt-zb-audit`) produced this. The full
|
||||
audit record becomes the library 9-field `ZB.MOM.WW.Audit.AuditEvent`; ~15 domain fields relocate into `DetailsJson`;
|
||||
ScadaBridge consumes the library `IAuditWriter`/`IAuditRedactor`/`AuditOutcome`. This is the program's largest task.
|
||||
|
||||
## Key resolutions (from the design)
|
||||
|
||||
- **Forwarding state machine (the crux) → resolved cleanly.** It lives **only in site SQLite**; the central MS SQL
|
||||
`AuditLog` table is **append-only** (DENY UPDATE/DELETE; central rows leave `ForwardState` null; reconciliation is
|
||||
pure idempotent-insert with in-memory cursors), and the gRPC `AuditEventDtoMapper` **already** drops
|
||||
`ForwardState`/`IngestedAtUtc` on the wire. So **central needs NO forwarding columns** (pure 9-col). On the **site**,
|
||||
add a **sidecar `audit_forward_state` table** keyed by `EventId` (`ForwardState`, `OccurredAtUtc`, precomputed
|
||||
`IsCachedKind`, optional `AttemptCount`/`LastAttemptUtc`) — `MarkForwarded`/`MarkReconciled` UPDATE the sidecar;
|
||||
`ReadPending*` JOIN it; the canonical `audit_event` table is write-once. Precomputing `IsCachedKind` keeps the drain
|
||||
hot path off JSON parsing (strictly faster than today's `Kind NOT IN(...)`).
|
||||
- **Central storage migration → new table + copy** (in-place collapse infeasible: partition-aligned indexes +
|
||||
`SwitchOutPartitionAsync` hard-codes a byte-identical staging column list). New 10-col table on the SAME
|
||||
`ps_AuditLog_Month(OccurredAtUtc)` scheme; per-partition data copy projecting old typed columns into `DetailsJson`
|
||||
(`FOR JSON PATH`); rename + role re-grant (append-only preserved). Partitioning preserved (`OccurredAtUtc` stays).
|
||||
- **Reporting queryability → persisted computed columns for hot filters.** `Category`(=Channel) + canonical
|
||||
`Outcome`/`Target`/`Actor`/`SourceNode`/`CorrelationId` cover most filters directly. Add **PERSISTED computed columns**
|
||||
`Kind`/`Status`/`SourceSiteId`/`ExecutionId`/`ParentExecutionId` (`JSON_VALUE(DetailsJson,'$.x')`) + partition-aligned
|
||||
indexes so the existing index semantics + the `GetExecutionTreeAsync` recursive CTE survive without a JSON perf cliff.
|
||||
- **Redactor → `ScadaBridgeAuditRedactor : IAuditRedactor`** on the canonical record: parse `DetailsJson` once, redact +
|
||||
byte-safe-truncate `requestSummary`/`responseSummary`/`errorDetail`/`extra` in the JSON tree, cap on canonical
|
||||
`Category`/`Outcome` (replacing the typed `Channel`/`Status` reads), set `payloadTruncated`, re-serialize. Add a
|
||||
fast-path that skips JSON parse when nothing to redact. `SafeDefault` → `SafeDefaultAuditRedactor`. Re-baseline the
|
||||
perf hot-path budgets (JSON parse/rewrite is ~2–4× the typed-field path).
|
||||
- **Canonical field mapping:** `Action = "{Channel}.{Kind}"`; `Category = Channel`; `Target/SourceNode/CorrelationId/
|
||||
Actor/OccurredAtUtc` direct (DateTime→DateTimeOffset UTC). **`Outcome`:** `Kind==InboundAuthFailure`→`Denied` (checked
|
||||
first); `Status==Delivered`→`Success`; `Status∈{Failed,Parked,Discarded}`→`Failure`; in-flight/`Skipped`→`Success`.
|
||||
- **`DetailsJson` schema (camelCase, stable):** channel, kind, status, executionId, parentExecutionId, sourceSiteId,
|
||||
sourceInstanceId, sourceScript, httpStatus, durationMs, errorMessage, errorDetail, requestSummary, responseSummary,
|
||||
payloadTruncated, extra, ingestedAtUtc. **One shared `AuditDetailsCodec` (Commons) with deterministic options is
|
||||
MANDATORY** — the canonical record uses value-equality + consumers dedup on it, so key-order/whitespace drift would
|
||||
break dedup. (`forwardState` is NOT in DetailsJson — it's site-sidecar only.)
|
||||
- **Commons takes the `ZB.MOM.WW.Audit` package ref** (the record lives in Commons; the package is a leaf canonical-types
|
||||
pkg, only dep `Microsoft.Extensions.DependencyInjection.Abstractions`). Acceptable.
|
||||
- **gRPC proto kept UNCHANGED** — the wire `AuditEventDto` stays 24-field internally; `AuditEventDtoMapper` projects
|
||||
to/from `DetailsJson`. Avoids a proto/codegen rev + a site/central version-skew handshake. (A proto collapse is a
|
||||
separate later task.)
|
||||
|
||||
## Staged decomposition (C1–C7)
|
||||
|
||||
| Stage | Scope | Green? | Class | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **C1** | Commons: add `ZB.MOM.WW.Audit` ref; new pure types `AuditDetails` record + `AuditDetailsCodec` (deterministic) + `Status/Kind→AuditOutcome` projection + `Action`/`Category` builders. No existing type changes. | yes | small | trivial |
|
||||
| **C2** | `ScadaBridgeAuditRedactor`/`SafeDefaultAuditRedactor : IAuditRedactor` (canonical record, parse/rewrite DetailsJson, fast-path) — additive, old `IAuditPayloadFilter` still wired; unit-tested in isolation. | yes | standard | low |
|
||||
| **C3** | **ATOMIC CUT — swap the record everywhere.** `Commons.Entities.Audit.AuditEvent` → `ZB.MOM.WW.Audit.AuditEvent` across ~40 src files + tests: emitters build canonical (domain→DetailsJson via codec); seams (`IAuditWriter`/`ICentralAuditWriter`/`ISiteAuditQueue`/`IAuditLogRepository`/`AuditLogQueryFilter`) re-type; `AuditEventDtoMapper` DTO↔canonical (proto unchanged); switch redactor wiring `IAuditPayloadFilter`→`IAuditRedactor`. | **boundaries only** | **high-risk** | **HIGHEST** |
|
||||
| **C4** | Site SQLite two-table forwarding: `SqliteAuditWriter` → `audit_event` + `audit_forward_state`; retarget `MarkForwarded/MarkReconciled/ReadPending*/GetBacklogStats/MapRow` to JOIN+sidecar; precompute `IsCachedKind`. Telemetry/Reconciliation actors unchanged (seam stable). Site SQLite is ephemeral (7-day) → in-place schema reset, no data migration. | yes | high-risk | HIGH |
|
||||
| **C5** | **ATOMIC CUT — central migration.** EF `CollapseAuditLogToCanonical`: new 10-col table on the partition scheme + per-partition data copy (old cols→DetailsJson) + persisted computed cols/indexes + rename + role re-grant; update `AuditLogRepository.InsertIfNotExistsAsync` + `SwitchOutPartitionAsync` staging list; regen ModelSnapshot. Maintenance-window; verify row-count + JSON spot-check. | **boundaries only** | **high-risk** | **HIGHEST** |
|
||||
| **C6** | Reporting/UI/export retarget: `QueryAsync`/`GetKpiSnapshotAsync`/`GetExecutionTreeAsync` predicates→canonical/computed cols; `AuditLogExportService`+`AuditEndpoints` CSV + CentralUI Audit components + CLI parse `DetailsJson` for display. | yes | standard | med |
|
||||
| **C7** | Tests + perf re-baseline + cleanup: rewrite `PayloadFilterContractTests`/redaction/`HotPathLatencyTests` to canonical+JSON + new budget; delete dead `Commons.Entities.Audit.AuditEvent`, 4 audit enums (or relocate behind codec), `IAuditPayloadFilter`/`Default`/`SafeDefault`, obsolete `AddColumnIfMissing`. | yes | standard | low |
|
||||
|
||||
**Atomic cuts:** only C3 (shared record type changes for all callers at once) and C5's data-copy half cannot stay green continuously. All other stages are green at completion.
|
||||
|
||||
## Top risks (carry into execution)
|
||||
1. **C5 partition + `SwitchOutPartitionAsync` + persisted computed columns** — staging table must carry identical computed defs for SWITCH; add a SWITCH round-trip integration test before C5 ships. **Documented fallback:** if too brittle, keep `Kind`/`Status` as 2 real non-canonical columns on the central table (pragmatic, not pure-9-col) — decide at C5 implementation if blocked.
|
||||
2. **DetailsJson determinism** — single `AuditDetailsCodec` (C1) is load-bearing for value-equality/dedup, not cosmetic.
|
||||
3. **Redactor perf** — budgets move; add the no-op fast-path + empirically re-baseline in C7.
|
||||
4. **gRPC** — keep the proto unchanged (mapper-internal projection); do NOT couple a wire change to this storage cut.
|
||||
5. **`Action=Channel.Kind`** lossiness — mitigated by `Category`(=channel) + persisted computed `Kind`; ScadaBridge-internal filtering uses those, not `Action` parsing.
|
||||
|
||||
Delivery: `feat/adopt-zb-audit` (stacked on auth), local-only. Each stage = one implementer + classification review chain; full ScadaBridge suite at C3/C4/C5/C7.
|
||||
|
||||
## Stage status (live)
|
||||
- **✅ C1 DONE** `3d77dc0` (code ✅) — `AuditDetails` + deterministic `AuditDetailsCodec` (pinned byte-exact) + `AuditOutcomeProjector` + `AuditFieldBuilders` + Commons→`ZB.MOM.WW.Audit` ref; 56 tests.
|
||||
- **✅ C2 DONE** `adfb4d3` + fix `5aaf9e2` (spec ✅, code ✅ after fix) — `ScadaBridgeAuditRedactor`/`SafeDefaultAuditRedactor : IAuditRedactor` on the canonical record; redaction primitives extracted into shared `AuditRedactionPrimitives`/`AuditRegexCache` (old filter delegates, behaviour-preserved); cap-selection reads `d.Status` (faithful to legacy `IsErrorStatus`); fast-path + never-throws; review-fix hardened `OverRedact` to scrub ALL free-text fields + marker alignment + outer-catch never-leak test. 61 redaction + 44 payload + 88 commons-audit green.
|
||||
- **✅ C3 DONE** `db707bb` + fix `c27b2c3` (spec ✅, code ✅; independently re-verified build 0/0 + AuditLog 241/Communication 201). Atomic record swap across all seams/emitters/gRPC DTO/redactor-wiring (127 files); `ScadaBridgeAuditEventFactory` single emit point; `AuditRowProjection` Decompose/Recompose transitional 24-col shim (lossless round-trip verified); proto unchanged; old `IAuditPayloadFilter` classes deleted (C7 pulled forward). Fix: safe enum-parse fallback in `MapRow`+`FromDto`.
|
||||
- **✅ C4 DONE** `946d3e2` + fix `1737d15` (spec ✅, code ✅; independently re-verified diff scope = writer+tests only, build 0/0, AuditLog 249/1-preexisting). Site SQLite → `audit_event` (canonical) + `audit_forward_state` sidecar; forwarding marks/reads on the sidecar via JOIN; `IsCachedKind`={CachedSubmit,ApiCallCached,DbWriteCached,CachedResolve} precomputed drain split; old `AuditLog` table dropped (ephemeral reset). Fix: `PRAGMA foreign_keys=ON` + `MarkForwarded` no-demote guard.
|
||||
- **✅ C5 DONE** `68a6bd1` (spec ✅, code ✅; a LIVE SQL Server was available so the migration + SWITCH were fully exercised — independently re-verified build 0/0 + ConfigurationDatabase 248/248). Central `dbo.AuditLog` collapsed to 10 canonical cols + 6 computed cols (5 PERSISTED + `IngestedAtUtc` non-persisted) on the preserved `ps_AuditLog_Month` scheme; `CollapseAuditLogToCanonical` new-table-and-copy migration (`FOR JSON PATH` projection, byte-verified round-trip; Down = documented one-way); repo writes/reads canonical directly; `SwitchOutPartition` staging matches the computed-col defs; append-only roles re-granted. C3 central shim retired. Forced deviations (all sound): IngestedAtUtc non-persisted, execution-id indexes unfiltered, provider-aware `OnModelCreating` strips JSON_VALUE for SQLite. Deferred to C7: a dedicated migration-projection test + the stale `CreatesFiveNamedIndexes` test name.
|
||||
- **✅ C6 SUBSUMED** (no commit) — reporting/UI/export/CLI retarget was already completed by the C3 record-swap (`AuditEventView`/`AuditExportRow` shims decode every domain field from `DetailsJson`) + the C5 repo-query retarget. Read-only explorer verdict: all consumer surfaces canonical-complete; the only flagged items (ExecutionId/ParentExecutionId not in CSV; SourceNodes not parsed in export `ParseFilter`) are PRE-rearch omissions, not regressions. CentralUI 595/595, ManagementService 125/125 confirm.
|
||||
- **✅ C7 DONE** `635461c` + doc-fix `bc0e5bf` (review ✅; independently re-verified build 0/0, PerformanceTests 10/10, ConfigurationDatabase 251/251 incl. the 3 new migration-projection tests PASSING on live MSSQL, zero dead crefs). Perf hot-path re-baselined (canonical JSON redactor measured ~14µs/2µs — faster than the old typed walk; budgets 200/30/5µs + fast-path `Assert.Same`); `CollapseAuditLogToCanonicalMigrationTests` (seed→migrate→assert Action/Category/Outcome/Actor-null/DetailsJson-round-trip + 5 persisted computed cols); index test → `CreatesNineNamedIndexes`; 26 dead-`<see cref>` across 13 files cleaned; doc-fix corrected the "six persisted" wording (5 persisted + IngestedAtUtc non-persisted).
|
||||
|
||||
## ✅ TASK 2.5 COMPLETE — ScadaBridge audit FULL re-architecture to pure 9-col canonical (2026-06-02)
|
||||
All of C1–C7 done, each spec+code reviewed, on `feat/adopt-zb-audit` (local-only, never pushed). ScadaBridge's audit subsystem now: the canonical `ZB.MOM.WW.Audit.AuditEvent` record everywhere (domain fields in `DetailsJson` via the deterministic `AuditDetailsCodec`); the library `IAuditRedactor`/`AuditOutcome` consumed; site SQLite = `audit_event` (canonical) + `audit_forward_state` sidecar (forwarding decoupled, `IsCachedKind` drain split); central `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols on the preserved partition scheme (`CollapseAuditLogToCanonical` migration, MSSQL-verified); UI/export/CLI canonical-complete via `AuditEventView`/`AuditExportRow`. The gRPC proto was intentionally left unchanged (mapper-internal projection). This was the program's single largest task.
|
||||
Reference in New Issue
Block a user