Merge pull request #191 from Sea-Haven-Industries/fix/ab/sh-402-effective-permissions

feat(team-members): expose the signed-in user's effective permissions
This commit is contained in:
Alexandre Brandizzi 2026-09-25 15:23:25 +00:00 • committed by GitHub
commit 85b7d1e1c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 318 additions and 1 deletions

View file

@ -0,0 +1,95 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class TeamMemberPermissionsEndpointTests
{
[Fact]
public async Task Signed_in_caller_receives_their_effective_keys_as_a_permissions_array()
{
var data = new Mock<ITeamPermissionOverrideDataService>();
data.Setup(d => d.GetUserAsync("u1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData
{
UserId = "u1",
RoleName = "Dispatcher",
Overrides = new Dictionary<string, UserPermissionState>
{
[TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow
}
});
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer")));
var ok = Assert.IsType<OkObjectResult>(await controller.GetMyPermissions(CancellationToken.None));
var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var document = JsonDocument.Parse(json);
var keys = document.RootElement.GetProperty("permissions").EnumerateArray()
.Select(element => element.GetString()).ToList();
Assert.Contains(TeamPermissionKeys.CreateCompletionDocTemplates, keys);
Assert.DoesNotContain(TeamPermissionKeys.DeleteCompletionDocTemplates, keys);
}
[Fact]
public async Task Unauthenticated_caller_gets_401_without_data_access()
{
var data = new Mock<ITeamPermissionOverrideDataService>(MockBehavior.Strict);
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity()));
var result = Assert.IsType<UnauthorizedResult>(await controller.GetMyPermissions(CancellationToken.None));
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
}
[Fact]
public void Route_is_get_me_permissions_and_requires_authentication()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore().AddApplicationPart(typeof(TeamMemberController).Assembly);
using var provider = services.BuildServiceProvider();
var descriptor = provider
.GetRequiredService<IActionDescriptorCollectionProvider>()
.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Single(d => d.ControllerTypeInfo == typeof(TeamMemberController)
&& d.ActionName == nameof(TeamMemberController.GetMyPermissions));
var methods = descriptor.ActionConstraints!.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods);
Assert.Equal(new[] { "GET" }, methods);
Assert.Equal("api/team-members/me/permissions", descriptor.AttributeRouteInfo!.Template);
Assert.NotEmpty(typeof(TeamMemberController).GetCustomAttributes(typeof(AuthorizeAttribute), true));
Assert.Empty(descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true));
}
private static TeamMemberController Controller(
ITeamPermissionOverrideDataService data,
ClaimsPrincipal user) =>
new(Mock.Of<ITeamMemberService>(), new TeamPermissionService(data, new TeamPermissionPolicy()))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
}
};
}

View file

