fix(management): canonicalize role-mapping casing at write path, closing UI/actor case split (arch-review C5)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:52:45 -04:00
parent 83e09fc210
commit 8221ee797e
3 changed files with 58 additions and 20 deletions
@@ -2103,28 +2103,27 @@ public class ManagementActor : ReceiveActor
return await repo.GetAllMappingsAsync();
}
// An LDAP-group mapping's Role is a free string on the
// wire (CLI/API), so reject anything outside the canonical Roles.All set at the single
// server-side write path. A non-canonical role never functioned (no policy or authz
// check matches it), so rejecting it removes a silent-misconfiguration footgun rather
// than changing behaviour. Membership is checked case-insensitively to match the rest
// of the actor's role comparisons; the existing case-sensitivity asymmetry between the
// UI RequireClaim policies and the ManagementActor check is a separately-deferred change
// and is deliberately NOT altered here — the stored value's verbatim casing is preserved.
private static void ValidateMappingRole(string role)
// An LDAP-group mapping's Role is a free string on the wire (CLI/API), so both
// validate membership AND canonicalize casing at the single server-side write path.
// The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
// compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
// row stored as "deployer" passes every actor gate but fails every UI policy — a
// CLI-works/UI-403s split-brain. Canonicalizing here closes the asymmetry (arch-review
// C5; closes the deferral formerly documented here). A role outside Roles.All never
// functioned (no policy or authz check matches it), so rejecting it removes a
// silent-misconfiguration footgun rather than changing behaviour.
private static string CanonicalizeMappingRole(string role)
{
if (!Roles.All.Contains(role, StringComparer.OrdinalIgnoreCase))
{
throw new ManagementCommandException(
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
return canonical ?? throw new ManagementCommandException(
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
private static async Task<object?> HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)
{
ValidateMappingRole(cmd.Role);
var role = CanonicalizeMappingRole(cmd.Role);
var repo = sp.GetRequiredService<ISecurityRepository>();
var mapping = new LdapGroupMapping(cmd.LdapGroupName, cmd.Role);
var mapping = new LdapGroupMapping(cmd.LdapGroupName, role);
await repo.AddMappingAsync(mapping);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
@@ -2133,12 +2132,12 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd, string user)
{
ValidateMappingRole(cmd.Role);
var role = CanonicalizeMappingRole(cmd.Role);
var repo = sp.GetRequiredService<ISecurityRepository>();
var mapping = await repo.GetMappingByIdAsync(cmd.MappingId)
?? throw new ManagementCommandException($"RoleMapping with ID {cmd.MappingId} not found.");
mapping.LdapGroupName = cmd.LdapGroupName;
mapping.Role = cmd.Role;
mapping.Role = role;
await repo.UpdateMappingAsync(mapping);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);