refactor(deployments): one paged read of DeploymentRecords + close its three drifts

Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the
three behavioral divergences it had accumulated, fixed together.

Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync`
is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same
insert-only table with different contracts — raw offset vs page number, no total vs
total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The
CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync`
with a `DeploymentListFilter`, exactly like the Central UI page. The
`DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single
row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains
`DefaultPageSize`.

Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was
claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through
the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and
only their granted sites on the Deployments page. One user, one dataset, two answers.
Administrator = system-wide is the intended semantic on both sides; the UI was the
defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather
than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the
identity carries the matching roleType — the two sides must not disagree on a
differently-cased role name. An Administrator's site claims are not collected at all,
so the invariant "system-wide => empty permitted-id set" holds for every caller.
Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide,
which was true of neither side, so the doc was corrected rather than the bypass
widened.

Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id
list by loading EVERY instance in the system, non-transactionally, on every scoped
query; the grant now rides as `SiteIdScope` and is resolved by the query's own join.
An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow
a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer
means UNLIMITED down at the repository (`--page-size 0` materialized the whole
retention window): it means "unspecified" and takes the CLI's own default of 50,
capped at 1000. Bounding without telling would be silent truncation, so the handler
now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page
coordinates actually applied — with a CLI table projection (mirroring row 57's
`AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note.

Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like
`--status Sucess` returned every status — a complete-looking answer to a question the
caller never asked. It is now a `ManagementError` naming the valid values. Parsing
stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse`
accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud
failure over silent full results, per row 46.

Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified
failing 6/7 against the pre-fix service, the Designer pin passing both ways); the
ManagementActor deployment suite migrated onto the consolidated query with new pins
for the status error, case-insensitive parse, numeric rejection, page-size default/
cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository
test for site-scope+status composing over a bounded page; and 6 CLI tests for the new
table projection.
This commit is contained in:
Joseph Doherty
2026-08-17 05:15:18 -04:00
parent 15e5887b52
commit 9f67147e7b
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