Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
T
Joseph Doherty 0329fa4d4b 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
2026-07-31 12:57:51 -04:00

132 lines
5.7 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Production <see cref="IOpcUaUserAuthenticator"/> for the machine-to-machine data plane (#520):
/// the client presents the static username <c>apikey</c> with an
/// <c>otua_&lt;keyId&gt;_&lt;secret&gt;</c> credential as the password. Replaces the LDAP adapter.
/// </summary>
/// <remarks>
/// <para>
/// <b>This runs inline on session activation.</b> The SDK raises <c>ImpersonateUser</c>
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
/// stall one request — it serializes every session activation server-wide. That is why
/// verification is a single indexed lookup plus one HMAC, and why there is no directory call,
/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys
/// over the LDAP bind this replaces.
/// </para>
/// <para>
/// <b>Fail closed, and say little.</b> Every rejection returns the same opaque message. The
/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key"
/// from "wrong secret" from "key disabled" and use the server as an oracle.
/// </para>
/// </remarks>
public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
/// <summary>
/// Returned for every failure mode, deliberately uninformative. Public so a test can assert that
/// the failure modes really are indistinguishable, rather than restating the literal and passing
/// even if the implementation started differentiating them.
/// </summary>
public const string DenyMessage = "Invalid API key.";
private readonly IApiKeyStore _store;
private readonly IOptions<ApiKeyOptions> _options;
private readonly ILogger<ApiKeyOpcUaUserAuthenticator> _log;
private readonly TimeProvider _time;
/// <summary>Initializes a new instance of the <see cref="ApiKeyOpcUaUserAuthenticator"/> class.</summary>
/// <param name="store">The API key source.</param>
/// <param name="options">The <c>Security:ApiKeys</c> options.</param>
/// <param name="log">The logger.</param>
/// <param name="time">Time provider, injected so expiry is testable.</param>
public ApiKeyOpcUaUserAuthenticator(
IApiKeyStore store,
IOptions<ApiKeyOptions> options,
ILogger<ApiKeyOpcUaUserAuthenticator> log,
TimeProvider? time = null)
{
_store = store;
_options = options;
_log = log;
_time = time ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, CancellationToken ct)
{
var opts = _options.Value;
// The username carries no information, but check it rather than ignore it: a client sending the
// wrong username gets an explicit denial it can diagnose, instead of "any username works".
if (!string.Equals(username, opts.Username, StringComparison.Ordinal))
{
_log.LogInformation(
"ApiKeyAuth: denied — username {Presented} is not the configured {Expected}",
username, opts.Username);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret))
{
_log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key");
return OpcUaUserAuthResult.Deny(DenyMessage);
}
var now = _time.GetUtcNow().UtcDateTime;
ApiKeyLookup? lookup;
try
{
lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// The contract forbids throwing: an exception here escapes into the SDK's session-activation
// path and surfaces as BadInternalError. A store outage denies, it does not crash.
_log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (lookup is null)
{
_log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
bool verified;
try
{
verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash);
}
catch (ArgumentException ex)
{
// Thrown only when the pepper is unset. This is a deployment error, not a bad credential —
// log it loudly, because otherwise it presents as "every key is rejected" with no clue why.
_log.LogError(ex,
"ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
ApiKeyOptions.SectionName);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (!verified)
{
_log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal);
_log.LogInformation(
"ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
}
}