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
This commit is contained in:
@@ -168,6 +168,85 @@ public sealed class ApiKeyAuthenticationTests
|
||||
(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()
|
||||
@@ -207,15 +286,25 @@ public sealed class ApiKeyAuthenticationTests
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+30
-5
@@ -68,19 +68,44 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
|
||||
}
|
||||
|
||||
/// <summary>Verifies anonymous identity tokens are passed through to the SDK default handler.</summary>
|
||||
/// <summary>
|
||||
/// An admitted anonymous session now carries a role-bearing identity (#520).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This test previously asserted the opposite — that anonymous fell through untouched to the SDK
|
||||
/// default identity. That was the pre-#520 behaviour and is exactly what changed: anonymous is a
|
||||
/// normal principal whose grants come from ACL rows, so it must arrive at the downstream gates as
|
||||
/// the same <see cref="RoleCarryingUserIdentity"/> a key produces, or every gate would need an
|
||||
/// anonymous-shaped branch.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default()
|
||||
public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity()
|
||||
{
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
// Handler leaves anonymous tokens untouched — no identity, no validation error.
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.ShouldBeNull();
|
||||
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator");
|
||||
args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
|
||||
// Still no UserName authentication — the anonymous path must not consult the credential seam.
|
||||
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the UserName authenticator");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With anonymous disallowed the session is rejected outright rather than admitted with no roles.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed()
|
||||
{
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(
|
||||
authenticator, args, NullLogger<object>.Instance, allowAnonymous: false);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
|
||||
|
||||
@@ -88,6 +88,20 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
|
||||
userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With <c>OpcUa:AllowAnonymous</c> false the Anonymous policy is not advertised, so a client is
|
||||
/// refused at discovery rather than only at activation — but UserName survives, which is what
|
||||
/// makes the server key-only rather than unreachable (#520).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildUserTokenPolicies_withholds_anonymous_when_disallowed()
|
||||
{
|
||||
var tokens = OpcUaApplicationHost.BuildUserTokenPolicies(allowAnonymous: false).ToList();
|
||||
|
||||
tokens.ShouldNotContain(t => t.TokenType == UserTokenType.Anonymous);
|
||||
tokens.ShouldContain(t => t.TokenType == UserTokenType.UserName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that StartAsync populates ServerConfiguration with all enabled security profiles.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user