test(acl): docker-dev pepper + live gate for the API-key identity path (#520)

Adds the Security__ApiKeys__Pepper anchor to docker-dev, merged into all six host
nodes — a driver node needs it to verify keys, an admin node to mint them, and a
mismatch is a silent authentication failure rather than a startup error.
Committed dev value under the same exception as the LDAP service account and the
deploy key.

Live gate on the rig (migrations applied, both central nodes rebuilt one at a
time to avoid the simultaneous-cold-start split):

- Migrator applied the rename + ApiKey migrations; NodeAcl.Principal exists,
  LdapGroup is gone, ApiKey created.
- sp_ComputeGenerationDiff EXECUTES and returns rows. Without the re-emit in the
  rename migration this call would have failed with Invalid column name, so the
  trap is confirmed closed rather than argued closed.
- A key whose hash was computed by an INDEPENDENT Python HMAC implementation
  authenticates against the C# verifier. That validates the hashing contract
  (otua_<keyId>_<secret>, peppered HMAC-SHA256, base64) far better than
  round-tripping my own code would.
- Wrong secret -> 0x80210000 BadIdentityTokenRejected.
- Key with a cluster-scoped WriteOperate grant -> log shows "granted key gate-key
  as principal gate-principal with roles [WriteOperate]" and the WRITE SUCCEEDS.
- Same node, anonymous -> "admitted with roles [none]", write denied
  0x801F0000 BadUserAccessDenied.
- D3's distinctive claim proven: adding a cluster-scoped WriteOperate row for
  principal 'anonymous' flips the SAME anonymous session to "admitted with roles
  [WriteOperate]" and its write succeeds. No code change, no restart.
- OpcUa__AllowAnonymous=false -> anonymous fails with "Endpoint does not support
  the user identity type provided", i.e. refused at DISCOVERY because the token
  policy is withheld, not merely rejected at activation; the keyed read still
  works, so the server is key-only rather than unreachable.

NOT verified: the /apikeys Razor page itself. The Chrome extension was not
connected, and this repo has no bUnit, so the page is exactly the kind of surface
that has bitten before (#505). The service beneath it is unit-tested and the
whole path below it is now live-proven, but the page needs a /run pass before
this can be called done.

Rig restored: override removed, gate key and both ACL rows deleted, anonymous
reads Good again.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 13:47:13 -04:00
parent 087a969ee6
commit 54b06aab33
+14 -6
View File
@@ -137,6 +137,14 @@ x-secrets-env: &secrets-env
# AllowInsecure=false ⇒ OptionsValidationException), so a node that carries no LDAP config crashes at boot.
# This anchor is merged into every host node's environment (via `<<: [*secrets-env, *ldap-env]`). Central
# still also lists the block inline for readability; the explicit keys win over the merge, same values.
# #520 — OPC UA data-plane API keys. The pepper is merged into every host node: a driver node needs it
# to VERIFY keys, an admin node to MINT them, and a mismatch between the two is a silent authentication
# failure. Committed dev value only, under the same committed-dev-secret exception as the LDAP service
# account and the deploy key below it; a real deployment supplies Security__ApiKeys__Pepper via env or
# a ${secret:} reference and NEVER commits it. Rotating it invalidates every existing key.
x-apikeys-env: &apikeys-env
Security__ApiKeys__Pepper: "docker-dev-apikey-pepper-not-for-production"
x-ldap-env: &ldap-env
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
@@ -268,7 +276,7 @@ services:
sql: { condition: service_healthy }
migrator: { condition: service_completed_successfully }
environment:
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
@@ -393,7 +401,7 @@ services:
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
environment:
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
@@ -548,7 +556,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
# mem_reservation are inherited from the *otopcua-host anchor.
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
@@ -649,7 +657,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -737,7 +745,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -809,7 +817,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"