@ -11,10 +11,21 @@ namespace Api.SeaHavenIndustries.Controllers;
public sealed class TeamMemberController : ControllerBase
{
private readonly ITeamMemberService _teamMemberService;
private readonly ITeamPermissionService _permissionService;
public TeamMemberController(ITeamMemberService teamMemberService)
public TeamMemberController(
ITeamMemberService teamMemberService,
ITeamPermissionService permissionService)
{
_teamMemberService = teamMemberService;
_permissionService = permissionService;
}
[HttpGet("me/permissions")]
public async Task<IActionResult> GetMyPermissions(CancellationToken cancellationToken)
{
var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
}
[HttpPost]

View file

@ -53,6 +53,7 @@ public sealed class TeamPermissionController : ControllerBase
return result.Status switch
{
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
TeamPermissionResultStatus.Unauthorized => new UnauthorizedResult(),
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
new Response { Status = "Error", Message = "User not found." }),

View file

@ -0,0 +1,174 @@
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using System.Security.Claims;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class TeamEffectivePermissionsTests
{
[Fact]
public async Task Scheduler_Receives_Role_Defaults()
{
var data = new FakeUsers().Add("u1", "Scheduler");
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value!.Permissions.Should().Contain(new[]
{
TeamPermissionKeys.CreateCompletionDocTemplates,
TeamPermissionKeys.EditCompletionDocTemplates
});
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.DeleteCompletionDocTemplates);
}
[Fact]
public async Task Grant_Override_Adds_A_Key_Outside_The_Role_Defaults()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher", (TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
}
[Fact]
public async Task Revoke_Override_Removes_A_Role_Default()
{
var data = new FakeUsers().Add(
"u1", "Scheduler", (TeamPermissionKeys.EditCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
result.Value.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
}
[Fact]
public async Task Admin_Holds_Every_Key_Even_With_A_Revoke_Override()
{
var data = new FakeUsers().Add(
"u1", "Admin", (TeamPermissionKeys.DeleteCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(TeamPermissionKeys.All);
}
[Fact]
public async Task Keys_Match_What_The_Write_Path_Enforces()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher",
(TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow),
(TeamPermissionKeys.CreateSites, UserPermissionState.Deny));
var policy = new TeamPermissionPolicy();
var user = await data.GetUserAsync("u1", CancellationToken.None);
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(
TeamPermissionKeys.All.Where(key => policy.IsAllowed(user!.RoleName, key, user.Overrides)));
}
[Fact]
public async Task Reads_Only_The_Caller_Identified_By_The_Token()
{
var data = new FakeUsers()
.Add("u1", "Dispatcher")
.Add("u2", "Dispatcher", (TeamPermissionKeys.DeleteWorkOrders, UserPermissionState.Allow));
using var cancellation = new CancellationTokenSource();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), cancellation.Token);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.DeleteWorkOrders);
data.RequestedUserIds.Should().Equal("u1");
data.LastToken.Should().Be(cancellation.Token);
}
[Fact]
public async Task Unauthenticated_Caller_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var anonymous = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }));
var result = await Service(data).GetEffectivePermissionsAsync(anonymous, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_Without_A_User_Id_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var noId = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
var result = await Service(data).GetEffectivePermissionsAsync(noId, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_For_A_Removed_User_Is_Rejected()
{
var data = new FakeUsers();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("gone"), CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
result.Value.Should().BeNull();
}
private static TeamPermissionService Service(FakeUsers data) =>
new(data, new TeamPermissionPolicy());
private static ClaimsPrincipal Caller(string userId) =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"));
private sealed class FakeUsers : ITeamPermissionOverrideDataService
{
private readonly Dictionary<string, TeamPermissionUserData> _users = new();
public List<string> RequestedUserIds { get; } = new();
public CancellationToken LastToken { get; private set; }
public FakeUsers Add(string userId, string role, params (string Key, UserPermissionState State)[] overrides)
{
_users[userId] = new TeamPermissionUserData
{
UserId = userId,
RoleName = role,
Overrides = overrides.ToDictionary(o => o.Key, o => o.State, StringComparer.OrdinalIgnoreCase)
};
return this;
}
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
{
RequestedUserIds.Add(userId);
LastToken = cancellationToken;
return Task.FromResult(_users.GetValueOrDefault(userId));
}
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task SetOverridesAsync(string userId, IReadOnlyDictionary<string, UserPermissionState> overrides, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
}
}

View file

@ -16,6 +16,11 @@ public sealed class TeamPermissionValueDTO
public bool IsGranted { get; init; }
}
public sealed class EffectivePermissionsDTO
{
public required IReadOnlyList<string> Permissions { get; init; }
}
public sealed class SetTeamPermissionOverrideDTO
{
public UserPermissionState State { get; init; }
@ -24,6 +29,7 @@ public sealed class SetTeamPermissionOverrideDTO
public enum TeamPermissionResultStatus
{
Success,
Unauthorized,
Forbidden,
NotFound,
InvalidPermissionKey

View file

@ -35,6 +35,28 @@ public sealed class TeamPermissionService : ITeamPermissionService
: TeamPermissionResult<TeamPermissionProfileDTO>.Success(BuildProfile(user));
}
public async Task<TeamPermissionResult<EffectivePermissionsDTO>> GetEffectivePermissionsAsync(
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
var userId = caller?.Identity?.IsAuthenticated == true
? caller.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (string.IsNullOrWhiteSpace(userId))
return TeamPermissionResult<EffectivePermissionsDTO>.Failure(TeamPermissionResultStatus.Unauthorized);
var user = await _dataService.GetUserAsync(userId, cancellationToken);
if (user is null)
return TeamPermissionResult<EffectivePermissionsDTO>.Failure(TeamPermissionResultStatus.Unauthorized);
return TeamPermissionResult<EffectivePermissionsDTO>.Success(new EffectivePermissionsDTO
{
Permissions = TeamPermissionKeys.All
.Where(key => _policy.IsAllowed(user.RoleName, key, user.Overrides))
.ToList()
});
}
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
string userId,
string permissionKey,

View file

@ -11,6 +11,14 @@ public interface ITeamPermissionService
ClaimsPrincipal caller,
CancellationToken cancellationToken);
/// <summary>
/// Keys the caller holds after role defaults and their own overrides. The
/// user is read from the caller's identity, never from request input.
/// </summary>
Task<TeamPermissionResult<EffectivePermissionsDTO>> GetEffectivePermissionsAsync(
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
string userId,
string permissionKey,