feat(auth)!: MxGateway canonical dashboard roles — Admin→Administrator (Task 1.7)

Standardize the dashboard role VALUE on the canonical six: Admin→Administrator
(Viewer unchanged). Pure value rename via DashboardRoles.Admin constant +
appsettings GroupToRole; the GatewayOptionsValidator allowed-set/message track
the constant so they now require 'Administrator' or 'Viewer'. Enforcement is
unchanged — Administrator authorizes exactly what Admin did.

Dashboard roles are derived at login from LDAP groups via GroupToRole and are
never persisted to the SQLite auth store, so no DB migration/seed change.

UNTOUCHED: the separate gRPC API-key scope GatewayScopes.Admin = "admin"
(lowercase) and every "admin" scope literal — a distinct data-plane system.
This commit is contained in:
Joseph Doherty
2026-06-02 07:22:42 -04:00
parent 9572045787
commit 04bce3ff9f
6 changed files with 47 additions and 4 deletions
+6
View File
@@ -67,6 +67,12 @@ GLAuth config — it must be provisioned before dashboard authn or the
LDAP live tests work. See [Provisioning the GwAdmin
group](#provisioning-the-gwadmin-group) below.
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
> maps to `Viewer`. This is a pure value rename via
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
## Two bind patterns
### 1. Direct bind (simplest)
@@ -8,8 +8,10 @@ public static class DashboardRoles
{
/// <summary>
/// Read-write access: API-key CRUD, settings, any state-changing UI.
/// Canonical role value (Task 1.7); formerly <c>"Admin"</c> — pure value
/// rename, the operations this role authorizes are unchanged.
/// </summary>
public const string Admin = "Admin";
public const string Admin = "Administrator";
/// <summary>
/// Read-only access: all pages render but write affordances are hidden.
@@ -60,7 +60,7 @@
"RecentSessionLimit": 200,
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwAdmin": "Administrator",
"GwReader": "Viewer"
}
},
@@ -87,7 +87,7 @@ public sealed class GatewayOptionsTests
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
{
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
@@ -85,6 +85,41 @@ public sealed class DashboardGroupRoleMapperTests
Assert.Empty(result.Roles);
}
/// <summary>
/// Task 1.7 (canonical roles): an LDAP user in the admin group must resolve
/// to the canonical role value <c>"Administrator"</c> (not the legacy
/// <c>"Admin"</c>), and the reader group to <c>"Viewer"</c>. Asserted with
/// string LITERALS — independent of <see cref="DashboardRoles"/> — so a
/// regression on the constant's value is caught here.
/// </summary>
[Fact]
public async Task MapAsync_AdminGroup_ResolvesToCanonicalAdministratorValue()
{
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
GroupRoleMapping<string> adminResult = await mapper.MapAsync(["GwAdmin"], CancellationToken.None);
GroupRoleMapping<string> readerResult = await mapper.MapAsync(["GwReader"], CancellationToken.None);
Assert.Equal("Administrator", Assert.Single(adminResult.Roles));
Assert.Equal("Viewer", Assert.Single(readerResult.Roles));
}
/// <summary>
/// Task 1.7: the canonical admin value (<c>"Administrator"</c>) passes the
/// admin-only gate while a <c>"Viewer"</c> fails it — asserted with literals,
/// proving enforcement is bound to the new value and the legacy <c>"Admin"</c>
/// string is no longer what authorizes admin actions.
/// </summary>
[Fact]
public void AdminOnly_AuthorizesCanonicalAdministratorButNotViewer()
{
IReadOnlyList<string> adminGate = DashboardAuthorizationRequirement.AdminOnly.RequiredRoles;
Assert.Contains("Administrator", adminGate);
Assert.DoesNotContain("Admin", adminGate);
Assert.DoesNotContain("Viewer", adminGate);
}
/// <summary>
/// Verifies the extracted shared helper is the single source of truth: it
/// produces the same roles the mapper does for the same inputs.
@@ -197,7 +197,7 @@ public sealed class GatewayApplicationTests
[InlineData(
"MxGateway:Dashboard:GroupToRole:GwAdmin",
"BogusRole",
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
[InlineData(
"MxGateway:Ldap:AllowInsecure",
"false",