Architecture remediation: P2 tier (completeness & polish) #122

Open
dohertj2 wants to merge 25 commits from fix/archreview-p2 into main
10 changed files with 270 additions and 297 deletions
Showing only changes of commit 06e1046317 - Show all commits
+1 -1
View File
@@ -80,7 +80,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
+2 -2
View File
@@ -31,8 +31,8 @@ Alternative Maven layout is acceptable if the repo standardizes on Maven.
Target Java:
- Java 21 recommended.
- The Gradle scaffold uses the Java 21 toolchain for compilation and tests.
- Java 17 required (retargeted from 21 for Ignition 8.3 compatibility).
- The Gradle scaffold uses the Java 17 toolchain for compilation and tests.
Expected dependencies:
+1 -1
View File
@@ -351,7 +351,7 @@ Run the Java checks from `clients/java`:
gradle test
```
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
The build uses the Java 17 Gradle toolchain, compiles generated protobuf/gRPC
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
+179 -235
View File
@@ -1,227 +1,215 @@
# Gateway Authentication
The gateway authentication subsystem verifies inbound API key credentials against a SQLite-backed key store, hashes secrets with a configurable pepper, and records administrative and verification events to an audit trail.
The gateway authenticates inbound gRPC callers with API keys: a bearer token is
parsed, its secret is hashed with a peppered HMAC and compared in constant time
against a stored hash, and administrative and verification events are recorded to
an audit trail.
The peppered-HMAC pipeline itself — token parsing, secret generation, hashing,
constant-time compare, the SQLite schema, the key store, the verifier, and schema
migration — lives in the shared **`ZB.MOM.WW.Auth.ApiKeys`** package, of which
this gateway is the donor. The gateway does not reimplement or fork those types;
it binds the library through `AddZbApiKeyAuth` and layers gateway-specific
concerns on top: constraint enforcement, the gRPC authorization interceptor,
hot-path decorators, the admin CLI, the dashboard, and a canonical audit store
that supersedes the library's own audit table. This document describes the
consumer side — the token format, the options the gateway binds, the pieces it
adds, and where the library boundary sits. For the library internals (the concrete
`ApiKeyVerifier`, the SQLite stores, the schema and migrator), read the
`ZB.MOM.WW.Auth.ApiKeys` sources; they are not duplicated in this repository.
## Token Format
API keys travel in the HTTP `Authorization` header as a bearer token shaped `mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the `<keyId>` segment is the public identifier used for lookup, and `<secret>` is the high-entropy portion that the gateway verifies against a stored hash.
API keys travel in the HTTP `Authorization` header as a bearer token shaped
`mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the
`<keyId>` segment is the public identifier used for lookup, and `<secret>` is the
high-entropy portion verified against a stored hash. The prefix and the pepper
configuration key the gateway pins are constants on
`AuthStoreServiceCollectionExtensions`
(`TokenPrefix = "mxgw"`, `PepperSecretName = "MxGateway:ApiKeyPepper"`); they are
supplied to the library at registration so the library's parser and pepper
provider use the gateway's contract. The library parser rejects a malformed token
before any database round-trip, and only a well-formed `mxgw_<keyId>_<secret>`
token reaches the store lookup.
`ApiKeyParser` enforces the format and rejects malformed tokens before any database round-trip:
## Secrets And Peppered Hashing
```csharp
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{
apiKey = null;
New secret material is high-entropy: the library generates 32 random bytes and
encodes them URL-safe base64 (no padding) so a secret embeds in a header without
escaping. The gateway never persists a plaintext secret — only its hash.
if (string.IsNullOrWhiteSpace(authorizationHeader)
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
Secrets are hashed with `HMAC-SHA256` keyed by a server-side **pepper**. The
pepper lives outside the database and is resolved from configuration under the
`MxGateway:ApiKeyPepper` key (the library's pepper provider reads it). Keeping the
pepper out of the SQLite file means an attacker who exfiltrates only the database
holds the hashes but lacks the keying material to brute-force candidate secrets,
even if the hash algorithm is known.
string token = authorizationHeader[BearerPrefix.Length..].Trim();
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
```
A successful parse produces a `ParsedApiKey(KeyId, Secret)` record. The `IApiKeyParser` interface exists so verification consumers can be tested without depending on header-format details.
## Parsing and Secrets
### Secret generation
`ApiKeySecretGenerator.Generate()` is the single source of new secret material. It uses 32 bytes from `RandomNumberGenerator.Fill` and encodes with URL-safe base64 (no padding) so secrets can be embedded in headers without escaping:
```csharp
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
```
### Peppered hashing
`ApiKeySecretHasher` (registered behind `IApiKeySecretHasher`) hashes secrets with `HMACSHA256` keyed by a server-side pepper. The pepper lives outside the database and is resolved by `IConfiguration` lookup against the configured `PepperSecretName`:
```csharp
public byte[] HashSecret(string secret)
{
string pepper = GetPepper();
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using HMACSHA256 hmac = new(pepperBytes);
return hmac.ComputeHash(secretBytes);
}
```
The pepper is intentionally not stored alongside the hash: an attacker who exfiltrates only the SQLite file holds the hashes but lacks the keying material to brute-force candidate secrets, even if the stored hash algorithm and salt scheme are known. If the pepper is missing the hasher throws `ApiKeyPepperUnavailableException`, which the verifier converts to a distinct failure code rather than treating it as a credential mismatch.
When the pepper is not configured, the library surfaces the failure as an
`InvalidOperationException` whose message reports the pepper is unavailable rather
than persisting a key with an unkeyed hash. The dashboard management path
(`DashboardApiKeyManagementService`) catches that condition and returns the
friendly "API key pepper is not configured." result instead of faulting the Blazor
circuit; it currently matches on the message text, so a library wording change
would need to be reflected there (a typed pepper-unavailable exception is a pending
library improvement).
## Verification
`ApiKeyVerifier` (`IApiKeyVerifier`) implements the verification flow:
The gateway consumes the library's `IApiKeyVerifier` from
`GatewayGrpcAuthorizationInterceptor`. The verifier's flow is:
1. Parse the `Authorization` header into a `ParsedApiKey`.
2. Look up the `ApiKeyRecord` by `KeyId` through `IApiKeyStore.FindByKeyIdAsync`.
3. Reject revoked records (`RevokedUtc is not null`) and expired records (`ExpiresUtc` in the past). Expiry is opt-in — keys created without an expiry never expire; an expired key fails opaquely, indistinguishable to the client from any other auth failure.
1. Parse the `Authorization` header into the key id and presented secret.
2. Look up the stored key record by key id.
3. Reject a revoked record, and reject an expired record whose `ExpiresUtc` is in
the past. Expiry is opt-in — keys created without an expiry never expire; an
expired key fails opaquely, indistinguishable to the client from any other auth
failure.
4. Hash the presented secret with the configured pepper.
5. Compare hashes with `CryptographicOperations.FixedTimeEquals` to avoid timing oracles.
6. Record a `LastUsedUtc` timestamp via `MarkKeyUsedAsync` and return an `ApiKeyIdentity`.
5. Compare hashes in constant time to avoid a timing oracle.
6. Stamp a `LastUsedUtc` timestamp and return a shared `ApiKeyIdentity` carrying
the key id, key prefix, display name, scopes, and the opaque constraints JSON.
```csharp
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
}
A verification failure is opaque to the client: the interceptor returns
`Unauthenticated`/`PermissionDenied` without disclosing which check failed, while
the failure detail is available server-side for audit.
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: storedKey.KeyId,
KeyPrefix: storedKey.KeyPrefix,
DisplayName: storedKey.DisplayName,
Scopes: storedKey.Scopes,
Constraints: storedKey.Constraints));
```
`ApiKeyVerificationResult` carries either an `ApiKeyIdentity` or a discriminated `ApiKeyVerificationFailure` value. The failure enum distinguishes parse errors, missing pepper, missing or revoked keys, and secret mismatch so the calling middleware can emit precise audit detail without leaking which check failed to the client.
`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`,
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
authorization code consumes.
`GatewayApiKeyIdentityMapper.ToGatewayIdentity` maps the library's shared
`ApiKeyIdentity` onto the gateway's own `ApiKeyIdentity`
(`Security/Authentication/ApiKeyIdentity.cs`), which exposes the deserialized
`ApiKeyConstraints` — parsed from the opaque constraints JSON via
`ApiKeyConstraintSerializer` — that the downstream `ConstraintEnforcer` and the
request-identity accessor enforce. The gateway identity exposes only non-secret
fields (`KeyId`, `KeyPrefix`, `DisplayName`, `Scopes`, `Constraints`).
### Hot-path caching and last-used coalescing
Left unmediated, every authenticated gRPC call costs a SQLite read plus a
`last_used_utc` **write** (the library verifier couples `MarkKeyUsed` into
`last_used_utc` **write** (the library verifier couples `MarkUsed` into
`VerifyAsync`), which makes the auth store the throughput ceiling on the
bulk-read workload. The gateway layers two decorators over the shared library's
registrations (in `AuthStoreServiceCollectionExtensions`) — it does not edit the
library:
- **`CachingApiKeyVerifier`** wraps `IApiKeyVerifier` with an `IMemoryCache`
entry per successful verification, keyed on a SHA-256 hash of the presented
token (never the plaintext secret). A cache hit within
- **`CachingApiKeyVerifier`** wraps the library `IApiKeyVerifier` with an
`IMemoryCache` entry per successful verification, keyed on a SHA-256 hash of the
presented token (never the plaintext secret). A cache hit within
`MxGateway:Security:ApiKeyVerificationCacheSeconds` (default 15 s) returns the
cached result without touching the store, so both the read and the coupled
write are skipped. Only successes are cached; failures always reach the inner
verifier. On a gateway-initiated revoke/rotate/delete the dashboard admin
service calls `IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached
entry immediately. The short TTL is the backstop for out-of-band mutations
(a direct DB edit, or a revoke run by the separate `apikey` CLI process, whose
in-memory cache is not the running gateway's cache).
- **`CoalescingMarkApiKeyStore`** wraps `IApiKeyStore` and forwards at most one
`MarkUsed` write per key per `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds`
(default 60 s), so even under a cache miss the `last_used_utc` write is bounded
to roughly one per key per minute rather than one per RPC. `last_used_utc` is a
coarse staleness hint, not an audit record (audit rows are written separately),
so bounded staleness of up to one window is acceptable.
cached result without touching the store, so both the read and the coupled write
are skipped. Only successes are cached; failures always reach the inner verifier.
On a gateway-initiated revoke/rotate/delete the dashboard admin service calls
`IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached entry
immediately. The short TTL is the backstop for out-of-band mutations (a direct DB
edit, or a revoke run by the separate `apikey` CLI process, whose in-memory cache
is not the running gateway's cache).
- **`CoalescingMarkApiKeyStore`** wraps the library `IApiKeyStore` and forwards at
most one `MarkUsed` write per key per
`MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` (default 60 s), so even under a
cache miss the `last_used_utc` write is bounded to roughly one per key per minute
rather than one per RPC. `last_used_utc` is a coarse staleness hint, not an audit
record (audit rows are written separately), so bounded staleness of up to one
window is acceptable.
`GatewayApiKeyIdentityMapper` additionally memoizes the constraints-JSON
deserialization by blob, so the per-call parse on the mapped identity collapses to
a dictionary lookup. Both windows are configurable and may be set to `0` to
disable the respective mechanism; see [GatewayConfiguration](./GatewayConfiguration.md).
a dictionary lookup. Both windows are configurable and may be set to `0` to disable
the respective mechanism; see
[GatewayConfiguration](./GatewayConfiguration.md).
## Storage
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
API-key state lives in a dedicated SQLite database owned by the shared library.
SQLite is sufficient because credential volume is small, the gateway runs as a
single process, and the file is straightforward to back up and rotate independently
of the main application data.
The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the credential store is never written relative to the launch working directory on a non-Windows host. The production hosts pin the explicit Windows path in `appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative) `SqlitePath` so a bad override fails fast at startup rather than scattering the store by launch CWD (SEC-01).
The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default
is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)`
(`C:\ProgramData\MxGateway\gateway-auth.db` on Windows,
`/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the
credential store is never written relative to the launch working directory on a
non-Windows host. The production hosts pin the explicit Windows path in
`appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative)
`SqlitePath` so a bad override fails fast at startup rather than scattering the store
by launch CWD (SEC-01).
### Connection factory
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
```csharp
SqliteConnectionStringBuilder builder = new()
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = true,
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
};
```
Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path.
### Schema
`SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved:
- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob,
`display_name`, serialized `scopes`, optional serialized `constraints`, and
the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps.
- `api_key_audit` is an append-only log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns.
- `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`.
### Read paths
`SqliteApiKeyStore` (`IApiKeyStore`) handles the two reads needed at request time: `FindByKeyIdAsync` returns any record (so revoked keys can be reported distinctly) and `FindActiveByKeyIdAsync` filters to non-revoked rows. `MarkKeyUsedAsync` updates `last_used_utc` only for non-revoked rows so a freshly revoked key cannot have its timestamp refreshed by a racing verification.
`ApiKeyRecord` is the in-memory projection. `ApiKeyRecordReader.Read` is shared by every read path so column ordering is defined in one place:
```csharp
public static ApiKeyRecord Read(SqliteDataReader reader)
{
return new ApiKeyRecord(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
SecretHash: (byte[])reader["secret_hash"],
DisplayName: reader.GetString(3),
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
}
```
### Write paths
`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable, and `DeleteAsync` permanently removes a row but only when `revoked_utc IS NOT NULL` — active keys are untouched (returns false) so the revoke event lands in the audit log before the row disappears.
Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) actions only for keys whose status is `Active`; revoked keys instead show a Delete action that calls `DeleteAsync`, so an operator can permanently remove a revoked row without ever risking un-revocation as a side effect of a rotation.
The dashboard API Keys page also surfaces expiry: each row shows an `Expires` column (`Never` when unset) and a status badge that reads `Expired` (past expiry, red), `Expiring` (within seven days, amber), `Revoked`, or `Active`. This is display-only staleness surfacing; expiry is set at creation time via the `apikey create-key --expires` CLI, not from the dashboard.
The library owns the SQLite schema and connection factory. The `api_keys` table
carries the key id, key prefix, secret-hash blob, display name, serialized scopes,
optional serialized constraints, and the `created_utc`, `last_used_utc`,
`revoked_utc`, and `expires_utc` timestamps. Because the schema, stores, and migrator
belong to `ZB.MOM.WW.Auth.ApiKeys`, this document does not restate their column
readers or SQL; consult the library for that detail.
### Audit trail
`SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`.
The library emits its own API-key audit entries (from the admin verbs — create,
revoke, rotate, `init-db`, and constraint denials), but the gateway **overrides**
the library's `IApiKeyAuditStore` registration with
`CanonicalForwardingApiKeyAuditStore`. That adapter canonicalizes every
library-emitted `ApiKeyAuditEntry` onto the gateway's `AuditEvent` shape and routes
it through `IAuditWriter` (`CanonicalAuditWriter`) into `SqliteCanonicalAuditStore`,
which persists to a single **`audit_event`** table (columns `event_id`,
`occurred_at_utc`, `actor`, `action`, `outcome`, `category`, `target`,
`source_node`, `correlation_id`, `details_json`). Reads for the dashboard "recent
audit" view go back through the same adapter, which maps `audit_event` rows back to
`ApiKeyAuditEntry` values so the existing view keeps working unchanged.
## Migration
Consequently the library's own `api_key_audit` table is left in place but
**unused** after adoption — nothing writes to it once the override is registered.
The canonical `audit_event` table is the single durable record of both API-key
administrative actions and the dashboard's own audit vocabulary
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
`dashboard-delete-key`, and the session Close/Kill actions). This is why any prose
that describes credential audits as landing in `api_key_audit` is stale: the
canonical store is `audit_event`.
Schema bring-up is centralised behind `IAuthStoreMigrator`. `SqliteAuthStoreMigrator` executes the migration inside a single transaction so a partial failure leaves the database untouched, refuses to start when the on-disk schema version is newer than the binary supports, and idempotently creates the v1 schema:
## Registration
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore(IConfiguration)` wires the
whole subsystem. It does not register the library types directly — it delegates to
the shared provider and then layers the gateway concerns:
```csharp
if (existingVersion > SqliteAuthSchema.CurrentVersion)
public static IServiceCollection AddSqliteAuthStore(
this IServiceCollection services,
IConfiguration configuration)
{
throw new AuthStoreMigrationException(
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
// Pin the gateway's token prefix ("mxgw") and pepper key ("MxGateway:ApiKeyPepper")
// as fallback defaults UNDER the supplied configuration, then register the shared
// provider: it binds ApiKeyOptions from MxGateway:Authentication and wires the SQLite
// stores, the configuration-backed pepper provider, the verifier, the migrator, and
// the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
// SEC-08 hot-path decorators layered over the library registrations.
services.AddMemoryCache();
// CoalescingMarkApiKeyStore decorates IApiKeyStore; CachingApiKeyVerifier decorates
// IApiKeyVerifier and also serves as IApiKeyCacheInvalidator.
// Canonical audit: override the library's IApiKeyAuditStore so every API-key audit
// event is forwarded through IAuditWriter into the audit_event table.
services.AddSingleton<IApiKeyAuditStore, CanonicalForwardingApiKeyAuditStore>();
// The shared admin command set (ApiKeyAdminCommands) and the gateway CLI runner.
services.AddSingleton<ApiKeyAdminCliRunner>();
return services;
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
```
`AuthStoreMigrationHostedService` runs the migrator at startup, but only when API-key authentication is enabled and `RunMigrationsOnStartup` is true. Operators who manage schema out-of-band can disable the hosted run and use the admin CLI's `init-db` command instead.
`AuthStoreMigrationException` is a sealed `InvalidOperationException` so it can be caught precisely without swallowing unrelated failures.
The decorators wrap the library's last registration for each interface rather than
replacing the library types, preserving singleton semantics; the audit override is
registered after `AddZbApiKeyAuth` so it wins as the resolved `IApiKeyAuditStore`.
## Admin CLI
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult` carrying an error). `ApiKeyAdminCliRunner` then executes the command, runs the migrator first, calls the relevant store method, appends an audit row, and writes either text or JSON output via `ApiKeyAdminOutput`. The returned `ApiKeyAdminListedKey` projection deliberately omits the `secret_hash` so listing a database does not surface hash material.
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and
dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each
parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult`
carrying an error). `ApiKeyAdminCliRunner` then runs the migrator, invokes the shared
`ApiKeyAdminCommands` verb, and writes text or JSON output via `ApiKeyAdminOutput`.
The returned `ApiKeyAdminListedKey` projection deliberately omits the secret hash so
listing a database never surfaces hash material.
The supported subcommands match `ApiKeyAdminCommandKind` exactly:
@@ -230,7 +218,7 @@ The supported subcommands match `ApiKeyAdminCommandKind` exactly:
| `init-db` | none | Runs the migrator and records an audit entry. |
| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash and optional constraints, and prints the assembled `mxgw_<keyId>_<secret>` token. Optional `--expires` sets an expiry (absolute ISO-8601 UTC, or a relative `<N>d`/`<N>h` from now); omit it for a non-expiring key. |
| `list-keys` | none | Lists every stored key with its scopes, constraints, revocation state, and expiry (`active`/`expired`/`revoked`). |
| `revoke-key` | `--key-id` | Sets `revoked_utc` if the key is currently active. |
| `revoke-key` | `--key-id` | Marks the key revoked if it is currently active. |
| `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. |
Examples:
@@ -249,68 +237,24 @@ mxgateway apikey rotate-key --key-id ops.alice
Constraint flags are optional. `--read-subtree`, `--write-subtree`,
`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable.
`--max-write-classification` accepts one integer. `--read-alarm-only` and
`--read-historized-only` are boolean flags. Existing rows with null
constraints remain fully unconstrained after migration.
`--read-historized-only` are boolean flags. Existing rows with null constraints
remain fully unconstrained after migration.
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling.
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens
so they remain safe to embed in the token format and in URL paths used by
administrative tooling.
The CLI is not the only management surface: the dashboard API Keys page
creates, rotates, revokes, and deletes (revoked-only) keys through the same
`IApiKeyAdminStore`. Every destructive dashboard action is gated by a
confirmation dialog and emits its own audit event
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
`dashboard-delete-key`). See
The CLI is not the only management surface: the dashboard API Keys page creates,
rotates, revokes, and deletes (revoked-only) keys through the same shared admin
command set. Every destructive dashboard action is gated by a confirmation dialog
and emits its own audit event (`dashboard-create-key`, `dashboard-rotate-key`,
`dashboard-revoke-key`, `dashboard-delete-key`) into the canonical `audit_event`
store. The page also surfaces expiry: each row shows an `Expires` column (`Never`
when unset) and a status badge that reads `Expired`, `Expiring` (within seven days),
`Revoked`, or `Active`. This staleness surfacing is display-only; expiry is set at
creation time via `apikey create-key --expires`, not from the dashboard. See
[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page).
## Scope Serialization
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
```csharp
public static string Serialize(IReadOnlySet<string> scopes)
{
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
public static IReadOnlySet<string> Deserialize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
}
```
`Deserialize` tolerates an empty column by returning an empty set so older rows or hand-edited records do not crash the verifier.
## Registration
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore` wires every service in this subsystem as a singleton and registers the migration hosted service:
```csharp
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
{
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
services.AddSingleton<ApiKeyAdminCliRunner>();
services.AddSingleton<AuthSqliteConnectionFactory>();
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
services.AddHostedService<AuthStoreMigrationHostedService>();
return services;
}
```
Singletons are safe because each operation opens its own short-lived `SqliteConnection` through the factory; there is no shared mutable state inside the services.
## Related Documentation
- [Gateway Configuration](./GatewayConfiguration.md)
+1 -1
View File
@@ -407,7 +407,7 @@ The stable client proto manifest defines the generated-code directories:
clients/dotnet/generated
clients/go/internal/generated
clients/rust/src/generated
clients/python/src/mxgateway/generated
clients/python/src/zb_mom_ww_mxgateway/generated
clients/java/src/main/generated
```
+12 -12
View File
@@ -48,8 +48,8 @@ dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csp
Build and test from the repository root:
```powershell
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln --no-build
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
```
Create local package artifacts:
@@ -156,8 +156,8 @@ Pop-Location
## Python
The Python package is `mxaccess-gateway-client`. Generated modules live under
`clients/python/src/mxgateway/generated`.
The Python package is `zb-mom-ww-mxaccess-gateway-client`. Generated modules
live under `clients/python/src/zb_mom_ww_mxgateway/generated`.
Regenerate the Python bindings:
@@ -184,21 +184,21 @@ Push-Location clients/python
mxgw-py version --json
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
python -m mxgateway_cli version --json
python -m zb_mom_ww_mxgateway_cli version --json
Pop-Location
```
## Java
The Java workspace uses Gradle, Java 21, `mxgateway-client`, and
`mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and
The Java workspace uses Gradle, Java 17, `zb-mom-ww-mxgateway-client`, and
`zb-mom-ww-mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and
gRPC sources under `clients/java/src/main/generated`.
Regenerate Java bindings:
```powershell
Push-Location clients/java
gradle :mxgateway-client:generateProto
gradle :zb-mom-ww-mxgateway-client:generateProto
Pop-Location
```
@@ -214,7 +214,7 @@ Create local library and CLI artifacts:
```powershell
Push-Location clients/java
gradle :mxgateway-client:jar :mxgateway-cli:installDist
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
Pop-Location
```
@@ -222,9 +222,9 @@ Run the CLI through Gradle:
```powershell
Push-Location clients/java
gradle :mxgateway-cli:run --args="version --json"
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
Pop-Location
```
+3 -3
View File
@@ -107,7 +107,7 @@ The manifest declares these generated-code directories:
| .NET | `clients/dotnet/generated` |
| Go | `clients/go/internal/generated` |
| Rust | `clients/rust/src/generated` |
| Python | `clients/python/src/mxgateway/generated` |
| Python | `clients/python/src/zb_mom_ww_mxgateway/generated` |
| Java | `clients/java/src/main/generated` |
Only generator output belongs in these directories. Handwritten client wrappers
@@ -128,7 +128,7 @@ Use these commands to regenerate language-specific client bindings:
| Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` |
| Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` |
| Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` |
| Java | `Push-Location clients/java; gradle :mxgateway-client:generateProto; Pop-Location` |
| Java | `Push-Location clients/java; gradle :zb-mom-ww-mxgateway-client:generateProto; Pop-Location` |
.NET generation currently runs through the contracts project:
@@ -172,7 +172,7 @@ cargo check --workspace
```
Python clients should use `grpc_tools.protoc` and write generated modules under
`clients/python/src/mxgateway/generated` so imports stay separate from
`clients/python/src/zb_mom_ww_mxgateway/generated` so imports stay separate from
handwritten async wrappers.
The Python scaffold provides a repo-local generation script:
+7 -3
View File
@@ -418,8 +418,12 @@ Do not show API key secrets or pepper values.
Dashboard authentication is LDAP-backed, distinct from the API-key model used
on the gRPC API. Users sign in with directory credentials; the gateway maps
their LDAP groups to one of two dashboard roles (`Admin` or `Viewer`) and
issues a cookie carrying those role claims.
their LDAP groups to one of two dashboard roles (`Administrator` or `Viewer`) and
issues a cookie carrying those role claims. `Administrator` is the canonical
role value — the exact string `GroupToRole` values and the validator accept
(`DashboardRoles.Admin`); the shorthand "Admin" used elsewhere in this document
names the same role and the `MxGateway.Dashboard.Admin` policy, not a distinct
config value.
Implemented behavior:
@@ -541,7 +545,7 @@ Effective configuration:
"RecentSessionLimit": 200,
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwAdmin": "Administrator",
"GwReader": "Viewer"
}
}
+7 -2
View File
@@ -10,7 +10,7 @@ The layer is composed of four collaborators:
| Type | Lifetime | Role |
|------|----------|------|
| `MxAccessGatewayService` | scoped (gRPC) | Implements the six `MxAccessGateway` RPCs, performs exception mapping. |
| `MxAccessGatewayService` | scoped (gRPC) | Implements the seven `MxAccessGateway` RPCs, performs exception mapping. |
| `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. |
| `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. |
| `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. |
@@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It
## RPC Handlers
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `StreamAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — seven in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, `StreamAlarms`, and `QueryActiveAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
Public gRPC send and receive message sizes are configured from
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
@@ -94,6 +94,10 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
### `QueryActiveAlarms`
`QueryActiveAlarms` is a server-streaming, **session-less** RPC that returns a point-in-time snapshot of the currently-active alarm set. Unlike `StreamAlarms`, it does not stay attached for live transitions — it writes the snapshot and completes, which clients use after a reconnect to reseed alarm state or to reconcile alarms that may have been missed during a transport blip. It is served from the always-on `GatewayAlarmMonitor` cache (`IGatewayAlarmService.CurrentAlarms`), so no worker session is opened. The handler does not run through `MxAccessGrpcRequestValidator`; it only null-checks the request inline, then streams one `ActiveAlarmSnapshot` per cached alarm, honouring cancellation between writes. When `alarm_filter_prefix` is non-empty, only alarms whose `alarm_full_reference` starts with that prefix (ordinal `StartsWith`) are emitted; an empty prefix returns the full set. Exceptions are translated by the same `MapException` path as the other handlers. The RPC requires the `event` scope (`GatewayScopes.EventsRead`), the same scope as `StreamEvents` and `StreamAlarms`.
#### Provider status on the alarm feed
`AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying
@@ -173,6 +177,7 @@ receive this event directly.
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
+57 -37
View File
@@ -297,43 +297,46 @@ Pipe security:
### Worker Envelope
Every IPC message uses a common envelope:
Every IPC message uses a common `WorkerEnvelope`. The authoritative definition
lives in `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` — treat
that file as the single source of truth and do not hand-copy the message here,
because an inlined copy drifts. The shape below describes the current contract so
this document explains *why* the envelope is structured the way it is.
```protobuf
message WorkerEnvelope {
uint32 protocol_version = 1;
string session_id = 2;
uint64 sequence = 3;
uint64 correlation_id = 4;
oneof body {
WorkerHello worker_hello = 10;
GatewayHello gateway_hello = 11;
WorkerReady worker_ready = 12;
WorkerCommand command = 20;
WorkerCommandReply command_reply = 21;
WorkerEvent event = 22;
WorkerHeartbeat heartbeat = 23;
WorkerCancel cancel = 24;
WorkerShutdown shutdown = 25;
WorkerFault fault = 26;
}
}
```
The envelope header carries four fields: `protocol_version`, `session_id`, a
`uint64 sequence`, and a **`string correlation_id`** (an opaque id, not a
numeric one). The `body` is a `oneof` whose arms are the handshake and traffic
messages, tagged from 10 upward:
- `gateway_hello` / `worker_hello` — the startup handshake pair.
- `worker_ready` — the worker signalling its MXAccess COM instance is live.
- `worker_command` / `worker_command_reply` — a command and its correlated reply.
- `worker_cancel` — best-effort cancellation of an in-flight command.
- `worker_shutdown` / `worker_shutdown_ack` — graceful shutdown request and its
acknowledgement.
- `worker_event` — a converted MXAccess event.
- `worker_heartbeat` — periodic worker liveness/state.
- `worker_fault` — a terminal worker fault report.
Rules:
- `sequence` is monotonic per sender.
- `correlation_id` links commands to replies.
- Events use their own correlation id or zero.
- `sequence` is a monotonic per-sender counter used as a diagnostic aid; it is
not validated for gaps or ordering on receive (the named pipe already
guarantees FIFO delivery).
- `correlation_id` links a command to its reply; it is authoritative on the
envelope, and the inner `MxCommandReply.correlation_id` echoes it for
MXAccess parity.
- Events carry their own correlation id or leave it empty.
- Replies must preserve MXAccess HRESULT/status information even when the
command is also represented as a protocol-level failure.
- Protocol version mismatch fails session creation.
## Public gRPC API
The external API should be session-oriented. A bidirectional stream is the best
long-term shape because it naturally carries commands, replies, events,
heartbeats, and cancellation.
The external API is session-oriented. The shipped service is unary plus
server-streaming; the authoritative definition is
`src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`. As built, the
`MxAccessGateway` service exposes seven RPCs:
```protobuf
service MxAccessGateway {
@@ -341,19 +344,34 @@ service MxAccessGateway {
rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply);
rpc Invoke(MxCommandRequest) returns (MxCommandReply);
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
rpc Session(stream ClientMessage) returns (stream ServerMessage);
rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage);
rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot);
}
```
Recommended rollout:
`OpenSession`, `CloseSession`, and `Invoke` are unary; `StreamEvents`,
`StreamAlarms`, and `QueryActiveAlarms` are server-streaming. `AcknowledgeAlarm`,
`StreamAlarms`, and `QueryActiveAlarms` are session-less — they route to the
gateway's always-on central alarm monitor rather than a client worker session.
The unary-plus-event-stream shape is easier to debug and reason about than a
single multiplexed channel.
1. Implement unary `OpenSession`, `CloseSession`, and `Invoke`.
2. Implement server-streaming `StreamEvents`.
3. Add bidirectional `Session` after the command/event model is stable.
### Future work: bidirectional `Session` (not implemented)
The unary plus event-stream shape is easier to debug initially. The
bidirectional stream can later reduce per-command overhead and improve
backpressure.
A single bidirectional `Session` stream carrying commands, replies, events,
heartbeats, and cancellation was considered as a possible long-term shape:
```protobuf
// Not implemented — design sketch only.
rpc Session(stream ClientMessage) returns (stream ServerMessage);
```
It is **not part of the shipped contract** and there are no `ClientMessage` /
`ServerMessage` types in `mxaccess_gateway.proto`. A bidirectional stream could
later reduce per-command overhead and improve backpressure, but it would only be
added after the command/event model is stable and if a concrete requirement
appears.
## Public MXAccess Command Surface
@@ -750,8 +768,10 @@ Gateway policy:
- one event sequencer per session,
- preserve per-session event order,
- allow one active client event subscriber per session,
- reject a second subscriber with a clear session error,
- allow one active client event subscriber per session **by default**, rejecting
a second subscriber with a clear session error; multi-subscriber fan-out is
config-gated (`AllowMultipleEventSubscribers`, off by default) — see the
fan-out and reconnect detail in [Sessions](docs/Sessions.md),
- use a bounded `EventStreamService` queue between worker events and gRPC
writes,
- fault the session when the bounded stream queue overflows,