Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
T
Joseph Doherty dd939d3c97 feat(acl): anonymous becomes a normal grantable principal (#520 D3)
Anonymous sessions used to short-circuit in HandleImpersonation — "let the SDK's
default validation stand" — so they always connected and always carried no roles.
Now admission and authority are separated:

  admission  -> OpcUa:AllowAnonymous, the HOST's decision (it owns token policies)
  authority  -> the authenticator, resolving the reserved principal "anonymous"
                through the SAME grant path a key's principal goes through

So anonymous can be granted WRITE, not merely tolerated as read-only, and it
arrives at the downstream gates as the same RoleCarryingUserIdentity a key
produces — which is the real payoff: no gate carries an anonymous-shaped branch,
so there is no bypass path to get wrong.

AllowAnonymous defaults to TRUE on purpose. Defaulting it false would silently
deny every client without a key the moment this deploys, including ScadaBridge's
Data Connection Layer in another repo. Making keys mandatory is a rollout
decision, not an upgrade side effect. When false, the Anonymous token policy is
withheld so a client is refused at DISCOVERY rather than only at activation, and
activation is rejected independently in case a client attempts a token type the
endpoint never advertised.

Two fail-closed choices, both falsified:
- A failure to read anonymous's grants DENIES rather than admitting with no
  roles. Admitting is the pre-#520 behaviour, and defaulting to it exactly when
  the server cannot determine what anonymous may do stops being harmless once
  reads are gated. Making it admit reds Anonymous_denies_when_grants_cannot_be_read.
- A key may not authenticate as the reserved "anonymous" principal, or a
  credentialled client silently inherits whatever anonymous was granted.

AuthenticateAnonymousAsync is a DEFAULT interface implementation returning
admit-with-no-roles, i.e. exactly the pre-#520 behaviour, so
NullOpcUaUserAuthenticator and every test double keep working rather than being
forced to reimplement it — important because driver-only nodes run the Null one
and would otherwise black out. Note a default interface method is only reachable
through the interface-typed reference, which is how OpcUaApplicationHost holds it.

An existing test asserted the old fall-through and correctly went red; it is
rewritten to pin the new contract rather than deleted, with the reason recorded.

OpcUaServer 366, Host 42 green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 13:20:14 -04:00

177 lines
8.1 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
// Explicit: an unqualified Configuration.* inside this namespace binds to Host.Configuration, not to
// the ZB.MOM.WW.OtOpcUa.Configuration assembly.
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
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);
}
if (string.Equals(lookup.Key.Principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
{
// Defence in depth against a key authored with the reserved principal (authoring blocks it).
// Allowing it would let a credentialled client silently inherit whatever anonymous was
// granted, and vice versa — two identities that are meant to be distinct sharing one grant.
_log.LogError(
"ApiKeyAuth: denied — key {ApiKeyId} uses the reserved principal '{Principal}'",
apiKeyId, OpcUaPrincipals.Anonymous);
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);
}
/// <inheritdoc />
/// <remarks>
/// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
/// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
/// Whether anonymous may connect at all is the host's decision
/// (<c>OpcUa:AllowAnonymous</c>) and is settled before this is called.
/// </remarks>
public async Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
{
IReadOnlyList<NodeAcl> grants;
try
{
grants = await _store.FindGrantsAsync(OpcUaPrincipals.Anonymous, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing
// it precisely when we could not read what anonymous is allowed to do is the wrong default:
// once reads are gated (stage 6) a no-roles admission stops being harmless.
_log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants");
return OpcUaUserAuthResult.Deny("Anonymous access is unavailable.");
}
var roles = ApiKeyInterimRoleMapper.Map(grants, OpcUaPrincipals.Anonymous);
_log.LogInformation(
"ApiKeyAuth: anonymous admitted with roles [{Roles}]",
roles.Count == 0 ? "none" : string.Join(",", roles));
return OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, roles);
}
}