mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #132 from Sea-Haven-Industries/feat/ab/sh-328-permission-overrides
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-328: expose per-person permission overrides
This commit is contained in:
commit
db9a94f335
20 changed files with 5099 additions and 0 deletions
|
|
@ -0,0 +1,80 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class TeamPermissionOverrideDataServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
SeedUserWithRole(context, "u1", "Dispatcher");
|
||||
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
||||
{
|
||||
UserId = "u1",
|
||||
PermissionKey = "deleteSites",
|
||||
State = UserPermissionState.Deny
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await new TeamPermissionOverrideDataService(context)
|
||||
.GetUserAsync("u1", CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.RoleName.Should().Be("Dispatcher");
|
||||
result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetOverrideAsync_UpsertsOneCompositeKey()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
SeedUserWithRole(context, "u1", "Scheduler");
|
||||
await context.SaveChangesAsync();
|
||||
var service = new TeamPermissionOverrideDataService(context);
|
||||
|
||||
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None);
|
||||
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None);
|
||||
|
||||
var rows = await context.UserPermissionOverrides.ToListAsync();
|
||||
rows.Should().ContainSingle();
|
||||
rows[0].State.Should().Be(UserPermissionState.Deny);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName)
|
||||
{
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = userId,
|
||||
UserName = userId,
|
||||
NormalizedUserName = userId.ToUpperInvariant(),
|
||||
Email = userId + "@example.com",
|
||||
NormalizedEmail = (userId + "@example.com").ToUpperInvariant()
|
||||
});
|
||||
context.Roles.Add(new IdentityRole
|
||||
{
|
||||
Id = "role-1",
|
||||
Name = roleName,
|
||||
NormalizedName = roleName.ToUpperInvariant()
|
||||
});
|
||||
context.UserRoles.Add(new IdentityUserRole<string>
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = "role-1"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/User/{userId}/Permissions")]
|
||||
public sealed class TeamPermissionController : ControllerBase
|
||||
{
|
||||
private readonly ITeamPermissionService _permissionService;
|
||||
|
||||
public TeamPermissionController(ITeamPermissionService permissionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetProfile(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
[HttpPut("{permissionKey}")]
|
||||
public async Task<IActionResult> SetOverride(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
[FromBody] SetTeamPermissionOverrideDTO request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.IsDefined(request.State))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." });
|
||||
|
||||
var result = await _permissionService.SetOverrideAsync(
|
||||
userId,
|
||||
permissionKey,
|
||||
request.State,
|
||||
User,
|
||||
cancellationToken);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
private static IActionResult ToActionResult(
|
||||
TeamPermissionResult<TeamPermissionProfileDTO> result)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
|
||||
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
|
||||
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
|
||||
new Response { Status = "Error", Message = "User not found." }),
|
||||
TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult(
|
||||
new Response { Status = "Error", Message = "Unknown permission key." }),
|
||||
_ => new BadRequestObjectResult(
|
||||
new Response { Status = "Error", Message = "Unable to update permissions." })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -225,6 +225,32 @@ namespace Data.SeaHavenIndustries
|
|||
.WithMany()
|
||||
.HasForeignKey(c => c.AreaId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Per-person team permission overrides. Composite primary key
|
||||
// (UserId + PermissionKey) plus an explicitly named unique composite
|
||||
// index; missing rows behave as Unset.
|
||||
builder.Entity<UserPermissionOverride>(entity =>
|
||||
{
|
||||
entity.HasKey(o => new { o.UserId, o.PermissionKey });
|
||||
|
||||
entity.Property(o => o.UserId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
entity.Property(o => o.PermissionKey)
|
||||
.HasMaxLength(64);
|
||||
|
||||
entity.Property(o => o.State)
|
||||
.IsRequired();
|
||||
|
||||
entity.HasOne(o => o.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(o => o.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasIndex(o => new { o.UserId, o.PermissionKey })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
});
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -279,6 +305,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<Department> Departments { get; set; }
|
||||
public DbSet<JobTitle> JobTitles { get; set; }
|
||||
public DbSet<Region> Regions { get; set; }
|
||||
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
|
|
|
|||
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-person team permission override, keyed by user and canonical
|
||||
/// permission key. Explicit Allow/Deny rows take precedence over role defaults;
|
||||
/// <see cref="UserPermissionState.Unset"/> (and a missing row) falls back to
|
||||
/// the role default.
|
||||
/// </summary>
|
||||
public class UserPermissionOverride
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public string PermissionKey { get; set; } = null!;
|
||||
public UserPermissionState State { get; set; } = UserPermissionState.Unset;
|
||||
public virtual ApplicationUser? User { get; set; }
|
||||
}
|
||||
}
|
||||
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// Tri-state state of a per-person team permission override.
|
||||
/// A missing row behaves the same as <see cref="Unset"/>.
|
||||
/// </summary>
|
||||
public enum UserPermissionState
|
||||
{
|
||||
Unset = 0,
|
||||
Allow = 1,
|
||||
Deny = 2
|
||||
}
|
||||
}
|
||||
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,46 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH327_UserPermissionOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserPermissionOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
PermissionKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
State = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserPermissionOverrides", x => new { x.UserId, x.PermissionKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserPermissionOverrides_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserPermissionOverrides_UserId_PermissionKey",
|
||||
table: "UserPermissionOverrides",
|
||||
columns: new[] { "UserId", "PermissionKey" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserPermissionOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2072,6 +2072,28 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Trades");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("PermissionKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("UserId", "PermissionKey");
|
||||
|
||||
b.HasIndex("UserId", "PermissionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
|
||||
b.ToTable("UserPermissionOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -3616,6 +3638,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("POC");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||
|
|
|
|||
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Dto;
|
||||
|
||||
public sealed class TeamPermissionUserData
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
public IReadOnlyDictionary<string, UserPermissionState> Overrides { get; init; }
|
||||
= new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public TeamPermissionOverrideDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<TeamPermissionUserData?> GetUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await _context.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||
|
||||
if (!userExists)
|
||||
return null;
|
||||
|
||||
var roleName = await (
|
||||
from userRole in _context.UserRoles.AsNoTracking()
|
||||
join role in _context.Roles.AsNoTracking()
|
||||
on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == userId
|
||||
select role.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var overrides = await _context.UserPermissionOverrides
|
||||
.AsNoTracking()
|
||||
.Where(permission => permission.UserId == userId)
|
||||
.ToDictionaryAsync(
|
||||
permission => permission.PermissionKey,
|
||||
permission => permission.State,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
|
||||
return new TeamPermissionUserData
|
||||
{
|
||||
UserId = userId,
|
||||
RoleName = roleName,
|
||||
Overrides = overrides
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _context.UserPermissionOverrides
|
||||
.SingleOrDefaultAsync(
|
||||
permission => permission.UserId == userId
|
||||
&& permission.PermissionKey == permissionKey,
|
||||
cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.State = state;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var addition = new UserPermissionOverride
|
||||
{
|
||||
UserId = userId,
|
||||
PermissionKey = permissionKey,
|
||||
State = state
|
||||
};
|
||||
await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent PUT inserted the same (UserId, PermissionKey) between our
|
||||
// existence check and this save, violating PK_UserPermissionOverrides.
|
||||
// Converge on the caller's intent by updating the persisted row.
|
||||
_context.Entry(addition).State = EntityState.Detached;
|
||||
|
||||
var winner = await _context.UserPermissionOverrides
|
||||
.SingleAsync(
|
||||
permission => permission.UserId == userId
|
||||
&& permission.PermissionKey == permissionKey,
|
||||
cancellationToken);
|
||||
winner.State = state;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +135,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Where(role => role.UserId == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _context.UserPermissionOverrides
|
||||
.Where(permissionOverride => permissionOverride.UserId == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _context.Users
|
||||
.Where(existingUser => existingUser.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface ITeamPermissionOverrideDataService
|
||||
{
|
||||
Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken);
|
||||
Task SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHaven.Services.Tests;
|
||||
|
||||
public class TeamPermissionPolicyTests
|
||||
{
|
||||
private static readonly string[] DispatcherKeys =
|
||||
{
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
TeamPermissionKeys.EditVendors,
|
||||
TeamPermissionKeys.DeactivateVendors,
|
||||
TeamPermissionKeys.CreateSites,
|
||||
TeamPermissionKeys.EditSites,
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
TeamPermissionKeys.EditOthersWorkOrders,
|
||||
TeamPermissionKeys.CancelWorkOrders,
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
};
|
||||
|
||||
private static readonly string[] SchedulerOnlyKeys =
|
||||
{
|
||||
TeamPermissionKeys.DeleteSites,
|
||||
TeamPermissionKeys.CreateServices,
|
||||
TeamPermissionKeys.EditServices,
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||
};
|
||||
|
||||
private static readonly string[] AdminOnlyKeys =
|
||||
{
|
||||
TeamPermissionKeys.ManageTeamMembers,
|
||||
TeamPermissionKeys.ChangeTeamMemberRole,
|
||||
TeamPermissionKeys.DeactivateServices,
|
||||
TeamPermissionKeys.DeleteCompletionDocTemplates,
|
||||
TeamPermissionKeys.DeleteWorkOrders,
|
||||
TeamPermissionKeys.ReviewUplifts
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, UserPermissionState> Overrides(
|
||||
string key, UserPermissionState state) =>
|
||||
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[key] = state
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Registry_Exposes_Exactly_The_22_Prototype_Keys()
|
||||
{
|
||||
TeamPermissionKeys.All.Should().HaveCount(22);
|
||||
TeamPermissionKeys.KnownKeys.Should().HaveCount(22);
|
||||
TeamPermissionKeys.All.Should().OnlyHaveUniqueItems();
|
||||
TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key));
|
||||
TeamPermissionKeys.All.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"manageTeamMembers",
|
||||
"changeTeamMemberRole",
|
||||
"createVendors",
|
||||
"editVendors",
|
||||
"deactivateVendors",
|
||||
"createSites",
|
||||
"editSites",
|
||||
"deleteSites",
|
||||
"createServices",
|
||||
"editServices",
|
||||
"deactivateServices",
|
||||
"createCompletionDocTemplates",
|
||||
"editCompletionDocTemplates",
|
||||
"deleteCompletionDocTemplates",
|
||||
"createWorkOrders",
|
||||
"editOthersWorkOrders",
|
||||
"cancelWorkOrders",
|
||||
"deleteWorkOrders",
|
||||
"requestUplifts",
|
||||
"autoApproveUplifts",
|
||||
"reviewUplifts",
|
||||
"viewAllDispatchersOnDashboard"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys()
|
||||
{
|
||||
TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue();
|
||||
TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue();
|
||||
TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse();
|
||||
TeamPermissionKeys.IsKnown("").Should().BeFalse();
|
||||
TeamPermissionKeys.IsKnown(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_Gets_Exactly_Prototype_Defaults()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal);
|
||||
policy.IsAllowed("Dispatcher", key).Should().Be(expected,
|
||||
$"Dispatcher default for '{key}' should be {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_Gets_Exactly_Prototype_Defaults()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal)
|
||||
|| DispatcherKeys.Contains(key, StringComparer.Ordinal)
|
||||
&& key is not TeamPermissionKeys.RequestUplifts
|
||||
&& key is not TeamPermissionKeys.AutoApproveUplifts;
|
||||
|
||||
policy.IsAllowed("Scheduler", key).Should().Be(expected,
|
||||
$"Scheduler default for '{key}' should be {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_Has_Effective_Access_To_Every_Key()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_Recognition_Is_Case_Insensitive()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue();
|
||||
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue();
|
||||
policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue();
|
||||
|
||||
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse();
|
||||
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Manager")]
|
||||
[InlineData("OfficeAdmin")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role)
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed(role, key).Should().BeFalse(
|
||||
$"unknown/legacy role '{role}' must not hold '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow))
|
||||
.Should().BeFalse($"an unknown role must not be elevated via '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allow_Override_Grants_Key_Outside_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts)
|
||||
.Should().BeFalse();
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deny_Override_Removes_Key_Inside_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors)
|
||||
.Should().BeTrue();
|
||||
policy.IsAllowed(
|
||||
"Dispatcher",
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Dispatcher",
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset))
|
||||
.Should().BeTrue();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset))
|
||||
.Should().BeFalse();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
new Dictionary<string, UserPermissionState>())
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_Is_Immune_To_Overrides()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny))
|
||||
.Should().BeTrue($"Admin must keep '{key}' despite a Deny override");
|
||||
policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Permission_Key_Is_Denied_For_Every_Role()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse();
|
||||
policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse();
|
||||
policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
130
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
130
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHaven.Services.Tests;
|
||||
|
||||
public sealed class TeamPermissionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetProfile_ReturnsEveryKeyAndRoleDefaults()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var result = await Service(data).GetProfileAsync("u1", Admin(), CancellationToken.None);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value!.Permissions.Should().HaveCount(22);
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeFalse();
|
||||
result.Value.Permissions.Should().OnlyContain(p => p.OverrideState == UserPermissionState.Unset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetOverride_ChangesOnlyOnePermissionAndPersistsCanonicalKey()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var result = await Service(data).SetOverrideAsync(
|
||||
"u1", "DELETEsites", UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value!.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeTrue();
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).OverrideState.Should().Be(UserPermissionState.Allow);
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
|
||||
data.LastSet.Should().Be(("u1", TeamPermissionKeys.DeleteSites, UserPermissionState.Allow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetOverride_RejectsUnknownKeyBeforeDataAccess()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var result = await Service(data).SetOverrideAsync(
|
||||
"u1", "not-a-permission", UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.InvalidPermissionKey);
|
||||
data.GetCalls.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonAdminCannotReadOrWritePermissions()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var service = Service(data);
|
||||
|
||||
var read = await service.GetProfileAsync("u1", User("Dispatcher"), CancellationToken.None);
|
||||
var write = await service.SetOverrideAsync("u1", TeamPermissionKeys.CreateSites, UserPermissionState.Deny, User("Dispatcher"), CancellationToken.None);
|
||||
|
||||
read.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
|
||||
write.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
|
||||
data.GetCalls.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownRoleReceivesNoGrantEvenWhenAnOverrideIsSet()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Legacy");
|
||||
var result = await Service(data).SetOverrideAsync(
|
||||
"u1", TeamPermissionKeys.CreateSites, UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||
|
||||
result.Value!.Permissions.Should().OnlyContain(p => !p.IsGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingUserReturnsNotFound()
|
||||
{
|
||||
var data = new FakeDataService(null, null);
|
||||
var result = await Service(data).GetProfileAsync("missing", Admin(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.NotFound);
|
||||
}
|
||||
|
||||
private static TeamPermissionService Service(FakeDataService data) =>
|
||||
new(data, new TeamPermissionPolicy());
|
||||
|
||||
private static ClaimsPrincipal Admin() => User("Admin");
|
||||
|
||||
private static ClaimsPrincipal User(string role) =>
|
||||
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, role) }, "test"));
|
||||
|
||||
private sealed class FakeDataService : ITeamPermissionOverrideDataService
|
||||
{
|
||||
private readonly string? _userId;
|
||||
private readonly string? _roleName;
|
||||
private readonly Dictionary<string, UserPermissionState> _overrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public FakeDataService(string? userId, string? roleName)
|
||||
{
|
||||
_userId = userId;
|
||||
_roleName = roleName;
|
||||
}
|
||||
|
||||
public int GetCalls { get; private set; }
|
||||
public (string UserId, string PermissionKey, UserPermissionState State)? LastSet { get; private set; }
|
||||
|
||||
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
GetCalls++;
|
||||
return Task.FromResult<TeamPermissionUserData?>(_userId != userId
|
||||
? null
|
||||
: new TeamPermissionUserData
|
||||
{
|
||||
UserId = userId,
|
||||
RoleName = _roleName,
|
||||
Overrides = new Dictionary<string, UserPermissionState>(_overrides, StringComparer.OrdinalIgnoreCase)
|
||||
});
|
||||
}
|
||||
|
||||
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken)
|
||||
{
|
||||
_overrides[permissionKey] = state;
|
||||
LastSet = (userId, permissionKey, state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
namespace SeaHaven.Services.Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical, immutable registry of team-members permission keys.
|
||||
/// Exactly the 22 approved prototype keys; stored overrides must reference
|
||||
/// these keys. Keys are stable identifiers and must never be renamed.
|
||||
/// </summary>
|
||||
public static class TeamPermissionKeys
|
||||
{
|
||||
public const string ManageTeamMembers = "manageTeamMembers";
|
||||
public const string ChangeTeamMemberRole = "changeTeamMemberRole";
|
||||
public const string CreateVendors = "createVendors";
|
||||
public const string EditVendors = "editVendors";
|
||||
public const string DeactivateVendors = "deactivateVendors";
|
||||
public const string CreateSites = "createSites";
|
||||
public const string EditSites = "editSites";
|
||||
public const string DeleteSites = "deleteSites";
|
||||
public const string CreateServices = "createServices";
|
||||
public const string EditServices = "editServices";
|
||||
public const string DeactivateServices = "deactivateServices";
|
||||
public const string CreateCompletionDocTemplates = "createCompletionDocTemplates";
|
||||
public const string EditCompletionDocTemplates = "editCompletionDocTemplates";
|
||||
public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates";
|
||||
public const string CreateWorkOrders = "createWorkOrders";
|
||||
public const string EditOthersWorkOrders = "editOthersWorkOrders";
|
||||
public const string CancelWorkOrders = "cancelWorkOrders";
|
||||
public const string DeleteWorkOrders = "deleteWorkOrders";
|
||||
public const string RequestUplifts = "requestUplifts";
|
||||
public const string AutoApproveUplifts = "autoApproveUplifts";
|
||||
public const string ReviewUplifts = "reviewUplifts";
|
||||
public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllKeys = Array.AsReadOnly(new[]
|
||||
{
|
||||
ManageTeamMembers,
|
||||
ChangeTeamMemberRole,
|
||||
CreateVendors,
|
||||
EditVendors,
|
||||
DeactivateVendors,
|
||||
CreateSites,
|
||||
EditSites,
|
||||
DeleteSites,
|
||||
CreateServices,
|
||||
EditServices,
|
||||
DeactivateServices,
|
||||
CreateCompletionDocTemplates,
|
||||
EditCompletionDocTemplates,
|
||||
DeleteCompletionDocTemplates,
|
||||
CreateWorkOrders,
|
||||
EditOthersWorkOrders,
|
||||
CancelWorkOrders,
|
||||
DeleteWorkOrders,
|
||||
RequestUplifts,
|
||||
AutoApproveUplifts,
|
||||
ReviewUplifts,
|
||||
ViewAllDispatchersOnDashboard
|
||||
});
|
||||
|
||||
public static IReadOnlyList<string> All => AllKeys;
|
||||
|
||||
public static IReadOnlySet<string> KnownKeys { get; } =
|
||||
new HashSet<string>(AllKeys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsKnown(string? permissionKey) =>
|
||||
!string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey);
|
||||
}
|
||||
}
|
||||
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs;
|
||||
|
||||
public sealed class TeamPermissionProfileDTO
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
public required IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TeamPermissionValueDTO
|
||||
{
|
||||
public required string PermissionKey { get; init; }
|
||||
public UserPermissionState OverrideState { get; init; }
|
||||
public bool IsGranted { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SetTeamPermissionOverrideDTO
|
||||
{
|
||||
public UserPermissionState State { get; init; }
|
||||
}
|
||||
|
||||
public enum TeamPermissionResultStatus
|
||||
{
|
||||
Success,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
InvalidPermissionKey
|
||||
}
|
||||
|
||||
public sealed class TeamPermissionResult<T>
|
||||
{
|
||||
private TeamPermissionResult(TeamPermissionResultStatus status, T? value)
|
||||
{
|
||||
Status = status;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public TeamPermissionResultStatus Status { get; }
|
||||
public T? Value { get; }
|
||||
public bool IsSuccess => Status == TeamPermissionResultStatus.Success;
|
||||
|
||||
public static TeamPermissionResult<T> Success(T value) =>
|
||||
new(TeamPermissionResultStatus.Success, value);
|
||||
|
||||
public static TeamPermissionResult<T> Failure(TeamPermissionResultStatus status) =>
|
||||
new(status, default);
|
||||
}
|
||||
88
SeaHaven.Services/Implementation/TeamPermissionPolicy.cs
Normal file
88
SeaHaven.Services/Implementation/TeamPermissionPolicy.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public sealed class TeamPermissionPolicy : ITeamPermissionPolicy
|
||||
{
|
||||
private static readonly StringComparer KeyComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private static readonly HashSet<string> DispatcherDefaults = new(KeyComparer)
|
||||
{
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
TeamPermissionKeys.EditVendors,
|
||||
TeamPermissionKeys.DeactivateVendors,
|
||||
TeamPermissionKeys.CreateSites,
|
||||
TeamPermissionKeys.EditSites,
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
TeamPermissionKeys.EditOthersWorkOrders,
|
||||
TeamPermissionKeys.CancelWorkOrders,
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SchedulerDefaults = new(
|
||||
DispatcherDefaults
|
||||
.Except(new[]
|
||||
{
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
}, KeyComparer)
|
||||
.Concat(new[]
|
||||
{
|
||||
TeamPermissionKeys.DeleteSites,
|
||||
TeamPermissionKeys.CreateServices,
|
||||
TeamPermissionKeys.EditServices,
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||
}), KeyComparer);
|
||||
|
||||
public bool IsAllowed(
|
||||
string? roleName,
|
||||
string permissionKey,
|
||||
IReadOnlyDictionary<string, UserPermissionState>? overrides = null)
|
||||
{
|
||||
if (!TeamPermissionKeys.IsKnown(permissionKey))
|
||||
return false;
|
||||
|
||||
if (IsAdmin(roleName))
|
||||
return true;
|
||||
|
||||
var defaults = ResolveRoleDefaults(roleName);
|
||||
if (defaults is null)
|
||||
return false;
|
||||
|
||||
if (overrides is not null
|
||||
&& overrides.TryGetValue(permissionKey, out var state))
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
UserPermissionState.Allow => true,
|
||||
UserPermissionState.Deny => false,
|
||||
_ => defaults.Contains(permissionKey)
|
||||
};
|
||||
}
|
||||
|
||||
return defaults.Contains(permissionKey);
|
||||
}
|
||||
|
||||
private static bool IsAdmin(string? roleName) =>
|
||||
string.Equals(roleName, "Admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IReadOnlySet<string>? ResolveRoleDefaults(string? roleName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleName))
|
||||
return null;
|
||||
|
||||
if (roleName.Equals("Dispatcher", StringComparison.OrdinalIgnoreCase))
|
||||
return DispatcherDefaults;
|
||||
|
||||
if (roleName.Equals("Scheduler", StringComparison.OrdinalIgnoreCase))
|
||||
return SchedulerDefaults;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
SeaHaven.Services/Implementation/TeamPermissionService.cs
Normal file
96
SeaHaven.Services/Implementation/TeamPermissionService.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHaven.Services.Implementation;
|
||||
|
||||
public sealed class TeamPermissionService : ITeamPermissionService
|
||||
{
|
||||
private readonly ITeamPermissionOverrideDataService _dataService;
|
||||
private readonly ITeamPermissionPolicy _policy;
|
||||
|
||||
public TeamPermissionService(
|
||||
ITeamPermissionOverrideDataService dataService,
|
||||
ITeamPermissionPolicy policy)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_policy = policy;
|
||||
}
|
||||
|
||||
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> GetProfileAsync(
|
||||
string userId,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAdmin(caller))
|
||||
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.Forbidden);
|
||||
|
||||
var user = await _dataService.GetUserAsync(userId, cancellationToken);
|
||||
return user is null
|
||||
? TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.NotFound)
|
||||
: TeamPermissionResult<TeamPermissionProfileDTO>.Success(BuildProfile(user));
|
||||
}
|
||||
|
||||
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAdmin(caller))
|
||||
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.Forbidden);
|
||||
|
||||
var canonicalKey = TeamPermissionKeys.All.FirstOrDefault(
|
||||
key => string.Equals(key, permissionKey, StringComparison.OrdinalIgnoreCase));
|
||||
if (canonicalKey is null)
|
||||
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(
|
||||
TeamPermissionResultStatus.InvalidPermissionKey);
|
||||
|
||||
var user = await _dataService.GetUserAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.NotFound);
|
||||
|
||||
await _dataService.SetOverrideAsync(userId, canonicalKey, state, cancellationToken);
|
||||
|
||||
var updatedOverrides = user.Overrides.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
updatedOverrides[canonicalKey] = state;
|
||||
|
||||
return TeamPermissionResult<TeamPermissionProfileDTO>.Success(
|
||||
BuildProfile(new TeamPermissionUserData
|
||||
{
|
||||
UserId = user.UserId,
|
||||
RoleName = user.RoleName,
|
||||
Overrides = updatedOverrides
|
||||
}));
|
||||
}
|
||||
|
||||
private TeamPermissionProfileDTO BuildProfile(TeamPermissionUserData user)
|
||||
{
|
||||
return new TeamPermissionProfileDTO
|
||||
{
|
||||
UserId = user.UserId,
|
||||
RoleName = user.RoleName,
|
||||
Permissions = TeamPermissionKeys.All
|
||||
.Select(key => new TeamPermissionValueDTO
|
||||
{
|
||||
PermissionKey = key,
|
||||
OverrideState = user.Overrides.TryGetValue(key, out var state)
|
||||
? state
|
||||
: UserPermissionState.Unset,
|
||||
IsGranted = _policy.IsAllowed(user.RoleName, key, user.Overrides)
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAdmin(ClaimsPrincipal? caller) =>
|
||||
caller?.IsInRole("Admin") == true;
|
||||
}
|
||||
23
SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs
Normal file
23
SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure evaluator for team-members permissions. Applies explicit
|
||||
/// per-person overrides on top of role defaults; Admin has effective access
|
||||
/// to every canonical key regardless of stored values. Unknown or legacy
|
||||
/// roles receive no permissions.
|
||||
/// </summary>
|
||||
public interface ITeamPermissionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves whether a user in <paramref name="roleName"/> holds
|
||||
/// <paramref name="permissionKey"/>. <paramref name="overrides"/> is the
|
||||
/// person's stored override set (missing key behaves as Unset).
|
||||
/// </summary>
|
||||
bool IsAllowed(
|
||||
string? roleName,
|
||||
string permissionKey,
|
||||
IReadOnlyDictionary<string, UserPermissionState>? overrides = null);
|
||||
}
|
||||
}
|
||||
20
SeaHaven.Services/Interfaces/ITeamPermissionService.cs
Normal file
20
SeaHaven.Services/Interfaces/ITeamPermissionService.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces;
|
||||
|
||||
public interface ITeamPermissionService
|
||||
{
|
||||
Task<TeamPermissionResult<TeamPermissionProfileDTO>> GetProfileAsync(
|
||||
string userId,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue