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:
Arthur Bassi 2026-08-26 14:38:34 -03:00
parent 4e4bb0ca90
commit 0f2e0dacc7
2 changed files with 94 additions and 0 deletions

View file

@ -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()
{

View file

@ -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,