Merge R2-05 AdminUI authorization (arch-review round 2) [PR #428]
v2-ci / build (push) Successful in 3m27s
v2-ci / unit-tests (push) Failing after 8m29s

Finding 04/C-1 (High): explicit named-policy gating on all 38 AdminUI pages via
new AdminUiPolicies (ConfigEditor + AuthenticatedRead) + anti-drift reflection
guard. T15 live pass deferred (docker-dev auto-admin can't observe deny).
STATUS.md conflict resolved additively. Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 10:12:57 -04:00
52 changed files with 467 additions and 71 deletions
@@ -3,7 +3,7 @@
grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so
this version only shows identity + the resolved fleet roles + raw LDAP groups for
troubleshooting. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@using System.Security.Claims
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -1,7 +1,7 @@
@page "/alarms-historian"
@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the
HistorianAdapterActor.GetStatus query landed in F11. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Akka.Actor
@using Akka.Hosting
@@ -2,9 +2,8 @@
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
and the AB CIP ALMD bridge. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@@ -162,7 +161,7 @@ else
// permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a
// mid-session token refresh lands in the published command + audit accurately.
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
}
@@ -1,5 +1,5 @@
@page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using System.Security.Cryptography.X509Certificates
@using Microsoft.Extensions.Configuration
@@ -87,7 +87,7 @@ else
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
{
<td>
<AuthorizeView Policy="FleetAdmin">
<AuthorizeView Policy="@AdminUiPolicies.FleetAdmin">
<Authorized>
@if (store.Kind == StoreKind.Rejected)
{
@@ -183,7 +183,7 @@ else
// Defense-in-depth: the action buttons are FleetAdmin-gated in markup, but this handler
// runs on the server circuit — re-check the policy before mutating the trust store.
var authState = await AuthState.GetAuthenticationStateAsync();
if (!(await AuthorizationService.AuthorizeAsync(authState.User, null, "FleetAdmin")).Succeeded)
if (!(await AuthorizationService.AuthorizeAsync(authState.User, null, AdminUiPolicies.FleetAdmin)).Succeeded)
{
_statusError = true;
_statusMsg = "Unauthorized — FleetAdmin required.";
@@ -1,6 +1,6 @@
@page "/clusters/{ClusterId}/acls/new"
@page "/clusters/{ClusterId}/acls/{NodeAclId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/acls"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,7 +1,7 @@
@page "/clusters/{ClusterId}/edit"
@* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor;
this page handles only the update case so the form can disable ClusterId (immutable). *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/namespaces"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/redundancy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/abcip"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/ablegacy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -2,7 +2,7 @@
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
no registered typed page. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,7 +1,7 @@
@* Driver type picker — presents a card grid of registered driver types and links to the
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
@page "/clusters/{ClusterId}/drivers/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New driver &middot; <span class="mono">@ClusterId</span></h4>
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/focas"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/galaxy"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/modbustcp"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/opcuaclient"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/s7"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/{ClusterId}/drivers/new/twincat"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -3,7 +3,7 @@
@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update.
RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency
conflicts surface as a toast and reload the current row. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,5 @@
@page "/clusters/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -2,7 +2,7 @@
@page "/clusters/{ClusterId}/nodes/{NodeId}"
@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this
at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,5 +1,4 @@
@page "/deployments"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@@ -9,7 +8,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations
@attribute [Authorize(Roles = "Administrator,Designer")]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject IAdminOperationsClient AdminOps
@@ -3,7 +3,7 @@
progress row owned by each DriverHostActor) and projects the most-recent row per node. The
Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied
anything yet alongside nodes that have. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@@ -1,4 +1,5 @@
@page "/"
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
<PageTitle>OtOpcUa</PageTitle>
@@ -4,7 +4,7 @@
cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows
are cluster-scoped (keyed per driver instance across the cluster, not per member). The section
reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Akka.Actor
@using Akka.Cluster
@@ -1,5 +1,5 @@
@page "/reservations"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -1,5 +1,5 @@
@page "/role-grants"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.Extensions.Options
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -2,7 +2,7 @@
@page "/scripts/{ScriptId}"
@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the
integrity check in v2's deployment pipeline doesn't require operator action. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@@ -1,7 +1,7 @@
@page "/script-log"
@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@@ -1,5 +1,5 @@
@page "/scripts"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Administrator,Designer")]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@@ -4,7 +4,7 @@
the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the
Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the
page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@@ -1,5 +1,5 @@
@page "/uns"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
@@ -163,7 +163,7 @@
// The username for audit logging is re-read at button-click time (not captured here)
// so token-refreshes mid-session land in audit entries accurately.
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
if (!Enabled)
@@ -121,7 +121,7 @@
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
@@ -72,7 +72,7 @@
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
_built = _nodeId;
await CurrentAddressChanged.InvokeAsync(_built);
@@ -1,21 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints
{
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated to the Administrator/Designer roles.</summary>
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
/// <returns>The same route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
// Require Administrator or Designer — matches the Script page gate and the /deployments gate.
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" });
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
@@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@@ -7,4 +8,5 @@
@using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Security.Auth
@using ZB.MOM.WW.Theme
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
/// <summary>
/// Names of the AdminUI authorization policies registered by <c>AddOtOpcUaAuth</c>.
/// Single source of truth — pages, AuthorizeView blocks, imperative checks, and endpoint
/// groups must reference these constants, never string literals (guarded by
/// PageAuthorizationGuardTests). Policy → role mapping lives in ServiceCollectionExtensions.
/// </summary>
public static class AdminUiPolicies
{
/// <summary>Fleet-wide admin actions (RoleGrants page, certificate mutations). Roles: Administrator.</summary>
public const string FleetAdmin = "FleetAdmin";
/// <summary>Live driver operations (reconnect/restart, alarm ack/shelve, live address browse). Roles: Operator, Administrator.</summary>
public const string DriverOperator = "DriverOperator";
/// <summary>Config authoring: UNS/equipment/cluster/driver/ACL/namespace/script editors + deploy + script analysis. Roles: Administrator, Designer.</summary>
public const string ConfigEditor = "ConfigEditor";
/// <summary>Read-only AdminUI access (dashboards, lists, live tails). Any authenticated user.</summary>
public const string AuthenticatedRead = "AuthenticatedRead";
}
@@ -152,12 +152,24 @@ public static class ServiceCollectionExtensions
// are the canonical control-plane roles: 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("Operator", "Administrator"));
o.AddPolicy(AdminUiPolicies.DriverOperator, policy =>
policy.RequireRole(DevAuthRoles.Operator, "Administrator"));
// 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"));
o.AddPolicy(AdminUiPolicies.FleetAdmin, policy => policy.RequireRole("Administrator"));
// ConfigEditor: may author configuration (UNS, equipment, clusters, drivers incl. live
// ResilienceConfig, ACLs, namespaces, scripts) and trigger deploys. Same semantics the
// Deployments/Scripts/ScriptEdit pages previously spelled as Roles="Administrator,Designer".
o.AddPolicy(AdminUiPolicies.ConfigEditor, policy =>
policy.RequireRole("Administrator", "Designer"));
// AuthenticatedRead: explicit name for "any authenticated user" so read-only pages can carry
// a non-bare attribute and the page-authorization guard can ban bare [Authorize] outright.
// Semantically identical to the FallbackPolicy.
o.AddPolicy(AdminUiPolicies.AuthenticatedRead, policy =>
policy.RequireAuthenticatedUser());
});
return services;