feat(security): merge DB-backed LDAP role grants into login claims
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user