feat(auth)!: OtOpcUa canonical control-plane roles + config-DB migration (Task 1.7)

Standardize the control-plane admin role VALUES on the canonical six
(ZB.MOM.WW.Auth CanonicalRole). OtOpcUa uses four:
  ConfigViewer   -> Viewer
  ConfigEditor   -> Designer
  FleetAdmin     -> Administrator
  DriverOperator -> Operator   (appsettings-only string role)

This is a rename, not a permission change: enforcement semantics are
preserved (whoever could deploy/administer/operate before still can).

- AdminRole enum members renamed (persisted as string names via
  HasConversion<string>); RoleGrants.razor dropdown default updated.
- EF DATA migration CanonicalizeAdminRoles rewrites existing
  LdapGroupRoleMapping.Role rows old->new (Up) and back (Down); schema /
  model snapshot byte-identical (no pending model changes).
- Enforcement role STRINGS canonicalized:
  * Security policies keep their NAMES ("DriverOperator"/"FleetAdmin")
    but require canonical roles: RequireRole("Operator","Administrator")
    and RequireRole("Administrator").
  * Deployments.razor [Authorize(Roles="Administrator,Designer")].
  * DevStub now grants "Administrator"; LdapOptions/doc-comment examples
    canonicalized.
- Data-plane authorization (NodePermissions/NodeAcl/IPermissionEvaluator/
  TriePermissionEvaluator/UserAuthorizationState) UNTOUCHED.
- New CanonicalAdminRolesTests pins canonical claim values end-to-end and
  the real registered policies; existing role-string tests updated.
This commit is contained in:
Joseph Doherty
2026-06-02 07:30:00 -04:00
parent 8ba289f975
commit c1619d95f5
16 changed files with 2063 additions and 97 deletions
@@ -64,10 +64,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
["Security:Jwt:Issuer"] = "otopcua-test",
["Security:Jwt:Audience"] = "otopcua-test",
// GroupToRole baseline bound onto LdapOptions: the production
// OtOpcUaGroupRoleMapper resolves "ConfigViewer" from the LDAP group
// OtOpcUaGroupRoleMapper resolves "Viewer" from the LDAP group
// "ReadOnly". This exercises the real mapper path — the stub no longer
// pre-populates roles, so ConfigViewer can only come from the mapper.
["Security:Ldap:GroupToRole:ReadOnly"] = "ConfigViewer",
// pre-populates roles, so Viewer can only come from the mapper.
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
}).Build();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
@@ -206,13 +206,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
public async Task Login_merges_db_role_grant_into_claims()
{
// StubLdapAuthService returns Groups ["ReadOnly"] with empty Roles (the real production
// shape). The mapper resolves the appsettings baseline "ReadOnly" → ConfigViewer, then a
// system-wide DB row maps "ReadOnly" → FleetAdmin, so the merged set is both.
// shape). The mapper resolves the appsettings baseline "ReadOnly" → Viewer, then a
// system-wide DB row maps "ReadOnly" → Administrator, so the merged set is both.
_roleMappings.Rows.Add(new LdapGroupRoleMapping
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
Role = AdminRole.Administrator,
IsSystemWide = true,
ClusterId = null,
});
@@ -229,8 +229,8 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
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
roles.ShouldContain("Viewer"); // appsettings baseline preserved
roles.ShouldContain("Administrator"); // DB grant merged in
}
/// <summary>Fail-closed (review I3): when the role mapper throws on the real production path
@@ -315,7 +315,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
Role = AdminRole.Administrator,
IsSystemWide = true,
ClusterId = null,
});
@@ -370,7 +370,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
[Fact]
public async Task Token_payload_uses_canonical_zb_claim_keys()
{
// Arrange — the appsettings baseline maps group "ReadOnly" → role "ConfigViewer", so alice
// Arrange — the appsettings baseline maps group "ReadOnly" → role "Viewer", so alice
// (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT.
// No extra DB rows needed — the appsettings GroupToRole entry is always active.
var client = NewClient();
@@ -401,7 +401,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
// Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role").
// This pins the role-key contract: any future rename of RoleClaimType will be caught here.
// The appsettings "ReadOnly" → "ConfigViewer" mapping guarantees alice has ≥1 role.
// The appsettings "ReadOnly" → "Viewer" mapping guarantees alice has ≥1 role.
payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue(
$"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " +
$"(\"{JwtTokenService.RoleClaimType}\")");