From 54b06aab3375e14d60a4dcac49097815e5320fd9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 13:47:13 -0400 Subject: [PATCH] test(acl): docker-dev pepper + live gate for the API-key identity path (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__, 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 --- docker-dev/docker-compose.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 0dfe2a73..89b9af91 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -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}"