feat(acl): API-key identity replaces LDAP on the OPC UA data plane (#520)
Stage 2a. Clients present the static username "apikey" with an
otua_<keyId>_<secret> credential as the password; the key resolves to a
principal. LDAP registrations are deliberately RETAINED for AdminUI login —
only IOpcUaUserAuthenticator moves, which is what D2 asked for.
The load-bearing decision here is what roles a key gets. The two live gates
(WriteOperate on Value writes, AlarmAck on Part 9 methods) check a SERVER-WIDE
role set, so the cutover has to keep producing one or it would lock every client
out of writes long before per-node evaluation lands in stage 5.
ApiKeyInterimRoleMapper therefore maps ACL grants to roles, but ONLY
cluster-scoped ones. The obvious version — union the permission flags across all
of a principal's grants — would let a grant authored narrowly ("WriteOperate on
Equipment X") confer server-wide write authority, because nothing in the pipeline
can enforce scope yet. That fails in the dangerous direction: the operator's
narrow intent silently becomes broad. Restricting to cluster scope under-grants
instead, which is no worse than today (where every ACL row does nothing) and can
never exceed what was authored. Falsified: deleting the scope check reds exactly
A_narrower_scoped_write_grant_confers_nothing_yet and nothing else.
Verification is one indexed lookup plus one HMAC, with no network I/O at all.
That matters because the SDK raises ImpersonateUser synchronously while holding a
SessionManager-wide event lock, so this path does not stall one request — it
serializes every session activation server-wide. It is the main practical gain
over the LDAP bind it replaces.
Every rejection returns one opaque message so the server cannot be used as an
oracle for whether a key id exists or which half of a credential was wrong; a
test asserts the failure modes really are indistinguishable AND that a valid key
still succeeds, so it cannot pass by denying everything.
Caught while wiring: OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as
a REQUIRED ctor parameter, so leaving a driver-only node unregistered is not a
quiet fallback to deny-all — it fails DI and the node does not boot.
NullOpcUaUserAuthenticator is now registered explicitly there. A driver-only node
has no ConfigDb since Phase 4 and so has no key source until the artifact-backed
store lands in stage 4; it denies UserName tokens until then, which is the safe
direction, and anonymous sessions are unaffected.
LdapOpcUaUserAuthenticator is now registered nowhere and is dead code. Deleting
it plus LdapOpcUaUserAuthenticatorTests / LdapAuthResilienceTests /
LdapGlauthOutageLiveTests is stage 2b, kept separate because those cover a
live-gated resilience feature (hard wall-clock bound, bounded concurrency,
directory-outage circuit) that exists precisely BECAUSE the LDAP bind ran inline
on session activation — a constraint the key path does not have. That deserves
its own commit, not a footnote in this one.
Anonymous-as-a-grantable-principal (D3) is also stage 2b.
Host 37, Security 89, OpcUaServer 364 green.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IApiKeyStore"/> reading the config database directly (#520, stage 2). Usable on a node
|
||||
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot
|
||||
/// be registered there and such a node has no key source until the artifact-backed store lands in
|
||||
/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to
|
||||
/// <see cref="OpcUaServer.Security.NullOpcUaUserAuthenticator"/>, which denies every attempt. That is
|
||||
/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until
|
||||
/// enforcement lands in stages 5–6) are unaffected.
|
||||
/// </remarks>
|
||||
public sealed class ConfigDbApiKeyStore : IApiKeyStore
|
||||
{
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
|
||||
/// <param name="factory">The config DB context factory.</param>
|
||||
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Enabled + expiry are filtered in SQL rather than in memory so a disabled key never
|
||||
// materialises into a process where something might later treat it as a match.
|
||||
var key = await db.ApiKeys
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
k => k.ApiKeyId == apiKeyId
|
||||
&& k.Enabled
|
||||
&& (k.ExpiresUtc == null || k.ExpiresUtc > utcNow),
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (key is null) return null;
|
||||
|
||||
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
|
||||
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
|
||||
var grants = await db.NodeAcls
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Principal == key.Principal
|
||||
&& a.ScopeKind == NodeAclScopeKind.Cluster)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ApiKeyLookup(key, grants);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user