Task #124 — Phase 6.2 multi-user authz interop matrix + close LdapGroups gap

The Phase 6.2 evaluator was wired but received no input in production:
RoleBasedIdentity (the IUserIdentity our LDAP path produces) implemented
IRoleBearer but not ILdapGroupsBearer, so AuthorizationGate.BuildSessionState
always returned null and the gate lax-mode-allowed every request. UserAuthResult
also never carried the resolved LDAP groups, only the role-mapped strings.

Closing the gap so the evaluator gets real data:

- UserAuthResult adds Groups alongside Roles. LdapUserAuthenticator now
  surfaces the raw RDN values (ReadOnly / WriteOperate / ...) it already
  collected during the directory query. Roles stay separate per decision #150
  (control-plane Admin role mapping vs data-plane NodeAcl key).
- RoleBasedIdentity implements ILdapGroupsBearer so AuthorizationGate sees
  the groups via the same seam unit tests already use.

ThreeUserInteropMatrixTests drives the closure end-to-end against the live
GLAuth dev directory:

- 5 distinct group memberships (readonly / writeop / writetune /
  writeconfig / alarmack) plus the multi-group admin user
- Each is bound through the real LdapUserAuthenticator
- Resolved groups feed an LdapBoundIdentity that goes through the strict-mode
  AuthorizationGate against a seeded TriePermissionEvaluator
- 31 InlineData rows assert the role × operation matrix; failures pinpoint
  the exact (user, op) cell

The remaining wire-level leg of #124 — a real OPC UA client driving UserName
tokens through an encrypted endpoint policy — still needs a deployment knob
and stays a manual cross-vendor smoke (#119 / #124 manual scope). The doc
audit note in admin-ui-phase-6-status.md is updated to reflect what's now
auto'd vs what stays manual.

33/33 new tests pass against live GLAuth; existing 270 non-LiveLdap tests
in Server.Tests still pass; Core.Tests 205/205, Admin.Tests 109/109. The 7
integration-test failures observed during this run pre-exist this commit
(NodeId-scheme regression from #134) and are tracked separately as #135.
This commit is contained in:
Joseph Doherty
2026-04-24 20:40:07 -04:00
parent d11d160395
commit 75c07149d4
5 changed files with 260 additions and 16 deletions

View File

@@ -125,7 +125,7 @@ public sealed class OtOpcUaServer : StandardServer
case AnonymousIdentityToken:
args.Identity = _anonymousRoles.Count == 0
? new UserIdentity() // anonymous, no roles — production default
: new RoleBasedIdentity("(anonymous)", "Anonymous", _anonymousRoles);
: new RoleBasedIdentity("(anonymous)", "Anonymous", _anonymousRoles, ldapGroups: []);
return;
case UserNameIdentityToken user:
@@ -139,7 +139,7 @@ public sealed class OtOpcUaServer : StandardServer
StatusCodes.BadUserAccessDenied,
"Invalid username or password ({0})", result.Error ?? "no detail");
}
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles, result.Groups);
return;
}
@@ -151,20 +151,24 @@ public sealed class OtOpcUaServer : StandardServer
}
/// <summary>
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
/// uses the stack's default.
/// Tiny UserIdentity carrier that preserves the resolved roles + LDAP groups so downstream
/// node managers can gate writes/reads via <c>session.Identity</c>. Implements both
/// <see cref="IRoleBearer"/> (control-plane: WriteAuthzPolicy + Admin role mapping) and
/// <see cref="ILdapGroupsBearer"/> (data-plane: <see cref="AuthorizationGate"/> evaluator).
/// Anonymous identity (no roles configured) still uses the stack's default UserIdentity.
/// </summary>
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer, ILdapGroupsBearer
{
public IReadOnlyList<string> Roles { get; }
public IReadOnlyList<string> LdapGroups { get; }
public string? Display { get; }
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles, IReadOnlyList<string> ldapGroups)
: base(userName, "")
{
Display = displayName;
Roles = roles;
LdapGroups = ldapGroups;
}
}

View File

@@ -10,7 +10,14 @@ public interface IUserAuthenticator
Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
}
public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnlyList<string> Roles, string? Error);
/// <param name="Success">True iff the bind succeeded and roles/groups were resolved.</param>
/// <param name="DisplayName">User display name from LDAP, or null on failure.</param>
/// <param name="Roles">Mapped OPC UA role names (Admin / control-plane consumption — see decision #150).</param>
/// <param name="Groups">Raw LDAP group names the user belongs to. Phase 6.2 data-path authorization
/// (NodeAcl evaluator) keys off this list directly, not Roles. Empty for anonymous / failed binds.</param>
/// <param name="Error">Human-readable failure reason, or null on success.</param>
public sealed record UserAuthResult(
bool Success, string? DisplayName, IReadOnlyList<string> Roles, IReadOnlyList<string> Groups, string? Error);
/// <summary>
/// Always-reject authenticator used when no security config is provided. Lets the server
@@ -19,5 +26,5 @@ public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnly
public sealed class DenyAllUserAuthenticator : IUserAuthenticator
{
public Task<UserAuthResult> AuthenticateAsync(string _, string __, CancellationToken ___)
=> Task.FromResult(new UserAuthResult(false, null, [], "UserName token not supported"));
=> Task.FromResult(new UserAuthResult(false, null, [], [], "UserName token not supported"));
}

View File

@@ -15,12 +15,12 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserA
public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (!options.Enabled)
return new UserAuthResult(false, null, [], "LDAP authentication disabled");
return new UserAuthResult(false, null, [], [], "LDAP authentication disabled");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return new UserAuthResult(false, null, [], "Credentials required");
return new UserAuthResult(false, null, [], [], "Credentials required");
if (!options.UseTls && !options.AllowInsecureLdap)
return new UserAuthResult(false, null, [],
return new UserAuthResult(false, null, [], [],
"Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");
try
@@ -84,17 +84,17 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserA
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new UserAuthResult(true, displayName, roles, null);
return new UserAuthResult(true, displayName, roles, groups, null);
}
catch (LdapException ex)
{
logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
return new UserAuthResult(false, null, [], "Invalid username or password");
return new UserAuthResult(false, null, [], [], "Invalid username or password");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
return new UserAuthResult(false, null, [], "Authentication error");
return new UserAuthResult(false, null, [], [], "Authentication error");
}
}