fix(security): scope-filter secured-write listing + align spec with enforced site scoping (plan R2-07 T6)

This commit is contained in:
Joseph Doherty
2026-07-13 10:53:16 -04:00
parent 92936747b7
commit 1fcfa4fb16
4 changed files with 77 additions and 15 deletions
@@ -467,7 +467,7 @@ public class ManagementActor : ReceiveActor
SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd),
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd, user),
// Transport bundle operations
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
@@ -1493,16 +1493,36 @@ public class ManagementActor : ReceiveActor
}
private static async Task<object?> HandleListSecuredWrites(
IServiceProvider sp, ListSecuredWritesCommand cmd)
IServiceProvider sp, ListSecuredWritesCommand cmd, AuthenticatedUser user)
{
// Site scoping (arch-review R2 N3): an explicit SiteId filter is scope-checked
// like every other identifier-keyed command; an UNFILTERED list from a
// site-scoped non-admin caller is constrained to their permitted sites at the
// query level (rows AND totalCount both scoped — see ISecuredWriteRepository).
IReadOnlyCollection<string>? permittedIdentifiers = null;
if (cmd.SiteId is not null)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId);
}
else if (user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var siteRepo = sp.GetRequiredService<ISiteRepository>();
var permitted = new HashSet<string>(user.PermittedSiteIds);
permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
.Where(s => permitted.Contains(s.Id.ToString()))
.Select(s => s.SiteIdentifier)
.ToList();
}
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
// Offset paging (arch-review P3): clamp untrusted paging inputs so a crafted
// command can neither request a negative offset nor an unbounded page.
var take = Math.Clamp(cmd.Take, 1, 500);
var skip = Math.Max(0, cmd.Skip);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take, permittedIdentifiers);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId, permittedIdentifiers);
// Opportunistic expiry sweep (arch-review S2): every time the list is read, walk
// the page and expire any overdue Pending row. TryExpireIfStaleAsync self-filters