feat(security): merge DB-backed LDAP role grants into login claims

This commit is contained in:
Joseph Doherty
2026-05-29 09:51:22 -04:00
parent 042f3b6a65
commit f210f09caf
2 changed files with 134 additions and 1 deletions
@@ -6,6 +6,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -40,6 +43,7 @@ public static class AuthEndpoints
private static async Task<IResult> LoginAsync(
HttpContext http,
ILdapAuthService ldap,
ILdapGroupRoleMappingService roleMappings,
CancellationToken ct)
{
var isForm = http.Request.HasFormContentType;
@@ -83,13 +87,27 @@ public static class AuthEndpoints
return Results.Redirect("/login" + qs);
}
IReadOnlyList<string> roles = result.Roles;
try
{
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
roles = RoleMapper.Merge(result.Roles, dbRows);
}
catch (Exception ex)
{
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
http.RequestServices.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, result.Username ?? username),
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
};
foreach (var role in result.Roles)
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
@@ -12,6 +12,9 @@ using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -29,6 +32,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
{
private IHost _host = null!;
private TestServer _server = null!;
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
private static CancellationToken Ct => TestContext.Current.CancellationToken;
@@ -58,6 +62,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}).Build();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
// The login handler now resolves the DB role-map service via DI to merge
// DB-backed grants on top of the appsettings baseline. Register the stub so
// the minimal-API handler can be constructed; tests drive its rows.
services.AddSingleton<ILdapGroupRoleMappingService>(_roleMappings);
});
web.Configure(app =>
{
@@ -174,6 +182,79 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
token!.Split('.').Length.ShouldBe(3);
}
/// <summary>A system-wide DB row for a group the user holds grants an extra role on top of
/// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.</summary>
[Fact]
public async Task Login_merges_db_role_grant_into_claims()
{
// StubLdapAuthService returns Groups ["ReadOnly"], baseline Roles ["ConfigViewer"].
// A system-wide row maps "ReadOnly" → FleetAdmin, so the merged set is both.
_roleMappings.Rows.Add(new LdapGroupRoleMapping
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
IsSystemWide = true,
ClusterId = null,
});
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var tokenResp = await client.SendAsync(tokenReq, Ct);
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
roles.ShouldContain("FleetAdmin"); // DB grant merged in
}
/// <summary>When the DB role-map lookup throws, sign-in still succeeds with the appsettings
/// baseline roles — a DB hiccup must never block login.</summary>
[Fact]
public async Task Login_when_db_role_map_throws_falls_back_to_baseline_roles()
{
_roleMappings.Throws = true;
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
// Login proceeds despite the simulated DB outage.
loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var tokenResp = await client.SendAsync(tokenReq, Ct);
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // baseline still present
}
/// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
private static IReadOnlyList<string> JwtRoleClaims(string jwt)
{
var payloadSegment = jwt.Split('.')[1];
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var json = JsonDocument.Parse(Convert.FromBase64String(padded));
if (!json.RootElement.TryGetProperty("Role", out var roleProp)) return [];
return roleProp.ValueKind == JsonValueKind.Array
? [.. roleProp.EnumerateArray().Select(e => e.GetString()!)]
: [roleProp.GetString()!];
}
/// <summary>Tests that logout clears the cookie.</summary>
[Fact]
public async Task Logout_clears_the_cookie()
@@ -260,4 +341,38 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
Error: "Invalid username or password"));
}
}
/// <summary>
/// In-memory stub for the DB-backed group→role mapping service. Tests seed <see cref="Rows"/>
/// and the login handler merges any system-wide row whose group the user holds. Set
/// <see cref="Throws"/> to simulate a DB outage and exercise the baseline-roles fallback.
/// </summary>
private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
{
public List<LdapGroupRoleMapping> Rows { get; } = [];
public bool Throws { get; set; }
/// <summary>Returns seeded rows whose group matches one of <paramref name="ldapGroups"/>.</summary>
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
{
if (Throws) throw new InvalidOperationException("simulated DB outage");
var groups = new HashSet<string>(ldapGroups, StringComparer.OrdinalIgnoreCase);
IReadOnlyList<LdapGroupRoleMapping> matched =
[.. Rows.Where(r => groups.Contains(r.LdapGroup))];
return Task.FromResult(matched);
}
/// <summary>Not exercised by these tests.</summary>
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <summary>Not exercised by these tests.</summary>
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <summary>Not exercised by these tests.</summary>
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
}