fix(SEC-33,SEC-34): host-meaningful path rooting; verification-cache invalidate race
SEC-33: make rooting host-meaningful and stop shipping foreign-platform literals. - Delete IsRootedForAnyPlatform; AddIfNotRooted now uses Path.IsPathRooted (current OS). - Promote AddIfNotRooted/AddIfInvalidPath to shared GatewayConfigPathRules so the new Galaxy validator reuses them and the two validators cannot drift. - Remove Authentication:SqlitePath and Galaxy:SnapshotCachePath Windows literals from appsettings.json; the CommonApplicationData-derived code defaults take over. The Galaxy default is seeded as a configuration value before AddZbGalaxyRepository (SnapshotCachePath is init-only, so a PostConfigure mutation cannot compile). - New GalaxyRepositoryOptionsValidator (ValidateOnStart) enforces a valid, host-rooted SnapshotCachePath when PersistSnapshot is true. - Root-cause the stray junk-named auth DB: host start eagerly builds AuthSqliteConnectionFactory; under the non-rooted Windows literal on macOS SQLite wrote it relative to the test bin CWD. The three real-host-start tests now pin SqlitePath to a temp path. SEC-34: verification cache Invalidate-vs-in-flight-repopulation race closed with a per-key generation counter (bump-before-evict, snapshot-then-recheck). The expiry cap (window 2) takes the documented fallback: the library verification identity carries no ExpiresUtc, so the cache cannot cap at the key's expiry (donor-library ask). GWC-24 rider: cap MxGateway:Events:QueueCapacity at int.MaxValue/2 so the derived checked(2 * EventChannelCapacity) in WorkerClient cannot overflow at session creation. SEC-35 (doc-only): note IsProduction() env-name semantics in GatewayConfiguration.md. Docs updated same commit (GatewayConfiguration.md, Authentication.md) and tracking registers/change-log flipped (00-tracking.md, 40-security-dashboard.md).
This commit is contained in:
@@ -101,9 +101,9 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
|---|---|:-:|:-:|---|---|---|
|
||||
| SEC-31 | Medium | P0 | M | — | Done | Failure limiter: composite (peer, key-id) partitions + cross-peer aggregate with probe admission |
|
||||
| SEC-32 | Low | P0 | S | SEC-31 | Done | Limiter LRU flushable by junk-token spray; validate token shape, cap per-peer partitions |
|
||||
| SEC-33 | Low | P1 | M | old SEC-23 (co-locate) | Not started | Host-meaningful path rooting; drop Windows literals from appsettings; validate Galaxy `SnapshotCachePath` |
|
||||
| SEC-34 | Low | P2 | S | — | Not started | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||
| SEC-35 | Info | — | S | — | N/A | Production hard-stops key on exact `Production` environment name (doc-only) |
|
||||
| SEC-33 | Low | P1 | M | old SEC-23 (co-locate) | Done | Host-meaningful path rooting; drop Windows literals from appsettings; validate Galaxy `SnapshotCachePath` |
|
||||
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||
| SEC-35 | Info | — | S | — | N/A (doc note discharged 2026-08-07) | Production hard-stops key on exact `Production` environment name (doc-only) |
|
||||
| SEC-36 | Low | P1 | M | cross-repo `scadaproj/infra/glauth` | Not started | Committed dev LDAP service-account password: rotate, remove, move dev channel to user-secrets |
|
||||
|
||||
### Clients — [50-clients.md](50-clients.md)
|
||||
@@ -168,4 +168,5 @@ Sequence these together rather than piecemeal — several are one change set spa
|
||||
| 2026-08-07 | Code review of `fix/gwc-26-27-alarm-attach` surfaced a **known pre-existing characteristic, now documented**: the alarm monitor's reconcile-derived feed repairs are **at-least-once, not exactly-once**. A reconcile reads the worker's current state while the matching live transition may still be buffered in the monitor's internal lease, so both broadcast and the duplicates are indistinguishable on the alarm feed (`StreamAlarms` + dashboard alarm hub). This pre-dates GWC-26 — the Raise/Clear presence repair has always had it, since nothing serializes a reconcile pass against the in-flight live stream — so closing it (reconcile/live serialization or transition-timestamp dedup) was ruled out of scope for a P2 fix. Documented instead in `GatewayAlarmMonitor.ApplyReconcile`, `gateway.md`, and `docs/Sessions.md`, with the consumer-side contract stated explicitly (apply transitions idempotently — "set this alarm to this state", never increment/toggle). **Candidate finding for the next review cycle.** |
|
||||
| 2026-08-07 | **CLI-37 + CLI-38 -> `Done`** (branch `fix/cli-37-38-conformance`), one cross-client conformance commit; **closes old-tracker CLI-08**. Canonical rules landed everywhere: an `MxStatusProxy` entry fails iff `category != MX_STATUS_CATEGORY_OK` (`success` is the raw COM member, diagnostics only; absent entry = success, present entry with `UNSPECIFIED` = failure), and a reply fails on HRESULT iff `hresult` is present and `< 0` (so `S_FALSE = 1` passes). Edits: .NET `MxStatusProxyExtensions.IsSuccess` (drop the `Success != 0` conjunct) + `MxCommandReplyExtensions` (`!= 0` -> `< 0`); Go `StatusSucceeded` (category) + `errors.go` (`< 0`); Java `MxStatuses.succeeded` (category, Javadoc corrected) + `MxGatewayErrors` (`< 0`); Python `errors.py` (category); Rust `ensure_mxaccess_success` (category, doc comment corrected). Four shared fixtures added under `clients/proto/fixtures/behavior/command-replies/` (`write.status-category-{error-success-set,ok-success-zero}.reply.json`, `write.hresult-{s-false,e-fail}.reply.json`) + manifest + `docs/ClientBehaviorFixtures.md`; each of the five suites now runs all four fixture-driven, plus a per-language table test for the two edges fixtures cannot express (nil/null entry, `UNSPECIFIED` category). Docs same commit: `ClientLibrariesDesign.md` per-item rule sentence (its existing HRESULT `< 0` claim is now true), .NET/Go/Java README error sections. Also fixed a Java test fake that built a status with a bare `setSuccess(1)` and no category. Verification: dotnet build 0 warnings + 110 passed/1 skipped; `gofmt -l` clean, `go build ./...`, `go test ./...` all ok; `gradle test` BUILD SUCCESSFUL with **no** generated-file churn to revert this time (no `.proto` changed and `generateProto` stayed up to date); `python -m pytest` 155 passed/1 skipped; `cargo fmt` (no unrelated reformat), `cargo check`, `cargo test --workspace` 100 passed, `cargo clippy --all-targets -- -D warnings` clean. Gateway-side `ClientBehaviorFixtureTests` 8/8 re-run because the new fixtures are validated there. |
|
||||
| 2026-08-07 | **CLI-45 → `Done`** on `fix/cli-45-credential-envvar`. All five CLIs now share one credential contract for `authenticate-user`: flags `--password` / `--password-env` (Go: `-password` / `-password-env`) defaulting to env `MXGATEWAY_VERIFY_PASSWORD`, resolution flag-then-env, and a resolved credential that is missing **or empty** is a usage error naming the flag and the variable — never the value, never sent to the wire. Go and Java previously authenticated with an empty password: Go now returns the guard error before dialing, Java throws a picocli `ParameterException` instead of falling back to `""`. Python's `--password-env` gained the canonical default (its `UsageError` was already conformant) and its message now names the resolved variable. Rust treats an empty `--password` or empty env value as missing (resolution extracted into a testable `resolve_verify_user_password`). .NET adopted the canonical flags and keeps its pre-existing names as **deprecated aliases for one release** — order: `--password`, `--verify-user-password`, the variable named by `--password-env` (or the deprecated `--verify-user-password-env`; default `MXGATEWAY_VERIFY_PASSWORD`), then `MXGATEWAY_VERIFY_USER_PASSWORD`. Tests: `TestRunAuthenticateUser{RejectsEmptyPassword,ReadsPasswordFromCanonicalEnv}` (Go), 3 picocli cases (Java), 3 click cases (Python), 2 clap/resolver cases (Rust), 4 xUnit cases covering the canonical flag, both env-name paths, the deprecated flag+env aliases, and the missing/empty failure (.NET). Docs same commit: `docs/CrossLanguageSmokeMatrix.md` gained a "Credential contract for `authenticate-user`" section **and** the per-CLI subcommand-coverage table — the half of this finding that is documented rather than fixed (.NET exposes all nine single-item session commands; Rust `unregister` + the credential pair; Go/Python/Java the credential pair only; verified against each dispatch table, and every gap is CLI surface only since all five *libraries* implement all nine helpers). All five client READMEs name the canonical variable and the fail-fast rule; the .NET README gained an `authenticate-user` credentials section carrying the deprecation note. **Deviation:** Java keeps `isBlank()` (per this design's "null or blank" wording for Java) where the other four test emptiness, so a whitespace-only credential is additionally rejected there. Verification (all five, on macOS): Go `gofmt -l .` clean, `go build ./...` clean, `go test ./...` ok; Java `JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` BUILD SUCCESSFUL, CLI suite 51 tests / 0 failures — **no generated-tree churn appeared this run**, `git status` for `clients/java/**/generated` clean with no revert needed (no `.proto` changed); Python `python -m pytest` 148 passed / 1 skipped (TLS opt-in); .NET `dotnet build …Client.slnx` 0 warnings / 0 errors and client tests 108 passed / 1 skipped (live-gateway opt-in); Rust `cargo fmt` (diff confined to the new code), `cargo check --workspace`, `cargo test --workspace` 100 tests across 6 targets all green, `cargo clippy --all-targets -- -D warnings` clean. |
|
||||
| 2026-08-07 | **SEC-33 + SEC-34 → `Done`, SEC-35 discharged (doc-only)** (branch `fix/sec-33-34`). SEC-33: `IsRootedForAnyPlatform` deleted and rooting made host-meaningful (`Path.IsPathRooted`); `AddIfNotRooted`/`AddIfInvalidPath` promoted to a shared `GatewayConfigPathRules` helper; both `C:\ProgramData\...` literals (`Authentication:SqlitePath`, `Galaxy:SnapshotCachePath`) removed from `appsettings.json` so the `CommonApplicationData`-derived code defaults take over; new `GalaxyRepositoryOptionsValidator` (`ValidateOnStart`) enforces a valid, host-rooted `SnapshotCachePath` when `PersistSnapshot`; the Galaxy default is seeded as a configuration value before `AddZbGalaxyRepository` (deviation: the package's `SnapshotCachePath` is init-only, so a `PostConfigure` mutation would not compile — same effect). Stray-file root cause: host start eagerly builds `AuthSqliteConnectionFactory`, which under the Windows literal materialized a junk-named relative auth DB under the test `bin/` on macOS; the three real-host-start tests now pin `SqlitePath` to a temp path (`find src -name 'C:*'` empty). SEC-34: window-3 `Invalidate` race fixed with a per-key generation counter (bump-before-evict, snapshot-then-recheck); window-2 expiry cap took the **documented fallback** because the library verification identity carries no `ExpiresUtc` (donor-library ask recorded) — so only `Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation` was added, not the two expiry-cap tests. GWC-24 rider: `MxGateway:Events:QueueCapacity` gained an `int.MaxValue/2` upper bound so `checked(2 * EventChannelCapacity)` in `WorkerClient` cannot overflow at session creation (+ two validator tests). SEC-35: doc note added to `docs/GatewayConfiguration.md` (`IsProduction()` env-name semantics). Docs same commit: `GatewayConfiguration.md`, `Authentication.md`. Evidence (macOS): `dotnet build …Server` 0 warnings/0 errors; `--filter ~GatewayOptionsValidator` 69/69, `~GalaxyRepositoryOptionsValidator` 5/5, `~CachingApiKeyVerifier` 10/10, `~GatewayTreeHygiene` 1/1. |
|
||||
| 2026-08-07 | **SEC-31 + SEC-32 → `Done`** (branch `fix/sec-31-32-limiter`, one change set as planned). `ApiKeyFailureLimiter` reworked from `IsBlocked/RecordFailure/Reset(string peer)` to a partition-pair API (`Check/RecordFailure/Reset(ApiKeyThrottlePartition)` returning `ApiKeyThrottleDecision`): layer 1 is the composite `(transport peer, key id)` partition, layer 2 a per-key-id aggregate across peers (`ApiKeyFailureAggregateLimit`, default 30), and an over-limit state now admits one probe per `ApiKeyFailureProbeIntervalSeconds` (default 5) instead of blocking absolutely — so a success can reset the state while throttled, killing the 10-packets-per-minute lockout. SEC-32 rides along: the interceptor validates token shape (`mxgw` prefix, ≥3 non-empty `_` segments, key id ≤ 64 chars) before minting a key-id partition, each peer may mint at most 32 of them (overflow collapses to its fallback partition), and eviction prefers expired windows, never dropping an over-limit partition below a 2× transient overshoot ceiling. New counter `mxgateway.auth.throttled` tagged `stage=peer\|aggregate` only (no key material — `/metrics` is still unauthenticated per open SEC-14). Docs updated in the same commit (`docs/GatewayConfiguration.md` limiter rows + two new keys, `docs/Authentication.md` hot-path paragraph, `docs/Authorization.md` SEC-11 section, limiter/`SecurityOptions` XML remarks). Evidence: `dotnet build …Server` clean; `--filter ~ApiKeyFailureLimiter` 11/11 passed (new `ApiKeyFailureLimiterTests`), `--filter ~GatewayGrpcAuthorizationInterceptor` 20/20 passed (incl. the four SEC-31 contract tests and `NonMxgwToken_FallsBackToTransportPeerPartition`), `--filter ~GatewayOptionsValidator` 66/66 passed. Full suite on macOS: 804 passed / 44 failed — all 44 are the pre-existing named-pipe fake-worker classes (`WorkerClientTests`, `FakeWorkerHarnessTests`, `SessionWorkerClientFactoryFakeWorkerTests`, `GatewayEndToEnd*`), verified identical (44) on the unmodified tree. Follow-up unchanged: the new `MxGateway:Security` keys belong in old **SEC-24**'s effective-config projection when that is picked up. **Code review of the branch found two defects in the first pass, both fixed before merge:** (1) probe admission was check-then-act across two lock scopes, so a burst arriving at an interval boundary could all observe "due" and all be admitted — the claim is now a single critical section (`TryConsumeProbe`), and because the two layers are claimed one at a time, a slot claimed on the partition is compensated (`ReleaseProbe`) when the aggregate then refuses; (2) `Reset` on a success whose key id had been collapsed into the address's shared fallback partition removed that shared partition, letting one authentication wipe an in-progress spray from the same address — it is now left to decay by window expiry, while the key's aggregate is still cleared. Tests added: `ProbeAdmission_UnderConcurrentArrivals_GrantsExactlyOneSlot`, `ProbeAdmission_WhenAggregateRefuses_ReturnsTheClaimedPeerSlot`, `Reset_WithOverCapKeyId_DoesNotClearSharedFallbackPartition`. **A second review pass found a residual defect in that compensation path:** the release identified its own reservation by comparing `NextProbeAtTicks` to `now + interval`, the identical expression a failure re-arm writes — so a concurrent `RecordFailure` on the same state sharing a clock tick (routine at ~1 ms resolution) was mistaken for the caller's own claim and stomped back to the stale, already-due value, prematurely reopening the probe slot. Replaced with a monotonic per-state `ProbeVersion` bumped by every writer of `NextProbeAtTicks` (claim and re-arm alike); the release restores only when the version still matches the one its claim stamped, and bumps it again on restore so no other stale release can match. Covered by `ProbeSlotRestore_DoesNotStompConcurrentRearmAtSameTick`, made deterministic by a new `internal ProbeReleaseInterleaveHook` test seam (null in production, one null check on the refused path) because the claim-to-release window is nanoseconds wide and racing threads cannot hit it reliably — verified as a genuine red against the timestamp guard (`Expected: ThrottledByPeer / Actual: ProbeAdmitted`). Limiter suite 11 → 15. |
|
||||
|
||||
@@ -12,9 +12,9 @@ Repo rules that bind every entry: docs change in the same commit as the source (
|
||||
|----|-----|------|-----|-----|--------|-------|
|
||||
| SEC-31 | Medium | P0 | M | — | Done | Failure limiter partitions on attacker-controlled key id and blocks before verification (lockout DoS) |
|
||||
| SEC-32 | Low | P0 | S | SEC-31 | Done | Failure-limiter LRU is flushable by junk-token spray; token prefix never validated |
|
||||
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Not started | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
|
||||
| SEC-34 | Low | P2 | S | — | Not started | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||
| SEC-35 | Info | — | S | — | N/A (doc-only note) | Production hard-stops key on the exact `Production` environment name |
|
||||
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
|
||||
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||
| SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name |
|
||||
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Not started | Committed dev LDAP service-account password: remove from repo and rotate |
|
||||
|
||||
---
|
||||
@@ -135,6 +135,8 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
|
||||
```
|
||||
Post-run, verify no new `C:\*` file exists under any `bin/` (manual `find src -name 'C:*'`).
|
||||
|
||||
**Outcome (2026-08-07 — Done).** Implemented as designed. `IsRootedForAnyPlatform` deleted; `AddIfNotRooted`/`AddIfInvalidPath` promoted to a shared `GatewayConfigPathRules` internal helper (used by both validators) and now use `Path.IsPathRooted` (current OS). Both Windows literals removed from `appsettings.json`; the Galaxy `SnapshotCachePath` default is applied gateway-side via a configuration value seeded before `AddZbGalaxyRepository` (the package's `SnapshotCachePath` is **init-only**, so a `PostConfigure` mutation does not compile — deviation from the design's "PostConfigure default"; same effect). New `GalaxyRepositoryOptionsValidator` registered with `ValidateOnStart`. **Stray-file root cause:** starting the full host eagerly constructs `AuthSqliteConnectionFactory`, which creates the auth DB path; with the shipped Windows literal that path is non-rooted on macOS, so SQLite materialized `C:\ProgramData\MxGateway\gateway-auth.db` as a junk-named relative file under the test's `bin/` CWD (invisible to the hygiene test's bin/obj filter). After the literal removal the code default resolves under an unwritable `/usr/share` on macOS, so the three tests that start the real host (`GatewayApplicationTests.Build_MapsMetricsEndpoint`, `.StartAsync_InvalidGatewayConfiguration_FailsStartup`, `GatewayTlsBootstrapTests`) now pin `SqlitePath` to a temp path. No stray file remains (`find src -name 'C:*'` empty).
|
||||
|
||||
---
|
||||
|
||||
## SEC-34 — Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation `Low` · `P2`
|
||||
@@ -165,6 +167,8 @@ Post-run, verify no new `C:\*` file exists under any `bin/` (manual `find src -n
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~CachingApiKeyVerifier"
|
||||
```
|
||||
|
||||
**Outcome (2026-08-07 — Done).** Window 3 (Invalidate race) implemented exactly as designed: per-key generation counter, bump-before-evict in `Invalidate`, snapshot-before-inner + set-then-recheck in `VerifyAsync`, key id parsed from the token up front. Covered by `Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation`. **Window 2 (expiry cap) took the documented fallback**, not the cap: the design's confirmation step failed — the library verification identity (`ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity`, the type on `ApiKeyVerification.Identity`) carries **no** `ExpiresUtc` (that property is on `ApiKeyRecord`, the store row, not the returned identity), so the cache cannot cap an entry at the key's expiry. Per the design's contingency, the ≤ TTL expiry window is documented in the class remarks and `docs/Authentication.md`, with a donor-library ask (surface expiry on the verification identity). Consequently the two expiry-cap tests (`CacheEntry_DoesNotOutliveKeyExpiry`, `AlreadyExpiredIdentity_IsNotCached`) are **not** added — they cannot be written against a type with no expiry field; window 1 (CLI) accepted and documented as before.
|
||||
|
||||
---
|
||||
|
||||
## SEC-35 — Production hard-stops key on the exact `Production` environment name `Info` · `—` (N/A: doc-only)
|
||||
@@ -179,6 +183,8 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
|
||||
|
||||
**Verification.** Doc-only; no build. Cross-read against `GatewayOptionsValidator.cs:23-27`.
|
||||
|
||||
**Outcome (2026-08-07 — discharged).** The documentation contract landed as a rider on the SEC-33/34 commit: `docs/GatewayConfiguration.md` gained a "Production hard-stops key on the exact environment name (SEC-35)" subsection stating that both hard-stops fire only on `IHostEnvironment.IsProduction()` (unset `ASPNETCORE_ENVIRONMENT` or the exact `Production` name) and that any other name keeps the permissive dev posture. No code change, as designed.
|
||||
|
||||
---
|
||||
|
||||
## SEC-36 — Committed dev LDAP service-account password: remove from repo and rotate `Low` · `P1` · cross-repo dependency
|
||||
|
||||
+22
-7
@@ -99,9 +99,20 @@ library:
|
||||
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).
|
||||
immediately. `Invalidate` bumps a per-key generation counter **before** it evicts,
|
||||
and `VerifyAsync` snapshots that generation before the inner verify and re-checks
|
||||
it after writing the cache entry (set-then-recheck); a revoke that lands while a
|
||||
verification is still in flight in the inner library therefore discards that
|
||||
verification's repopulation instead of re-caching the just-revoked identity for a
|
||||
full TTL (SEC-34). The short TTL remains the backstop for two bounded-staleness
|
||||
windows it cannot close directly: (1) 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); and (2) a key whose `ExpiresUtc` passes while cached keeps
|
||||
authenticating until the entry's TTL elapses — expiry is enforced by the inner
|
||||
library verifier, which a cache hit never reaches, and the verification identity the
|
||||
library returns carries no expiry timestamp, so the cache cannot cap an entry at the
|
||||
key's expiry (capping it needs the donor library to surface expiry on the
|
||||
verification identity). The default 15 s TTL bounds both windows.
|
||||
- **`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
|
||||
@@ -148,10 +159,14 @@ 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).
|
||||
non-Windows host. `appsettings.json` no longer ships an explicit path (SEC-33): the
|
||||
removed Windows literal matched the Windows code default and, being non-rooted on a
|
||||
Unix host, would have resolved against the CWD there; deployed hosts override it
|
||||
through the NSSM environment (`MxGateway__Authentication__SqlitePath`).
|
||||
`GatewayOptionsValidator` rejects a `SqlitePath` that is not rooted **on the host
|
||||
running the gateway** (`Path.IsPathRooted`, current OS) — a relative filename or a
|
||||
foreign-platform literal fails fast at startup rather than scattering the store by
|
||||
launch CWD (SEC-01, SEC-33).
|
||||
|
||||
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,
|
||||
|
||||
@@ -14,7 +14,6 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
||||
"MxGateway": {
|
||||
"Authentication": {
|
||||
"Mode": "ApiKey",
|
||||
"SqlitePath": "C:\\ProgramData\\MxGateway\\gateway-auth.db",
|
||||
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
||||
"RunMigrationsOnStartup": true
|
||||
},
|
||||
@@ -71,8 +70,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
"CommandTimeoutSeconds": 60,
|
||||
"DashboardRefreshIntervalSeconds": 30,
|
||||
"PersistSnapshot": true,
|
||||
"SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json"
|
||||
"PersistSnapshot": true
|
||||
},
|
||||
"Alarms": {
|
||||
"Enabled": false,
|
||||
@@ -93,15 +91,17 @@ Environment variables use the normal .NET double-underscore form. For example,
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Authentication:Mode` | `ApiKey` | Selects public gRPC authentication. Supported values are `ApiKey` and `Disabled`. `Disabled` bypasses API-key verification and is for local development only. |
|
||||
| `MxGateway:Authentication:SqlitePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) | SQLite database path for API-key records and audit rows when API-key authentication is enabled. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so the credential store never lands in the launch working directory on a non-Windows host; the production hosts pin the explicit Windows path in `appsettings.json`, which overrides the code default. |
|
||||
| `MxGateway:Authentication:SqlitePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) | SQLite database path for API-key records and audit rows when API-key authentication is enabled. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so the credential store never lands in the launch working directory on a non-Windows host. `appsettings.json` no longer ships an explicit value (SEC-33): the removed Windows literal was byte-identical to the Windows code default, and a Windows-absolute literal is **not** rooted on a Unix host, so it would have resolved against the CWD there. Deployed hosts still override the path through the NSSM environment (`MxGateway__Authentication__SqlitePath`). |
|
||||
| `MxGateway:Authentication:PepperSecretName` | `MxGateway:ApiKeyPepper` | Configuration key used to read the HMAC pepper for API-key secret hashing. The dashboard effective configuration redacts this value. |
|
||||
| `MxGateway:Authentication:RunMigrationsOnStartup` | `true` | Runs SQLite auth schema migrations at gateway startup when API-key authentication is enabled. |
|
||||
|
||||
When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
|
||||
`SqlitePath` must be a valid filesystem path and must be **rooted** (absolute):
|
||||
the validator rejects a non-rooted path so a relative override cannot silently
|
||||
resolve against the working directory and scatter the credential store by launch
|
||||
CWD (SEC-01).
|
||||
`SqlitePath` must be a valid filesystem path and must be **rooted** (absolute)
|
||||
**on the host running the gateway**: the validator uses `Path.IsPathRooted`
|
||||
(current OS), so a bare filename — or a foreign-platform literal such as a
|
||||
Windows `C:\...` path on a Unix host — fails fast at startup instead of silently
|
||||
resolving against the working directory and scattering the credential store by
|
||||
launch CWD (SEC-01, SEC-33).
|
||||
|
||||
## Worker Options
|
||||
|
||||
@@ -149,13 +149,15 @@ All numeric session options must be greater than zero.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Events:QueueCapacity` | `10000` | Capacity for bounded per-session event queues used by the gateway worker event channel and the public gRPC event stream queue. Gateway-side buffering per session is at most `3 ×` this value: the bounded worker event channel plus the read loop's staging channel, which is bounded at `2 ×` it. Overflow of either bound faults the session with `ProtocolViolation` and kills its worker. |
|
||||
| `MxGateway:Events:QueueCapacity` | `10000` | Capacity for bounded per-session event queues used by the gateway worker event channel and the public gRPC event stream queue. Gateway-side buffering per session is at most `3 ×` this value: the bounded worker event channel plus the read loop's staging channel, which is bounded at `2 ×` it. Overflow of either bound faults the session with `ProtocolViolation` and kills its worker. Must be between `1` and `int.MaxValue / 2` so the derived `2 ×` staging bound cannot overflow when a worker client is created. |
|
||||
| `MxGateway:Events:BackpressurePolicy` | `FailFast` | Per-subscriber event backpressure behavior when a subscriber's bounded event channel overflows. Overflow is isolated to the offending subscriber: it is always disconnected with an `EventQueueOverflow` fault while the session pump and other subscribers keep running. `FailFast` additionally faults the whole session only in the legacy single-subscriber case (the current default mode); with multiple subscribers it degrades to a per-subscriber disconnect so one slow consumer never faults a shared session. `DisconnectSubscriber` disconnects only the slow subscriber in all cases. |
|
||||
| `MxGateway:Events:ReplayBufferCapacity` | `1024` | Maximum number of events retained per session in the replay ring buffer, used to re-deliver events a returning subscriber missed (reconnect/reattach). The oldest retained event is evicted once this count is exceeded. `0` disables replay retention. |
|
||||
| `MxGateway:Events:ReplayRetentionSeconds` | `300` | Maximum age, in seconds, of an event retained in the replay ring buffer. Entries older than this are evicted regardless of capacity. `0` disables age-based eviction. |
|
||||
| `MxGateway:Events:MaxSparseArrayLength` | `1000000` | Maximum `total_length` a sparse-array write (`MxSparseArray`) may declare. A write above this cap is rejected with `InvalidArgument` before the full array is materialized, guarding against a single write forcing a multi-GB allocation. Must be between `1` and `Array.MaxLength`. |
|
||||
|
||||
`QueueCapacity` must be greater than zero; it bounds each per-subscriber event
|
||||
`QueueCapacity` must be greater than zero and no greater than `int.MaxValue / 2`
|
||||
(the validator rejects a larger value so the derived `2 ×` staging bound cannot
|
||||
throw `OverflowException` at session creation); it bounds each per-subscriber event
|
||||
channel fed by the session's single event pump, and — at `2 ×` — the worker
|
||||
read loop's event staging channel, so a consumer that drains slower than its
|
||||
worker produces faults the session at a fixed ceiling instead of growing gateway
|
||||
@@ -257,6 +259,20 @@ When LDAP is enabled, `Server`, `SearchBase`, `ServiceAccountDn`,
|
||||
must be in range. See `glauth.md` for the shared dev instance and the
|
||||
dev→production hardening posture.
|
||||
|
||||
### Production hard-stops key on the exact environment name (SEC-35)
|
||||
|
||||
Both production hard-stops above — `Dashboard:DisableLogin` and the plaintext
|
||||
`Ldap:Transport=None` guard — fire only when `IHostEnvironment.IsProduction()` is
|
||||
true, i.e. `ASPNETCORE_ENVIRONMENT` is unset (it defaults to `Production`, which
|
||||
covers the NSSM-deployed hosts) or is set to the exact string `Production`. This
|
||||
is ASP.NET Core's environment-name convention. A host launched under any other
|
||||
name — `Staging`, `Prod`, or a custom label — keeps the permissive dev posture
|
||||
and these guards do **not** fire, by design (inverting to "anything but
|
||||
Development is production-like" would refuse to boot a legitimate permissive
|
||||
staging rig, e.g. one pointed at the plaintext shared GLAuth). A production-like
|
||||
deployment must therefore run with the literal `Production` environment name for
|
||||
the hard-stops to apply.
|
||||
|
||||
## Secrets Master Key
|
||||
|
||||
`${secret:...}` tokens in configuration — currently just
|
||||
@@ -377,7 +393,7 @@ model requires otherwise.
|
||||
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
|
||||
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
|
||||
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. |
|
||||
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). Set an **absolute** path — this option is bound by the shared `ZB.MOM.WW.GalaxyRepository` package (not by `GatewayOptions`), so the gateway validator does not enforce rooting on it; a relative value would resolve against the launch working directory (SEC-01). |
|
||||
| `MxGateway:Galaxy:SnapshotCachePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\galaxy-snapshot.json` on Windows, `/usr/share/MxGateway/galaxy-snapshot.json` or the container equivalent elsewhere) | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). `appsettings.json` no longer ships an explicit value (SEC-33): the option is bound by the shared `ZB.MOM.WW.GalaxyRepository` package, so the gateway supplies the `CommonApplicationData`-derived default when the bound value is blank and registers `GalaxyRepositoryOptionsValidator` to enforce that — when `PersistSnapshot` is `true` — the path is non-blank, valid, and **rooted on the host running the gateway** (`Path.IsPathRooted`, current OS). A bare filename or a foreign-platform literal fails startup instead of resolving against the launch working directory (SEC-01, SEC-33). |
|
||||
|
||||
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
|
||||
behavior.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gateway-side startup validation for the shared <see cref="GalaxyRepositoryOptions"/>. The
|
||||
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
|
||||
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
|
||||
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
|
||||
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a
|
||||
/// non-rooted value silently resolves against the launch working directory.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, GalaxyRepositoryOptions options)
|
||||
{
|
||||
if (!options.PersistSnapshot)
|
||||
{
|
||||
// Persistence disabled: the snapshot path is never used, so nothing to validate.
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.SnapshotCachePath))
|
||||
{
|
||||
builder.Add(
|
||||
"MxGateway:Galaxy:SnapshotCachePath is required when MxGateway:Galaxy:PersistSnapshot is true.");
|
||||
return;
|
||||
}
|
||||
|
||||
GatewayConfigPathRules.AddIfInvalidPath(
|
||||
options.SnapshotCachePath,
|
||||
"MxGateway:Galaxy:SnapshotCachePath must be a valid filesystem path.",
|
||||
builder);
|
||||
GatewayConfigPathRules.AddIfNotRooted(
|
||||
options.SnapshotCachePath,
|
||||
"MxGateway:Galaxy:SnapshotCachePath must be an absolute (rooted) path so the Galaxy snapshot never lands in the launch working directory.",
|
||||
builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Shared filesystem-path validation primitives used by more than one options validator
|
||||
/// (<see cref="GatewayOptionsValidator"/> and <see cref="GalaxyRepositoryOptionsValidator"/>).
|
||||
/// Both the auth credential store and the Galaxy snapshot are written by the running gateway
|
||||
/// process, so both must reject paths the host cannot use — the rules live here once so the two
|
||||
/// validators cannot drift.
|
||||
/// </summary>
|
||||
internal static class GatewayConfigPathRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Fails validation when <paramref name="value"/> is not an absolute (rooted) path <em>on the
|
||||
/// host running the validator</em>. Security-sensitive paths (the auth DB, the self-signed
|
||||
/// private key, the Galaxy snapshot) must be absolute: a non-rooted value silently resolves
|
||||
/// against the launch working directory, so the store moves with the CWD and can leak into the
|
||||
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
|
||||
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
|
||||
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject
|
||||
/// rather than auto-root; silent relocation of a credential store is worse than a boot error.
|
||||
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
|
||||
/// </summary>
|
||||
/// <param name="value">The configured path value.</param>
|
||||
/// <param name="message">The failure message to record when the value is not rooted.</param>
|
||||
/// <param name="builder">The validation builder accumulating failures.</param>
|
||||
public static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(value))
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails validation when <paramref name="value"/> is non-blank but not a syntactically valid
|
||||
/// filesystem path (as judged by <see cref="Path.GetFullPath(string)"/>). Blank values are the
|
||||
/// caller's required-field concern and pass here.
|
||||
/// </summary>
|
||||
/// <param name="value">The configured path value.</param>
|
||||
/// <param name="message">The failure message to record when the value is not a valid path.</param>
|
||||
/// <param name="builder">The validation builder accumulating failures.</param>
|
||||
public static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = Path.GetFullPath(value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,10 +292,23 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
// knob, not incorrect behavior.
|
||||
}
|
||||
|
||||
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
|
||||
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
|
||||
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
|
||||
// upper-bound pattern.
|
||||
private const int MaximumEventQueueCapacity = int.MaxValue / 2;
|
||||
|
||||
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
|
||||
{
|
||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
|
||||
|
||||
if (options.QueueCapacity > MaximumEventQueueCapacity)
|
||||
{
|
||||
builder.Add(
|
||||
$"MxGateway:Events:QueueCapacity must be less than or equal to {MaximumEventQueueCapacity} "
|
||||
+ "so the derived worker event-staging channel (2 x QueueCapacity) cannot overflow.");
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||
{
|
||||
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||
@@ -524,79 +537,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder.RequireThat(value >= 0, message);
|
||||
}
|
||||
|
||||
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
|
||||
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
|
||||
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
|
||||
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
|
||||
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
// Security-sensitive paths (the auth DB, the self-signed private key) must be absolute:
|
||||
// a non-rooted value silently resolves against the launch working directory, so the store
|
||||
// moves with the CWD and can leak into the source tree. Reject rather than auto-root —
|
||||
// silent relocation of a credential store is worse than a boot error. Blank is handled by
|
||||
// AddIfBlank; an empty value is not treated as non-rooted here.
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsRootedForAnyPlatform(value))
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
|
||||
/// not just the host running the validator. This matters on the macOS dev box, where the
|
||||
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
|
||||
/// that <see cref="Path.IsPathRooted(string)"/> reports as non-rooted on Unix. The intent of the
|
||||
/// rooting check is to reject bare filenames that resolve against the launch working directory,
|
||||
/// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS.
|
||||
/// </summary>
|
||||
private static bool IsRootedForAnyPlatform(string value)
|
||||
{
|
||||
// Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows).
|
||||
if (Path.IsPathRooted(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host.
|
||||
if (value.Length >= 3
|
||||
&& char.IsLetter(value[0])
|
||||
&& value[1] == ':'
|
||||
&& (value[2] == '\\' || value[2] == '/'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Windows UNC path ("\\server\share") checked on a non-Windows host.
|
||||
return value.StartsWith(@"\\", StringComparison.Ordinal);
|
||||
}
|
||||
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
|
||||
|
||||
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = Path.GetFullPath(value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
}
|
||||
=> GatewayConfigPathRules.AddIfInvalidPath(value, message, builder);
|
||||
}
|
||||
|
||||
@@ -134,8 +134,29 @@ public static class GatewayApplication
|
||||
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||
|
||||
// The Galaxy package binds GalaxyRepositoryOptions but ships no validator or default for the
|
||||
// snapshot path (A2 handoff): the gateway owns both because it is the process that writes the
|
||||
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
|
||||
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
|
||||
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
|
||||
// byte-identical to the removed appsettings literal on Windows (SEC-33).
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
|
||||
{
|
||||
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"MxGateway",
|
||||
"galaxy-snapshot.json");
|
||||
}
|
||||
|
||||
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
|
||||
|
||||
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33).
|
||||
builder.Services.AddSingleton<
|
||||
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
|
||||
Configuration.GalaxyRepositoryOptionsValidator>();
|
||||
builder.Services.AddOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>().ValidateOnStart();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,15 +39,30 @@ public interface IApiKeyCacheInvalidator
|
||||
/// on every cache miss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Correctness on mutation is provided by two mechanisms: gateway-initiated revoke/rotate/delete
|
||||
/// call <see cref="Invalidate"/> directly (see <c>DashboardApiKeyManagementService</c>), and the
|
||||
/// short TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke issued by the
|
||||
/// separate <c>apikey</c> CLI process, whose in-memory cache is not this process's cache).
|
||||
/// Gateway-initiated revoke/rotate/delete call <see cref="Invalidate"/> directly (see
|
||||
/// <c>DashboardApiKeyManagementService</c>), which bumps a per-key generation counter <em>before</em>
|
||||
/// evicting so an in-flight verification that started under the old generation discards its own
|
||||
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
|
||||
/// take effect immediately" contract true even against a verify that was already in the inner library
|
||||
/// when the revoke landed (SEC-34 window 3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
|
||||
/// mutations — a direct DB edit, or a revoke issued by the separate <c>apikey</c> CLI process whose
|
||||
/// in-memory cache is not this process's cache — take effect only after the TTL elapses. (2) A key
|
||||
/// whose expiry passes while cached keeps authenticating for up to the TTL: expiry is enforced by the
|
||||
/// inner library verifier, which a cache hit never reaches, and the verification identity the library
|
||||
/// returns (<c>ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity</c>) carries no expiry timestamp, so
|
||||
/// the cache cannot cap an entry at the key's <c>ExpiresUtc</c>. Capping it requires the donor library
|
||||
/// to surface expiry on the verification identity (donor-library ask); until then the TTL bounds this
|
||||
/// window and it is intentionally kept short (<see cref="SecurityOptions.ApiKeyVerificationCacheSeconds"/>,
|
||||
/// default 15 s).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalidator
|
||||
{
|
||||
private const string CacheKeyPrefix = "mxgw:apikeyverif:";
|
||||
private const string TokenPrefix = "mxgw";
|
||||
|
||||
private readonly IApiKeyVerifier _inner;
|
||||
private readonly IMemoryCache _cache;
|
||||
@@ -58,6 +73,11 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _keyIdIndex =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
// keyId -> monotonic generation, bumped by Invalidate before it evicts. A VerifyAsync snapshots
|
||||
// the generation before calling the inner verifier and only caches if it is unchanged after the
|
||||
// inner call and again after the Set — closing the revoke-vs-in-flight-repopulation race.
|
||||
private readonly ConcurrentDictionary<string, long> _generations = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CachingApiKeyVerifier"/> class.</summary>
|
||||
/// <param name="inner">The wrapped verifier (the library verifier) reached on a cache miss.</param>
|
||||
/// <param name="cache">The shared memory cache.</param>
|
||||
@@ -105,20 +125,58 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Snapshot the key's generation BEFORE the inner verify so a concurrent Invalidate that runs
|
||||
// while we are in the library is detected below and its repopulation discarded.
|
||||
string? tokenKeyId = TryParseKeyId(authorizationHeader);
|
||||
long generationAtStart = tokenKeyId is null ? 0 : ReadGeneration(tokenKeyId);
|
||||
|
||||
ApiKeyVerification result = await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Succeeded && result.Identity is not null)
|
||||
{
|
||||
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _ttl,
|
||||
});
|
||||
IndexCacheKey(result.Identity.KeyId, cacheKey);
|
||||
TryCacheSuccess(cacheKey, result, tokenKeyId, generationAtStart);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Caches a successful verification unless the key was invalidated while the inner verify was in
|
||||
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the
|
||||
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
|
||||
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
|
||||
private void TryCacheSuccess(
|
||||
string cacheKey,
|
||||
ApiKeyVerification result,
|
||||
string? tokenKeyId,
|
||||
long generationAtStart)
|
||||
{
|
||||
// If Invalidate bumped the generation while we were verifying, the identity we hold may be
|
||||
// stale — do not cache it.
|
||||
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string keyId = result.Identity!.KeyId;
|
||||
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _ttl,
|
||||
});
|
||||
IndexCacheKey(keyId, cacheKey);
|
||||
|
||||
// Set-then-recheck: an Invalidate that landed between the pre-Set check and IndexCacheKey
|
||||
// would have missed this entry (it was not yet indexed). If the generation has moved, evict
|
||||
// the just-written entry so the revoke still takes effect immediately.
|
||||
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
|
||||
{
|
||||
_cache.Remove(cacheKey);
|
||||
if (_keyIdIndex.TryGetValue(keyId, out ConcurrentDictionary<string, byte>? set))
|
||||
{
|
||||
set.TryRemove(cacheKey, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string keyId)
|
||||
{
|
||||
@@ -127,6 +185,10 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
return;
|
||||
}
|
||||
|
||||
// Bump the generation BEFORE evicting so an in-flight VerifyAsync that snapshotted the old
|
||||
// generation refuses to cache (or self-evicts) its now-stale repopulation.
|
||||
_generations.AddOrUpdate(keyId, 1, static (_, current) => current + 1);
|
||||
|
||||
if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary<string, byte>? cacheKeys))
|
||||
{
|
||||
foreach (string cacheKey in cacheKeys.Keys)
|
||||
@@ -136,6 +198,37 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
}
|
||||
}
|
||||
|
||||
private long ReadGeneration(string keyId) => _generations.TryGetValue(keyId, out long generation)
|
||||
? generation
|
||||
: 0;
|
||||
|
||||
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
|
||||
// the same split the authorization interceptor does. Returns null for a header this cache cannot
|
||||
// attribute to a key id (in which case the generation race-guard is simply not applied).
|
||||
private static string? TryParseKeyId(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authorizationHeader))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
|
||||
const string bearer = "Bearer ";
|
||||
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: header;
|
||||
|
||||
string[] parts = token.ToString().Split('_');
|
||||
if (parts.Length < 3
|
||||
|| !string.Equals(parts[0], TokenPrefix, StringComparison.Ordinal)
|
||||
|| parts[1].Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
private void IndexCacheKey(string keyId, string cacheKey)
|
||||
{
|
||||
ConcurrentDictionary<string, byte> set = _keyIdIndex.GetOrAdd(
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"MxGateway": {
|
||||
"Authentication": {
|
||||
"Mode": "ApiKey",
|
||||
"SqlitePath": "C:\\ProgramData\\MxGateway\\gateway-auth.db",
|
||||
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
||||
"RunMigrationsOnStartup": true
|
||||
},
|
||||
@@ -82,8 +81,7 @@
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
"CommandTimeoutSeconds": 60,
|
||||
"DashboardRefreshIntervalSeconds": 30,
|
||||
"PersistSnapshot": true,
|
||||
"SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json"
|
||||
"PersistSnapshot": true
|
||||
},
|
||||
"Alarms": {
|
||||
"Enabled": true,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gateway-owned validation of the shared <see cref="GalaxyRepositoryOptions"/> (SEC-33): the Galaxy
|
||||
/// package binds the options but ships no validator, so the gateway rejects a persisted snapshot
|
||||
/// whose path is blank, invalid, or non-rooted on the running host, and supplies a rooted per-OS
|
||||
/// default when the shipped config leaves the path blank.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies a blank snapshot path with persistence enabled fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenPersistSnapshotAndPathBlank()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new() { PersistSnapshot = true, SnapshotCachePath = "" };
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Galaxy:SnapshotCachePath"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-rooted snapshot path with persistence enabled fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenPersistSnapshotAndPathNotRooted()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
PersistSnapshot = true,
|
||||
SnapshotCachePath = "galaxy-snapshot.json",
|
||||
};
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Galaxy:SnapshotCachePath") && f.Contains("rooted"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a blank path passes when persistence is disabled (the path is never used).</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenPersistSnapshotDisabled()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new() { PersistSnapshot = false, SnapshotCachePath = "" };
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a valid, host-rooted snapshot path with persistence enabled passes.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenPersistSnapshotAndPathRooted()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
PersistSnapshot = true,
|
||||
SnapshotCachePath = Path.Combine(Path.GetTempPath(), "galaxy-snapshot.json"),
|
||||
};
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the gateway supplies a rooted per-OS default when the shipped config leaves
|
||||
/// SnapshotCachePath blank, so the removed appsettings literal is not needed and validation
|
||||
/// passes on any host (SEC-33). Resolving the options triggers the registered validator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_DefaultsBlankSnapshotCachePathToRootedDefault()
|
||||
{
|
||||
using WebApplication app = GatewayApplication.Build([]);
|
||||
|
||||
GalaxyRepositoryOptions options =
|
||||
app.Services.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value;
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(options.SnapshotCachePath));
|
||||
Assert.True(Path.IsPathRooted(options.SnapshotCachePath));
|
||||
}
|
||||
}
|
||||
@@ -598,6 +598,61 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies rooting is host-meaningful (SEC-33): a Windows drive-qualified literal fails on a
|
||||
/// Unix host (where it is not rooted) rather than being blessed and written as a junk-named
|
||||
/// relative file. On Windows the same literal is genuinely rooted and passes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_SqlitePath_RootingIsHostMeaningful()
|
||||
{
|
||||
const string windowsLiteral = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
GatewayOptions options = CloneWithAuthentication(
|
||||
ValidOptions(),
|
||||
new AuthenticationOptions { SqlitePath = windowsLiteral });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails (GWC-24 rider): the value
|
||||
/// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenQueueCapacityExceedsUpperBound()
|
||||
{
|
||||
GatewayOptions options = CloneWithEvents(
|
||||
ValidOptions(),
|
||||
new EventOptions { QueueCapacity = (int.MaxValue / 2) + 1 });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Events:QueueCapacity"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary).</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenQueueCapacityAtUpperBound()
|
||||
{
|
||||
GatewayOptions options = CloneWithEvents(
|
||||
ValidOptions(),
|
||||
new EventOptions { QueueCapacity = int.MaxValue / 2 });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
|
||||
@@ -80,8 +81,13 @@ public sealed class GatewayApplicationTests
|
||||
public async Task Build_MapsMetricsEndpoint()
|
||||
{
|
||||
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||
// started-host test must avoid a fixed port to prevent a bind collision.
|
||||
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
|
||||
// started-host test must avoid a fixed port to prevent a bind collision. Starting the host
|
||||
// eagerly opens the auth SQLite store; the shipped config no longer carries a SqlitePath, so
|
||||
// override it to a writable temp path (the code default resolves under an unwritable
|
||||
// /usr/share on macOS). See SEC-33.
|
||||
using TempDatabaseDirectory authDir = TempDatabaseDirectory.Create(nameof(GatewayApplicationTests));
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
["--urls=http://127.0.0.1:0", $"--MxGateway:Authentication:SqlitePath={authDir.DatabasePath()}"]);
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
@@ -258,9 +264,13 @@ public sealed class GatewayApplicationTests
|
||||
string expectedFailure)
|
||||
{
|
||||
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||
// WebApplication-building test must avoid a fixed port to prevent a bind collision.
|
||||
// WebApplication-building test must avoid a fixed port to prevent a bind collision. Override
|
||||
// the auth SqlitePath to a writable temp path: startup opens the store before the injected
|
||||
// misconfiguration is validated on some paths, and the code-default path is unwritable on
|
||||
// macOS (SEC-33).
|
||||
using TempDatabaseDirectory authDir = TempDatabaseDirectory.Create(nameof(GatewayApplicationTests));
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
|
||||
[$"--{key}={value}", "--urls=http://127.0.0.1:0", $"--MxGateway:Authentication:SqlitePath={authDir.DatabasePath()}"]);
|
||||
|
||||
OptionsValidationException exception = await Assert.ThrowsAsync<OptionsValidationException>(
|
||||
() => app.StartAsync());
|
||||
|
||||
@@ -35,6 +35,11 @@ public sealed class GatewayTlsBootstrapTests
|
||||
Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", "https://127.0.0.1:0");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"MxGateway__Tls__SelfSignedCertPath", Path.Combine(certDir, "gw.pfx"));
|
||||
// Starting the host opens the auth SQLite store; the shipped config no longer ships a
|
||||
// SqlitePath and the code default is unwritable on macOS (/usr/share), so pin it to the
|
||||
// writable temp dir. See SEC-33.
|
||||
Environment.SetEnvironmentVariable(
|
||||
"MxGateway__Authentication__SqlitePath", Path.Combine(certDir, "gateway-auth.db"));
|
||||
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await app.StartAsync();
|
||||
@@ -53,6 +58,8 @@ public sealed class GatewayTlsBootstrapTests
|
||||
{
|
||||
Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", null);
|
||||
Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", null);
|
||||
Environment.SetEnvironmentVariable("MxGateway__Authentication__SqlitePath", null);
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(certDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,36 @@ public sealed class CachingApiKeyVerifierTests
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-34 window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> that lands while a
|
||||
/// verification is in flight in the inner library must discard that verification's repopulation,
|
||||
/// so the very next request re-verifies (revoke takes effect immediately, not after the TTL).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation()
|
||||
{
|
||||
GatedVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30));
|
||||
|
||||
// Begin a verification and wait until it is parked inside the inner verifier.
|
||||
Task<ApiKeyVerification> inFlight = verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
await inner.Entered;
|
||||
|
||||
// Revoke while the verify is still awaiting the inner library.
|
||||
((IApiKeyCacheInvalidator)verifier).Invalidate("operator01");
|
||||
|
||||
// Release the inner verifier; the in-flight call completes but must NOT cache its result.
|
||||
inner.Release();
|
||||
ApiKeyVerification result = await inFlight;
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
// The follow-up request finds no cached entry and reaches the inner verifier again.
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
|
||||
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
|
||||
@@ -196,6 +226,43 @@ public sealed class CachingApiKeyVerifierTests
|
||||
}
|
||||
}
|
||||
|
||||
// A verifier that parks inside VerifyAsync until Release() is called, so a test can interleave an
|
||||
// Invalidate with an in-flight verification.
|
||||
private sealed class GatedVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
private readonly TaskCompletionSource _entered =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private readonly TaskCompletionSource _release =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the number of times <see cref="VerifyAsync"/> has been called.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Completes once the first call has entered and parked in the inner verifier.</summary>
|
||||
public Task Entered => _entered.Task;
|
||||
|
||||
/// <summary>Unparks the first (gated) call.</summary>
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
/// <summary>Records the call; the first call parks on the gate, later calls return immediately.</summary>
|
||||
/// <param name="authorizationHeader">The authorization header presented by the caller.</param>
|
||||
/// <param name="ct">A token to observe for cancellation.</param>
|
||||
/// <returns>The fixed verification result.</returns>
|
||||
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
bool first = CallCount == 0;
|
||||
CallCount++;
|
||||
if (first)
|
||||
{
|
||||
_entered.TrySetResult();
|
||||
await _release.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeStore : IApiKeyStore
|
||||
{
|
||||
/// <summary>Gets the number of times <see cref="MarkUsedAsync"/> has been called.</summary>
|
||||
|
||||
Reference in New Issue
Block a user