06e1046317
Reconcile load-bearing docs with shipped behavior: - IPC-06: gateway.md Worker Envelope sketch -> points to mxaccess_worker.proto as source of truth (string correlation_id, real oneof arms incl. worker_shutdown_ack/worker_ready). - IPC-07: docs/Grpc.md six RPCs -> seven; document QueryActiveAlarms handler + validation row. - IPC-21: gateway.md Session RPC moved from live API into a 'Future work: not implemented' subsection. - TST-13: drop stale design-era sketches from gateway.md; correct the single-subscriber-default (config-gated fan-out) note. - SEC-09: dashboard GroupToRole sample GwAdmin:Admin -> Administrator so it passes GatewayOptionsValidator; clarify Administrator is the canonical role. - SEC-22: rewrite docs/Authentication.md to the pipeline that actually ships (ZB.MOM.WW.Auth.ApiKeys package + gateway-owned CachingApiKeyVerifier, CoalescingMarkApiKeyStore, CanonicalForwardingApiKeyAuditStore, etc.); remove 18 stale type names (grep-verified absent). - IPC-17: correct wrong Python generated dir (mxgateway -> zb_mom_ww_mxgateway) in CLAUDE.md + 3 docs. - CLI-12: Java docs Java 21 -> Java 17 (JDK17 retarget for Ignition 8.3). - CLI-16: docs/ClientPackaging.md reconciled with real .slnx, Python package name, and gradle project names; fix stale generateProto task name. Docs-only; type/path/version claims verified against source. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
264 lines
15 KiB
Markdown
264 lines
15 KiB
Markdown
# Gateway Authentication
|
|
|
|
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 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.
|
|
|
|
## Secrets And Peppered Hashing
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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
|
|
|
|
The gateway consumes the library's `IApiKeyVerifier` from
|
|
`GatewayGrpcAuthorizationInterceptor`. The verifier's flow is:
|
|
|
|
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 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.
|
|
|
|
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.
|
|
|
|
`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 `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 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 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).
|
|
|
|
## Storage
|
|
|
|
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 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
|
|
|
|
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.
|
|
|
|
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`.
|
|
|
|
## 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
|
|
public static IServiceCollection AddSqliteAuthStore(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
// 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;
|
|
}
|
|
```
|
|
|
|
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 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:
|
|
|
|
| Subcommand | Required options | Behaviour |
|
|
|------------|------------------|-----------|
|
|
| `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` | Marks the key revoked if it is currently active. |
|
|
| `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. |
|
|
|
|
Examples:
|
|
|
|
```bash
|
|
mxgateway apikey init-db
|
|
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
|
|
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
|
|
mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes invoke:read --expires 90d
|
|
mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z
|
|
mxgateway apikey list-keys --json
|
|
mxgateway apikey revoke-key --key-id ops.alice
|
|
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.
|
|
|
|
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 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).
|
|
|
|
## Related Documentation
|
|
|
|
- [Gateway Configuration](./GatewayConfiguration.md)
|
|
- [Authorization](./Authorization.md)
|
|
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
|
|
- [Diagnostics](./Diagnostics.md)
|