mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
fix(locations): authorize location owner on every board update
Reject account-scoped updates of foreign or orphan locations even when accountId is omitted, matching fail-closed tenant scope.
This commit is contained in:
parent
4e4bb0ca90
commit
0f2e0dacc7
2 changed files with 94 additions and 0 deletions
|
|
@ -183,6 +183,81 @@ public class LocationServiceTests
|
|||
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateForeignLocationWhenAccountIdOmitted()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Foreign", "Dallas");
|
||||
existing.AccountId = 99;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
existing.Id,
|
||||
new LocationUpdateRequestDTO { Name = "Hijacked", City = "Austin" },
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
var row = ctx.Locations.Single();
|
||||
row.Name.Should().Be("Foreign");
|
||||
row.City.Should().Be("Dallas");
|
||||
row.AccountId.Should().Be(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CanUpdateOwnLocationWhenAccountIdOmitted()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Owned", "Dallas");
|
||||
existing.AccountId = 4;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
existing.Id,
|
||||
new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" },
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
var row = ctx.Locations.Single();
|
||||
row.Name.Should().Be("Renamed");
|
||||
row.City.Should().Be("Austin");
|
||||
row.AccountId.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_AccountScopedCaller_CannotUpdateOrphanWhenAccountIdOmitted()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Orphan", "Dallas");
|
||||
|
||||
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
existing.Id,
|
||||
new LocationUpdateRequestDTO { Name = "Claimed", City = "Austin" },
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
ctx.Locations.Single().Name.Should().Be("Orphan");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_MissingScope_CannotUpdate()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Owned", "Dallas");
|
||||
existing.AccountId = 4;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
existing.Id,
|
||||
new LocationUpdateRequestDTO { Name = "Renamed" },
|
||||
MissingScope(),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
ctx.Locations.Single().Name.Should().Be("Owned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_AppliesClientAccountIdWhenProvided()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -215,6 +215,8 @@ namespace SeaHaven.Services.Implementation
|
|||
if (location == null)
|
||||
throw new KeyNotFoundException($"Location with ID {id} not found");
|
||||
|
||||
EnsureLocationInCallerScope(user, location.AccountId);
|
||||
|
||||
location.Name = request.Name;
|
||||
location.Title = request.Title;
|
||||
location.Address1 = request.Address;
|
||||
|
|
@ -239,6 +241,23 @@ namespace SeaHaven.Services.Implementation
|
|||
return _locationDataService.DeleteByIdAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
private static void EnsureLocationInCallerScope(ClaimsPrincipal user, int? locationAccountId)
|
||||
{
|
||||
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
|
||||
{
|
||||
case MediaAccountScope.OrgWide:
|
||||
return;
|
||||
|
||||
case MediaAccountScope.Account caller:
|
||||
if (locationAccountId != caller.AccountId)
|
||||
throw new UnauthorizedAccessException();
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureAccountAssignableAsync(
|
||||
ClaimsPrincipal user,
|
||||
int? requestedAccountId,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue