fix(locations): gate account assignment by scope and active accounts

Reject soft-deleted accounts and stop account-scoped callers from assigning or stealing locations across tenants.
This commit is contained in:
Arthur Bassi 2026-08-26 14:16:59 -03:00
parent 2be36d4eac
commit 2718fdd294
7 changed files with 175 additions and 20 deletions

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs; using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
@ -87,7 +88,7 @@ public class LocationControllerTests
var response = ok.Value.Should().BeOfType<DataResponse>().Subject; var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200"); response.Status.Should().Be("200");
response.Message.Should().Be("Location Created Successfully"); response.Message.Should().Be("Location Created Successfully");
service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<CancellationToken>()), Times.Once); service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact] [Fact]
@ -96,8 +97,8 @@ public class LocationControllerTests
var service = new Mock<ILocationService>(); var service = new Mock<ILocationService>();
LocationCreateRequestDTO? captured = null; LocationCreateRequestDTO? captured = null;
service service
.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<CancellationToken>())) .Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.Callback<LocationCreateRequestDTO, CancellationToken>((dto, _) => captured = dto) .Callback<LocationCreateRequestDTO, ClaimsPrincipal, CancellationToken>((dto, _, _) => captured = dto)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var result = await NewController(service).AddLocation( var result = await NewController(service).AddLocation(
@ -113,7 +114,7 @@ public class LocationControllerTests
public async Task EditLocation_WhenMissing_ReturnsNotFound() public async Task EditLocation_WhenMissing_ReturnsNotFound()
{ {
var service = new Mock<ILocationService>(); var service = new Mock<ILocationService>();
service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<CancellationToken>())) service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException()); .ThrowsAsync(new KeyNotFoundException());
var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None); var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None);

View file

