fix(work-orders): document single-org media scope (ADR 0001)

Clarify SH-116 tenant scope as board-aligned ApplyBaseScope + claims, and add out-of-org-scope GET/mutation tests for deleted/template/missing WOs.
This commit is contained in:
Arthur Bassi 2026-08-04 16:07:26 -03:00
parent 6b18327d6b
commit 8ff4ab1742
4 changed files with 149 additions and 8 deletions

View file

@ -6,8 +6,11 @@ namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Claims-derived authorization for work-order media read/mutations.
/// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT;
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
/// Organization scope matches the board: callers must already have resolved
/// the work order via <c>ApplyBaseScope</c> (non-deleted, non-template).
/// Staff may access any in-org work order; technicians only when
/// <see cref="WorkOrder.AssignTo"/> matches the actor. See
/// <c>docs/adr/0001-work-order-single-org-scope.md</c>.
/// </summary>
public static class WorkOrderMediaAuthorization
{
@ -51,14 +54,19 @@ namespace SeaHaven.Services.Helpers
}
/// <summary>
/// Staff: any in-scope (non-deleted/non-template) work order.
/// Technician: only work orders assigned to the caller. Out-of-scope → NotFound (no disclosure).
/// Caller-scope check after a base-scoped (in-org) work-order load.
/// Staff: any in-org work order already resolved via <c>ApplyBaseScope</c>.
/// Technician: only when assigned to the caller.
/// Out of caller scope → NotFound (no disclosure).
/// </summary>
public static void EnsureWorkOrderInCallerScope(
ClaimsPrincipal user,
string actorId,
WorkOrder workOrder)
{
// Organization boundary is enforced by the data-layer ApplyBaseScope
// load that produced <paramref name="workOrder"/>. Staff are authorized
// for any in-org work order (same as the board). See ADR 0001.
if (IsStaff(user))
return;

View file

@ -34,6 +34,8 @@ namespace SeaHaven.Services.Implementation
{
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
// Organization scope: ApplyBaseScope via Exists / GetWorkOrderForMedia.
// Outside org (deleted/template/missing) → null (no disclosure). ADR 0001.
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken))
return null;
@ -257,7 +259,7 @@ namespace SeaHaven.Services.Implementation
string actorId,
CancellationToken cancellationToken)
{
// Base-scoped lookup: deleted/template work orders surface as NotFound (no disclosure).
// Organization scope via ApplyBaseScope: deleted/template → NotFound (ADR 0001).
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");

View file

@ -731,6 +731,57 @@ public class WorkOrderMediaServiceTests
service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token));
}
/// <summary>
/// Out-of-organization-scope (single-org analogue of SH-116 cross-tenant):
/// deleted work orders are outside ApplyBaseScope → null / no media disclosure.
/// </summary>
[Fact]
public async Task GetMedia_StaffOnDeletedWorkOrder_ReturnsNull_OutOfOrgScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
IsDeleted = true,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.Null(media);
}
/// <summary>
/// Out-of-organization-scope: template work orders are outside ApplyBaseScope.
/// </summary>
[Fact]
public async Task GetMedia_StaffOnTemplateWorkOrder_ReturnsNull_OutOfOrgScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
istemplate = true,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.Null(media);
}
[Fact]
public async Task GetMedia_MissingWorkOrder_ReturnsNull_OutOfOrgScope()
{
var (_, service) = CreateSut();
var media = await service.GetMediaAsync(99, AuthenticatedUser(), "actor-1");
Assert.Null(media);
}
[Fact]
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
{
@ -937,8 +988,11 @@ public class WorkOrderMediaServiceTests
Assert.Equal("Forbidden", ex.Code);
}
/// <summary>
/// Out-of-organization-scope mutation: missing id → NotFound (no disclosure).
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound()
public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound_OutOfOrgScope()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
@ -946,8 +1000,11 @@ public class WorkOrderMediaServiceTests
Assert.Equal("NotFound", ex.Code);
}
/// <summary>
/// Out-of-organization-scope mutation: deleted WO outside ApplyBaseScope → NotFound.
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound()
public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound_OutOfOrgScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
@ -965,8 +1022,11 @@ public class WorkOrderMediaServiceTests
Assert.Equal("NotFound", ex.Code);
}
/// <summary>
/// Out-of-organization-scope mutation: template WO outside ApplyBaseScope → NotFound.
/// </summary>
[Fact]
public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound()
public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound_OutOfOrgScope()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder

View file

@ -0,0 +1,71 @@
# ADR 0001: Work-order media uses single-org scope (board-aligned)
## Status
Accepted — 2026-08-04
## Context
SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access
be rejected without metadata disclosure. The repository hard rule
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) requires **server-derived tenant
scope**: filtering by tenant/customer/owner comes from the authenticated
principal and the data layer, never from client-supplied body/query as the
source of truth.
The work-order domain does not model `TenantId` / `CustomerId` on `WorkOrder`
or on JWT claims. The board, search, and detail paths already treat the
deployment as a single organization: staff see any work order that passes
`ApplyBaseScope` (non-deleted, non-template). Introducing a multi-tenant key
would require product modeling plus a schema migration, which is out of scope
for the SH-116 media contract.
## Decision
Work-order media authorization matches the board:
1. **Organization / “tenant” boundary** = `ApplyBaseScope` in the data layer
(`istemplate != true` and not deleted). Lookups outside that set resolve as
missing → `NotFound` / null (no disclosure).
2. **Authorization at service entry** from claims: staff roles
(`Admin`, `Manager`, `Dispatcher`, `Supervisor`) may read/mutate any
in-org work order; role `User` (technician) only when
`WorkOrder.AssignTo == actorId`; delete remains staff-only.
3. Scope is never taken from request body or query as the trust source;
`actorId` and roles come from the authenticated principal.
This satisfies server-derived scope for the current single-org deployment.
True multi-tenant isolation remains deferred until product models a tenant key
and emits a matching claim.
## Consequences
- Out-of-organization-scope tests cover deleted, template, and missing work
order ids (GET and mutations) as the single-org analogue of SH-116
“cross-tenant” rejection.
- Staff org-wide access by numeric id remains intentional and aligned with the
board; it is not a substitute for future multi-tenant keys.
- Reviewers of media PRs should cite this ADR when evaluating tenant-scope
findings against SH-116.
## Excepted / clarified rule
Hard rule: **server-derived tenant scope**
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2).
Clarification: in the work-order domain, the server-derived scope key is the
organization boundary enforced by `ApplyBaseScope` plus claims-derived
role/assignee — not a `TenantId`/`CustomerId` column. Absence of a multi-tenant
key is an accepted product/architecture state until superseded.
## Review / expiry
Re-review by **2027-02-04**, or earlier if product introduces
`TenantId`/`CustomerId` on work orders or JWT claims.
## References
- SH-116 — Completion document: fields + media categorization
- PR that relies on this ADR: Sea-Haven-Industries/shoc-backend#47
- `WorkOrderBoardQueryFilters.ApplyBaseScope`
- `WorkOrderMediaAuthorization`