fix(archreview): security authz+hub cluster (SEC-07/05/08/11) + validation hardening
SEC-07: add QueryActiveAlarmsRequest -> events:read scope arm; fix two tests that
constructed StreamAlarmsRequest instead of QueryActiveAlarmsRequest.
SEC-05: shorten hub-token lifetime 30m -> 5m; document that the ?access_token= query
carriage must never be request-logged.
SEC-08: gateway-side CachingApiKeyVerifier (short TTL, keyed on a hash of the presented
secret) skips the per-call store read+last_used write; CoalescingMarkApiKeyStore bounds
last_used writes to <=1/key/min; identity constraints are cached. Invalidation is wired
at the gateway admin sites (revoke/rotate/delete); short TTL backstops out-of-process CLI.
SEC-11: fixed-window rate limit on POST /auth/login + a per-peer (key-id) failure limiter
checked before VerifyAsync; new MxGateway:Security options bound + validated.
Also fixes regressions from the SEC-01/04/06 commit (c185f62) that a narrow test filter
missed (all now covered by a full-suite checkpoint):
- Rooting check is cross-platform: accepts Windows C:\/UNC forms on Unix so the shipped
appsettings path validates on the macOS dev box, still rejecting bare filenames.
- AddGatewayConfiguration TryAdds a non-production IHostEnvironment fallback so the validator
resolves in minimal test/tooling containers; the real host + apikey CLI register the actual
environment first (TryAdd no-op there).
- Test assembly defaults ASPNETCORE_ENVIRONMENT=Development (ModuleInitializer) so full-host
tests exercise wiring instead of tripping the SEC-04/06 production guards.
- GatewayOptionsTests asserts the SEC-01 CommonApplicationData-derived default (platform-correct).
archreview: SEC-07/05/08/11 (P1). Verified: NonWindows build clean; full gateway suite
747 passed / 42 failed, where all 42 are the pre-existing macOS named-pipe-harness failures
(Unix-socket path limit) and 0 are validation/regression failures.
This commit is contained in:
@@ -101,6 +101,38 @@ return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
||||
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
|
||||
authorization code consumes.
|
||||
|
||||
### 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
|
||||
`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
|
||||
`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.
|
||||
|
||||
`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
|
||||
|
||||
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.
|
||||
|
||||
@@ -89,6 +89,12 @@ The flow is:
|
||||
|
||||
The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
|
||||
|
||||
### Rate limiting the auth surface (SEC-11)
|
||||
|
||||
Before the verification store read, the helper checks a cheap in-process per-peer failure counter (`ApiKeyFailureLimiter`). A peer that has accumulated more than `MxGateway:Security:ApiKeyFailureLimit` failed attempts inside the sliding `ApiKeyFailureWindowSeconds` window is short-circuited with `StatusCode.ResourceExhausted` — so online guessing of API-key secrets cannot spend a SQLite read (and, in a naive design, a cache miss) per attempt. The peer is keyed on the presented key id where the token parses, falling back to the transport peer address; keying on key id throttles a single abusive credential without penalizing co-located clients behind a shared NAT. A successful verification resets the peer's counter. The counter is a bounded LRU (`ApiKeyFailureTrackedPeers`) so it cannot grow without limit. `ResourceExhausted` reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property.
|
||||
|
||||
The dashboard login surface is throttled independently: `POST /auth/login` carries a fixed-window ASP.NET Core rate-limiter policy keyed per remote IP (`MxGateway:Security:LoginRateLimit*`), rejecting a burst with HTTP 429 before the LDAP bind is relayed to the directory. See [GatewayConfiguration](./GatewayConfiguration.md#security-options).
|
||||
|
||||
## Scope Resolution
|
||||
|
||||
`GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
|
||||
@@ -104,6 +110,7 @@ public string ResolveRequiredScope(object request)
|
||||
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
||||
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
||||
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
|
||||
TestConnectionRequest or
|
||||
GetLastDeployTimeRequest or
|
||||
DiscoverHierarchyRequest or
|
||||
@@ -113,7 +120,7 @@ public string ResolveRequiredScope(object request)
|
||||
}
|
||||
```
|
||||
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`. Both alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
|
||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||
|
||||
@@ -205,7 +212,7 @@ blocking constraint; secured values and raw credentials are never logged.
|
||||
|----------|-------|--------------|
|
||||
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
|
||||
| `SessionClose` | `session:close` | `CloseSessionRequest` |
|
||||
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
||||
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `QueryActiveAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
||||
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
||||
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
||||
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
||||
|
||||
@@ -218,9 +218,13 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
|
||||
side-effect; the dashboard only sees events while a gRPC client is also
|
||||
subscribed to that session.
|
||||
|
||||
`GET /hubs/token` (cookie-only) mints a 30-minute data-protected bearer
|
||||
`GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
|
||||
token for the calling user; the Blazor pages use it via
|
||||
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
||||
The factory refreshes the token on every (re)connect, so the short lifetime
|
||||
(SEC-05) is transparent to clients. The token is not server-side revocable;
|
||||
its short lifetime bounds exposure of a captured token (see
|
||||
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
|
||||
|
||||
## LDAP Options (Dashboard Login)
|
||||
|
||||
@@ -258,6 +262,23 @@ The protocol option is exposed for diagnostics and explicit deployment
|
||||
configuration, not for compatibility negotiation. A mismatch fails validation
|
||||
at startup.
|
||||
|
||||
## Security Options
|
||||
|
||||
Bound from `MxGateway:Security`. These knobs tune the authentication hot path
|
||||
(SEC-08 verification cache / last-used coalescing) and the auth-surface rate
|
||||
limiting (SEC-11). Defaults are safe; leave them unless profiling or a threat
|
||||
model requires otherwise.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Security:ApiKeyVerificationCacheSeconds` | `15` | TTL, in seconds, of a cached successful API-key verification. A cache hit within this window skips the per-call SQLite read (and the coupled `last_used` write). Gateway-initiated revoke/rotate/delete invalidate the entry immediately; this TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke run by the separate `apikey` CLI process). `0` disables the cache. Must be zero or greater. |
|
||||
| `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` | `60` | Coalescing window, in seconds, for the `last_used_utc` write. The library verifier writes `last_used` on every successful verification; this bounds the write to at most one per key per window, so a hammered key does not churn the WAL. `0` forwards every write. Must be zero or greater. |
|
||||
| `MxGateway:Security:LoginRateLimitPermitLimit` | `10` | Maximum `POST /auth/login` attempts permitted per remote IP within `LoginRateLimitWindowSeconds` before requests are rejected with HTTP 429. Throttles LDAP credential stuffing before the bind is relayed to the directory. Must be greater than zero. |
|
||||
| `MxGateway:Security:LoginRateLimitWindowSeconds` | `60` | Fixed-window length, in seconds, for the per-IP login rate limit. Must be greater than zero. |
|
||||
| `MxGateway:Security:ApiKeyFailureLimit` | `10` | Failed API-key verifications, per peer, within `ApiKeyFailureWindowSeconds` that trip the in-process short-circuit. Once tripped, the gRPC auth path rejects further attempts with `ResourceExhausted` **before** the store read; a successful verification resets the peer's counter. The peer is keyed on the presented key id (falling back to the transport address) so a single abusive credential behind a shared NAT is throttled without locking out co-located clients. Must be greater than zero. |
|
||||
| `MxGateway:Security:ApiKeyFailureWindowSeconds` | `60` | Sliding-window length, in seconds, over which API-key verification failures are counted per peer. Must be greater than zero. |
|
||||
| `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct peers tracked by the failure counter (a bounded LRU) so a spray of unique peer keys cannot grow memory without limit. Must be greater than zero. |
|
||||
|
||||
## Galaxy Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|
||||
@@ -486,9 +486,13 @@ dashboard mints short-lived bearer tokens for the connection:
|
||||
and role claims to JSON, encrypts with the ASP.NET Core data-protection
|
||||
time-limited protector under purpose
|
||||
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
|
||||
string. Lifetime is 30 minutes.
|
||||
string. Lifetime is **5 minutes**.
|
||||
3. The SignalR client passes the token as either `Authorization: Bearer …` or
|
||||
`?access_token=…` (WebSocket upgrade query string).
|
||||
`?access_token=…` (WebSocket upgrade query string). The query-string form is
|
||||
the standard SignalR carriage for WebSocket upgrades, which cannot attach a
|
||||
custom header; because it is easy to capture (proxy logs, browser history),
|
||||
it must never be request-logged (see the remarks on
|
||||
`HubTokenAuthenticationHandler`).
|
||||
4. `HubTokenAuthenticationHandler` validates the protected payload and
|
||||
rebuilds the `ClaimsPrincipal` with the carried roles.
|
||||
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
||||
@@ -496,7 +500,17 @@ dashboard mints short-lived bearer tokens for the connection:
|
||||
|
||||
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
|
||||
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
|
||||
every (re)connect.
|
||||
every (re)connect, so the short 5-minute lifetime is transparent to clients.
|
||||
|
||||
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
||||
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
||||
carry no server-side revocation state (no jti denylist). A token captured before
|
||||
logout remains valid until it expires, and a role change or key revocation does
|
||||
not take effect on an already-issued token until then. The 5-minute lifetime is
|
||||
the deliberate mitigation: it bounds that exposure window without the cost of a
|
||||
revocation store. Server-side revocation is deferred until per-session hub ACLs
|
||||
land (see the per-session-ACL note), at which point tokens gain session/role
|
||||
binding and a denylist becomes worthwhile.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -203,6 +203,18 @@ metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
|
||||
|
||||
`Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./GatewayDashboardDesign.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format.
|
||||
|
||||
## Auth Store Write Churn
|
||||
|
||||
The per-RPC authentication path is a hidden write source: the shared verifier
|
||||
refreshes `last_used_utc` on every authenticated call. To keep that off the
|
||||
auth-store throughput ceiling, the gateway caches successful verifications for a
|
||||
short TTL and coalesces the `last_used_utc` write to at most one per key per
|
||||
window (`MxGateway:Security:ApiKeyVerificationCacheSeconds` /
|
||||
`ApiKeyLastUsedCoalesceSeconds`, defaults 15 s / 60 s). This bounds WAL churn on
|
||||
the bulk-read workload without dropping any metric — auth activity is still
|
||||
observable via command-throughput counters and audit rows. See
|
||||
[Authentication](./Authentication.md#hot-path-caching-and-last-used-coalescing).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
|
||||
|
||||
Reference in New Issue
Block a user