@ -1,8 +1,10 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using FluentAssertions; using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation; using SeaHaven.Services.Validation;
using Xunit; using Xunit;
@ -40,6 +42,38 @@ public class LocationServiceTests
return loc; return loc;
} }
private static ClaimsPrincipal OrgWideAdmin()
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "admin-1"),
new(ClaimTypes.Role, "Admin"),
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static ClaimsPrincipal AccountUser(int accountId, string role = "Dispatcher")
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, role),
new(SeaHavenClaimTypes.AccountId, accountId.ToString())
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static ClaimsPrincipal MissingScope()
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, "Dispatcher")
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
[Fact] [Fact]
public async Task CreateLocationFromRequestAsync_PersistsMappedFields() public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
{ {
@ -59,7 +93,7 @@ public class LocationServiceTests
ContactEmail = "wh@example.com", ContactEmail = "wh@example.com",
Status = "Active", Status = "Active",
AccountId = 9 AccountId = 9
}, CancellationToken.None); }, OrgWideAdmin(), CancellationToken.None);
var entity = ctx.Locations.Single(); var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse"); entity.Name.Should().Be("Warehouse");
@ -118,7 +152,7 @@ public class LocationServiceTests
Address = "9 New St", Address = "9 New St",
City = "Plano", City = "Plano",
Status = "Inactive" Status = "Inactive"
}, CancellationToken.None); }, OrgWideAdmin(), CancellationToken.None);
var row = ctx.Locations.Single(); var row = ctx.Locations.Single();
row.Name.Should().Be("New"); row.Name.Should().Be("New");
@ -126,7 +160,7 @@ public class LocationServiceTests
row.City.Should().Be("Plano"); row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive"); row.Status.Should().Be("Inactive");
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, CancellationToken.None); var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, OrgWideAdmin(), CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>(); await act.Should().ThrowAsync<KeyNotFoundException>();
} }
@ -142,7 +176,7 @@ public class LocationServiceTests
{ {
Name = "New", Name = "New",
City = "Plano" City = "Plano"
}, CancellationToken.None); }, OrgWideAdmin(), CancellationToken.None);
ctx.Locations.Single().AccountId.Should().Be(4); ctx.Locations.Single().AccountId.Should().Be(4);
} }
@ -161,7 +195,7 @@ public class LocationServiceTests
{ {
Name = "Owned", Name = "Owned",
AccountId = 99 AccountId = 99
}, CancellationToken.None); }, OrgWideAdmin(), CancellationToken.None);
ctx.Locations.Single().AccountId.Should().Be(99); ctx.Locations.Single().AccountId.Should().Be(99);
} }
@ -175,6 +209,7 @@ public class LocationServiceTests
var act = () => NewService(ctx).UpdateLocationFromRequestAsync( var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
existing.Id, existing.Id,
new LocationUpdateRequestDTO { Name = "Owned", AccountId = 404 }, new LocationUpdateRequestDTO { Name = "Owned", AccountId = 404 },
OrgWideAdmin(),
CancellationToken.None); CancellationToken.None);
await act.Should().ThrowAsync<FluentValidation.ValidationException>(); await act.Should().ThrowAsync<FluentValidation.ValidationException>();
@ -188,12 +223,80 @@ public class LocationServiceTests
var act = () => NewService(ctx).CreateLocationFromRequestAsync( var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 },
OrgWideAdmin(),
CancellationToken.None); CancellationToken.None);
await act.Should().ThrowAsync<FluentValidation.ValidationException>(); await act.Should().ThrowAsync<FluentValidation.ValidationException>();
ctx.Locations.Should().BeEmpty(); ctx.Locations.Should().BeEmpty();
} }
[Fact]
public async Task CreateLocationFromRequestAsync_SoftDeletedAccount_ThrowsValidationException()
{
using var ctx = NewContext();
ctx.Accounts.Add(new Accounts { Id = 9, Name = "Gone", IsDeleted = true });
ctx.SaveChanges();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
OrgWideAdmin(),
CancellationToken.None);
await act.Should().ThrowAsync<FluentValidation.ValidationException>();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task CreateLocationFromRequestAsync_AccountScopedCaller_CannotAssignOtherAccount()
{
using var ctx = NewContext();
SeedAccount(ctx, 4);
SeedAccount(ctx, 99);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
AccountUser(4),
CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotStealOtherAccountLocation()
{
using var ctx = NewContext();
SeedAccount(ctx, 4);
SeedAccount(ctx, 99);
var existing = SeedLocation(ctx, "Owned", "Austin");
existing.AccountId = 4;
await ctx.SaveChangesAsync();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
existing.Id,
new LocationUpdateRequestDTO { Name = "Owned", AccountId = 99 },
AccountUser(99),
CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task CreateLocationFromRequestAsync_MissingScope_CannotAssignAccount()
{
using var ctx = NewContext();
SeedAccount(ctx, 9);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
MissingScope(),
CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Locations.Should().BeEmpty();
}
[Fact] [Fact]
public async Task CreateLocationAsync_IgnoresClientAccountId() public async Task CreateLocationAsync_IgnoresClientAccountId()
{ {

View file

@ -3,6 +3,7 @@ using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -101,7 +102,7 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken); await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -109,6 +110,10 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors }); return BadRequest(new Response { Status = "Validation Error", Message = errors });
} }
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to assign this account." });
}
catch (Exception ex) catch (Exception ex)
{ {
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -120,7 +125,7 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken); await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -128,6 +133,10 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors }); return BadRequest(new Response { Status = "Validation Error", Message = errors });
} }
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to assign this account." });
}
catch (KeyNotFoundException) catch (KeyNotFoundException)
{ {
return NotFound(new Response { Status = "Error", Message = "Location not found" }); return NotFound(new Response { Status = "Error", Message = "Location not found" });

View file

@ -92,6 +92,15 @@ namespace SeaHaven.DataServices.Implementation
return await _context.Accounts.AnyAsync(a => a.Id == id); return await _context.Accounts.AnyAsync(a => a.Id == id);
} }
public async Task<bool> ExistsActiveAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Accounts
.AsNoTracking()
.AnyAsync(
a => a.Id == id && (a.IsDeleted == false || a.IsDeleted == null),
cancellationToken);
}
public async Task<int> CountAsync() public async Task<int> CountAsync()
{ {
return await _context.Accounts.CountAsync(); return await _context.Accounts.CountAsync();

View file

@ -12,6 +12,7 @@ namespace SeaHaven.DataServices.Interfaces
Task UpdateAsync(Accounts account); Task UpdateAsync(Accounts account);
Task DeleteAsync(int id); Task DeleteAsync(int id);
Task<bool> ExistsAsync(int id); Task<bool> ExistsAsync(int id);
Task<bool> ExistsActiveAsync(int id, CancellationToken cancellationToken = default);
Task<int> CountAsync(); Task<int> CountAsync();
/// <summary> /// <summary>

View file

@ -1,8 +1,10 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using FluentValidation; using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation; using SeaHaven.Services.Validation;
@ -179,9 +181,12 @@ namespace SeaHaven.Services.Implementation
return location == null ? null : MapToDTO(location); return location == null ? null : MapToDTO(location);
} }
public async Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken) public async Task CreateLocationFromRequestAsync(
LocationCreateRequestDTO request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{ {
await EnsureAccountExistsAsync(request.AccountId); await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken);
var location = new Locations var location = new Locations
{ {
@ -200,7 +205,11 @@ namespace SeaHaven.Services.Implementation
await _locationDataService.AddAsync(location, cancellationToken); await _locationDataService.AddAsync(location, cancellationToken);
} }
public async Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken) public async Task UpdateLocationFromRequestAsync(
int id,
LocationUpdateRequestDTO request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{ {
var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken);
if (location == null) if (location == null)
@ -218,7 +227,7 @@ namespace SeaHaven.Services.Implementation
if (request.AccountId is int accountId) if (request.AccountId is int accountId)
{ {
await EnsureAccountExistsAsync(accountId); await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken);
location.AccountId = accountId; location.AccountId = accountId;
} }
@ -230,18 +239,40 @@ namespace SeaHaven.Services.Implementation
return _locationDataService.DeleteByIdAsync(id, cancellationToken); return _locationDataService.DeleteByIdAsync(id, cancellationToken);
} }
private async Task EnsureAccountExistsAsync(int? accountId) private async Task EnsureAccountAssignableAsync(
ClaimsPrincipal user,
int? requestedAccountId,
int? existingLocationAccountId,
CancellationToken cancellationToken)
{ {
if (accountId is not int id) if (requestedAccountId is not int accountId)
return; return;
if (!await _accountDataService.ExistsAsync(id)) if (!await _accountDataService.ExistsActiveAsync(accountId, cancellationToken))
{ {
throw new ValidationException(new[] throw new ValidationException(new[]
{ {
new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Account was not found.") new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Account was not found.")
}); });
} }
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
{
case MediaAccountScope.OrgWide:
return;
case MediaAccountScope.Account caller:
if (accountId != caller.AccountId
|| (existingLocationAccountId is int current && current != caller.AccountId))
{
throw new UnauthorizedAccessException();
}
return;
default:
throw new UnauthorizedAccessException();
}
} }
// Private helper methods // Private helper methods

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
@ -18,8 +19,8 @@ namespace SeaHaven.Services.Interfaces
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken); Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken); Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken); Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken); Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken);
} }
} }