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,67 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the interim server-wide role set for an authenticated principal from its
|
||||
/// <see cref="NodeAcl"/> grants (#520, stage 2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists at all.</b> The two live authorization gates —
|
||||
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> on Value writes and
|
||||
/// <see cref="OpcUaDataPlaneRoles.AlarmAck"/> on the Part 9 alarm methods — check a server-wide
|
||||
/// role set on the session identity. Replacing LDAP with API keys therefore has to keep
|
||||
/// producing that role set, or the cutover would lock every client out of writes and alarm acks
|
||||
/// long before per-node ACL evaluation lands in stage 5.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Only <see cref="NodeAclScopeKind.Cluster"/> grants confer a role. This is deliberate,
|
||||
/// and it under-grants.</b> The obvious implementation — union the permission flags across all of
|
||||
/// a principal's grants — would let a grant authored narrowly ("WriteOperate on Equipment X")
|
||||
/// confer the server-wide WriteOperate role, because nothing here can enforce scope yet. That
|
||||
/// fails in the dangerous direction: the operator's narrow intent silently becomes broad
|
||||
/// authority. Restricting to cluster-scoped grants means a narrow grant does nothing until
|
||||
/// stage 5 can honour its scope, which is no worse than today (where every ACL row does
|
||||
/// nothing) and never exceeds what was authored.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Scope-narrower-than-cluster grants are not an error and are not logged as one — they are
|
||||
/// simply inert until enforcement lands. The AdminUI banner is where an operator is told that.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ApiKeyInterimRoleMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a principal's cluster-scoped grants to the server-wide data-plane roles.
|
||||
/// </summary>
|
||||
/// <param name="grants">Candidate ACL rows. Rows for other principals are ignored.</param>
|
||||
/// <param name="principal">The authenticated principal.</param>
|
||||
/// <returns>The distinct role names to attach to the session identity; never null.</returns>
|
||||
public static IReadOnlyList<string> Map(IEnumerable<NodeAcl> grants, string principal)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(grants);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(principal)) return Array.Empty<string>();
|
||||
|
||||
var effective = NodePermissions.None;
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
if (grant.ScopeKind != NodeAclScopeKind.Cluster) continue;
|
||||
if (!string.Equals(grant.Principal, principal, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
effective |= grant.PermissionFlags;
|
||||
}
|
||||
|
||||
var roles = new List<string>(2);
|
||||
if (effective.HasFlag(NodePermissions.WriteOperate)) roles.Add(OpcUaDataPlaneRoles.WriteOperate);
|
||||
|
||||
// AlarmAcknowledge is the flag an operator authors; AlarmAck is the role the Part 9 method gates
|
||||
// check. The gate covers acknowledge/confirm/shelve as one role, so Confirm/Shelve do not map
|
||||
// separately — mapping them would imply a granularity the gate does not have.
|
||||
if (effective.HasFlag(NodePermissions.AlarmAcknowledge)) roles.Add(OpcUaDataPlaneRoles.AlarmAck);
|
||||
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
|
||||
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyOptions
|
||||
{
|
||||
/// <summary>The configuration section name.</summary>
|
||||
public const string SectionName = "Security:ApiKeys";
|
||||
|
||||
/// <summary>
|
||||
/// The static username every key-bearing client presents. The username carries no information —
|
||||
/// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed
|
||||
/// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent
|
||||
/// "any username works".
|
||||
/// </summary>
|
||||
public string Username { get; set; } = "apikey";
|
||||
|
||||
/// <summary>
|
||||
/// Server-side pepper for the stored key hashes. Supply via <c>Security__ApiKeys__Pepper</c> or a
|
||||
/// <c>${secret:}</c> reference — <b>never commit it</b>. Rotating it invalidates every existing key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a
|
||||
/// working-looking deployment with none of the protection it believes it has, so
|
||||
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
||||
/// </remarks>
|
||||
public string Pepper { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Source of API keys and their ACL grants for OPC UA session activation (#520).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused
|
||||
/// admin+driver node reads the config database directly; a <b>driver-only</b> node has no ConfigDb
|
||||
/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact.
|
||||
/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4.
|
||||
/// </remarks>
|
||||
public interface IApiKeyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Looks up an enabled, unexpired key by its public id, together with the ACL grants naming that
|
||||
/// key's principal.
|
||||
/// </summary>
|
||||
/// <param name="apiKeyId">The public key id parsed from the presented credential.</param>
|
||||
/// <param name="utcNow">Current UTC time, for the expiry check.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The key and its principal's grants, or null when no usable key matches.</returns>
|
||||
Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>An API key resolved for authentication, with the ACL grants naming its principal.</summary>
|
||||
/// <param name="Key">The matched key row.</param>
|
||||
/// <param name="Grants">ACL rows whose principal matches <see cref="ApiKey.Principal"/>.</param>
|
||||
public sealed record ApiKeyLookup(ApiKey Key, IReadOnlyList<NodeAcl> Grants);
|
||||
@@ -339,7 +339,28 @@ if (hasDriver)
|
||||
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
|
||||
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||
|
||||
// #520 stage 2 — the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key
|
||||
// (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.
|
||||
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
|
||||
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.
|
||||
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
|
||||
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.
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
||||
}
|
||||
|
||||
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
||||
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role
|
||||
/// mapping it feeds the two live authorization gates from.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyAuthenticationTests
|
||||
{
|
||||
private const string Pepper = "unit-test-pepper";
|
||||
private const string Principal = "scadabridge-dcl";
|
||||
|
||||
// ---------- interim role mapping ----------
|
||||
|
||||
[Fact]
|
||||
public void A_cluster_scoped_write_grant_confers_the_write_role()
|
||||
{
|
||||
var roles = ApiKeyInterimRoleMapper.Map(
|
||||
[Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal);
|
||||
|
||||
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_narrower_scoped_write_grant_confers_nothing_yet()
|
||||
{
|
||||
// THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until
|
||||
// stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into
|
||||
// server-wide write authority — the operator's narrow intent silently becoming broad. Under-
|
||||
// granting is the only safe reading while scope is unenforceable.
|
||||
foreach (var narrower in new[]
|
||||
{
|
||||
NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea,
|
||||
NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace,
|
||||
})
|
||||
{
|
||||
var roles = ApiKeyInterimRoleMapper.Map(
|
||||
[Grant(narrower, NodePermissions.WriteOperate)], Principal);
|
||||
|
||||
roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_grant_for_a_different_principal_is_ignored()
|
||||
{
|
||||
var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin);
|
||||
other.Principal = "someone-else";
|
||||
|
||||
ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing()
|
||||
{
|
||||
ApiKeyInterimRoleMapper
|
||||
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal)
|
||||
.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
|
||||
|
||||
// ReadOnly carries no gated permission — reads are ungated today, so it must confer no role
|
||||
// rather than quietly granting one.
|
||||
ApiKeyInterimRoleMapper
|
||||
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flags_union_across_several_cluster_grants()
|
||||
{
|
||||
var roles = ApiKeyInterimRoleMapper.Map(
|
||||
[
|
||||
Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
||||
Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
|
||||
], Principal);
|
||||
|
||||
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
|
||||
roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Principal_matching_is_case_insensitive_but_not_prefix_based()
|
||||
{
|
||||
ApiKeyInterimRoleMapper
|
||||
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant())
|
||||
.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
|
||||
|
||||
// A principal that merely starts with the granted name must not inherit it.
|
||||
ApiKeyInterimRoleMapper
|
||||
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------- authenticator ----------
|
||||
|
||||
[Fact]
|
||||
public async Task A_valid_key_authenticates_as_its_principal()
|
||||
{
|
||||
var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster);
|
||||
|
||||
var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, default);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe(Principal);
|
||||
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_wrong_username_is_denied()
|
||||
{
|
||||
var (presented, store) = SetUp();
|
||||
var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, default);
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically()
|
||||
{
|
||||
var (presented, store) = SetUp();
|
||||
var auth = Auth(store);
|
||||
|
||||
var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", default);
|
||||
var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default);
|
||||
var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default);
|
||||
var empty = await auth.AuthenticateUserNameAsync("apikey", "", default);
|
||||
|
||||
foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty })
|
||||
{
|
||||
r.Success.ShouldBeFalse();
|
||||
// Identical message for every failure mode: the server must not be usable as an oracle for
|
||||
// which half of the credential was wrong, or whether a key id exists at all.
|
||||
r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
|
||||
}
|
||||
|
||||
// ...and the valid one still works, so the above is not passing because everything is denied.
|
||||
(await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_store_that_throws_denies_rather_than_propagating()
|
||||
{
|
||||
// The interface contract forbids throwing — an exception escapes into the SDK session-activation
|
||||
// path and surfaces as BadInternalError instead of a clean rejection.
|
||||
var store = new ThrowingApiKeyStore();
|
||||
var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", default);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unset_pepper_denies_rather_than_authenticating_everything()
|
||||
{
|
||||
var (presented, store) = SetUp();
|
||||
var auth = new ApiKeyOpcUaUserAuthenticator(
|
||||
store,
|
||||
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }),
|
||||
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
(await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclId = $"ACL-{scope}-{flags}",
|
||||
ClusterId = "MAIN",
|
||||
Principal = Principal,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE",
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
|
||||
store,
|
||||
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
|
||||
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
private static (string Presented, FakeApiKeyStore Store) SetUp(
|
||||
NodePermissions flags = NodePermissions.None,
|
||||
NodeAclScopeKind scope = NodeAclScopeKind.Cluster)
|
||||
{
|
||||
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
|
||||
var key = new ApiKey
|
||||
{
|
||||
ApiKeyId = apiKeyId,
|
||||
Name = "test key",
|
||||
SecretHash = hash,
|
||||
Principal = Principal,
|
||||
Enabled = true,
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
};
|
||||
var grants = flags == NodePermissions.None ? [] : new List<NodeAcl> { Grant(scope, flags) };
|
||||
return (presented, new FakeApiKeyStore(key, grants));
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList<NodeAcl> grants) : IApiKeyStore
|
||||
{
|
||||
public string KeyId => key.ApiKeyId;
|
||||
|
||||
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
||||
Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
|
||||
? new ApiKeyLookup(key, grants)
|
||||
: null);
|
||||
}
|
||||
|
||||
private sealed class ThrowingApiKeyStore : IApiKeyStore
|
||||
{
|
||||
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("store is down");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user