mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
2 commits
e572786b1c
...
ea2dedf579
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2dedf579 | ||
|
|
fdc315d8fe |
21 changed files with 4474 additions and 140 deletions
|
|
@ -41,7 +41,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Name = user.Name,
|
||||
Email = user.Email,
|
||||
Contact = user.Contact,
|
||||
Role = user.Role
|
||||
Role = user.Role,
|
||||
AccountId = user.AccountId
|
||||
};
|
||||
var outcome = await _userService.AddUserAsync(dto, cancellationToken);
|
||||
if (!outcome.Success)
|
||||
|
|
@ -68,7 +69,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Id = user.Id,
|
||||
Name = user.Name,
|
||||
Email = user.Email,
|
||||
Role = user.Role
|
||||
Role = user.Role,
|
||||
AccountId = user.AccountId
|
||||
};
|
||||
var succeeded = await _userService.EditUserAsync(dto, cancellationToken);
|
||||
if (!succeeded)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
public string? Email { get; set; }
|
||||
public string? Contact { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
}
|
||||
public class EditUser_DTO
|
||||
{
|
||||
|
|
@ -13,5 +14,6 @@
|
|||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,24 @@ namespace Data.SeaHavenIndustries
|
|||
.HasForeignKey(w => w.PrimaryDispatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasOne(w => w.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(w => w.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.AccountId);
|
||||
|
||||
builder.Entity<ApplicationUser>()
|
||||
.HasOne(u => u.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(u => u.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.AccountId);
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasIndex(c => c.NormalizedName)
|
||||
.IsUnique();
|
||||
|
|
@ -284,6 +302,10 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Initials { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public int? Type { get; set; } // 1 for users 0 for admin
|
||||
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
|
||||
public int? AccountId { get; set; }
|
||||
[ForeignKey(nameof(AccountId))]
|
||||
public virtual Accounts? Account { get; set; }
|
||||
public ICollection<Template>? Templates { get; set; }
|
||||
public ICollection<WorkOrder>? WorkOrders { get; set; }
|
||||
}
|
||||
|
|
|
|||
3814
Data.SeaHavenIndustries/Migrations/20260806123838_SH221_WorkOrderAccountScope.Designer.cs
generated
Normal file
3814
Data.SeaHavenIndustries/Migrations/20260806123838_SH221_WorkOrderAccountScope.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,110 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH221_WorkOrderAccountScope : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountId",
|
||||
table: "workOrders",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountId",
|
||||
table: "AspNetUsers",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_AccountId",
|
||||
table: "workOrders",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUsers_AccountId",
|
||||
table: "AspNetUsers",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_AspNetUsers_Accounts_AccountId",
|
||||
table: "AspNetUsers",
|
||||
column: "AccountId",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_workOrders_Accounts_AccountId",
|
||||
table: "workOrders",
|
||||
column: "AccountId",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
// Best-effort backfill: unique Accounts.Name match against WorkOrder.Customer.
|
||||
migrationBuilder.Sql(@"
|
||||
;WITH UniqueAccounts AS (
|
||||
SELECT
|
||||
a.[Id],
|
||||
a.[Name]
|
||||
FROM [Accounts] a
|
||||
WHERE a.[Name] IS NOT NULL
|
||||
AND LTRIM(RTRIM(a.[Name])) <> ''
|
||||
AND (a.[IsDeleted] IS NULL OR a.[IsDeleted] = 0)
|
||||
AND a.[Name] IN (
|
||||
SELECT a2.[Name]
|
||||
FROM [Accounts] a2
|
||||
WHERE a2.[Name] IS NOT NULL
|
||||
AND LTRIM(RTRIM(a2.[Name])) <> ''
|
||||
AND (a2.[IsDeleted] IS NULL OR a2.[IsDeleted] = 0)
|
||||
GROUP BY a2.[Name]
|
||||
HAVING COUNT(*) = 1
|
||||
)
|
||||
)
|
||||
UPDATE wo
|
||||
SET wo.[AccountId] = ua.[Id]
|
||||
FROM [workOrders] wo
|
||||
INNER JOIN UniqueAccounts ua
|
||||
ON ua.[Name] = wo.[Customer]
|
||||
WHERE wo.[AccountId] IS NULL
|
||||
AND wo.[Customer] IS NOT NULL
|
||||
AND LTRIM(RTRIM(wo.[Customer])) <> '';
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_AspNetUsers_Accounts_AccountId",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_workOrders_Accounts_AccountId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_AccountId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AspNetUsers_AccountId",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AccountId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AccountId",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -188,6 +188,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -270,6 +273,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
|
|
@ -2314,6 +2319,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("AfterPhotoAttachment")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -2553,6 +2561,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("ExternalWorkOrderId")
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
|
@ -3107,6 +3117,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Contact");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
|
||||
|
|
@ -3506,6 +3526,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
|
||||
.WithMany("WorkOrders")
|
||||
.HasForeignKey("AssignTo")
|
||||
|
|
@ -3521,6 +3546,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasForeignKey("PrimaryDispatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("AssignToUser");
|
||||
|
||||
b.Navigation("Locations");
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ namespace Data.SeaHavenIndustries
|
|||
public string? WorkerOrderTitle { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Customer { get; set; }
|
||||
/// <summary>CRM account (customer) boundary for server-derived media/tenant scope (SH-221).</summary>
|
||||
public int? AccountId { get; set; }
|
||||
[ForeignKey(nameof(AccountId))]
|
||||
public virtual Accounts? Account { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? Building { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ namespace SeaHaven.DataServices.Helpers
|
|||
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
|
||||
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
/// <summary>
|
||||
/// Restricts to the given CRM account. Callers with org-wide scope must not
|
||||
/// invoke this method (use <see cref="ApplyBaseScope"/> only).
|
||||
/// </summary>
|
||||
public static IQueryable<WorkOrder> ApplyAccountScope(IQueryable<WorkOrder> query, int accountId)
|
||||
=> query.Where(w => w.AccountId == accountId);
|
||||
|
||||
/// <summary>
|
||||
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
|
||||
/// <paramref name="currentUserId"/> is set, that takes precedence and
|
||||
|
|
|
|||
|
|
@ -14,9 +14,17 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default)
|
||||
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.AnyAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
public Task<bool> ExistsAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default,
|
||||
int? accountId = null)
|
||||
{
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId.HasValue)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||
|
||||
return query.AnyAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||
{
|
||||
|
|
@ -90,10 +98,14 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
int? accountId = null)
|
||||
{
|
||||
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId.HasValue)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||
|
||||
return await query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
|
|
@ -13,10 +14,29 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
|
||||
cancellationToken);
|
||||
public Task<WorkOrder?> GetWorkOrderForMediaAuthAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId.HasValue)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||
|
||||
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders);
|
||||
if (accountId.HasValue)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
|
||||
|
||||
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IWorkOrderDetailDataService
|
||||
{
|
||||
Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default);
|
||||
Task<bool> ExistsAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default,
|
||||
int? accountId = null);
|
||||
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
|
||||
|
|
@ -16,7 +19,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
|
||||
Task<WorkOrder?> GetWorkOrderForMediaAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
int? accountId = null);
|
||||
}
|
||||
|
||||
public interface ICompletionDocTemplateDataService
|
||||
|
|
|
|||
|
|
@ -4,7 +4,17 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IWorkOrderMediaDataService
|
||||
{
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
/// <summary>AsNoTracking base (+ optional account) scoped load for pre-mutation auth.</summary>
|
||||
Task<WorkOrder?> GetWorkOrderForMediaAuthAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
||||
void TrackAttachment(WorkOrderAttachments attachment);
|
||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Email { get; set; }
|
||||
public string? Contact { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
}
|
||||
|
||||
public class AddUserOutcomeDTO
|
||||
|
|
@ -45,6 +46,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
}
|
||||
|
||||
public class UserListRowDTO
|
||||
|
|
|
|||
27
SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs
Normal file
27
SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>Server-derived claim type names emitted at token issuance.</summary>
|
||||
public static class SeaHavenClaimTypes
|
||||
{
|
||||
/// <summary>CRM account id from <c>ApplicationUser.AccountId</c> (never from request body).</summary>
|
||||
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>
|
||||
/// Claims-derived authorization for work-order media read/mutations.
|
||||
/// Callers must already have resolved the work order via
|
||||
/// <c>ApplyBaseScope</c> (non-deleted, non-template). Staff may access any
|
||||
/// such work order; technicians only when <see cref="WorkOrder.AssignTo"/>
|
||||
/// matches the actor. This is <b>not</b> tenant/customer isolation — that
|
||||
/// hard rule remains open pending
|
||||
/// <c>docs/adr/0001-work-order-single-org-scope.md</c> (Proposed).
|
||||
/// Scope is fail-closed: callers need a valid <see cref="SeaHavenClaimTypes.AccountId"/>
|
||||
/// or explicit <see cref="SeaHavenClaimTypes.OrgScope"/>=<see cref="SeaHavenClaimTypes.OrgScopeAll"/>.
|
||||
/// Absence of scope does not elevate. Staff may access any resulting work order;
|
||||
/// technicians only when <see cref="WorkOrder.AssignTo"/> matches the actor.
|
||||
/// Delete is staff-only.
|
||||
/// </summary>
|
||||
public static class WorkOrderMediaAuthorization
|
||||
{
|
||||
|
|
@ -23,9 +22,39 @@ namespace SeaHaven.Services.Helpers
|
|||
"Supervisor"
|
||||
};
|
||||
|
||||
public static MediaAccountScope ResolveMediaScope(ClaimsPrincipal user)
|
||||
{
|
||||
if (user is null)
|
||||
return new MediaAccountScope.Missing();
|
||||
|
||||
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)
|
||||
{
|
||||
EnsureAuthenticated(user, actorId, "You are not allowed to view work order media.");
|
||||
EnsureHasMediaScope(user);
|
||||
|
||||
if (IsStaff(user) || user.IsInRole("User"))
|
||||
return;
|
||||
|
|
@ -36,6 +65,7 @@ namespace SeaHaven.Services.Helpers
|
|||
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
|
||||
{
|
||||
EnsureAuthenticated(user, actorId);
|
||||
EnsureHasMediaScope(user);
|
||||
|
||||
if (IsStaff(user) || user.IsInRole("User"))
|
||||
return;
|
||||
|
|
@ -46,6 +76,7 @@ namespace SeaHaven.Services.Helpers
|
|||
public static void EnsureCanDelete(ClaimsPrincipal user, string? actorId)
|
||||
{
|
||||
EnsureAuthenticated(user, actorId);
|
||||
EnsureHasMediaScope(user);
|
||||
|
||||
// Technician (User) may upload/categorize assigned media but not delete.
|
||||
if (IsStaff(user))
|
||||
|
|
@ -55,8 +86,8 @@ namespace SeaHaven.Services.Helpers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caller-scope check after an <c>ApplyBaseScope</c> work-order load.
|
||||
/// Staff: any base-scoped work order (interim; no tenant key — ADR 0001 Proposed).
|
||||
/// Caller-scope check after a base (+ account when scoped) work-order load.
|
||||
/// Staff: any resulting work order.
|
||||
/// Technician: only when assigned to the caller.
|
||||
/// Out of caller scope → NotFound (no disclosure).
|
||||
/// </summary>
|
||||
|
|
@ -65,9 +96,6 @@ namespace SeaHaven.Services.Helpers
|
|||
string actorId,
|
||||
WorkOrder workOrder)
|
||||
{
|
||||
// ApplyBaseScope already filtered the load. Staff may reach any such
|
||||
// work order (board-aligned interim). Not a tenant boundary — see
|
||||
// ADR 0001 (Proposed).
|
||||
if (IsStaff(user))
|
||||
return;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
|
@ -50,6 +51,19 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
|
||||
}
|
||||
if (user.AccountId.HasValue)
|
||||
{
|
||||
authClaims.Add(new Claim(
|
||||
SeaHavenClaimTypes.AccountId,
|
||||
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);
|
||||
return new LoginResultDTO
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ namespace SeaHaven.Services.Implementation
|
|||
UserName = dto.Email,
|
||||
FirstName = dto.Name,
|
||||
Email = dto.Email,
|
||||
AccountId = dto.AccountId
|
||||
};
|
||||
|
||||
var exist = await _userDataService.GetByIdAsync(model.Id);
|
||||
|
|
@ -92,6 +93,7 @@ namespace SeaHaven.Services.Implementation
|
|||
exist1.LastName = model.LastName;
|
||||
exist1.Contact = model.Contact;
|
||||
exist1.PhoneNumber = model.PhoneNumber;
|
||||
exist1.AccountId = dto.AccountId;
|
||||
|
||||
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
|
||||
|
||||
|
|
@ -119,6 +121,7 @@ namespace SeaHaven.Services.Implementation
|
|||
exist.CreatedDate = DateTime.Now;
|
||||
exist.UniqueName = "Active";
|
||||
exist.PhoneNumber = dto.Role;
|
||||
exist.AccountId = dto.AccountId;
|
||||
|
||||
var existingRole = await _userManager.GetRolesAsync(exist);
|
||||
if (existingRole != null && existingRole.Any())
|
||||
|
|
|
|||
|
|
@ -33,13 +33,12 @@ namespace SeaHaven.Services.Implementation
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
|
||||
var accountFilter = ResolveAccountFilter(user);
|
||||
|
||||
// Organization scope: ApplyBaseScope via Exists / GetWorkOrderForMedia.
|
||||
// Outside org (deleted/template/missing) → null (no disclosure). ADR 0001.
|
||||
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken))
|
||||
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter))
|
||||
return null;
|
||||
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken);
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter);
|
||||
if (workOrder == null)
|
||||
return null;
|
||||
|
||||
|
|
@ -56,7 +55,8 @@ namespace SeaHaven.Services.Implementation
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
|
||||
await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
|
||||
// AsNoTracking pre-check so the subsequent AddMediaAsync load is not stale-cached.
|
||||
await GetMutableWorkOrderForAuthAsync(workOrderId, user, actorId!, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||
|
|
@ -253,14 +253,14 @@ namespace SeaHaven.Services.Implementation
|
|||
await SaveMediaAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<WorkOrder> GetMutableWorkOrderAsync(
|
||||
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
string actorId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Organization scope via ApplyBaseScope: deleted/template → NotFound (ADR 0001).
|
||||
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken);
|
||||
var accountFilter = ResolveAccountFilter(user);
|
||||
var workOrder = await _mediaData.GetWorkOrderForMediaAuthAsync(workOrderId, accountFilter, cancellationToken);
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
|
|
@ -272,6 +272,42 @@ namespace SeaHaven.Services.Implementation
|
|||
return workOrder;
|
||||
}
|
||||
|
||||
private async Task<WorkOrder> GetMutableWorkOrderAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
string actorId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accountFilter = ResolveAccountFilter(user);
|
||||
// Fresh tracked load (not the AsNoTracking pre-check entity).
|
||||
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, accountFilter, cancellationToken);
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder);
|
||||
|
||||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||
|
||||
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)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -189,7 +189,8 @@ public class WorkOrderMediaConcurrencyRelationalTests
|
|||
new[]
|
||||
{
|
||||
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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -590,13 +590,37 @@ public class WorkOrderMediaServiceTests
|
|||
private static string ToVersion(WorkOrder workOrder)
|
||||
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
|
||||
private static ClaimsPrincipal AuthenticatedUser(string actorId = "actor-1", string role = "Admin")
|
||||
private static ClaimsPrincipal AuthenticatedUser(
|
||||
string actorId = "actor-1",
|
||||
string role = "Admin",
|
||||
int? accountId = null,
|
||||
bool omitScopeClaims = false)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, actorId),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
};
|
||||
if (!omitScopeClaims)
|
||||
{
|
||||
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");
|
||||
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, role)
|
||||
new Claim(ClaimTypes.Role, "Admin"),
|
||||
new Claim(SeaHavenClaimTypes.AccountId, "not-an-int")
|
||||
},
|
||||
authenticationType: "Test");
|
||||
return new ClaimsPrincipal(identity);
|
||||
|
|
@ -647,6 +671,7 @@ public class WorkOrderMediaServiceTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
AssignTo = "tech-1",
|
||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||
});
|
||||
|
|
@ -654,7 +679,7 @@ public class WorkOrderMediaServiceTests
|
|||
|
||||
var media = await service.GetMediaAsync(
|
||||
1,
|
||||
AuthenticatedUser("tech-1", "User"),
|
||||
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||
"tech-1");
|
||||
|
||||
Assert.NotNull(media);
|
||||
|
|
@ -668,13 +693,14 @@ public class WorkOrderMediaServiceTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
AssignTo = "other-tech",
|
||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -916,6 +942,7 @@ public class WorkOrderMediaServiceTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
AssignTo = "other-tech",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
|
|
@ -927,7 +954,7 @@ public class WorkOrderMediaServiceTests
|
|||
1,
|
||||
WorkOrderMediaCategory.Extra,
|
||||
"https://example.com/photo.jpg",
|
||||
AuthenticatedUser("tech-1", "User"),
|
||||
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||
"tech-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
|
|
@ -940,6 +967,7 @@ public class WorkOrderMediaServiceTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
AssignTo = "tech-1",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
|
|
@ -950,7 +978,7 @@ public class WorkOrderMediaServiceTests
|
|||
1,
|
||||
null,
|
||||
"https://example.com/photo.jpg",
|
||||
AuthenticatedUser("tech-1", "User"),
|
||||
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||
"tech-1");
|
||||
|
||||
Assert.True(media.Id > 0);
|
||||
|
|
@ -964,6 +992,7 @@ public class WorkOrderMediaServiceTests
|
|||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
AssignTo = "tech-1",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
|
|
@ -982,7 +1011,7 @@ public class WorkOrderMediaServiceTests
|
|||
1,
|
||||
10,
|
||||
ToVersion(context.workOrders.Single()),
|
||||
AuthenticatedUser("tech-1", "User"),
|
||||
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||
"tech-1"));
|
||||
|
||||
Assert.Equal("Forbidden", ex.Code);
|
||||
|
|
@ -1159,6 +1188,228 @@ public class WorkOrderMediaServiceTests
|
|||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMedia_StaffWithAccountClaim_CrossAccount_ReturnsNull()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 20,
|
||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var media = await service.GetMediaAsync(1, AuthenticatedUser(accountId: 10), "actor-1");
|
||||
|
||||
Assert.Null(media);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMedia_StaffWithAccountClaim_SameAccount_Succeeds()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 10,
|
||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var media = await service.GetMediaAsync(1, AuthenticatedUser(accountId: 10), "actor-1");
|
||||
|
||||
Assert.NotNull(media);
|
||||
Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMedia_StaffWithOrgScopeClaim_Succeeds()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 99,
|
||||
BeforPhotoAttachment = "https://example.com/before.jpg"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
|
||||
|
||||
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]
|
||||
public async Task AddMedia_StaffWithAccountClaim_CrossAccount_ThrowsNotFound()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 20,
|
||||
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(accountId: 10),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_TechnicianAssigned_CrossAccount_ThrowsNotFound()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 20,
|
||||
AssignTo = "tech-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("tech-1", "User", accountId: 10),
|
||||
"tech-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_AfterPreCheck_BecomesReadOnly_ThrowsReadOnly_OnFreshLoad()
|
||||
{
|
||||
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();
|
||||
|
||||
await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
wo.LifecycleStatus = LifecycleStatus.Completed;
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.AddMediaAsync(
|
||||
1,
|
||||
WorkOrderMediaCategory.Extra,
|
||||
"https://example.com/photo.jpg",
|
||||
AuthenticatedUser(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_AfterPreCheck_AssignmentChanged_ThrowsNotFound_OnFreshLoad()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AccountId = 1,
|
||||
AssignTo = "tech-1",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.EnsureCanMutateMediaAsync(1, AuthenticatedUser("tech-1", "User", accountId: 1), "tech-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
wo.AssignTo = "other-tech";
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.AddMediaAsync(
|
||||
1,
|
||||
WorkOrderMediaCategory.Extra,
|
||||
"https://example.com/photo.jpg",
|
||||
AuthenticatedUser("tech-1", "User", accountId: 1),
|
||||
"tech-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderMediaFileRulesTests
|
||||
|
|
|
|||
|
|
@ -2,122 +2,60 @@
|
|||
|
||||
## Status
|
||||
|
||||
**Proposed — awaiting approval** (2026-08-05).
|
||||
**Superseded** (2026-08-06) by the SH-221 media slice in PR #47, with
|
||||
**fail-closed** account scope (follow-up on the same PR):
|
||||
|
||||
This ADR is **not** self-accepted by the PR author. Approval is required from:
|
||||
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys
|
||||
- JWT `account_id` when `ApplicationUser.AccountId` is set
|
||||
- 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)
|
||||
|
||||
- CODEOWNERS team `@Sea-Haven-Industries/internal-dev` (see `.github/CODEOWNERS`)
|
||||
- SH-116 product owner
|
||||
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.
|
||||
|
||||
Until approved (or until real tenant enforcement lands), this document records a
|
||||
**pending** exception request against the hard rule below — it does not waive
|
||||
the rule on its own.
|
||||
|
||||
## Context
|
||||
## Context (historical)
|
||||
|
||||
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.
|
||||
be rejected without metadata disclosure. An interim Proposed ADR allowed
|
||||
org-wide staff access via absence of an account claim; that path was rejected
|
||||
in review (fail-open) and replaced by the contract below.
|
||||
|
||||
Verified against the current codebase:
|
||||
## Current media contract (superseding)
|
||||
|
||||
- There is no `TenantId` / `CustomerId` column on `WorkOrder`,
|
||||
`WorkOrderAttachments`, or `ApplicationUser`.
|
||||
- `WorkOrder.Customer` is free-text (`nvarchar`), not a FK.
|
||||
- JWT issuance (`AuthenticationService.GetToken`) emits only `Name`,
|
||||
`NameIdentifier`, `Jti`, and `Role` — no tenant/customer claim.
|
||||
- Board, search, and detail already treat the deployment as a single
|
||||
organization via `ApplyBaseScope` (non-deleted, non-template).
|
||||
|
||||
Introducing a multi-tenant key requires product modeling plus a schema
|
||||
migration, which is out of scope for the SH-116 media contract and blocked by
|
||||
`AGENTS.md` (no migrations / product behavior without explicit instruction).
|
||||
|
||||
## Decision (proposed)
|
||||
|
||||
Until real tenant enforcement exists, work-order media authorization matches
|
||||
the board:
|
||||
|
||||
1. **Organization 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 is **not** a substitute for SH-116 cross-tenant isolation. It is the
|
||||
interim behavior while the exception is under review or real tenant keys land.
|
||||
|
||||
## Accepted risk (while Proposed / if Accepted)
|
||||
|
||||
Any authenticated **staff** principal who knows a numeric work-order id can
|
||||
read or mutate media for that work order, provided it passes `ApplyBaseScope`.
|
||||
There is no server-derived tenant/customer boundary separating staff access
|
||||
across customers. Deleted / template / missing ids are **not** a cross-tenant
|
||||
test; they only prove the `ApplyBaseScope` filter.
|
||||
1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template).
|
||||
2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`.
|
||||
3. **Org-wide** = claim `org_scope=all` only (issued to Admin without
|
||||
AccountId). Not inferred from missing `account_id`.
|
||||
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
|
||||
5. **Authorization at service entry**: staff roles may read/mutate any resulting
|
||||
work order; role `User` only when `AssignTo == actorId`; delete staff-only.
|
||||
6. **User lifecycle** persists `AccountId` on create/edit so non-Admin principals
|
||||
can receive `account_id`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tests for deleted, template, and missing work-order ids cover
|
||||
`ApplyBaseScope` rejection only — they must not be labeled as SH-116
|
||||
cross-tenant coverage.
|
||||
- Staff org-wide access by numeric id remains intentional and aligned with the
|
||||
board for this interim state.
|
||||
- Merge of PR #47 that relies on this ADR requires either:
|
||||
1. formal approval of this ADR by the approvers listed in Status, or
|
||||
2. landing of real tenant enforcement (see below).
|
||||
|
||||
## Path to real enforcement
|
||||
|
||||
Candidate design for the deferred multi-tenant work (tracked in the linked
|
||||
Jira ticket):
|
||||
|
||||
1. **Tenant key** — reuse the existing `Accounts` entity (CRM customer) as the
|
||||
customer boundary; add `WorkOrder.AccountId` (FK) and
|
||||
`ApplicationUser.AccountId` (or an equivalent membership table).
|
||||
2. **Claim** — emit a server-derived `account_id` (or equivalent) claim in
|
||||
`AuthenticationService.GetToken` from the authenticated user’s account
|
||||
membership; never accept account id from body/query as trust source.
|
||||
3. **Data filter** — extend `ApplyBaseScope` (or a sibling filter) so board,
|
||||
detail, search, and media loads restrict by the claim-derived account
|
||||
scope; staff may still be broader if product defines org-wide roles, but
|
||||
that must be an explicit claims rule, not “any numeric id”.
|
||||
4. **Backfill** — map free-text `WorkOrder.Customer` strings to `Accounts`
|
||||
rows where possible; unresolved rows need a product decision (block,
|
||||
orphan bucket, or manual remapping).
|
||||
5. **Tests** — add true cross-tenant rejection tests (staff/tech of account A
|
||||
cannot read or mutate media of a work order owned by account B) with stable
|
||||
`NotFound` / `Forbidden` and no metadata disclosure.
|
||||
- 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.
|
||||
|
||||
## Excepted rule
|
||||
|
||||
Hard rule: **server-derived tenant scope**
|
||||
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2).
|
||||
|
||||
Requested clarification while this ADR is Proposed/Accepted: in the work-order
|
||||
domain, the interim 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 a temporary
|
||||
product/architecture gap until superseded by the path above.
|
||||
None for media. Hard rule **server-derived tenant scope**
|
||||
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims.
|
||||
|
||||
## Review / expiry
|
||||
|
||||
Re-review by **2027-02-04**, or earlier if product introduces
|
||||
`TenantId`/`CustomerId`/`AccountId` on work orders or JWT claims, or when the
|
||||
linked multi-tenant ticket closes.
|
||||
Re-review when SH-221 closes remaining board/search/detail account filters, or
|
||||
by **2027-02-04**.
|
||||
|
||||
## References
|
||||
|
||||
- SH-116 — Completion document: fields + media categorization
|
||||
- SH-221 — Server-derived tenant/customer scope for Work Order domain (deferred enforcement)
|
||||
- PR that relies on this ADR: Sea-Haven-Industries/shoc-backend#47
|
||||
- `WorkOrderBoardQueryFilters.ApplyBaseScope`
|
||||
- `WorkOrderMediaAuthorization`
|
||||
- SH-221 — Server-derived tenant/customer scope for Work Order domain
|
||||
- PR: Sea-Haven-Industries/shoc-backend#47
|
||||
- `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope`
|
||||
- `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes`
|
||||
- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10
|
||||
- `REVIEW_AND_PR_FRAMEWORK.md` §7, §8
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue