feat: restrict admin access to club operations and rollout test environment
This commit is contained in:
@@ -85,7 +85,7 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
|
||||
{
|
||||
return clubRole switch
|
||||
{
|
||||
ClubRole.Admin => "Admin",
|
||||
|
||||
ClubRole.Manager => "Manager",
|
||||
ClubRole.Member => "Member",
|
||||
ClubRole.Viewer => "Viewer",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WorkClub.Api.Services;
|
||||
using WorkClub.Application.Clubs.DTOs;
|
||||
|
||||
namespace WorkClub.Api.Endpoints.Clubs;
|
||||
|
||||
public static class AdminClubEndpoints
|
||||
{
|
||||
public static void MapAdminClubEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/admin/clubs")
|
||||
.RequireAuthorization("RequireGlobalAdmin")
|
||||
.WithTags("AdminClubs");
|
||||
|
||||
group.MapGet("", GetClubs)
|
||||
.WithName("AdminGetClubs");
|
||||
|
||||
group.MapPost("", CreateClub)
|
||||
.WithName("AdminCreateClub");
|
||||
|
||||
group.MapPut("{id:guid}", UpdateClub)
|
||||
.WithName("AdminUpdateClub");
|
||||
|
||||
group.MapDelete("{id:guid}", DeleteClub)
|
||||
.WithName("AdminDeleteClub");
|
||||
}
|
||||
|
||||
private static async Task<Ok<List<ClubDetailDto>>> GetClubs(AdminClubService adminClubService)
|
||||
{
|
||||
var result = await adminClubService.GetAllClubsAsync();
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Created<ClubDetailDto>> CreateClub(
|
||||
[FromBody] CreateClubRequest request,
|
||||
AdminClubService adminClubService)
|
||||
{
|
||||
var result = await adminClubService.CreateClubAsync(request);
|
||||
return TypedResults.Created($"/api/admin/clubs/{result.Id}", result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ClubDetailDto>, NotFound>> UpdateClub(
|
||||
Guid id,
|
||||
[FromBody] UpdateClubRequest request,
|
||||
AdminClubService adminClubService)
|
||||
{
|
||||
var (result, error) = await adminClubService.UpdateClubAsync(id, request);
|
||||
|
||||
if (error != null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(result!);
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound>> DeleteClub(
|
||||
Guid id,
|
||||
AdminClubService adminClubService)
|
||||
{
|
||||
var success = await adminClubService.DeleteClubAsync(id);
|
||||
|
||||
if (!success)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public static class ShiftEndpoints
|
||||
.WithName("UpdateShift");
|
||||
|
||||
group.MapDelete("{id:guid}", DeleteShift)
|
||||
.RequireAuthorization("RequireAdmin")
|
||||
.RequireAuthorization("RequireManager")
|
||||
.WithName("DeleteShift");
|
||||
|
||||
group.MapPost("{id:guid}/signup", SignUpForShift)
|
||||
|
||||
@@ -28,7 +28,7 @@ public static class TaskEndpoints
|
||||
.WithName("UpdateTask");
|
||||
|
||||
group.MapDelete("{id:guid}", DeleteTask)
|
||||
.RequireAuthorization("RequireAdmin")
|
||||
.RequireAuthorization("RequireManager")
|
||||
.WithName("DeleteTask");
|
||||
|
||||
group.MapPost("{id:guid}/assign", AssignTaskToMe)
|
||||
|
||||
@@ -22,8 +22,9 @@ public class TenantValidationMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Exempt /api/clubs/me from tenant validation - this is the bootstrap endpoint
|
||||
if (context.Request.Path.StartsWithSegments("/api/clubs/me"))
|
||||
// Exempt bootstrap and admin endpoints from tenant validation
|
||||
if (context.Request.Path.StartsWithSegments("/api/clubs/me") ||
|
||||
context.Request.Path.StartsWithSegments("/api/admin"))
|
||||
{
|
||||
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
|
||||
await _next(context);
|
||||
|
||||
@@ -24,6 +24,7 @@ builder.Services.AddScoped<SeedDataService>();
|
||||
builder.Services.AddScoped<TaskService>();
|
||||
builder.Services.AddScoped<ShiftService>();
|
||||
builder.Services.AddScoped<ClubService>();
|
||||
builder.Services.AddScoped<AdminClubService>();
|
||||
builder.Services.AddScoped<MemberService>();
|
||||
builder.Services.AddScoped<MemberSyncService>();
|
||||
|
||||
@@ -49,9 +50,13 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
builder.Services.AddScoped<IClaimsTransformation, ClubRoleClaimsTransformation>();
|
||||
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin"))
|
||||
.AddPolicy("RequireManager", policy => policy.RequireRole("Admin", "Manager"))
|
||||
.AddPolicy("RequireMember", policy => policy.RequireRole("Admin", "Manager", "Member"))
|
||||
.AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context =>
|
||||
{
|
||||
var realmAccess = context.User.FindFirst("realm_access")?.Value;
|
||||
return realmAccess != null && realmAccess.Contains("\"admin\"");
|
||||
}))
|
||||
.AddPolicy("RequireManager", policy => policy.RequireRole("Manager"))
|
||||
.AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member"))
|
||||
.AddPolicy("RequireViewer", policy => policy.RequireAuthenticatedUser());
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
|
||||
@@ -122,6 +127,7 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
||||
app.MapTaskEndpoints();
|
||||
app.MapShiftEndpoints();
|
||||
app.MapClubEndpoints();
|
||||
app.MapAdminClubEndpoints();
|
||||
app.MapMemberEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using WorkClub.Application.Clubs.DTOs;
|
||||
using WorkClub.Domain.Entities;
|
||||
using WorkClub.Infrastructure.Data;
|
||||
|
||||
namespace WorkClub.Api.Services;
|
||||
|
||||
public class AdminClubService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public AdminClubService(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<List<ClubDetailDto>> GetAllClubsAsync()
|
||||
{
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
|
||||
var clubs = await _context.Clubs.ToListAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return clubs.Select(c => new ClubDetailDto(
|
||||
c.Id, c.Name, c.SportType.ToString(), c.Description, c.CreatedAt, c.UpdatedAt)).ToList();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ClubDetailDto> CreateClubAsync(CreateClubRequest request)
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString();
|
||||
var club = new Club
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
SportType = request.SportType,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
|
||||
_context.Clubs.Add(club);
|
||||
await _context.SaveChangesAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
|
||||
await transaction.CommitAsync();
|
||||
});
|
||||
|
||||
return new ClubDetailDto(club.Id, club.Name, club.SportType.ToString(), club.Description, club.CreatedAt, club.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task<(ClubDetailDto? club, string? error)> UpdateClubAsync(Guid id, UpdateClubRequest request)
|
||||
{
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
return await strategy.ExecuteAsync<(ClubDetailDto? club, string? error)>(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
|
||||
|
||||
var club = await _context.Clubs.FindAsync(id);
|
||||
if (club == null)
|
||||
{
|
||||
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
|
||||
return (null, "Club not found");
|
||||
}
|
||||
|
||||
club.Name = request.Name;
|
||||
club.SportType = request.SportType;
|
||||
club.Description = request.Description;
|
||||
club.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return (new ClubDetailDto(club.Id, club.Name, club.SportType.ToString(), club.Description, club.CreatedAt, club.UpdatedAt), null);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteClubAsync(Guid id)
|
||||
{
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
return await strategy.ExecuteAsync<bool>(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
|
||||
|
||||
var club = await _context.Clubs.FindAsync(id);
|
||||
if (club == null)
|
||||
{
|
||||
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
|
||||
return false;
|
||||
}
|
||||
|
||||
_context.Clubs.Remove(club);
|
||||
await _context.SaveChangesAsync();
|
||||
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ public class MemberSyncService
|
||||
var roleClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "Member";
|
||||
var clubRole = roleClaim.ToLowerInvariant() switch
|
||||
{
|
||||
"admin" => ClubRole.Admin,
|
||||
"manager" => ClubRole.Manager,
|
||||
"member" => ClubRole.Member,
|
||||
"viewer" => ClubRole.Viewer,
|
||||
|
||||
Reference in New Issue
Block a user