feat(acl): keys + ACLs reach driver-only nodes via the artifact (#520 stage 4)
A driver-only node has had no ConfigDb since per-cluster mesh Phase 4, so it registered NullOpcUaUserAuthenticator and denied every UserName token. Its keys now ride the deployment artifact instead. - ConfigComposer snapshots ApiKeys alongside the NodeAcls it already carried. Only the peppered HMAC ships, never a secret, and the pepper is node config that is deliberately not in the artifact. - DeploymentArtifact.ParseDataPlaneAuthority decodes both node-side. Grants are scoped to the node's own cluster; keys are not, because ApiKey carries no ClusterId — a key is a fleet-wide credential and the ACL rows are what bind it. - OpcUaPublishActor installs them on DataPlaneAuthorityState on every apply, BEFORE its plan.IsEmpty early-return: an artifact that only revokes a key produces an empty address-space plan, so feeding after that return would leave the revoked key working until an unrelated structural change redeployed. - ArtifactApiKeyStore serves them to session activation; a node with a ConfigDb keeps ConfigDbApiKeyStore, whose per-request read is what makes revocation immediate there. - OtOpcUaNodeManager gains DataPlaneAuthority — the cluster identity blocker #4 named. NodeScope.ClusterId is required and the evaluator fails closed on a mismatch, so until now no scope it built could have evaluated. Nothing reads it until stages 5/6. The generation is a node-local COUNTER driven by the RevisionHash, not derived from its bits. PermissionTrieCache treats the highest GenerationId as current, so a generation taken from hash bytes would install a lower number about half the time and the cache would go on serving the previous deployment's ACLs, silently. Sessions are not stamped with it yet — that belongs with the first seam that reads a UserAuthorizationState, in stage 5, rather than being built now and consulted by nothing. Live-gated on docker-dev against site-a-1 (driver-only, no ConfigDb): key authenticates, wrong secret denied, both halves of the revocation asymmetry observed, and a SITE-A grant confirmed not to reach site-b-1. Key minted with an independent Python HMAC so the verifier was checked against something other than itself. Rig left clean. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -351,12 +351,31 @@ if (hasDriver)
|
||||
// (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP
|
||||
// registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP.
|
||||
// Only IOpcUaUserAuthenticator moves.
|
||||
// #520 stage 4 — the node's data-plane authorization inputs (keys + ACL grants + cluster + auth
|
||||
// generation), refreshed by OpcUaPublishActor from every applied artifact. Registered on any
|
||||
// driver-role node because that is what serves an OPC UA data plane; the two interfaces resolve to
|
||||
// the SAME instance, since the writer and the readers must see one snapshot.
|
||||
if (hasDriver)
|
||||
{
|
||||
builder.Services.AddSingleton<DataPlaneAuthorityState>();
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
|
||||
}
|
||||
else
|
||||
{
|
||||
// The SOURCE half must always resolve — OtOpcUaServerHostedService takes it as a required ctor
|
||||
// parameter, and the built-in DI container does not fall back to a default value for a parameter
|
||||
// whose service is unregistered; it throws and the node fails to start. Same trap, and the same
|
||||
// fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional
|
||||
// (nothing publishes artifacts on a node with no driver role).
|
||||
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
|
||||
}
|
||||
|
||||
if (hasAdmin)
|
||||
{
|
||||
// ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin.
|
||||
// A driver-only node therefore has no key source until the artifact-backed store lands in
|
||||
// stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token.
|
||||
// Safe direction, and anonymous sessions are unaffected.
|
||||
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
||||
// on the very next request rather than on the next deployment. Preferred wherever it is available,
|
||||
// including on a fused admin+driver node.
|
||||
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||
|
||||
@@ -365,11 +384,19 @@ if (hasDriver)
|
||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
||||
}
|
||||
else if (hasDriver)
|
||||
{
|
||||
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
|
||||
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
|
||||
// token — safe, but it meant no machine client could authenticate against a site node at all.
|
||||
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes
|
||||
// IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a
|
||||
// quiet fallback to deny-all — it fails DI and the node does not start at all.
|
||||
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
|
||||
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
|
||||
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user