feat(management): any-of role gates; restrict ListSecuredWrites to Operator/Verifier/Admin (arch-review UA1)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:03:51 -04:00
parent 4fb754f9f6
commit 6c9de9f7ab
8 changed files with 133 additions and 18 deletions
@@ -1,5 +1,8 @@
@page "/operations/secured-writes"
@attribute [Authorize]
@* arch-review UA1: the Secured Writes history exposes process-sensitive tag
values, so the page is gated to the Secured Write roles + Administrator
(any-of), aligning with the ManagementActor ListSecuredWrites gate. *@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireSecuredWriteAccess)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@@ -100,12 +100,14 @@ public class ManagementActor : ReceiveActor
var correlationId = envelope.CorrelationId;
var user = envelope.User;
// Check authorization
var requiredRole = GetRequiredRole(envelope.Command);
if (requiredRole != null && !user.Roles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase))
// Check authorization. A command may require ANY of a set of roles
// (any-of): the caller is authorized when they hold at least one.
var requiredRoles = GetRequiredRoles(envelope.Command);
if (requiredRoles is not null
&& !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
{
sender.Tell(new ManagementUnauthorized(correlationId,
$"Role '{requiredRole}' required for {envelope.Command.GetType().Name}"));
$"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
return;
}
@@ -157,7 +159,28 @@ public class ManagementActor : ReceiveActor
return new ManagementError(correlationId, clientMessage, "COMMAND_FAILED");
}
private static string? GetRequiredRole(object command) => command switch
// Static readonly role sets for the authorization gate. Single-role arms
// stay one-element arrays so every arm has the uniform any-of shape.
private static readonly string[] AdminOnly = [Roles.Administrator];
private static readonly string[] DesignerOnly = [Roles.Designer];
private static readonly string[] DeployerOnly = [Roles.Deployer];
private static readonly string[] OperatorOnly = [Roles.Operator];
private static readonly string[] VerifierOnly = [Roles.Verifier];
// arch-review UA1: the Secured Writes history table exposes
// process-sensitive tag values, so reading it is restricted to the
// two-person Secured Write roles plus Administrator (any-of) — NOT any
// authenticated user like the other list/query commands.
private static readonly string[] SecuredWriteReaders =
[Roles.Operator, Roles.Verifier, Roles.Administrator];
/// <summary>
/// Returns the set of roles that satisfy the authorization gate for
/// <paramref name="command"/> (any-of semantics), or <c>null</c> when the
/// command is available to any authenticated user. Internal so the
/// authorization matrix can be asserted directly in tests.
/// </summary>
internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
{
// Administrator operations
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
@@ -180,7 +203,7 @@ public class ManagementActor : ReceiveActor
// gate must match — otherwise a Designer blocked in the UI could still
// rotate a production credential via the CLI/Management API.
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand => Roles.Administrator,
or UpdateSmsConfigCommand => AdminOnly,
// Designer operations
CreateAreaCommand or DeleteAreaCommand
@@ -208,13 +231,13 @@ public class ManagementActor : ReceiveActor
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand
or ExportBundleCommand => Roles.Designer,
or ExportBundleCommand => DesignerOnly,
// Transport import operations (mirror the Central UI gating:
// Administrator for inbound bundle handling because they mutate
// cross-cutting configuration; Export stays Designer because it only
// reads).
PreviewBundleCommand or ImportBundleCommand => Roles.Administrator,
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
// Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
@@ -226,16 +249,19 @@ public class ManagementActor : ReceiveActor
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => Roles.Deployer,
or DebugSnapshotCommand => DeployerOnly,
// Two-person secured write. Submit is an Operator action;
// approve/reject are Verifier actions. Separation of duties (a write may
// not be verified by its submitter) is enforced inside the handler — the
// role gate only ensures the caller holds the right coarse role.
SubmitSecuredWriteCommand => Roles.Operator,
RejectSecuredWriteCommand or ApproveSecuredWriteCommand => Roles.Verifier,
// ListSecuredWritesCommand is read-only -> falls through to "any
// authenticated user" below, like the other list/query commands.
SubmitSecuredWriteCommand => OperatorOnly,
RejectSecuredWriteCommand or ApproveSecuredWriteCommand => VerifierOnly,
// ListSecuredWritesCommand is read-only, but the history table exposes
// process-sensitive tag values, so it is NOT open to any authenticated
// user — restricted to the Secured Write roles + Administrator
// (arch-review UA1).
ListSecuredWritesCommand => SecuredWriteReaders,
// Read-only queries -- any authenticated user
_ => null
@@ -88,6 +88,24 @@ public static class AuthorizationPolicies
/// </summary>
public const string RequireVerifier = "RequireVerifier";
/// <summary>
/// Read access to the Secured Writes surface (the Secured Writes page +
/// its Pending/history list). The history table exposes process-sensitive
/// tag values, so it is NOT open to any authenticated user — satisfied by
/// any of {<see cref="Roles.Operator"/>, <see cref="Roles.Verifier"/>,
/// <see cref="Roles.Administrator"/>} (arch-review UA1). Aligns with the
/// ManagementActor's <c>ListSecuredWritesCommand</c> any-of gate.
/// </summary>
public const string RequireSecuredWriteAccess = "RequireSecuredWriteAccess";
/// <summary>
/// Roles that satisfy <see cref="RequireSecuredWriteAccess"/>. Held in one
/// place so the policy, the docs, and the ManagementActor gate stay in
/// lockstep: {<c>Operator</c>, <c>Verifier</c>, <c>Administrator</c>}.
/// </summary>
public static readonly string[] SecuredWriteAccessRoles =
{ Roles.Operator, Roles.Verifier, Roles.Administrator };
/// <summary>
/// Read access to the Audit Log surface (Audit Log page,
/// Configuration Audit Log page, Audit nav group). Granted to the
@@ -155,6 +173,12 @@ public static class AuthorizationPolicies
options.AddPolicy(RequireVerifier, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Verifier));
// Any-of read gate for the Secured Writes surface (arch-review UA1):
// succeeds when the principal holds any of Operator/Verifier/
// Administrator, mirroring the ManagementActor ListSecuredWrites gate.
options.AddPolicy(RequireSecuredWriteAccess, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, SecuredWriteAccessRoles));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
// multiple allowed values is the right primitive: it checks