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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user