c1619d95f5
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.
156 lines
7.3 KiB
C#
156 lines
7.3 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
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.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
|
|
|
/// <summary>
|
|
/// Task 1.7 — control-plane admin roles are standardized on the canonical six
|
|
/// (<c>Viewer / Operator / Engineer / Designer / Deployer / Administrator</c>). OtOpcUa
|
|
/// uses four of them: ConfigViewer→Viewer, ConfigEditor→Designer, FleetAdmin→Administrator,
|
|
/// and the appsettings-only DriverOperator→Operator. These tests pin the canonical role
|
|
/// VALUES end-to-end (mapper output claims + the real registered authorization policies) and
|
|
/// prove enforcement semantics are preserved (whoever could deploy/administer/operate before
|
|
/// still can — it is a rename, not a permission change).
|
|
/// </summary>
|
|
public sealed class CanonicalAdminRolesTests
|
|
{
|
|
// --- (a) the mapper mints the CANONICAL role claim for each native group ----------------
|
|
|
|
[Theory]
|
|
[InlineData("Viewer")] // was ConfigViewer
|
|
[InlineData("Designer")] // was ConfigEditor
|
|
[InlineData("Administrator")] // was FleetAdmin
|
|
[InlineData("Operator")] // was DriverOperator (appsettings-only string role)
|
|
public async Task Mapper_yields_canonical_role_for_native_group(string canonicalRole)
|
|
{
|
|
// appsettings GroupToRole baseline carries the canonical value verbatim.
|
|
var mapper = BuildMapper(new Dictionary<string, string> { ["the-group"] = canonicalRole });
|
|
|
|
var result = await mapper.MapAsync(["the-group"], CancellationToken.None);
|
|
|
|
result.Roles.ShouldContain(canonicalRole);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AdminRole.Viewer, "Viewer")]
|
|
[InlineData(AdminRole.Designer, "Designer")]
|
|
[InlineData(AdminRole.Administrator, "Administrator")]
|
|
public async Task System_wide_db_row_role_renders_as_canonical_string(AdminRole role, string expected)
|
|
{
|
|
// The DB path stringifies the enum member name (row.Role.ToString()); renaming the enum
|
|
// members is what makes the persisted/emitted string canonical.
|
|
var mapper = BuildMapper(
|
|
new Dictionary<string, string>(),
|
|
new LdapGroupRoleMapping { LdapGroup = "g", Role = role, IsSystemWide = true });
|
|
|
|
var result = await mapper.MapAsync(["g"], CancellationToken.None);
|
|
|
|
result.Roles.ShouldContain(expected);
|
|
}
|
|
|
|
// --- (b)/(c) the REAL registered authorization policies enforce on the canonical values ---
|
|
|
|
[Fact]
|
|
public async Task Deployments_role_check_authorizes_Designer_and_Administrator()
|
|
{
|
|
// Deployments.razor uses [Authorize(Roles="Administrator,Designer")] — a direct role-string
|
|
// check (not a named policy). Reproduce it via RequireRole and prove both still pass.
|
|
var policy = new AuthorizationPolicyBuilder()
|
|
.RequireRole("Administrator", "Designer")
|
|
.Build();
|
|
var authz = BuildAuthorizationService();
|
|
|
|
(await authz.AuthorizeAsync(UserInRole("Designer"), policy)).Succeeded.ShouldBeTrue();
|
|
(await authz.AuthorizeAsync(UserInRole("Administrator"), policy)).Succeeded.ShouldBeTrue();
|
|
(await authz.AuthorizeAsync(UserInRole("Viewer"), policy)).Succeeded.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FleetAdmin_policy_authorizes_only_Administrator()
|
|
{
|
|
var authz = BuildAuthorizationService();
|
|
|
|
// RoleGrants.razor is gated by the "FleetAdmin" named policy → RequireRole("Administrator").
|
|
(await authz.AuthorizeAsync(UserInRole("Administrator"), "FleetAdmin")).Succeeded.ShouldBeTrue();
|
|
(await authz.AuthorizeAsync(UserInRole("Designer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
|
|
(await authz.AuthorizeAsync(UserInRole("Operator"), "FleetAdmin")).Succeeded.ShouldBeFalse();
|
|
(await authz.AuthorizeAsync(UserInRole("Viewer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DriverOperator_policy_authorizes_Operator_and_Administrator()
|
|
{
|
|
var authz = BuildAuthorizationService();
|
|
|
|
// DriverStatusPanel/pickers gate on the "DriverOperator" named policy →
|
|
// RequireRole("Operator","Administrator"). Operator (was DriverOperator) and Administrator
|
|
// (was FleetAdmin) both pass; a plain Viewer does not.
|
|
(await authz.AuthorizeAsync(UserInRole("Operator"), "DriverOperator")).Succeeded.ShouldBeTrue();
|
|
(await authz.AuthorizeAsync(UserInRole("Administrator"), "DriverOperator")).Succeeded.ShouldBeTrue();
|
|
(await authz.AuthorizeAsync(UserInRole("Viewer"), "DriverOperator")).Succeeded.ShouldBeFalse();
|
|
}
|
|
|
|
// --- helpers ----------------------------------------------------------------------------
|
|
|
|
private static ClaimsPrincipal UserInRole(string role)
|
|
{
|
|
// ZbClaimTypes.Role aliases ClaimTypes.Role, the default role-claim type, so RequireRole /
|
|
// IsInRole resolve against it.
|
|
var identity = new ClaimsIdentity(
|
|
[new Claim(ZbClaimTypes.Role, role)], authenticationType: "Test");
|
|
return new ClaimsPrincipal(identity);
|
|
}
|
|
|
|
private static IAuthorizationService BuildAuthorizationService()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
|
// Use the REAL policy registrations from AddOtOpcUaAuth; it needs the ConfigDbContext for
|
|
// DataProtection key persistence, so register an in-memory one.
|
|
services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
|
|
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
|
|
services.AddOtOpcUaAuth(new ConfigurationBuilder().Build());
|
|
|
|
return services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
|
|
}
|
|
|
|
private static OtOpcUaGroupRoleMapper BuildMapper(
|
|
IDictionary<string, string> groupToRole,
|
|
params LdapGroupRoleMapping[] dbRows)
|
|
{
|
|
var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions
|
|
{
|
|
GroupToRole = new Dictionary<string, string>(groupToRole, StringComparer.OrdinalIgnoreCase),
|
|
});
|
|
return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows));
|
|
}
|
|
|
|
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
|
|
{
|
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
|
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
|
=> Task.FromResult(rows);
|
|
|
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
|
=> Task.FromResult(rows);
|
|
|
|
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
}
|
|
}
|