mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
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:
parent
6b18327d6b
commit
8ff4ab1742
4 changed files with 149 additions and 8 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
docs/adr/0001-work-order-single-org-scope.md
Normal file
71
docs/adr/0001-work-order-single-org-scope.md
Normal 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`
|
||||
Loading…
Add table
Reference in a new issue