mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 08:22:10 +00:00
!fix(work-orders): fail-closed media account scope with org_scope claim [SH-221]
This commit is contained in:
parent
fdc315d8fe
commit
ea2dedf579
15 changed files with 245 additions and 87 deletions
|
|
@ -41,7 +41,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Contact = user.Contact,
|
Contact = user.Contact,
|
||||||
Role = user.Role
|
Role = user.Role,
|
||||||
|
AccountId = user.AccountId
|
||||||
};
|
};
|
||||||
var outcome = await _userService.AddUserAsync(dto, cancellationToken);
|
var outcome = await _userService.AddUserAsync(dto, cancellationToken);
|
||||||
if (!outcome.Success)
|
if (!outcome.Success)
|
||||||
|
|
@ -68,7 +69,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
Id = user.Id,
|
Id = user.Id,
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
Role = user.Role
|
Role = user.Role,
|
||||||
|
AccountId = user.AccountId
|
||||||
};
|
};
|
||||||
var succeeded = await _userService.EditUserAsync(dto, cancellationToken);
|
var succeeded = await _userService.EditUserAsync(dto, cancellationToken);
|
||||||
if (!succeeded)
|
if (!succeeded)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Contact { get; set; }
|
public string? Contact { get; set; }
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
}
|
}
|
||||||
public class EditUser_DTO
|
public class EditUser_DTO
|
||||||
{
|
{
|
||||||
|
|
@ -13,5 +14,6 @@
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,11 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When <paramref name="accountId"/> is set, restrict to that CRM account.
|
/// Restricts to the given CRM account. Callers with org-wide scope must not
|
||||||
/// When null, no account filter (org-wide staff without an account claim).
|
/// invoke this method (use <see cref="ApplyBaseScope"/> only).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IQueryable<WorkOrder> ApplyAccountScope(IQueryable<WorkOrder> query, int? accountId)
|
public static IQueryable<WorkOrder> ApplyAccountScope(IQueryable<WorkOrder> query, int accountId)
|
||||||
{
|
=> query.Where(w => w.AccountId == accountId);
|
||||||
if (!accountId.HasValue)
|
|
||||||
return query;
|
|
||||||
|
|
||||||
return query.Where(w => w.AccountId == accountId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
|
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,13 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
int? accountId = null)
|
int? accountId = null)
|
||||||
=> WorkOrderBoardQueryFilters.ApplyAccountScope(
|
{
|
||||||
WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()),
|
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
accountId)
|
if (accountId.HasValue)
|
||||||
.AnyAsync(w => w.Id == workOrderId, cancellationToken);
|
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||||
|
|
||||||
|
return query.AnyAsync(w => w.Id == workOrderId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||||
{
|
{
|
||||||
|
|
@ -98,10 +101,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
int? accountId = null)
|
int? accountId = null)
|
||||||
{
|
{
|
||||||
return await WorkOrderBoardQueryFilters.ApplyAccountScope(
|
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()),
|
if (accountId.HasValue)
|
||||||
accountId)
|
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
|
||||||
|
return await query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,25 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int? accountId,
|
int? accountId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
=> WorkOrderBoardQueryFilters.ApplyAccountScope(
|
{
|
||||||
WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()),
|
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
accountId)
|
if (accountId.HasValue)
|
||||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||||
|
|
||||||
|
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int? accountId,
|
int? accountId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
=> WorkOrderBoardQueryFilters.ApplyAccountScope(
|
{
|
||||||
WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders),
|
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders);
|
||||||
accountId)
|
if (accountId.HasValue)
|
||||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||||
|
|
||||||
|
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
||||||
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
public interface IWorkOrderMediaDataService
|
public interface IWorkOrderMediaDataService
|
||||||
{
|
{
|
||||||
/// <summary>AsNoTracking base+account scoped load for pre-mutation auth (does not pollute the change tracker).</summary>
|
/// <summary>AsNoTracking base (+ optional account) scoped load for pre-mutation auth.</summary>
|
||||||
Task<WorkOrder?> GetWorkOrderForMediaAuthAsync(
|
Task<WorkOrder?> GetWorkOrderForMediaAuthAsync(
|
||||||
int workOrderId,
|
int workOrderId,
|
||||||
int? accountId,
|
int? accountId,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Contact { get; set; }
|
public string? Contact { get; set; }
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AddUserOutcomeDTO
|
public class AddUserOutcomeDTO
|
||||||
|
|
@ -45,6 +46,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
|
public int? AccountId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserListRowDTO
|
public class UserListRowDTO
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,23 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
/// <summary>CRM account id from <c>ApplicationUser.AccountId</c> (never from request body).</summary>
|
/// <summary>CRM account id from <c>ApplicationUser.AccountId</c> (never from request body).</summary>
|
||||||
public const string AccountId = "account_id";
|
public const string AccountId = "account_id";
|
||||||
|
|
||||||
|
/// <summary>Explicit org-wide media scope; value <see cref="OrgScopeAll"/>.</summary>
|
||||||
|
public const string OrgScope = "org_scope";
|
||||||
|
|
||||||
|
/// <summary>Signed org-wide elevation (Admin without AccountId).</summary>
|
||||||
|
public const string OrgScopeAll = "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolved media tenant scope from signed claims (fail-closed when Missing).</summary>
|
||||||
|
public abstract record MediaAccountScope
|
||||||
|
{
|
||||||
|
private MediaAccountScope() { }
|
||||||
|
|
||||||
|
public sealed record Account(int AccountId) : MediaAccountScope;
|
||||||
|
|
||||||
|
public sealed record OrgWide : MediaAccountScope;
|
||||||
|
|
||||||
|
public sealed record Missing : MediaAccountScope;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Claims-derived authorization for work-order media read/mutations.
|
/// Claims-derived authorization for work-order media read/mutations.
|
||||||
/// Callers must resolve the work order via <c>ApplyBaseScope</c> plus
|
/// Scope is fail-closed: callers need a valid <see cref="SeaHavenClaimTypes.AccountId"/>
|
||||||
/// <c>ApplyAccountScope</c> when the principal carries
|
/// or explicit <see cref="SeaHavenClaimTypes.OrgScope"/>=<see cref="SeaHavenClaimTypes.OrgScopeAll"/>.
|
||||||
/// <see cref="SeaHavenClaimTypes.AccountId"/>. Staff may access any
|
/// Absence of scope does not elevate. Staff may access any resulting work order;
|
||||||
/// resulting work order; technicians only when <see cref="WorkOrder.AssignTo"/>
|
/// technicians only when <see cref="WorkOrder.AssignTo"/> matches the actor.
|
||||||
/// matches the actor. Delete is staff-only.
|
/// Delete is staff-only.
|
||||||
/// Staff without an account claim remain org-wide (base scope only).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WorkOrderMediaAuthorization
|
public static class WorkOrderMediaAuthorization
|
||||||
{
|
{
|
||||||
|
|
@ -23,18 +22,39 @@ namespace SeaHaven.Services.Helpers
|
||||||
"Supervisor"
|
"Supervisor"
|
||||||
};
|
};
|
||||||
|
|
||||||
public static int? ResolveAccountId(ClaimsPrincipal user)
|
public static MediaAccountScope ResolveMediaScope(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
var raw = user?.FindFirstValue(SeaHavenClaimTypes.AccountId);
|
if (user is null)
|
||||||
if (string.IsNullOrWhiteSpace(raw))
|
return new MediaAccountScope.Missing();
|
||||||
return null;
|
|
||||||
|
|
||||||
return int.TryParse(raw, out var accountId) ? accountId : null;
|
var accountRaw = user.FindFirstValue(SeaHavenClaimTypes.AccountId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(accountRaw))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(accountRaw, out var accountId) || accountId <= 0)
|
||||||
|
return new MediaAccountScope.Missing();
|
||||||
|
|
||||||
|
return new MediaAccountScope.Account(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgScope = user.FindFirstValue(SeaHavenClaimTypes.OrgScope);
|
||||||
|
if (string.Equals(orgScope, SeaHavenClaimTypes.OrgScopeAll, StringComparison.Ordinal))
|
||||||
|
return new MediaAccountScope.OrgWide();
|
||||||
|
|
||||||
|
return new MediaAccountScope.Missing();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void EnsureHasMediaScope(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (ResolveMediaScope(user) is MediaAccountScope.Missing)
|
||||||
|
{
|
||||||
|
throw Forbidden("You are not allowed to access work order media without account scope.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void EnsureCanRead(ClaimsPrincipal user, string? actorId)
|
public static void EnsureCanRead(ClaimsPrincipal user, string? actorId)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(user, actorId, "You are not allowed to view work order media.");
|
EnsureAuthenticated(user, actorId, "You are not allowed to view work order media.");
|
||||||
|
EnsureHasMediaScope(user);
|
||||||
|
|
||||||
if (IsStaff(user) || user.IsInRole("User"))
|
if (IsStaff(user) || user.IsInRole("User"))
|
||||||
return;
|
return;
|
||||||
|
|
@ -45,6 +65,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
|
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(user, actorId);
|
EnsureAuthenticated(user, actorId);
|
||||||
|
EnsureHasMediaScope(user);
|
||||||
|
|
||||||
if (IsStaff(user) || user.IsInRole("User"))
|
if (IsStaff(user) || user.IsInRole("User"))
|
||||||
return;
|
return;
|
||||||
|
|
@ -55,6 +76,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
public static void EnsureCanDelete(ClaimsPrincipal user, string? actorId)
|
public static void EnsureCanDelete(ClaimsPrincipal user, string? actorId)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated(user, actorId);
|
EnsureAuthenticated(user, actorId);
|
||||||
|
EnsureHasMediaScope(user);
|
||||||
|
|
||||||
// Technician (User) may upload/categorize assigned media but not delete.
|
// Technician (User) may upload/categorize assigned media but not delete.
|
||||||
if (IsStaff(user))
|
if (IsStaff(user))
|
||||||
|
|
@ -64,7 +86,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Caller-scope check after a base+account scoped work-order load.
|
/// Caller-scope check after a base (+ account when scoped) work-order load.
|
||||||
/// Staff: any resulting work order.
|
/// Staff: any resulting work order.
|
||||||
/// Technician: only when assigned to the caller.
|
/// Technician: only when assigned to the caller.
|
||||||
/// Out of caller scope → NotFound (no disclosure).
|
/// Out of caller scope → NotFound (no disclosure).
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
SeaHavenClaimTypes.AccountId,
|
SeaHavenClaimTypes.AccountId,
|
||||||
user.AccountId.Value.ToString()));
|
user.AccountId.Value.ToString()));
|
||||||
}
|
}
|
||||||
|
else if (userRoles.Contains("Admin"))
|
||||||
|
{
|
||||||
|
// Explicit signed org-wide elevation — never elevate via absence of account_id.
|
||||||
|
authClaims.Add(new Claim(
|
||||||
|
SeaHavenClaimTypes.OrgScope,
|
||||||
|
SeaHavenClaimTypes.OrgScopeAll));
|
||||||
|
}
|
||||||
var token = GetToken(authClaims);
|
var token = GetToken(authClaims);
|
||||||
return new LoginResultDTO
|
return new LoginResultDTO
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
UserName = dto.Email,
|
UserName = dto.Email,
|
||||||
FirstName = dto.Name,
|
FirstName = dto.Name,
|
||||||
Email = dto.Email,
|
Email = dto.Email,
|
||||||
|
AccountId = dto.AccountId
|
||||||
};
|
};
|
||||||
|
|
||||||
var exist = await _userDataService.GetByIdAsync(model.Id);
|
var exist = await _userDataService.GetByIdAsync(model.Id);
|
||||||
|
|
@ -92,6 +93,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
exist1.LastName = model.LastName;
|
exist1.LastName = model.LastName;
|
||||||
exist1.Contact = model.Contact;
|
exist1.Contact = model.Contact;
|
||||||
exist1.PhoneNumber = model.PhoneNumber;
|
exist1.PhoneNumber = model.PhoneNumber;
|
||||||
|
exist1.AccountId = dto.AccountId;
|
||||||
|
|
||||||
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
|
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
|
||||||
|
|
||||||
|
|
@ -119,6 +121,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
exist.CreatedDate = DateTime.Now;
|
exist.CreatedDate = DateTime.Now;
|
||||||
exist.UniqueName = "Active";
|
exist.UniqueName = "Active";
|
||||||
exist.PhoneNumber = dto.Role;
|
exist.PhoneNumber = dto.Role;
|
||||||
|
exist.AccountId = dto.AccountId;
|
||||||
|
|
||||||
var existingRole = await _userManager.GetRolesAsync(exist);
|
var existingRole = await _userManager.GetRolesAsync(exist);
|
||||||
if (existingRole != null && existingRole.Any())
|
if (existingRole != null && existingRole.Any())
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
|
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
|
||||||
var accountId = WorkOrderMediaAuthorization.ResolveAccountId(user);
|
var accountFilter = ResolveAccountFilter(user);
|
||||||
|
|
||||||
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountId))
|
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountId);
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter);
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|
@ -259,8 +259,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
string actorId,
|
string actorId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = WorkOrderMediaAuthorization.ResolveAccountId(user);
|
var accountFilter = ResolveAccountFilter(user);
|
||||||
var workOrder = await _mediaData.GetWorkOrderForMediaAuthAsync(workOrderId, accountId, cancellationToken);
|
var workOrder = await _mediaData.GetWorkOrderForMediaAuthAsync(workOrderId, accountFilter, cancellationToken);
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
|
@ -278,9 +278,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
string actorId,
|
string actorId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accountId = WorkOrderMediaAuthorization.ResolveAccountId(user);
|
var accountFilter = ResolveAccountFilter(user);
|
||||||
// Fresh tracked load (not the AsNoTracking pre-check entity).
|
// Fresh tracked load (not the AsNoTracking pre-check entity).
|
||||||
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, accountId, cancellationToken);
|
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, accountFilter, cancellationToken);
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
|
@ -292,6 +292,22 @@ namespace SeaHaven.Services.Implementation
|
||||||
return workOrder;
|
return workOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
|
||||||
|
/// Call only after EnsureCan* has verified scope is not Missing.
|
||||||
|
/// </summary>
|
||||||
|
private static int? ResolveAccountFilter(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
return WorkOrderMediaAuthorization.ResolveMediaScope(user) switch
|
||||||
|
{
|
||||||
|
MediaAccountScope.Account account => account.AccountId,
|
||||||
|
MediaAccountScope.OrgWide => null,
|
||||||
|
_ => throw new WorkOrderBoardValidationException(
|
||||||
|
"Forbidden",
|
||||||
|
"You are not allowed to access work order media without account scope.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveMediaAsync(CancellationToken cancellationToken)
|
private async Task SaveMediaAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.NameIdentifier, actorId),
|
new Claim(ClaimTypes.NameIdentifier, actorId),
|
||||||
new Claim(ClaimTypes.Role, "Admin")
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
|
new Claim(SeaHaven.Services.Helpers.SeaHavenClaimTypes.OrgScope, SeaHaven.Services.Helpers.SeaHavenClaimTypes.OrgScopeAll)
|
||||||
},
|
},
|
||||||
"Test"));
|
"Test"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -593,20 +593,39 @@ public class WorkOrderMediaServiceTests
|
||||||
private static ClaimsPrincipal AuthenticatedUser(
|
private static ClaimsPrincipal AuthenticatedUser(
|
||||||
string actorId = "actor-1",
|
string actorId = "actor-1",
|
||||||
string role = "Admin",
|
string role = "Admin",
|
||||||
int? accountId = null)
|
int? accountId = null,
|
||||||
|
bool omitScopeClaims = false)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.NameIdentifier, actorId),
|
new Claim(ClaimTypes.NameIdentifier, actorId),
|
||||||
new Claim(ClaimTypes.Role, role)
|
new Claim(ClaimTypes.Role, role)
|
||||||
};
|
};
|
||||||
if (accountId.HasValue)
|
if (!omitScopeClaims)
|
||||||
claims.Add(new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString()));
|
{
|
||||||
|
if (accountId.HasValue)
|
||||||
|
claims.Add(new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString()));
|
||||||
|
else if (role == "Admin")
|
||||||
|
claims.Add(new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
|
||||||
|
}
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, authenticationType: "Test");
|
var identity = new ClaimsIdentity(claims, authenticationType: "Test");
|
||||||
return new ClaimsPrincipal(identity);
|
return new ClaimsPrincipal(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal AuthenticatedWithMalformedAccountClaim(string actorId = "actor-1")
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, actorId),
|
||||||
|
new Claim(ClaimTypes.Role, "Admin"),
|
||||||
|
new Claim(SeaHavenClaimTypes.AccountId, "not-an-int")
|
||||||
|
},
|
||||||
|
authenticationType: "Test");
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
|
||||||
private static ClaimsPrincipal AuthenticatedWithoutRole(string actorId = "actor-1")
|
private static ClaimsPrincipal AuthenticatedWithoutRole(string actorId = "actor-1")
|
||||||
{
|
{
|
||||||
var identity = new ClaimsIdentity(
|
var identity = new ClaimsIdentity(
|
||||||
|
|
@ -652,6 +671,7 @@ public class WorkOrderMediaServiceTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AccountId = 1,
|
||||||
AssignTo = "tech-1",
|
AssignTo = "tech-1",
|
||||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||||
});
|
});
|
||||||
|
|
@ -659,7 +679,7 @@ public class WorkOrderMediaServiceTests
|
||||||
|
|
||||||
var media = await service.GetMediaAsync(
|
var media = await service.GetMediaAsync(
|
||||||
1,
|
1,
|
||||||
AuthenticatedUser("tech-1", "User"),
|
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||||
"tech-1");
|
"tech-1");
|
||||||
|
|
||||||
Assert.NotNull(media);
|
Assert.NotNull(media);
|
||||||
|
|
@ -673,13 +693,14 @@ public class WorkOrderMediaServiceTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AccountId = 1,
|
||||||
AssignTo = "other-tech",
|
AssignTo = "other-tech",
|
||||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
service.GetMediaAsync(1, AuthenticatedUser("tech-1", "User"), "tech-1"));
|
service.GetMediaAsync(1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1"));
|
||||||
|
|
||||||
Assert.Equal("NotFound", ex.Code);
|
Assert.Equal("NotFound", ex.Code);
|
||||||
}
|
}
|
||||||
|
|
@ -921,6 +942,7 @@ public class WorkOrderMediaServiceTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AccountId = 1,
|
||||||
AssignTo = "other-tech",
|
AssignTo = "other-tech",
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
|
@ -932,7 +954,7 @@ public class WorkOrderMediaServiceTests
|
||||||
1,
|
1,
|
||||||
WorkOrderMediaCategory.Extra,
|
WorkOrderMediaCategory.Extra,
|
||||||
"https://example.com/photo.jpg",
|
"https://example.com/photo.jpg",
|
||||||
AuthenticatedUser("tech-1", "User"),
|
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||||
"tech-1"));
|
"tech-1"));
|
||||||
|
|
||||||
Assert.Equal("NotFound", ex.Code);
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
|
@ -945,6 +967,7 @@ public class WorkOrderMediaServiceTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AccountId = 1,
|
||||||
AssignTo = "tech-1",
|
AssignTo = "tech-1",
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
|
@ -955,7 +978,7 @@ public class WorkOrderMediaServiceTests
|
||||||
1,
|
1,
|
||||||
null,
|
null,
|
||||||
"https://example.com/photo.jpg",
|
"https://example.com/photo.jpg",
|
||||||
AuthenticatedUser("tech-1", "User"),
|
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||||
"tech-1");
|
"tech-1");
|
||||||
|
|
||||||
Assert.True(media.Id > 0);
|
Assert.True(media.Id > 0);
|
||||||
|
|
@ -969,6 +992,7 @@ public class WorkOrderMediaServiceTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AccountId = 1,
|
||||||
AssignTo = "tech-1",
|
AssignTo = "tech-1",
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
|
@ -987,7 +1011,7 @@ public class WorkOrderMediaServiceTests
|
||||||
1,
|
1,
|
||||||
10,
|
10,
|
||||||
ToVersion(context.workOrders.Single()),
|
ToVersion(context.workOrders.Single()),
|
||||||
AuthenticatedUser("tech-1", "User"),
|
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||||
"tech-1"));
|
"tech-1"));
|
||||||
|
|
||||||
Assert.Equal("Forbidden", ex.Code);
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
|
@ -1201,7 +1225,7 @@ public class WorkOrderMediaServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetMedia_StaffWithoutAccountClaim_OrgWide_Succeeds()
|
public async Task GetMedia_StaffWithOrgScopeClaim_Succeeds()
|
||||||
{
|
{
|
||||||
var (context, service) = CreateSut();
|
var (context, service) = CreateSut();
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
|
|
@ -1217,6 +1241,65 @@ public class WorkOrderMediaServiceTests
|
||||||
Assert.NotNull(media);
|
Assert.NotNull(media);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMedia_StaffWithoutScopeClaim_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AccountId = 99,
|
||||||
|
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.GetMediaAsync(1, AuthenticatedUser(omitScopeClaims: true), "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMedia_MalformedAccountClaim_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AccountId = 10,
|
||||||
|
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.GetMediaAsync(1, AuthenticatedWithMalformedAccountClaim(), "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_StaffWithoutScopeClaim_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.AddMediaAsync(
|
||||||
|
1,
|
||||||
|
WorkOrderMediaCategory.Extra,
|
||||||
|
"https://example.com/photo.jpg",
|
||||||
|
AuthenticatedUser(role: "Dispatcher", omitScopeClaims: true),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("Forbidden", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_StaffWithAccountClaim_CrossAccount_ThrowsNotFound()
|
public async Task AddMedia_StaffWithAccountClaim_CrossAccount_ThrowsNotFound()
|
||||||
{
|
{
|
||||||
|
|
@ -1303,13 +1386,14 @@ public class WorkOrderMediaServiceTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AccountId = 1,
|
||||||
AssignTo = "tech-1",
|
AssignTo = "tech-1",
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser("tech-1", "User"), "tech-1");
|
await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1");
|
||||||
|
|
||||||
var wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
var wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
wo.AssignTo = "other-tech";
|
wo.AssignTo = "other-tech";
|
||||||
|
|
@ -1321,7 +1405,7 @@ public class WorkOrderMediaServiceTests
|
||||||
1,
|
1,
|
||||||
WorkOrderMediaCategory.Extra,
|
WorkOrderMediaCategory.Extra,
|
||||||
"https://example.com/photo.jpg",
|
"https://example.com/photo.jpg",
|
||||||
AuthenticatedUser("tech-1", "User"),
|
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||||
"tech-1"));
|
"tech-1"));
|
||||||
|
|
||||||
Assert.Equal("NotFound", ex.Code);
|
Assert.Equal("NotFound", ex.Code);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Superseded** (2026-08-06) by the SH-221 media slice in PR #47:
|
**Superseded** (2026-08-06) by the SH-221 media slice in PR #47, with
|
||||||
|
**fail-closed** account scope (follow-up on the same PR):
|
||||||
|
|
||||||
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys
|
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys
|
||||||
- JWT `account_id` claim emitted from `ApplicationUser.AccountId`
|
- JWT `account_id` when `ApplicationUser.AccountId` is set
|
||||||
- Media loads filter via `ApplyBaseScope` + `ApplyAccountScope` when the claim is present
|
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
|
||||||
|
- Media loads: `ApplyBaseScope` + `ApplyAccountScope(int)` when account-scoped;
|
||||||
|
org-wide path skips account filter
|
||||||
|
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
|
||||||
|
|
||||||
Board, detail, and search outside media still use base scope only until the
|
Board, detail, and search outside media still use base scope only until the
|
||||||
remainder of [SH-221](https://luby-us.atlassian.net/browse/SH-221) lands.
|
remainder of [SH-221](https://luby-us.atlassian.net/browse/SH-221) lands.
|
||||||
|
|
@ -14,41 +18,33 @@ remainder of [SH-221](https://luby-us.atlassian.net/browse/SH-221) lands.
|
||||||
## Context (historical)
|
## Context (historical)
|
||||||
|
|
||||||
SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access
|
SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access
|
||||||
be rejected without metadata disclosure. When this ADR was Proposed, the
|
be rejected without metadata disclosure. An interim Proposed ADR allowed
|
||||||
work-order domain had no `TenantId` / `CustomerId` / `AccountId` on
|
org-wide staff access via absence of an account claim; that path was rejected
|
||||||
`WorkOrder` or `ApplicationUser`, and JWT issuance emitted only identity/role
|
in review (fail-open) and replaced by the contract below.
|
||||||
claims. Media authorization matched the board via `ApplyBaseScope` plus
|
|
||||||
role/assignee checks.
|
|
||||||
|
|
||||||
## Decision (historical — Proposed interim)
|
|
||||||
|
|
||||||
Until real tenant enforcement existed, work-order media authorization matched
|
|
||||||
the board: `ApplyBaseScope` + claims-derived roles/assignee. That interim is
|
|
||||||
no longer the media contract.
|
|
||||||
|
|
||||||
## Current media contract (superseding)
|
## Current media contract (superseding)
|
||||||
|
|
||||||
1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template).
|
1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template).
|
||||||
2. **Account boundary** = when the principal has claim `account_id`, media
|
2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`.
|
||||||
queries require `WorkOrder.AccountId == claim`. Cross-account → stable
|
3. **Org-wide** = claim `org_scope=all` only (issued to Admin without
|
||||||
`NotFound` / null (no disclosure).
|
AccountId). Not inferred from missing `account_id`.
|
||||||
3. **Org-wide staff** = staff principals **without** `account_id` keep base
|
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
|
||||||
scope only (explicit claims rule).
|
5. **Authorization at service entry**: staff roles may read/mutate any resulting
|
||||||
4. **Authorization at service entry** from claims: staff roles may read/mutate
|
work order; role `User` only when `AssignTo == actorId`; delete staff-only.
|
||||||
any resulting work order; role `User` only when `AssignTo == actorId`;
|
6. **User lifecycle** persists `AccountId` on create/edit so non-Admin principals
|
||||||
delete remains staff-only.
|
can receive `account_id`.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- Cross-account media tests are required for principals that carry `account_id`.
|
- Cross-account and missing-scope media tests are required.
|
||||||
|
- Dispatcher/Manager/Supervisor/User without AccountId cannot access media until
|
||||||
|
AccountId is assigned (or they are Admin with `org_scope=all`).
|
||||||
- Board/search/detail without account filtering remain a SH-221 follow-up.
|
- Board/search/detail without account filtering remain a SH-221 follow-up.
|
||||||
- This ADR no longer grants an exception to §2 for media; the claim+FK path is
|
|
||||||
the enforcement.
|
|
||||||
|
|
||||||
## Excepted rule
|
## Excepted rule
|
||||||
|
|
||||||
None for media (superseded). Hard rule **server-derived tenant scope**
|
None for media. Hard rule **server-derived tenant scope**
|
||||||
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced for media via `account_id`.
|
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims.
|
||||||
|
|
||||||
## Review / expiry
|
## Review / expiry
|
||||||
|
|
||||||
|
|
@ -61,5 +57,5 @@ by **2027-02-04**.
|
||||||
- SH-221 — Server-derived tenant/customer scope for Work Order domain
|
- SH-221 — Server-derived tenant/customer scope for Work Order domain
|
||||||
- PR: Sea-Haven-Industries/shoc-backend#47
|
- PR: Sea-Haven-Industries/shoc-backend#47
|
||||||
- `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope`
|
- `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope`
|
||||||
- `WorkOrderMediaAuthorization`
|
- `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes`
|
||||||
- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10
|
- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue