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
@@ -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;
}