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.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
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.Jwt;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ public static class AuthEndpoints
|
|||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
|
ILdapGroupRoleMappingService roleMappings,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var isForm = http.Request.HasFormContentType;
|
var isForm = http.Request.HasFormContentType;
|
||||||
@@ -83,13 +87,27 @@ public static class AuthEndpoints
|
|||||||
return Results.Redirect("/login" + qs);
|
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>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||||
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? 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));
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
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.Endpoints;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
private IHost _host = null!;
|
private IHost _host = null!;
|
||||||
private TestServer _server = null!;
|
private TestServer _server = null!;
|
||||||
|
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
|
||||||
|
|
||||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
@@ -58,6 +62,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
}).Build();
|
}).Build();
|
||||||
services.AddOtOpcUaAuth(configuration);
|
services.AddOtOpcUaAuth(configuration);
|
||||||
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
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 =>
|
web.Configure(app =>
|
||||||
{
|
{
|
||||||
@@ -174,6 +182,79 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
token!.Split('.').Length.ShouldBe(3);
|
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>
|
/// <summary>Tests that logout clears the cookie.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Logout_clears_the_cookie()
|
public async Task Logout_clears_the_cookie()
|
||||||
@@ -260,4 +341,38 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
Error: "Invalid username or password"));
|
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