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:
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
// Explicit: an unqualified Configuration.* inside this namespace binds to Host.Configuration, not to
|
||||
// the ZB.MOM.WW.OtOpcUa.Configuration assembly.
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
@@ -120,6 +123,17 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
return OpcUaUserAuthResult.Deny(DenyMessage);
|
||||
}
|
||||
|
||||
if (string.Equals(lookup.Key.Principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Defence in depth against a key authored with the reserved principal (authoring blocks it).
|
||||
// Allowing it would let a credentialled client silently inherit whatever anonymous was
|
||||
// granted, and vice versa — two identities that are meant to be distinct sharing one grant.
|
||||
_log.LogError(
|
||||
"ApiKeyAuth: denied — key {ApiKeyId} uses the reserved principal '{Principal}'",
|
||||
apiKeyId, OpcUaPrincipals.Anonymous);
|
||||
return OpcUaUserAuthResult.Deny(DenyMessage);
|
||||
}
|
||||
|
||||
var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal);
|
||||
|
||||
_log.LogInformation(
|
||||
@@ -128,4 +142,35 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
|
||||
return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
|
||||
/// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
|
||||
/// Whether anonymous may connect at all is the host's decision
|
||||
/// (<c>OpcUa:AllowAnonymous</c>) and is settled before this is called.
|
||||
/// </remarks>
|
||||
public async Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<NodeAcl> grants;
|
||||
try
|
||||
{
|
||||
grants = await _store.FindGrantsAsync(OpcUaPrincipals.Anonymous, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing
|
||||
// it precisely when we could not read what anonymous is allowed to do is the wrong default:
|
||||
// once reads are gated (stage 6) a no-roles admission stops being harmless.
|
||||
_log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants");
|
||||
return OpcUaUserAuthResult.Deny("Anonymous access is unavailable.");
|
||||
}
|
||||
|
||||
var roles = ApiKeyInterimRoleMapper.Map(grants, OpcUaPrincipals.Anonymous);
|
||||
_log.LogInformation(
|
||||
"ApiKeyAuth: anonymous admitted with roles [{Roles}]",
|
||||
roles.Count == 0 ? "none" : string.Join(",", roles));
|
||||
|
||||
return OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, roles);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user