Merge branch 'worktree-agent-a349413889618d94c' into fix/flow-debugger-arch-review
This commit is contained in:
+40
-18
@@ -3,7 +3,10 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@* arch-review F3: opening the page is a Replay-capable read (Deployer + the OperationalAudit
|
||||
roles) so the Audit Log / Execution Chain drill-ins work for an auditor. Every Live-run
|
||||
affordance below is additionally gated on _canLive (the Deployer role). *@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireFlowDebugger)]
|
||||
|
||||
<PageTitle>Flow Debugger</PageTitle>
|
||||
|
||||
@@ -13,15 +16,19 @@
|
||||
|
||||
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
|
||||
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Entry point</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
|
||||
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
|
||||
@* The entry point only selects what a Live run starts from, so it is Deployer-only. *@
|
||||
@if (_canLive)
|
||||
{
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Entry point</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
|
||||
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_mode == FlowMode.Live)
|
||||
{
|
||||
@@ -85,15 +92,19 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Mode</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Mode">
|
||||
<button type="button" class="btn @(_mode == FlowMode.Live ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-live" disabled="@_running" @onclick="() => SetMode(FlowMode.Live)">Live</button>
|
||||
<button type="button" class="btn @(_mode == FlowMode.Replay ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-replay" disabled="@_running" @onclick="() => SetMode(FlowMode.Replay)">Replay</button>
|
||||
@* Without Deployer there is only Replay, so the toggle would be a single dead button. *@
|
||||
@if (_canLive)
|
||||
{
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Mode</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Mode">
|
||||
<button type="button" class="btn @(_mode == FlowMode.Live ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-live" disabled="@_running" @onclick="() => SetMode(FlowMode.Live)">Live</button>
|
||||
<button type="button" class="btn @(_mode == FlowMode.Replay ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-replay" disabled="@_running" @onclick="() => SetMode(FlowMode.Replay)">Replay</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-auto ms-auto">
|
||||
@if (_mode == FlowMode.Live)
|
||||
@@ -115,6 +126,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!_canLive)
|
||||
{
|
||||
<div class="text-muted small mb-2" data-test="flow-replay-only">
|
||||
Replay only — rebuilding a past execution from the audit log. Starting a live run
|
||||
requires the Deployer role.
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Inline alerts ───────────────────────────────────────────────────── *@
|
||||
@if (_error != null)
|
||||
{
|
||||
@@ -232,10 +251,13 @@
|
||||
|
||||
@* ── Detail panel ──────────────────────────────────────────────────── *@
|
||||
<div class="col-12">
|
||||
@* Retry / Discard relay a command to the site — only wired for a Deployer, so the
|
||||
buttons never render for an auditor (arch-review F3). *@
|
||||
<FlowStepDetail Step="_selected" Graph="_graph"
|
||||
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
||||
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
|
||||
OnRetry="OnRetryStepAsync" OnDiscard="OnDiscardStepAsync" ActionInProgress="_actionInProgress" />
|
||||
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
|
||||
ActionInProgress="_actionInProgress" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+51
-2
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
@@ -15,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
|
||||
@@ -76,6 +78,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
[Inject] private IDialogService Dialog { get; set; } = default!;
|
||||
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||
|
||||
/// <summary>Drill-in: pre-select an inbound method.</summary>
|
||||
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
||||
@@ -93,6 +96,16 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private FlowMode _mode = FlowMode.Live;
|
||||
private FlowState _state = FlowState.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// True when the principal holds <see cref="Roles.Deployer"/> and may therefore start a
|
||||
/// Live run (and relay Retry/Discard on a parked node). The page itself opens to the
|
||||
/// wider <see cref="AuthorizationPolicies.RequireFlowDebugger"/> set so an auditor can
|
||||
/// follow the Audit Log / Execution Chain drill-in into Replay (arch-review F3); every
|
||||
/// Live affordance is hidden when this is false, and the server enforces the same split
|
||||
/// (the two run commands stay Deployer-only in the ManagementActor role matrix).
|
||||
/// </summary>
|
||||
private bool _canLive;
|
||||
|
||||
private List<ApiMethod> _methods = [];
|
||||
private List<Site> _sites = [];
|
||||
private List<Instance> _instances = [];
|
||||
@@ -146,7 +159,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
private bool CanRefresh => !_running && _raw is not null;
|
||||
|
||||
private bool CanRun => !_running && _mode == FlowMode.Live && (
|
||||
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
|
||||
_entry == EntryKind.Method ? _selectedMethodId > 0
|
||||
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute));
|
||||
|
||||
@@ -184,9 +197,29 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// <inheritdoc/>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// arch-review F3: page access is the wider RequireFlowDebugger set; only a Deployer
|
||||
// gets the Live half. Resolved before anything else so the Live-only drill-ins below
|
||||
// are skipped and the mode starts on Replay for an auditor.
|
||||
_canLive = (await Auth.GetAuthenticationStateAsync()).User
|
||||
.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
|
||||
if (!_canLive)
|
||||
_mode = FlowMode.Replay;
|
||||
|
||||
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
|
||||
if (!_canLive)
|
||||
{
|
||||
// ?methodId= / ?instanceId= are Live-run drill-ins; ?executionId= is the audit
|
||||
// drill-in and is the only one that means anything without Deployer.
|
||||
if (ExecutionId is Guid auditExec && auditExec != Guid.Empty)
|
||||
{
|
||||
_replayExecutionIdText = auditExec.ToString();
|
||||
await LoadReplayAsync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (InstanceId is int iid && iid > 0)
|
||||
{
|
||||
_entry = EntryKind.ValueChange;
|
||||
@@ -383,6 +416,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private void SetMode(FlowMode mode)
|
||||
{
|
||||
if (_mode == mode) return;
|
||||
// Without Deployer there is no Live half to switch to (arch-review F3).
|
||||
if (mode == FlowMode.Live && !_canLive) return;
|
||||
CancelPendingRefresh();
|
||||
_mode = mode;
|
||||
}
|
||||
@@ -558,6 +593,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
|
||||
private Task OnDiscardStepAsync(FlowStep step) => ActOnStepAsync(step, retry: false);
|
||||
|
||||
/// <summary>
|
||||
/// The parked-node Retry callback, left unset (<c>default</c>) without the Deployer role so
|
||||
/// <c>FlowStepDetail</c>'s <c>OnRetry.HasDelegate</c> check hides the button (arch-review F3).
|
||||
/// </summary>
|
||||
private EventCallback<FlowStep> RetryCallback
|
||||
=> _canLive ? EventCallback.Factory.Create<FlowStep>(this, OnRetryStepAsync) : default;
|
||||
|
||||
/// <summary>The parked-node Discard callback; unset without Deployer, as <see cref="RetryCallback"/>.</summary>
|
||||
private EventCallback<FlowStep> DiscardCallback
|
||||
=> _canLive ? EventCallback.Factory.Create<FlowStep>(this, OnDiscardStepAsync) : default;
|
||||
|
||||
/// <summary>
|
||||
/// Retry / Discard a Parked outbound node: site-scope re-check → confirm → relay through the
|
||||
/// service → toast → re-poll Replay until the node leaves Parked. Mirrors the Site Calls /
|
||||
@@ -565,7 +611,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// </summary>
|
||||
private async Task ActOnStepAsync(FlowStep step, bool retry)
|
||||
{
|
||||
if (_actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr)
|
||||
// _canLive: relaying Retry/Discard to the site is a Deployer act. The buttons are not
|
||||
// rendered without it (the callbacks are left unset), but the gate is repeated here so
|
||||
// the split does not rest on markup alone (arch-review F3).
|
||||
if (!_canLive || _actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr)
|
||||
return;
|
||||
|
||||
if (!await IsStepSiteAllowedAsync(step))
|
||||
|
||||
@@ -195,6 +195,11 @@ public class ManagementActor : ReceiveActor
|
||||
private static readonly string[] OperatorOnly = [Roles.Operator];
|
||||
private static readonly string[] VerifierOnly = [Roles.Verifier];
|
||||
|
||||
// arch-review F3: Flow Debugger Replay is an audit read, so it carries the same role
|
||||
// set as the RequireFlowDebugger page policy ({Deployer, Administrator, Viewer}). The
|
||||
// two Live run commands stay DeployerOnly.
|
||||
private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles;
|
||||
|
||||
// arch-review C6: area management is reconciled to any-of [Designer, Deployer].
|
||||
// Areas are authored on both real surfaces — the Designer template/topology
|
||||
// tooling AND the Central UI Deployment Topology workflow (Topology.razor is
|
||||
@@ -305,9 +310,13 @@ public class ManagementActor : ReceiveActor
|
||||
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
||||
or DebugSnapshotCommand => DeployerOnly,
|
||||
|
||||
// Flow Debugger (design 2026-09-05 §9): Live runs execute scripts and write attributes
|
||||
// on operator input, Replay reads the audit chain — Deployer, like the Debug View.
|
||||
FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugReplayCommand => DeployerOnly,
|
||||
// Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3). A Live run executes
|
||||
// scripts and writes attributes on operator input, so it stays Deployer-only, like
|
||||
// the Debug View. Replay only reads the audit chain, so it additionally accepts the
|
||||
// OperationalAudit roles — the Audit Log / Execution Chain drill-ins are the
|
||||
// feature's main entry for a past incident and an auditor must be able to use them.
|
||||
FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand => DeployerOnly,
|
||||
FlowDebugReplayCommand => FlowDebuggerReplayRoles,
|
||||
|
||||
// Two-person secured write. Submit is an Operator action;
|
||||
// approve/reject are Verifier actions. Separation of duties (a write may
|
||||
|
||||
@@ -149,6 +149,33 @@ public static class AuthorizationPolicies
|
||||
/// </remarks>
|
||||
public static readonly string[] AuditExportRoles = { Roles.Administrator };
|
||||
|
||||
/// <summary>
|
||||
/// Permission to OPEN the Flow Debugger page (arch-review F3 / design D6).
|
||||
/// Replay only reads the audit chain, and the Audit Log / Execution Chain
|
||||
/// pages — themselves gated on <see cref="OperationalAudit"/> — carry an
|
||||
/// "Open in Flow Debugger" drill-in that is the feature's main entry for a
|
||||
/// past incident, so an auditor must be able to follow it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is page ACCESS only, and is deliberately NOT sufficient for a Live
|
||||
/// run: the Run controls, the Live entry-point pickers and the parked-node
|
||||
/// Retry/Discard actions are gated on <see cref="RequireDeployment"/> in the
|
||||
/// page, and <c>FlowDebugRunMethodCommand</c> /
|
||||
/// <c>FlowDebugRunValueChangeCommand</c> stay Deployer-only in the
|
||||
/// ManagementActor role matrix. Only <c>FlowDebugReplayCommand</c> accepts
|
||||
/// <see cref="FlowDebuggerAccessRoles"/>.
|
||||
/// </remarks>
|
||||
public const string RequireFlowDebugger = "RequireFlowDebugger";
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="RequireFlowDebugger"/> — the
|
||||
/// <see cref="OperationalAuditRoles"/> plus <c>Deployer</c>. Held in one
|
||||
/// place so the policy, the page and the ManagementActor's
|
||||
/// <c>FlowDebugReplayCommand</c> gate stay in lockstep.
|
||||
/// </summary>
|
||||
public static readonly string[] FlowDebuggerAccessRoles =
|
||||
{ Roles.Deployer, Roles.Administrator, Roles.Viewer };
|
||||
|
||||
/// <summary>
|
||||
/// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
|
||||
/// </summary>
|
||||
@@ -191,6 +218,12 @@ public static class AuthorizationPolicies
|
||||
|
||||
options.AddPolicy(AuditExport, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
|
||||
|
||||
// Flow Debugger page access (arch-review F3): opening the page is a
|
||||
// Replay-capable read; the Live run controls are separately gated on
|
||||
// RequireDeployment inside the page.
|
||||
options.AddPolicy(RequireFlowDebugger, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, FlowDebuggerAccessRoles));
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -119,6 +119,73 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
Assert.NotNull(cut.Find("[data-test='flow-graph-empty']"));
|
||||
Assert.Equal("Not run", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
|
||||
// arch-review F3: a Deployer gets the whole surface — entry point, both modes, Run.
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-entry-method']"));
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-mode-live']"));
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
|
||||
}
|
||||
|
||||
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
|
||||
|
||||
[Fact]
|
||||
public void Renders_ForViewer_ReplayOnly_WithNoLiveControls()
|
||||
{
|
||||
// A Viewer holds OperationalAudit but not Deployer: the page opens (it is the
|
||||
// Audit Log / Execution Chain drill-in target) pinned to Replay, with no way to
|
||||
// start a Live run.
|
||||
RegisterServices(role: "Viewer");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
|
||||
Assert.NotNull(cut.Find("[data-test='flow-toolbar']"));
|
||||
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-load']"));
|
||||
Assert.NotNull(cut.Find("[data-test='flow-replay-only']"));
|
||||
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']"));
|
||||
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode()
|
||||
{
|
||||
// Retry / Discard relay a command to the site — a Deployer act. The auditor sees
|
||||
// the Parked node and its detail, but not the two action buttons.
|
||||
RegisterServices(role: "Viewer");
|
||||
var root = Guid.NewGuid();
|
||||
var (parked, _, _) = ParkedThenResolved(root);
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(parked);
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
|
||||
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-step-discard']"));
|
||||
_flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface()
|
||||
{
|
||||
// ?methodId= is a Live-run drill-in. For a non-Deployer it must not put the page
|
||||
// into Live mode or surface the Run button.
|
||||
RegisterServices(role: "Viewer");
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/deployment/flow-debugger?methodId=4");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
|
||||
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
|
||||
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -234,9 +234,20 @@ public class FlowDebugRunnerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleGate_AllThreeCommands_AreDeployerOnly()
|
||||
public void RoleGate_TheTwoRunCommands_AreDeployerOnly()
|
||||
{
|
||||
foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b"), new FlowDebugReplayCommand(Guid.NewGuid()) })
|
||||
// arch-review F3: a Live run executes scripts and writes attributes, so it stays Deployer-only.
|
||||
foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b") })
|
||||
Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleGate_Replay_AlsoAcceptsTheOperationalAuditRoles()
|
||||
{
|
||||
// arch-review F3: Replay only reads the audit chain, and the Audit Log / Execution
|
||||
// Chain drill-ins must work for an auditor who holds no Deployer role.
|
||||
var actual = ManagementActor.GetRequiredRoles(new FlowDebugReplayCommand(Guid.NewGuid()));
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(new[] { "Administrator", "Deployer", "Viewer" }, actual!.OrderBy(r => r, StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ public class RequiredRoleMatrixTests
|
||||
private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];
|
||||
private static readonly string[] SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];
|
||||
|
||||
// arch-review F3: Flow Debugger Replay is an audit read, so it is open to the
|
||||
// OperationalAudit roles {Administrator, Viewer} in addition to Deployer. Written out
|
||||
// by hand (not read from AuthorizationPolicies.FlowDebuggerAccessRoles) to keep the
|
||||
// freeze meaningful — a change to the policy's role set must fail this table first.
|
||||
private static readonly string[] FlowDebuggerReplayers = [Roles.Deployer, Roles.Administrator, Roles.Viewer];
|
||||
|
||||
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// ---- Administrator-only -----------------------------------------------------
|
||||
@@ -138,10 +144,14 @@ public class RequiredRoleMatrixTests
|
||||
["RetryParkedMessage"] = [Roles.Deployer],
|
||||
["DiscardParkedMessage"] = [Roles.Deployer],
|
||||
["DebugSnapshot"] = [Roles.Deployer],
|
||||
// Flow Debugger (design 2026-09-05 §9): Live runs + Replay are Deployer, like Debug View.
|
||||
// Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3): a Live run executes
|
||||
// scripts and writes attributes on operator input, so it stays Deployer-only.
|
||||
// Replay only reads the audit chain, so it carries the OperationalAudit roles as
|
||||
// well — the Audit Log / Execution Chain drill-ins are the feature's main entry
|
||||
// for a past incident and an auditor must be able to follow them.
|
||||
["FlowDebugRunMethod"] = [Roles.Deployer],
|
||||
["FlowDebugRunValueChange"] = [Roles.Deployer],
|
||||
["FlowDebugReplay"] = [Roles.Deployer],
|
||||
["FlowDebugReplay"] = FlowDebuggerReplayers,
|
||||
|
||||
// ---- Two-person secured write ----------------------------------------------
|
||||
["SubmitSecuredWrite"] = [Roles.Operator],
|
||||
|
||||
@@ -1264,6 +1264,44 @@ public class AuthorizationPolicyTests
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireSecuredWriteAccess, principal));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Flow Debugger page access (arch-review F3 / design D6). Replay is an
|
||||
// audit read, so opening the page is any-of {Deployer, Administrator,
|
||||
// Viewer} — the OperationalAudit roles plus Deployer. The Live run
|
||||
// controls are separately gated on Deployer by the page and, server-side,
|
||||
// by the ManagementActor role matrix; Designer gets neither.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Deployer")]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Viewer")]
|
||||
public async Task RequireFlowDebugger_ReplayCapableRoles_Satisfy(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Designer")]
|
||||
[InlineData("Operator")]
|
||||
[InlineData("Verifier")]
|
||||
public async Task RequireFlowDebugger_OtherRoles_Denied(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireFlowDebugger_DoesNotImplyDeployment()
|
||||
{
|
||||
// A Viewer may open the page (Replay) but must not satisfy the policy the
|
||||
// Live run controls are gated on.
|
||||
var viewer = CreatePrincipal(new[] { Roles.Viewer });
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, viewer));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, viewer));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
|
||||
Reference in New Issue
Block a user