mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
feat(team-members): expose the caller's effective permissions
GET api/team-members/me/permissions returns the keys the signed-in user holds after role defaults and their own overrides, evaluated by the same policy that guards writes. The user comes from the token; a missing or unknown identity gets 401.
This commit is contained in:
parent
c8073123e3
commit
13fec977fa
7 changed files with 318 additions and 1 deletions
|
|
@ -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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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." }),
|
||||
|
|
|
|||
174
SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs
Normal file
174
SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue