diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs index 6212a79d..b5ff6f95 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs @@ -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 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 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()? + .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 { 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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs index 59d893de..3e84cf4d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -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(); + // 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(_roleMappings); }); web.Configure(app => { @@ -174,6 +182,79 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime token!.Split('.').Length.ShouldBe(3); } + /// 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. + [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(Ct); + var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!); + roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved + roles.ShouldContain("FleetAdmin"); // DB grant merged in + } + + /// When the DB role-map lookup throws, sign-in still succeeds with the appsettings + /// baseline roles — a DB hiccup must never block login. + [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(Ct); + var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!); + roles.ShouldContain("ConfigViewer"); // baseline still present + } + + /// Extracts the "Role" claim values from a JWT's payload segment. + private static IReadOnlyList 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()!]; + } + /// Tests that logout clears the cookie. [Fact] public async Task Logout_clears_the_cookie() @@ -260,4 +341,38 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime Error: "Invalid username or password")); } } + + /// + /// In-memory stub for the DB-backed group→role mapping service. Tests seed + /// and the login handler merges any system-wide row whose group the user holds. Set + /// to simulate a DB outage and exercise the baseline-roles fallback. + /// + private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService + { + public List Rows { get; } = []; + public bool Throws { get; set; } + + /// Returns seeded rows whose group matches one of . + public Task> GetByGroupsAsync( + IEnumerable ldapGroups, CancellationToken cancellationToken) + { + if (Throws) throw new InvalidOperationException("simulated DB outage"); + var groups = new HashSet(ldapGroups, StringComparer.OrdinalIgnoreCase); + IReadOnlyList matched = + [.. Rows.Where(r => groups.Contains(r.LdapGroup))]; + return Task.FromResult(matched); + } + + /// Not exercised by these tests. + public Task> ListAllAsync(CancellationToken cancellationToken) => + throw new NotSupportedException(); + + /// Not exercised by these tests. + public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + /// Not exercised by these tests. + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => + throw new NotSupportedException(); + } }