0329fa4d4b
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
132 lines
5.7 KiB
C#
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_<keyId>_<secret></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);
|
|
}
|
|
}
|