dd939d3c97
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
311 lines
12 KiB
C#
311 lines
12 KiB
C#
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();
|
|
}
|
|
|
|
// ---------- anonymous as a normal principal (D3) ----------
|
|
|
|
[Fact]
|
|
public async Task Anonymous_resolves_through_the_same_grant_path_as_a_key()
|
|
{
|
|
var (_, store) = SetUp();
|
|
store.PrincipalGrants.Add(new NodeAcl
|
|
{
|
|
NodeAclId = "ACL-anon",
|
|
ClusterId = "MAIN",
|
|
Principal = OpcUaPrincipals.Anonymous,
|
|
ScopeKind = NodeAclScopeKind.Cluster,
|
|
PermissionFlags = NodePermissions.WriteOperate,
|
|
});
|
|
|
|
var result = await Auth(store).AuthenticateAnonymousAsync(default);
|
|
|
|
result.Success.ShouldBeTrue();
|
|
result.DisplayName.ShouldBe(OpcUaPrincipals.Anonymous);
|
|
// The point of D3: anonymous can be granted WRITE, not merely tolerated as read-only.
|
|
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Anonymous_with_no_grants_is_admitted_with_no_roles()
|
|
{
|
|
// Today's behaviour preserved: it connects, and every role-gated operation denies.
|
|
var (_, store) = SetUp();
|
|
|
|
var result = await Auth(store).AuthenticateAnonymousAsync(default);
|
|
|
|
result.Success.ShouldBeTrue();
|
|
result.Roles.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Anonymous_denies_when_grants_cannot_be_read()
|
|
{
|
|
// Deliberately NOT admit-with-no-roles. That is the pre-#520 behaviour, and defaulting to it
|
|
// exactly when we cannot read what anonymous may do stops being harmless once reads are gated.
|
|
var result = await Auth(new ThrowingApiKeyStore()).AuthenticateAnonymousAsync(default);
|
|
|
|
result.Success.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_key_may_not_authenticate_as_the_reserved_anonymous_principal()
|
|
{
|
|
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
|
|
var key = new ApiKey
|
|
{
|
|
ApiKeyId = apiKeyId,
|
|
Name = "sneaky",
|
|
SecretHash = hash,
|
|
Principal = OpcUaPrincipals.Anonymous,
|
|
Enabled = true,
|
|
CreatedUtc = DateTime.UtcNow,
|
|
};
|
|
|
|
var result = await Auth(new FakeApiKeyStore(key, [])).AuthenticateUserNameAsync("apikey", presented, default);
|
|
|
|
// Otherwise a credentialled client silently inherits whatever anonymous was granted.
|
|
result.Success.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_default_interface_implementation_admits_anonymous_with_no_roles()
|
|
{
|
|
// NullOpcUaUserAuthenticator (driver-only nodes, pre-boot) must keep the pre-#520 behaviour
|
|
// rather than denying anonymous outright, or turning this on would black out those nodes.
|
|
// Interface-typed on purpose: a default interface implementation is only reachable through the
|
|
// interface, which is how OpcUaApplicationHost holds it.
|
|
IOpcUaUserAuthenticator sut = NullOpcUaUserAuthenticator.Instance;
|
|
var result = await sut.AuthenticateAnonymousAsync(default);
|
|
|
|
result.Success.ShouldBeTrue();
|
|
result.Roles.ShouldBeEmpty();
|
|
}
|
|
|
|
// ---------- 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;
|
|
|
|
/// <summary>Grants returned for a bare principal lookup (the anonymous path).</summary>
|
|
public List<NodeAcl> PrincipalGrants { get; } = [];
|
|
|
|
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
|
Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
|
|
? new ApiKeyLookup(key, grants)
|
|
: null);
|
|
|
|
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<NodeAcl>>(
|
|
[.. PrincipalGrants.Where(g => string.Equals(g.Principal, principal, StringComparison.OrdinalIgnoreCase))]);
|
|
}
|
|
|
|
private sealed class ThrowingApiKeyStore : IApiKeyStore
|
|
{
|
|
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
|
|
throw new InvalidOperationException("store is down");
|
|
|
|
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
|
|
throw new InvalidOperationException("store is down");
|
|
}
|
|
}
|