diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs new file mode 100644 index 0000000..b41e2d8 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberPermissionsEndpointTests.cs @@ -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(); + data.Setup(d => d.GetUserAsync("u1", It.IsAny())) + .ReturnsAsync(new TeamPermissionUserData + { + UserId = "u1", + RoleName = "Dispatcher", + Overrides = new Dictionary + { + [TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow + } + }); + var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer"))); + + var ok = Assert.IsType(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(MockBehavior.Strict); + var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity())); + + var result = Assert.IsType(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() + .ActionDescriptors.Items + .OfType() + .Single(d => d.ControllerTypeInfo == typeof(TeamMemberController) + && d.ActionName == nameof(TeamMemberController.GetMyPermissions)); + + var methods = descriptor.ActionConstraints!.OfType() + .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(), new TeamPermissionService(data, new TeamPermissionPolicy())) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = user } + } + }; +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs index f6537b6..ad2bfca 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -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 GetMyPermissions(CancellationToken cancellationToken) + { + var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : Unauthorized(); } [HttpPost] diff --git a/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs index 72704cc..0f6aac0 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamPermissionController.cs @@ -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." }), diff --git a/SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs b/SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs new file mode 100644 index 0000000..0277c22 --- /dev/null +++ b/SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs @@ -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 _users = new(); + + public List 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 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 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."); + } +} diff --git a/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs b/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs index d930a83..79da06a 100644 --- a/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs +++ b/SeaHaven.Services/DTOs/TeamPermissionDTOs.cs @@ -16,6 +16,11 @@ public sealed class TeamPermissionValueDTO public bool IsGranted { get; init; } } +public sealed class EffectivePermissionsDTO +{ + public required IReadOnlyList 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 diff --git a/SeaHaven.Services/Implementation/TeamPermissionService.cs b/SeaHaven.Services/Implementation/TeamPermissionService.cs index 34e6f26..040f1d3 100644 --- a/SeaHaven.Services/Implementation/TeamPermissionService.cs +++ b/SeaHaven.Services/Implementation/TeamPermissionService.cs @@ -35,6 +35,28 @@ public sealed class TeamPermissionService : ITeamPermissionService : TeamPermissionResult.Success(BuildProfile(user)); } + public async Task> GetEffectivePermissionsAsync( + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + var userId = caller?.Identity?.IsAuthenticated == true + ? caller.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (string.IsNullOrWhiteSpace(userId)) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.Unauthorized); + + var user = await _dataService.GetUserAsync(userId, cancellationToken); + if (user is null) + return TeamPermissionResult.Failure(TeamPermissionResultStatus.Unauthorized); + + return TeamPermissionResult.Success(new EffectivePermissionsDTO + { + Permissions = TeamPermissionKeys.All + .Where(key => _policy.IsAllowed(user.RoleName, key, user.Overrides)) + .ToList() + }); + } + public async Task> SetOverrideAsync( string userId, string permissionKey, diff --git a/SeaHaven.Services/Interfaces/ITeamPermissionService.cs b/SeaHaven.Services/Interfaces/ITeamPermissionService.cs index d4b3892..d25b9a1 100644 --- a/SeaHaven.Services/Interfaces/ITeamPermissionService.cs +++ b/SeaHaven.Services/Interfaces/ITeamPermissionService.cs @@ -11,6 +11,14 @@ public interface ITeamPermissionService ClaimsPrincipal caller, CancellationToken cancellationToken); + /// + /// Keys the caller holds after role defaults and their own overrides. The + /// user is read from the caller's identity, never from request input. + /// + Task> GetEffectivePermissionsAsync( + ClaimsPrincipal caller, + CancellationToken cancellationToken); + Task> SetOverrideAsync( string userId, string permissionKey,