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:
@@ -7,20 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
/// <see cref="Entities.NodeAcl"/> joined against LDAP group memberships directly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary
|
||||
/// (<c>ZB.MOM.WW.Auth</c> <c>CanonicalRole</c>): <c>ConfigViewer → Viewer</c>,
|
||||
/// <c>ConfigEditor → Designer</c>, <c>FleetAdmin → Administrator</c>. The appsettings-only
|
||||
/// <c>DriverOperator</c> string role likewise became <c>Operator</c>. These members persist
|
||||
/// as their string names (EF <c>HasConversion<string></c>); the rename is paired with
|
||||
/// a data migration (<c>CanonicalizeAdminRoles</c>) that rewrites existing rows. This is a
|
||||
/// rename, not a permission change — enforcement semantics are preserved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum AdminRole
|
||||
{
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history.</summary>
|
||||
ConfigViewer,
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history. (Canonical: Viewer; was ConfigViewer.)</summary>
|
||||
Viewer,
|
||||
|
||||
/// <summary>Can author drafts + submit for publish.</summary>
|
||||
ConfigEditor,
|
||||
/// <summary>Can author drafts + submit for publish. (Canonical: Designer; was ConfigEditor.)</summary>
|
||||
Designer,
|
||||
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions.</summary>
|
||||
FleetAdmin,
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions. (Canonical: Administrator; was FleetAdmin.)</summary>
|
||||
Administrator,
|
||||
}
|
||||
|
||||
+1755
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the
|
||||
/// <c>LdapGroupRoleMapping.Role</c> column. The column stores the <c>AdminRole</c> enum
|
||||
/// member name as a string (EF <c>HasConversion<string></c>, <c>nvarchar(32)</c>);
|
||||
/// renaming the enum members (<c>ConfigViewer → Viewer</c>, <c>ConfigEditor → Designer</c>,
|
||||
/// <c>FleetAdmin → Administrator</c>) therefore requires rewriting existing rows so the C#
|
||||
/// enum and the stored strings stay in sync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged,
|
||||
/// so the model snapshot is byte-identical to the prior migration. The new canonical strings
|
||||
/// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing
|
||||
/// <c>nvarchar(32)</c> column. Enforcement semantics are preserved — it is a rename only.
|
||||
/// </remarks>
|
||||
public partial class CanonicalizeAdminRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations
|
||||
|
||||
@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]
|
||||
@attribute [Authorize(Roles = "Administrator,Designer")]
|
||||
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject IAdminOperationsClient AdminOps
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
private LdapOptions? _options;
|
||||
private IReadOnlyList<LdapGroupRoleMapping> _rows = [];
|
||||
private string _newGroup = "";
|
||||
private AdminRole _newRole = AdminRole.ConfigViewer;
|
||||
private AdminRole _newRole = AdminRole.Viewer;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null,
|
||||
}, default);
|
||||
_newGroup = "";
|
||||
_newRole = AdminRole.ConfigViewer;
|
||||
_newRole = AdminRole.Viewer;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class LdapOptions
|
||||
|
||||
/// <summary>
|
||||
/// Dev-only stub: when <c>true</c>, <see cref="OtOpcUaLdapAuthService"/> bypasses the real LDAP
|
||||
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||
/// bind and accepts any non-empty username/password, returning a single Administrator role
|
||||
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||
/// </summary>
|
||||
public bool DevStubMode { get; set; }
|
||||
@@ -76,8 +76,9 @@ public sealed class LdapOptions
|
||||
|
||||
/// <summary>
|
||||
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
|
||||
/// role whose source group is in their membership list. Example dev mapping:
|
||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||
/// role whose source group is in their membership list. Values are the canonical control-plane
|
||||
/// roles (Task 1.7). Example dev mapping:
|
||||
/// <code>"ReadOnly":"Viewer","ReadWrite":"Designer","AlarmAck":"Administrator"</code>
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
/// library deliberately does not model:
|
||||
/// <list type="number">
|
||||
/// <item>the <see cref="LdapOptions.Enabled"/> master switch (disabled ⇒ deny, no bind); and</item>
|
||||
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants a FleetAdmin
|
||||
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants an Administrator
|
||||
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
|
||||
/// against a machine with no directory.</item>
|
||||
/// </list>
|
||||
@@ -24,12 +24,13 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
|
||||
/// <see cref="LdapAuthResult.Groups"/>. The only path that pre-populates
|
||||
/// <see cref="LdapAuthResult.Roles"/> is the DevStub success; consumers union that pre-resolved
|
||||
/// set with the mapper output so the dev FleetAdmin grant survives the move to the mapper.
|
||||
/// set with the mapper output so the dev Administrator grant survives the move to the mapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
|
||||
/// DevStub result mirrors the legacy bespoke service exactly (group <c>"dev"</c>, role
|
||||
/// <c>"FleetAdmin"</c>) so behaviour is preserved bit-for-bit on dev nodes.
|
||||
/// DevStub result grants the canonical <c>"Administrator"</c> control-plane role (group
|
||||
/// <c>"dev"</c>) so the dev session can navigate the full Admin UI (Task 1.7 renamed the prior
|
||||
/// <c>"FleetAdmin"</c> to the canonical <c>"Administrator"</c>).
|
||||
/// </remarks>
|
||||
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
||||
{
|
||||
@@ -77,12 +78,13 @@ public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
// Dev bypass: accept any non-empty credentials and grant FleetAdmin WITHOUT a real bind.
|
||||
// Dev bypass: accept any non-empty credentials and grant Administrator WITHOUT a real bind.
|
||||
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
|
||||
// survives the move to IGroupRoleMapper. Mirrors the legacy bespoke service exactly.
|
||||
// survives the move to IGroupRoleMapper. (Task 1.7 canonicalized the role string from the
|
||||
// prior "FleetAdmin" to "Administrator".)
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||
return new(true, username, username, ["dev"], ["Administrator"], null);
|
||||
}
|
||||
|
||||
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
|
||||
|
||||
@@ -103,14 +103,17 @@ public static class ServiceCollectionExtensions
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
|
||||
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
|
||||
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||
// DriverOperator (policy NAME kept stable): may issue Reconnect/Restart commands against
|
||||
// live driver instances from the Admin UI DriverStatusPanel. The role STRINGS it requires
|
||||
// are the canonical control-plane roles (Task 1.7): Operator (was DriverOperator) and
|
||||
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
|
||||
// (e.g. "ot-driver-operator": "Operator").
|
||||
o.AddPolicy("DriverOperator", policy =>
|
||||
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||
policy.RequireRole("Operator", "Administrator"));
|
||||
|
||||
// FleetAdmin: full administrative access; gates fleet-wide pages such as RoleGrants.
|
||||
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));
|
||||
// FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages
|
||||
// such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin).
|
||||
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("Administrator"));
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
+14
-14
@@ -38,7 +38,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_SetsId_AndCreatedAtUtc()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin);
|
||||
var row = Make("cn=fleet,dc=x", AdminRole.Administrator);
|
||||
|
||||
var saved = await svc.CreateAsync(row, CancellationToken.None);
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_Rejects_EmptyLdapGroup()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("", AdminRole.FleetAdmin);
|
||||
var row = Make("", AdminRole.Administrator);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
@@ -62,7 +62,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_Rejects_SystemWide_With_ClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true);
|
||||
var row = Make("cn=g", AdminRole.Viewer, clusterId: "c1", isSystemWide: true);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
@@ -73,7 +73,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false);
|
||||
var row = Make("cn=g", AdminRole.Viewer, clusterId: null, isSystemWide: false);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
@@ -84,15 +84,15 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task GetByGroups_Returns_MatchingGrants_Only()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.ConfigEditor), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.ConfigViewer), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.Designer), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.Viewer), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync(
|
||||
["cn=fleet,dc=x", "cn=viewer,dc=x"], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.Administrator, AdminRole.Viewer], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetByGroups returns empty when input is empty.</summary>
|
||||
@@ -100,7 +100,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync([], CancellationToken.None);
|
||||
|
||||
@@ -112,9 +112,9 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task ListAll_Orders_ByGroupThenCluster()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c1", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c1", isSystemWide: false), CancellationToken.None);
|
||||
|
||||
var results = await svc.ListAllAsync(CancellationToken.None);
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Delete_Removes_Matching_Row()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
|
||||
await svc.DeleteAsync(saved.Id, CancellationToken.None);
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var saved = await svc.CreateAsync(
|
||||
Make("cn=sysadmins,dc=x", AdminRole.FleetAdmin, clusterId: null, isSystemWide: true),
|
||||
Make("cn=sysadmins,dc=x", AdminRole.Administrator, clusterId: null, isSystemWide: true),
|
||||
CancellationToken.None);
|
||||
|
||||
saved.IsSystemWide.ShouldBeTrue();
|
||||
|
||||
+11
-11
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
|
||||
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
|
||||
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
|
||||
/// roles (the DevStub FleetAdmin grant) in, and turns LDAP backend exceptions into a denial rather
|
||||
/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather
|
||||
/// than letting them escape into the SDK.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
@@ -23,33 +23,33 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
|
||||
{
|
||||
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
|
||||
// group "configeditor" -> "ConfigEditor".
|
||||
// group "configeditor" -> "Designer" (canonical, Task 1.7).
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray());
|
||||
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("Alice");
|
||||
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||
result.Roles.ShouldBe(new[] { "Designer" });
|
||||
}
|
||||
|
||||
/// <summary>The DevStub pre-resolved roles (FleetAdmin) survive the move to the mapper: they are
|
||||
/// <summary>The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are
|
||||
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
|
||||
{
|
||||
// DevStub-shaped result: group "dev", pre-resolved role "FleetAdmin". Mapper maps "dev" to
|
||||
// nothing, so the union is exactly {FleetAdmin}.
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
|
||||
// DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to
|
||||
// nothing, so the union is exactly {Administrator}.
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
|
||||
var mapper = new FakeMapper(_ => Array.Empty<string>());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
|
||||
@@ -57,14 +57,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
[Fact]
|
||||
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
|
||||
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
|
||||
|
||||
@@ -313,8 +313,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
Success: password == "valid-password",
|
||||
DisplayName: username,
|
||||
Username: username,
|
||||
Groups: ["FleetAdmin"],
|
||||
Roles: ["FleetAdmin"],
|
||||
Groups: ["Administrator"],
|
||||
Roles: ["Administrator"],
|
||||
Error: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}\")");
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,11 @@ public sealed class OtOpcUaGroupRoleMapperTests
|
||||
[Fact]
|
||||
public async Task Maps_config_group_and_drops_unmapped_group()
|
||||
{
|
||||
var mapper = Build(new Dictionary<string, string> { ["AdminGroup"] = "FleetAdmin" });
|
||||
var mapper = Build(new Dictionary<string, string> { ["AdminGroup"] = "Administrator" });
|
||||
|
||||
var result = await mapper.MapAsync(["AdminGroup", "UnmappedGroup"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldBe(["FleetAdmin"]);
|
||||
result.Roles.ShouldBe(["Administrator"]);
|
||||
result.Scope.ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -43,13 +43,13 @@ public sealed class OtOpcUaGroupRoleMapperTests
|
||||
public async Task System_wide_db_row_adds_role_on_top_of_config_baseline()
|
||||
{
|
||||
var mapper = Build(
|
||||
new Dictionary<string, string> { ["viewers"] = "ConfigViewer" },
|
||||
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true });
|
||||
new Dictionary<string, string> { ["viewers"] = "Viewer" },
|
||||
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true });
|
||||
|
||||
var result = await mapper.MapAsync(["viewers", "admins"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldContain("ConfigViewer");
|
||||
result.Roles.ShouldContain("FleetAdmin");
|
||||
result.Roles.ShouldContain("Viewer");
|
||||
result.Roles.ShouldContain("Administrator");
|
||||
result.Scope.ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -61,14 +61,14 @@ public sealed class OtOpcUaGroupRoleMapperTests
|
||||
new LdapGroupRoleMapping
|
||||
{
|
||||
LdapGroup = "site-a-editors",
|
||||
Role = AdminRole.ConfigEditor,
|
||||
Role = AdminRole.Designer,
|
||||
IsSystemWide = false,
|
||||
ClusterId = "SITE-A",
|
||||
});
|
||||
|
||||
var result = await mapper.MapAsync(["site-a-editors"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldNotContain("ConfigEditor");
|
||||
result.Roles.ShouldNotContain("Designer");
|
||||
result.Roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@ public sealed class OtOpcUaGroupRoleMapperTests
|
||||
{
|
||||
var groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["viewers"] = "ConfigViewer",
|
||||
["editors"] = "ConfigEditor",
|
||||
["viewers"] = "Viewer",
|
||||
["editors"] = "Designer",
|
||||
};
|
||||
var dbRows = new[]
|
||||
{
|
||||
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
};
|
||||
var groups = new[] { "viewers", "editors", "admins", "site-a", "noise" };
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ public sealed class OtOpcUaLdapAuthServiceTests
|
||||
private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
|
||||
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
|
||||
|
||||
/// <summary>DevStubMode on → stub FleetAdmin success WITHOUT hitting the library.</summary>
|
||||
/// <summary>DevStubMode on → stub Administrator success WITHOUT hitting the library.</summary>
|
||||
[Fact]
|
||||
public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library()
|
||||
public async Task DevStubMode_grants_Administrator_without_calling_the_library()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner);
|
||||
@@ -33,7 +33,7 @@ public sealed class OtOpcUaLdapAuthServiceTests
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Username.ShouldBe("anyone");
|
||||
result.Groups.ShouldBe(new[] { "dev" });
|
||||
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ public sealed class RoleMapperTests
|
||||
{
|
||||
RoleMapper.Map(
|
||||
new[] { "AdminGroup" },
|
||||
new Dictionary<string, string> { ["AdminGroup"] = "FleetAdmin" })
|
||||
.ShouldBe(new[] { "FleetAdmin" });
|
||||
new Dictionary<string, string> { ["AdminGroup"] = "Administrator" })
|
||||
.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +40,9 @@ public sealed class RoleMapperTests
|
||||
new[] { "admingroup" },
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["AdminGroup"] = "FleetAdmin",
|
||||
["AdminGroup"] = "Administrator",
|
||||
})
|
||||
.ShouldBe(new[] { "FleetAdmin" });
|
||||
.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -55,11 +55,11 @@ public sealed class RoleMapperTests
|
||||
new[] { "AdminGroup", "AlsoAdmin" },
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["AdminGroup"] = "FleetAdmin",
|
||||
["AlsoAdmin"] = "FleetAdmin",
|
||||
["AdminGroup"] = "Administrator",
|
||||
["AlsoAdmin"] = "Administrator",
|
||||
});
|
||||
|
||||
roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -67,16 +67,16 @@ public sealed class RoleMapperTests
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.Administrator, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
};
|
||||
var result = RoleMapper.Merge(["ConfigViewer"], rows);
|
||||
result.ShouldContain("ConfigViewer");
|
||||
result.ShouldContain("FleetAdmin");
|
||||
result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only)
|
||||
var result = RoleMapper.Merge(["Viewer"], rows);
|
||||
result.ShouldContain("Viewer");
|
||||
result.ShouldContain("Administrator");
|
||||
result.ShouldNotContain("Designer"); // cluster-scoped row ignored (global-only)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_with_no_db_rows_returns_baseline()
|
||||
=> RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]);
|
||||
=> RoleMapper.Merge(["Administrator"], []).ShouldBe(["Administrator"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user