merge(agent-a): rows 37/86/87/88 — deployment reads consolidated on QueryDeploymentListPageAsync (QueryDeploymentSummariesAsync deleted); SiteScopeService honors the Administrator system-wide rule (claims no longer scope admins — the documented ManagementActor semantic now holds on both surfaces); site scope rides the query join (whole-table instance read gone), PageSize bounded (default 50, cap 1000); unparseable --status fails loudly naming valid values

This commit is contained in:
Joseph Doherty
2026-08-17 05:28:51 -04:00
21 changed files with 760 additions and 256 deletions
@@ -94,10 +94,18 @@ public static class DeployCommands
private static Command BuildStatus(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
// The valid set is named in the help text rather than enforced with
// AcceptOnlyFromAmong: that check is case-SENSITIVE, and the server has
// always accepted `--status success`. The server rejects an unrecognised
// value with the same list (register row 88) and is the authoritative
// check — HTTP API callers never pass through this option at all.
var statusOption = new Option<string?>("--status")
{
Description = "Filter by status (Pending, InProgress, Success, Failed; case-insensitive)",
};
var pageOption = new Option<int>("--page") { Description = "Page number" };
pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size (1-1000; 0 or unset uses 50)" };
pageSizeOption.DefaultValueFactory = _ => 50;
var cmd = new Command("status") { Description = "Query deployment status" };
@@ -113,7 +121,8 @@ public static class DeployCommands
var pageSize = result.GetValue(pageSizeOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new QueryDeploymentsCommand(instanceId, status, page, pageSize));
new QueryDeploymentsCommand(instanceId, status, page, pageSize),
tableProjector: DeploymentStatusTableProjection.ProjectRows);
});
return cmd;
}
@@ -0,0 +1,120 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Table projection for <c>deploy status</c> (deferred-work register rows 37/87).
///
/// <para>
/// The handler now returns a <c>DeploymentQueryResult</c> envelope —
/// <c>{ "deployments": [ … ], "totalCount": N, "page": P, "pageSize": S }</c> — where
/// it used to return a bare row array. Handed to the generic renderer unprojected,
/// an object takes the Property/Value branch and the whole <c>deployments</c> array
/// lands in a single unreadable cell. This unwraps the envelope to the row array the
/// renderer expects, exactly as <see cref="AuditConfigTableProjection"/> does for
/// <c>audit-config query</c>.
/// </para>
///
/// <para>
/// The envelope's counts would be lost by a plain unwrap, and losing THOSE is the one
/// thing this projection must not do: the page size is bounded server-side now, so a
/// full page is no longer proof that there are no more rows. When the page is a strict
/// subset a single trailing row carries a <c>note</c> cell saying how many of how many
/// are shown and how to get the rest. When the page IS the whole result set the column
/// is not emitted at all.
/// </para>
///
/// <para>
/// JSON output is never touched — this runs only on the table path.
/// </para>
/// </summary>
internal static class DeploymentStatusTableProjection
{
/// <summary>
/// Projects a <c>DeploymentQueryResult</c> envelope (or a bare row array) to the
/// row array the table renderer consumes. Returns the input unchanged when it is
/// not JSON or not one of those shapes, so the renderer's own fallbacks apply.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <returns>JSON suitable for table rendering.</returns>
internal static string ProjectRows(string json)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
return json;
}
using (doc)
{
var root = doc.RootElement;
// A bare array is accepted so the command still renders usefully against
// an older server that returns the pre-envelope shape.
if (root.ValueKind == JsonValueKind.Array)
return BuildRows(root, totalCount: null).ToJsonString();
if (root.ValueKind == JsonValueKind.Object
&& TryGetPropertyCI(root, "deployments", out var rows)
&& rows.ValueKind == JsonValueKind.Array)
{
int? total = TryGetPropertyCI(root, "totalCount", out var tc)
&& tc.ValueKind == JsonValueKind.Number
&& tc.TryGetInt32(out var n)
? n
: null;
return BuildRows(rows, total).ToJsonString();
}
// Some other object (e.g. an error envelope) — leave it to the renderer.
return json;
}
}
private static JsonArray BuildRows(JsonElement rows, int? totalCount)
{
var arr = new JsonArray();
var shown = 0;
foreach (var item in rows.EnumerateArray())
{
arr.Add(item.ValueKind == JsonValueKind.Object
? JsonNode.Parse(item.GetRawText())!
: JsonValue.Create(item.ToString())!);
shown++;
}
// Only when the page is a strict subset — otherwise the count is just the row
// count the operator can already see, and the extra column would be noise.
if (totalCount is int total && total > shown)
{
arr.Add(new JsonObject
{
["note"] = JsonValue.Create(
$"showing {shown} of {total} — use --page / --page-size for the rest"),
});
}
return arr;
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
}
+16 -2
View File
@@ -1011,9 +1011,23 @@ scadabridge --url <url> deploy status [--instance-id <int>] [--status <string>]
| Option | Required | Default | Description |
|--------|----------|---------|-------------|
| `--instance-id` | no | — | Filter by instance ID |
| `--status` | no | — | Filter by deployment status string |
| `--status` | no | — | Filter by deployment status: `Pending`, `InProgress`, `Success`, `Failed` (case-insensitive) |
| `--page` | no | `1` | Page number |
| `--page-size` | no | `50` | Results per page |
| `--page-size` | no | `50` | Results per page; capped at `1000`. `0` means "unspecified" and takes the default — it does **not** mean unlimited |
Output is an envelope: `deployments` (the page of rows, newest first), `totalCount`
(rows matching the filter across all pages), plus the `page` and `pageSize` the server
actually applied. `--format table` renders the rows and appends a `showing N of M`
note when the page is a strict subset.
An unrecognised `--status` is an **error** naming the valid values, not a silent
fallback to "all statuses" — a typo like `--status Sucess` used to return every
deployment record, which looks exactly like a complete answer to the question you
meant to ask.
Site scoping: a site-scoped Deployer sees only deployments for instances at their
permitted sites. A user holding `Administrator` is system-wide regardless of any site
grant, matching the Central UI.
---
@@ -6,14 +6,26 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary>
/// Resolves the set of sites the current user is permitted to operate on, from
/// the <c>SiteId</c> claims attached at login.
/// the roles and <c>SiteId</c> claims attached at login.
/// <para>
/// The design (Component-CentralUI, CLAUDE.md "Security &amp; Auth") makes the
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group
/// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/>
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
/// A Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is
/// system-wide.
/// A user with no <c>SiteId</c> claim is system-wide, and so is any user holding
/// the <see cref="Roles.Administrator"/> role — regardless of the <c>SiteId</c>
/// claims they carry.
/// </para>
/// <para>
/// <b>The Administrator bypass is not a convenience, it is the agreed semantic on
/// the other side of the same rule.</b> <c>ManagementActor</c> has always exempted
/// Administrator from site scoping (<c>EnforceSiteScope</c> and every handler that
/// calls it), so a user holding Administrator ALONGSIDE a scoped Deployer grant
/// could act fleet-wide through the CLI/HTTP surface while this service hid the
/// same sites in the UI — the UI was the side that disagreed with the documented
/// rule (deferred-work register row 86). Note that Designer is NOT exempt: the
/// enforced rule has only ever bypassed Administrator, and this doc used to claim
/// "any Admin/Design user" is system-wide, which was never true of either side.
/// </para>
/// <para>
/// Deployment and Monitoring pages must filter every site/instance list through
@@ -35,8 +47,9 @@ public sealed class SiteScopeService
}
/// <summary>
/// True when the user is not restricted to a site subset (no <c>SiteId</c>
/// claims). System-wide users see and act on every site.
/// True when the user is not restricted to a site subset — either because they
/// hold <see cref="Roles.Administrator"/> or because they carry no
/// <c>SiteId</c> claims. System-wide users see and act on every site.
/// </summary>
/// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns>
public async Task<bool> IsSystemWideAsync()
@@ -83,19 +96,36 @@ public sealed class SiteScopeService
return cached;
var state = await _authStateProvider.GetAuthenticationStateAsync();
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
// Administrator => system-wide, whatever SiteId claims are present. Read
// the role CLAIM rather than IsInRole: the principal's role claim type is
// only bound to JwtTokenService.RoleClaimType when the identity was built
// by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match
// ManagementActor's — IsInRole compares claim VALUES ordinally, which would
// make the two sides disagree on a differently-cased role name.
var isAdministrator = state.User
.FindAll(JwtTokenService.RoleClaimType)
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
var ids = new HashSet<int>();
foreach (var claim in siteClaims)
if (!isAdministrator)
{
if (int.TryParse(claim.Value, out var id))
ids.Add(id);
// An Administrator's SiteId claims are not merely overridden, they are
// not collected at all: the invariant "system-wide => empty set" then
// holds for every caller, so a consumer that reads the id set without
// first consulting IsSystemWide cannot silently narrow an Administrator
// to a subset of sites.
foreach (var claim in state.User.FindAll(JwtTokenService.SiteIdClaimType))
{
if (int.TryParse(claim.Value, out var id))
ids.Add(id);
}
}
// No SiteId claims => system-wide. Absence of scope rules means an
// unrestricted deployer (this service is the sole
// site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
var result = (IsSystemWide: isAdministrator || ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result;
return result;
}
@@ -16,8 +16,21 @@ public interface IDeploymentManagerRepository
/// <returns>The deployment record, or null if not found.</returns>
Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Database-side paged, filtered and instance-joined deployment listing —
/// the read path behind the Central UI deployment-status page.
/// Database-side paged, filtered and instance-joined deployment listing — THE
/// paged read of <c>DeploymentRecords</c>, shared by the Central UI
/// deployment-status page and the CLI/HTTP <c>QueryDeployments</c> command.
///
/// <para>
/// It was two reads until deferred-work register row 37. The other one
/// (<c>QueryDeploymentSummariesAsync</c>) differed in three ways that were all
/// defects rather than requirements: it took a raw <c>skip</c> instead of a page
/// number, returned no total (so a truncated page was indistinguishable from a
/// complete one), skipped the <c>Instance</c> join, and scoped by a caller-resolved
/// INSTANCE-ID LIST — which its one caller built by loading every instance in the
/// system on every scoped query. Site scoping is expressed here as
/// <see cref="Types.Deployment.DeploymentListFilter.SiteIdScope"/> and resolved by
/// the join, so no caller needs that read.
/// </para>
///
/// <para>
/// Replaces a <c>GetAllDeploymentRecords</c> whole-table read. That method's only
@@ -50,7 +63,7 @@ public interface IDeploymentManagerRepository
/// </summary>
/// <param name="filter">Status / search / site-scope / instance constraints; all applied DB-side.</param>
/// <param name="pageNumber">1-based page number; values below 1 are floored at 1.</param>
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentRecordSummary.MaxPageSize]</c>.</param>
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentListPage.MaxPageSize]</c>. There is no "unlimited" value.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The requested page of rows plus the total count of the filtered set.</returns>
Task<Types.Deployment.DeploymentListPage> QueryDeploymentListPageAsync(
@@ -80,31 +93,6 @@ public interface IDeploymentManagerRepository
Types.Deployment.DeploymentListFilter filter,
CancellationToken cancellationToken = default);
/// <summary>
/// Database-side paged and filtered deployment query returning row summaries.
/// Backs the <c>QueryDeployments</c> management command, which previously
/// loaded the ENTIRE deployment-record table (insert-only, one row per deploy
/// attempt for the life of the system) and ignored the page arguments the CLI
/// was already sending.
/// </summary>
/// <param name="instanceId">Restrict to one instance's history, or <see langword="null"/> for all.</param>
/// <param name="status">Restrict to one deployment status, or <see langword="null"/> for all.</param>
/// <param name="instanceIdScope">
/// When non-null, restrict to these instance ids. Used to apply a site-scoped
/// user's permitted-site filter IN THE DATABASE rather than by loading every
/// record and every instance and filtering in memory.
/// </param>
/// <param name="skip">Rows to skip; negative values are floored at 0.</param>
/// <param name="take">Page size; non-positive or null returns every remaining row.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>One page of deployment summaries, newest first.</returns>
Task<IReadOnlyList<Types.Deployment.DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes TERMINAL deployment records (<see cref="DeploymentStatus.Success"/> /
/// <see cref="DeploymentStatus.Failed"/>) that completed before
/// <paramref name="cutoffUtc"/>, in one bounded batch.
@@ -23,4 +23,36 @@ public record MgmtDeployArtifactsCommand(int? SiteId = null);
/// <param name="SiteId">Database id of the target site. Required — this command is never fleet-wide.</param>
public record MgmtDeploySiteCommand(int SiteId);
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
/// <summary>
/// Result wrapper for <see cref="QueryDeploymentsCommand"/>.
///
/// <para>
/// The handler used to return a bare row array. It cannot any more: the page size
/// is now BOUNDED (a non-positive <c>PageSize</c> no longer means "unlimited" —
/// deferred-work register row 87), and a bounded page without a total is a silent
/// truncation. <see cref="TotalCount"/> is what lets a caller tell "that is all of
/// them" from "there is more, ask for page 2". Mirrors
/// <c>AuditLogQueryResult</c> / <c>SecuredWriteListResult</c>, the other paged
/// management query results.
/// </para>
///
/// <para>
/// <see cref="Page"/> and <see cref="PageSize"/> echo the values the server
/// actually applied, which are not necessarily the ones requested: both are
/// floored/clamped/defaulted server-side, so the echo is the only way a caller
/// learns the effective slice. Additive contract; new fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Deployments">The requested page of deployment rows, newest first.</param>
/// <param name="TotalCount">Total rows matching the filter across all pages.</param>
/// <param name="Page">The 1-based page number actually served.</param>
/// <param name="PageSize">The page size actually applied.</param>
public record DeploymentQueryResult(
IReadOnlyList<Types.Deployment.DeploymentListRow> Deployments,
int TotalCount,
int Page,
int PageSize);
public record GetDeploymentDiffCommand(int InstanceId);
@@ -19,6 +19,24 @@ public sealed record DeploymentListPage(
IReadOnlyList<DeploymentListRow> Rows,
int TotalCount)
{
/// <summary>
/// Hard ceiling applied to a requested page size by every caller of the paged
/// deployment read. Lives on the page contract because the page is the single
/// shape that read now has: the older <c>DeploymentRecordSummary</c> projection
/// (which owned this constant, and whose query accepted an unlimited
/// <c>take</c>) was folded into this one by deferred-work register row 37.
/// </summary>
public const int MaxPageSize = 1000;
/// <summary>
/// Page size used when a caller supplies none (or a non-positive one). Matches
/// the CLI's own <c>--page-size</c> default, so <c>deploy status</c> with no
/// paging flags behaves identically whether the value travels on the wire or
/// is defaulted here (register row 87 — a non-positive size used to mean
/// "unlimited" and materialized the whole retention window).
/// </summary>
public const int DefaultPageSize = 50;
/// <summary>An empty page — no rows, no matches.</summary>
public static DeploymentListPage Empty { get; } = new(Array.Empty<DeploymentListRow>(), 0);
@@ -3,8 +3,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// One rendered row of the Central UI deployment-status list: a
/// <c>DeploymentRecord</c> projection already joined to its <c>Instance</c>.
/// One row of a deployment listing: a <c>DeploymentRecord</c> projection already
/// joined to its <c>Instance</c>. THE row projection — the Central UI
/// deployment-status page and the CLI/HTTP <c>QueryDeployments</c> command share
/// it, having previously had a projection each (deferred-work register row 37;
/// the CLI-side <c>DeploymentRecordSummary</c> was the one that went).
///
/// <para>
/// The join is what removes the page's SECOND full-table read. Rendering a row
@@ -14,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// </para>
///
/// <para>
/// Like <see cref="DeploymentRecordSummary"/>, the <c>RowVersion</c>
/// The <c>RowVersion</c>
/// optimistic-concurrency token is deliberately absent — this is a READ projection
/// and must never be mistaken for something that can be written back. Mutating
/// paths keep loading the tracked entity.
@@ -1,54 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// Row-shaped projection of a <c>DeploymentRecord</c> for list surfaces (CLI
/// <c>deploy list</c> / <c>instance</c> history, the Central UI deployment-status
/// page).
///
/// <para>
/// Exists so a deployment listing can be paged and filtered in the DATABASE.
/// Deployment records are insert-only — one row per deploy attempt, retained for
/// the configured retention window — so the previous "load the whole table, then
/// filter in memory" read path degraded with the age of the system rather than
/// with the size of the requested page.
/// </para>
///
/// <para>
/// The <c>RowVersion</c> optimistic-concurrency token is deliberately absent: this
/// is a READ projection, and a summary must never be mistaken for something that
/// can be written back. Mutating paths keep loading the tracked entity.
/// </para>
///
/// <para>
/// Message-contract evolution rule: additive-only. New fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Id">Deployment record row id.</param>
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
/// <param name="InstanceId">Instance the deployment targeted.</param>
/// <param name="Status">Terminal or in-flight deployment status.</param>
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
/// <param name="DeployedBy">User who initiated the deployment.</param>
/// <param name="DeployedAt">When the deployment was initiated.</param>
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
public record DeploymentRecordSummary(
int Id,
string DeploymentId,
int InstanceId,
DeploymentStatus Status,
string? RevisionHash,
string DeployedBy,
DateTimeOffset DeployedAt,
DateTimeOffset? CompletedAt,
string? ErrorMessage)
{
/// <summary>
/// Hard ceiling applied to a requested page size, mirroring the in-memory
/// <c>Page</c> clamp the management actor already applies elsewhere.
/// </summary>
public const int MaxPageSize = 1000;
}
@@ -41,8 +41,9 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration<Deployment
builder.HasIndex(d => d.InstanceId);
// Deployment-list sort key. Every list read orders DeployedAt DESC, Id DESC
// (DeploymentManagerRepository.QueryDeploymentListPageAsync and
// QueryDeploymentSummariesAsync) — Id is the deterministic tie-break, because
// (DeploymentManagerRepository.QueryDeploymentListPageAsync, which register
// row 37 made the single paged read behind BOTH the Central UI page and the
// CLI/HTTP QueryDeployments command) — Id is the deterministic tie-break, because
// a bulk site deploy stamps many rows in the same DeployedAt instant and a
// non-deterministic order would let a row repeat or vanish across pages.
// Declaring both key columns descending lets a forward scan feed the paged
@@ -115,7 +115,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
CancellationToken cancellationToken = default)
{
var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, DeploymentRecordSummary.MaxPageSize);
var size = Math.Clamp(pageSize, 1, DeploymentListPage.MaxPageSize);
var query = BuildListQuery(filter, applyStatus: true);
@@ -396,58 +396,6 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
return expired.Count;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _dbContext.DeploymentRecords.AsNoTracking();
if (instanceId.HasValue)
query = query.Where(d => d.InstanceId == instanceId.Value);
if (status.HasValue)
query = query.Where(d => d.Status == status.Value);
if (instanceIdScope != null)
{
// Site scoping applied DB-side. An empty scope is a real, meaningful
// filter — a user permitted no in-scope instances sees nothing — so it
// must not be short-circuited into "no filter".
var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
query = query.Where(d => scope.Contains(d.InstanceId));
}
// ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
// does it: DeployedAt ties on rapid redeploys, and an unstable sort key
// makes Skip/Take paging non-deterministic (rows repeat or vanish between
// pages).
var paged = query
.OrderByDescending(d => d.DeployedAt)
.ThenByDescending(d => d.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));
return await paged
.Select(d => new DeploymentRecordSummary(
d.Id,
d.DeploymentId,
d.InstanceId,
d.Status,
d.RevisionHash,
d.DeployedBy,
d.DeployedAt,
d.CompletedAt,
d.ErrorMessage))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeTerminalDeploymentRecordsAsync(
DateTimeOffset cutoffUtc,
@@ -25,6 +25,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -2558,53 +2559,112 @@ public class ManagementActor : ReceiveActor
if (sp.GetService<DeploymentService>() is { } deploymentService)
await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();
// Page and Status are honoured DB-side. Both arrived on the command
// contract (and the CLI has been sending --page / --page-size / --status
// all along) but the handler previously ignored all three and returned the
// whole table.
// Page, PageSize and Status are all honoured DB-side, through the SAME
// paged read the Central UI deployment page uses (register row 37 — there
// used to be a second, subtly different repository query behind this
// handler alone).
var status = ParseDeploymentStatus(cmd.Status);
var skip = Math.Max(0, (Math.Max(1, cmd.Page) - 1) * Math.Max(1, cmd.PageSize));
var take = cmd.PageSize > 0 ? cmd.PageSize : (int?)null;
var page = Math.Max(1, cmd.Page);
var pageSize = ResolveDeploymentPageSize(cmd.PageSize);
// Instance-scoped query: enforce against the target instance's site
// before reading its deployment history.
if (cmd.InstanceId.HasValue)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
return await repo.QueryDeploymentSummariesAsync(
cmd.InstanceId.Value, status, instanceIdScope: null, skip, take);
return await QueryDeploymentPage(
repo, new DeploymentListFilter(status, InstanceId: cmd.InstanceId.Value), page, pageSize);
}
// Unfiltered query: a site-scoped Deployment user must only see records
// for instances at sites within their scope. DeploymentRecord has no
// SiteId, so the scope is resolved to the set of in-scope instance ids and
// pushed into the query as an id filter — rather than loading every record
// AND every instance and intersecting them in memory, which made an
// unfiltered listing cost two full-table loads regardless of page size.
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
return await repo.QueryDeploymentSummariesAsync(null, status, instanceIdScope: null, skip, take);
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var allInstances = await templateRepo.GetAllInstancesAsync();
var scopedInstanceIds = allInstances
.Where(i => permittedIds.Contains(i.SiteId.ToString()))
.Select(i => i.Id)
.ToArray();
return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
// SiteId of its own, so the scope rides as a SITE-ID filter resolved by the
// query's own join to Instance. It used to be resolved here instead, by
// loading EVERY instance in the system on every scoped query to turn the
// site grant into an instance-id list (register row 87).
var siteScope = ResolveSiteIdScope(user);
return await QueryDeploymentPage(
repo, new DeploymentListFilter(status, SiteIdScope: siteScope), page, pageSize);
}
/// <summary>
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
/// An unparseable value is treated as "no filter" rather than an error, which
/// preserves the previous handler's behaviour of ignoring the field entirely.
/// Runs the shared paged deployment read and wraps it in the command's result
/// envelope, echoing the page coordinates that were actually applied.
/// </summary>
private static DeploymentStatus? ParseDeploymentStatus(string? status) =>
!string.IsNullOrWhiteSpace(status) && Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
? parsed
: null;
private static async Task<DeploymentQueryResult> QueryDeploymentPage(
IDeploymentManagerRepository repo, DeploymentListFilter filter, int page, int pageSize)
{
var result = await repo.QueryDeploymentListPageAsync(filter, page, pageSize);
return new DeploymentQueryResult(result.Rows, result.TotalCount, page, pageSize);
}
/// <summary>
/// Resolves the caller's site-scope grant into the query's
/// <see cref="DeploymentListFilter.SiteIdScope"/>: <see langword="null"/> for a
/// system-wide caller, otherwise the permitted site ids.
/// </summary>
/// <remarks>
/// Administrator is system-wide here, matching <see cref="EnforceSiteScope"/>
/// and the rest of this actor. A permitted-site id that is not an integer is
/// DROPPED rather than ignored: dropping can only narrow the grant, whereas
/// treating the whole grant as unparseable would widen it to fleet-wide. A
/// grant of nothing but garbage therefore yields an EMPTY scope, which the
/// filter treats as a real "sees nothing" constraint.
/// </remarks>
private static IReadOnlyCollection<int>? ResolveSiteIdScope(AuthenticatedUser user)
{
if (user.PermittedSiteIds.Length == 0) return null;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
var ids = new List<int>(user.PermittedSiteIds.Length);
foreach (var raw in user.PermittedSiteIds)
{
if (int.TryParse(raw, out var id))
ids.Add(id);
}
return ids;
}
/// <summary>
/// Bounds the requested deployment page size: a non-positive value means
/// "unspecified" and takes the default, and every value is capped at
/// <see cref="DeploymentListPage.MaxPageSize"/>.
/// </summary>
/// <remarks>
/// Register row 87. A non-positive size used to mean UNLIMITED all the way down
/// to the repository, so <c>--page-size 0</c> materialized the entire
/// terminal-record retention window into one response. The result envelope
/// carries the total count precisely so bounding the page does not hide rows.
/// </remarks>
private static int ResolveDeploymentPageSize(int requested) =>
requested <= 0
? DeploymentListPage.DefaultPageSize
: Math.Min(requested, DeploymentListPage.MaxPageSize);
/// <summary>
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
/// Absent/blank means "no filter"; an unrecognised value is an ERROR naming the
/// valid ones.
/// </summary>
/// <remarks>
/// Register row 88. An unparseable value used to fall back to "no filter", so
/// <c>--status Sucess</c> silently returned every status — the caller saw a
/// plausible, complete-looking result that did not answer the question they
/// asked. Same loud-failure principle as row 46.
/// </remarks>
/// <exception cref="ManagementCommandException">The value is not a deployment status.</exception>
private static DeploymentStatus? ParseDeploymentStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
if (Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
{
return parsed;
}
throw new ManagementCommandException(
$"Unknown deployment status '{status}'. Valid values: {string.Join(", ", Enum.GetNames<DeploymentStatus>())}.");
}
// ========================================================================
// Audit Log handler