fix(security): pin the cached-key expiry guard, meter cache_hit after it, and close two wiring gaps (review fixes)

This commit is contained in:
Joseph Doherty
2026-08-20 10:18:52 -04:00
parent 14094e3a18
commit 62515f3279
5 changed files with 116 additions and 5 deletions
@@ -781,3 +781,39 @@ Two notes for whoever runs it:
cannot resurrect a clock-expired key.
4. New public members added purely so wiring could be pinned by reference: `DataPlaneAuthorizer.Clock`,
`BoundedApiKeyStore.Inner` / `.Clock`.
---
## Review outcome — FIX-FIRST, applied 2026-08-20
Coordinator verdict on the branch was **FIX-FIRST** with four items. All four applied; Host.Tests and
OpcUaServer.Tests rerun green.
1. **[Important] `IsExpired` had no test — the review was right, and the gap was mine.** T3's execution
note called the guard a "fail-closed refinement" and shipped it with *no case that fails without it*:
deleting `&& !IsExpired(cached, utcNow)` left all 14 `BoundedApiKeyStoreTests` green. That is the
unchanged-observable trap — the guard's whole effect is invisible unless a cached entry outlives its
key's `ExpiresUtc` **inside** the TTL, which no existing case constructed.
Added **`A_cached_key_that_expires_by_the_clock_is_not_served`**: key expiring at `start + 2 s`, TTL
60 s, first `FindAsync` non-null (and now cached); flip the inner store to `Revoked`; advance the fake
clock 5 s — past `ExpiresUtc`, far inside the TTL; assert `null` and `inner.Calls == 2`. The
`Revoked` flip is deliberate: it makes "served from cache" and "re-read" differ in the **answer**, not
only in a call count, so a build that re-read and ignored the result cannot pass either.
**RED confirmed by falsification:** with the guard removed, exactly **1 failed / 14 passed** — only the
new case — then restored, 15/15 green.
2. **[Minor] `cache_hit` was metered before the expiry guard could reject.** `TryServe` counted a hit for
an entry that then fell through to a store read, so the counter over-reported. The `Add` moved to the
two call sites, **after** the guard, behind a `MeterCacheHit()` helper; `TryServe` is now purely a
lookup. This matters operationally — `cache_hit` is the ratio §"Bounding the key store" tells an
operator to watch.
3. **[Minor] Added `A_node_with_neither_role_registers_the_deny_all_authenticator`** — asserts the
`IOpcUaUserAuthenticator` singleton **instance** is `NullOpcUaUserAuthenticator.Instance` and that no
`IApiKeyStore` is registered. It closes the third branch of the role conditional, which the guard suite
had left unpinned even though that branch is the one whose absence stops a node booting.
4. **[Minor] `StartRevalidationTimer` now disposes before overwriting.** `StartAsync` can run again when a
host restarts the service around the same instance, and the overwritten field would have left the
previous timer armed with nothing left to dispose it — the dispose-before-overwrite rule the driver
reconnect paths already carry.
**Verification after the fixes:** solution build 0 errors · Host.Tests **134 passed / 0 failed** (was 132)
· OpcUaServer.Tests **493 passed / 1 skipped**. T11 remains `pending` for the orchestrator.