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:
@@ -51,6 +51,29 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; set; } = "pki";
|
||||
|
||||
/// <summary>
|
||||
/// Whether anonymous sessions may connect at all (#520). When <see langword="false"/> the
|
||||
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
|
||||
/// must present an API key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Defaults to <see langword="true"/> deliberately, which preserves today's behaviour.</b>
|
||||
/// Flipping the default would silently deny every client that has no key the moment this
|
||||
/// code deploys — including ScadaBridge's Data Connection Layer, which consumes this address
|
||||
/// space from another repo. Turning it off is the deployment-time decision that makes keys
|
||||
/// mandatory, and it belongs in the rollout once keys have been issued.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
|
||||
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> rows naming it,
|
||||
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
|
||||
/// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole
|
||||
/// address space, because reads carry no authorization check yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool AllowAnonymous { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
|
||||
/// descriptor per profile and clients choose at session open. Default = all three
|
||||
@@ -228,7 +251,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger);
|
||||
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous);
|
||||
|
||||
/// <summary>
|
||||
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
|
||||
@@ -256,11 +279,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
internal static void HandleImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
bool allowAnonymous = true)
|
||||
{
|
||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
||||
{
|
||||
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewIdentity is not UserNameIdentityToken token)
|
||||
{
|
||||
// Anonymous + X509 tokens — let the SDK's default validation stand.
|
||||
// X509 tokens — let the SDK's default validation stand.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -309,6 +339,66 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
token.UserName, string.Join(",", result.Roles));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
|
||||
/// <c>anonymous</c> principal has been granted (#520).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Before #520 this path simply returned, leaving the SDK's default identity in place: anonymous
|
||||
/// always connected and carried no roles. Two things change. Anonymous can now be refused
|
||||
/// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it
|
||||
/// can be given read or write like any key. The identity attached is the same
|
||||
/// <see cref="RoleCarryingUserIdentity"/> the UserName path uses, so every downstream gate reads
|
||||
/// it identically and there is no anonymous-shaped branch in any of them.
|
||||
/// </remarks>
|
||||
private static void HandleAnonymousImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger,
|
||||
bool allowAnonymous,
|
||||
AnonymousIdentityToken token)
|
||||
{
|
||||
if (!allowAnonymous)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is disabled on this server.");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
|
||||
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail closed. An anonymous session whose grants could not be resolved must not fall through
|
||||
// to the SDK default identity, because that would silently ADMIT it — the pre-#520
|
||||
// behaviour — at exactly the moment we could not determine what it is allowed to do.
|
||||
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Anonymous access is denied.");
|
||||
return;
|
||||
}
|
||||
|
||||
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
|
||||
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
|
||||
OpcUaPrincipals.Anonymous,
|
||||
result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
|
||||
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
|
||||
@@ -350,7 +440,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
}
|
||||
foreach (var token in BuildUserTokenPolicies())
|
||||
foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
|
||||
{
|
||||
serverConfig.UserTokenPolicies.Add(token);
|
||||
}
|
||||
@@ -442,13 +532,20 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// UserName logins when none is registered.
|
||||
/// </summary>
|
||||
/// <returns>The Anonymous and UserName token policies.</returns>
|
||||
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
|
||||
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies(bool allowAnonymous = true)
|
||||
{
|
||||
yield return new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
// Withholding the policy is what makes the refusal visible to a client at DISCOVERY rather than
|
||||
// only at activation: it simply is not offered an anonymous option. HandleImpersonation still
|
||||
// rejects anonymous activation independently, because a client may attempt a token type the
|
||||
// endpoint never advertised.
|
||||
if (allowAnonymous)
|
||||
{
|
||||
PolicyId = "anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
yield return new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
{
|
||||
PolicyId = "anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
}
|
||||
yield return new UserTokenPolicy(UserTokenType.UserName)
|
||||
{
|
||||
PolicyId = "username_basic256sha256",
|
||||
|
||||
@@ -22,6 +22,43 @@ public interface IOpcUaUserAuthenticator
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
|
||||
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves what an <b>anonymous</b> session is allowed to do (#520). Anonymous is a normal
|
||||
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted
|
||||
/// read or write through ordinary ACL rows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is asked only AFTER the host has decided anonymous sessions are admissible at all
|
||||
/// (<c>OpcUaApplicationHostOptions.AllowAnonymous</c>). Admission is the host's decision;
|
||||
/// authority is this seam's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default implementation admits anonymous with <b>no roles</b>, which is exactly the
|
||||
/// behaviour before #520: an anonymous session connects, and every role-gated operation
|
||||
/// (write, alarm ack) denies. Implementors that can resolve grants override it; the rest
|
||||
/// keep the safe status quo rather than being forced to reimplement it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The roles to attach to the anonymous session, or a denial.</returns>
|
||||
Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct) =>
|
||||
Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
|
||||
public static class OpcUaPrincipals
|
||||
{
|
||||
/// <summary>
|
||||
/// The principal an anonymous session authenticates as. <c>NodeAcl</c> rows naming it grant
|
||||
/// anonymous clients permissions exactly as they would a key's principal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reserved: an API key must not be created with this principal, or a key would silently inherit
|
||||
/// whatever anonymous was granted (and vice versa). Enforced at authoring time.
|
||||
/// </remarks>
|
||||
public const string Anonymous = "anonymous";
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||
|
||||
Reference in New Issue
Block a user