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,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