From 1af41b91a21d18bd3a5d24601f1237c1f9acab9a Mon Sep 17 00:00:00 2001 From: namdh Date: Sat, 12 Sep 2026 19:40:59 +0700 Subject: [PATCH] fix: change permission --- CLAUDE.md | 2 +- .../Projects/Commands/ArchiveProject.cs | 10 ++- .../Projects/Commands/CreateProject.cs | 5 +- .../Projects/Commands/UpdateProject.cs | 11 ++-- infrastructure/Persistence/DbSeeder.cs | 65 ++++++++++++++----- 5 files changed, 60 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a40dab..554b66c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Two independent mechanisms — don't cross-wire them: - PostgreSQL via `Npgsql.EntityFrameworkCore.PostgreSQL` 10. Connection string key is `ConnectionStrings:Default` (see [Mws.Api/appsettings.json](Mws.Api/appsettings.json)); fallback to `localhost:5432` dev creds `mws/mws`. Override via env if you must. - Enum columns are stored as `varchar(20)` (not ints) — preserve that for any new enum. - Self-referencing `Document.ParentId` uses `Restrict` to block accidental cycles. Cascade delete is implemented in `DeleteDocumentHandler` via a manual descendant walk (`DocumentAccess.DeleteDescendantsAsync`); do not switch the FK to cascade. -- `DbSeeder` ([Mws.Infrastructure/Persistence/DbSeeder.cs](Mws.Infrastructure/Persistence/DbSeeder.cs)) seeds two system roles (`Admin` — full CRUD on every screen; `Member` — CRUD on everything except Users/Permissions/Master Data, no delete) plus admin/alice/bob (password: `password`, admin → Admin role, alice/bob → Member) and one demo project on first boot. Skips if any users exist. System roles (`Role.IsSystem = true`) can't be deleted via `DeleteRoleCommand`. +- `DbSeeder` ([Mws.Infrastructure/Persistence/DbSeeder.cs](Mws.Infrastructure/Persistence/DbSeeder.cs)) seeds two system roles (`Admin` — full CRUD on every screen; `Member` — view-only on Projects, CRUD on Tasks/Documents (no delete), and no access to Users/Permissions/Master Data) plus admin/alice/bob (password: `password`, admin → Admin role, alice/bob → Member) and one demo project on first boot. System roles are re-synced to this canonical permission matrix on every startup (`SyncSystemRoleAsync`), so edits made to them through the Roles UI do not persist across restarts. Skips user/project seeding if any users exist. System roles (`Role.IsSystem = true`) can't be deleted via `DeleteRoleCommand`. ### Config diff --git a/application/Projects/Commands/ArchiveProject.cs b/application/Projects/Commands/ArchiveProject.cs index 671fbba..1a84afc 100644 --- a/application/Projects/Commands/ArchiveProject.cs +++ b/application/Projects/Commands/ArchiveProject.cs @@ -1,21 +1,19 @@ using MediatR; using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; public record ArchiveProjectCommand(Guid UserId, Guid ProjectId) : IRequest; -public class ArchiveProjectHandler(IUnitOfWork uow) : IRequestHandler +public class ArchiveProjectHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler { public async Task Handle(ArchiveProjectCommand command, CancellationToken ct) { - var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); + await permissions.EnsureAsync(command.UserId, "projects", PermissionAction.Delete, ct); - if (await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct) != MemberRole.Owner) - { - throw new ForbiddenException("Only the project owner can archive the project"); - } + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); project.Status = ProjectStatus.Archived; project.UpdatedAt = DateTime.UtcNow; diff --git a/application/Projects/Commands/CreateProject.cs b/application/Projects/Commands/CreateProject.cs index e03fac8..7585cfe 100644 --- a/application/Projects/Commands/CreateProject.cs +++ b/application/Projects/Commands/CreateProject.cs @@ -1,16 +1,19 @@ using AutoMapper; using MediatR; using mws.backend.dotnet.application.Common; +using mws.backend.dotnet.application.Permissions; using mws.backend.dotnet.domain.Projects; namespace mws.backend.dotnet.application.Projects; public record CreateProjectCommand(Guid UserId, CreateProjectRequest Request) : IRequest; -public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper, IPermissionService permissions) : IRequestHandler { public async Task Handle(CreateProjectCommand command, CancellationToken ct) { + await permissions.EnsureAsync(command.UserId, "projects", PermissionAction.Create, ct); + var request = command.Request; if (string.IsNullOrWhiteSpace(request.Name)) { diff --git a/application/Projects/Commands/UpdateProject.cs b/application/Projects/Commands/UpdateProject.cs index 3756edd..791db40 100644 --- a/application/Projects/Commands/UpdateProject.cs +++ b/application/Projects/Commands/UpdateProject.cs @@ -1,22 +1,19 @@ using AutoMapper; using MediatR; using mws.backend.dotnet.application.Common; -using mws.backend.dotnet.domain.Projects; +using mws.backend.dotnet.application.Permissions; namespace mws.backend.dotnet.application.Projects; public record UpdateProjectCommand(Guid UserId, Guid ProjectId, UpdateProjectRequest Request) : IRequest; -public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler +public class UpdateProjectHandler(IUnitOfWork uow, IMapper mapper, IPermissionService permissions) : IRequestHandler { public async Task Handle(UpdateProjectCommand command, CancellationToken ct) { - var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); + await permissions.EnsureAsync(command.UserId, "projects", PermissionAction.Edit, ct); - if (MemberRole.Owner != await uow.Projects.GetMemberRoleAsync(command.ProjectId, command.UserId, ct)) - { - throw new ForbiddenException("Only the project owner can update the project"); - } + var project = await ProjectAccess.GetForUserOrThrowAsync(uow, command.UserId, command.ProjectId, ct); var request = command.Request; if (string.IsNullOrWhiteSpace(request.Name)) diff --git a/infrastructure/Persistence/DbSeeder.cs b/infrastructure/Persistence/DbSeeder.cs index d4623d1..e960913 100644 --- a/infrastructure/Persistence/DbSeeder.cs +++ b/infrastructure/Persistence/DbSeeder.cs @@ -134,29 +134,58 @@ public static class DbSeeder { var now = DateTime.UtcNow; - if (!await db.Roles.AnyAsync(r => r.Name == "Admin")) - { - var adminRole = new Role { Id = Guid.NewGuid(), Name = "Admin", IsSystem = true, CreatedAt = now, UpdatedAt = now }; - adminRole.Permissions = ScreenCatalog.Screens.Select(s => new RolePermission + await SyncSystemRoleAsync( + db, + "Admin", + _ => (View: true, Create: true, Edit: true, Delete: true), + now); + + await SyncSystemRoleAsync( + db, + "Member", + screen => screen.Key switch { - RoleId = adminRole.Id, Screen = s.Key, CanView = true, CanCreate = true, CanEdit = true, CanDelete = true, - }).ToList(); - db.Roles.Add(adminRole); - await db.SaveChangesAsync(); + "users" or "permissions" or "masterdata" => (View: false, Create: false, Edit: false, Delete: false), + "projects" => (View: true, Create: false, Edit: false, Delete: false), + _ => (View: true, Create: true, Edit: true, Delete: false), + }, + now); + } + + private static async Task SyncSystemRoleAsync( + AppDbContext db, + string name, + Func rule, + DateTime now) + { + var role = await db.Roles + .Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Name == name); + + if (role is null) + { + role = new Role { Id = Guid.NewGuid(), Name = name, IsSystem = true, CreatedAt = now, UpdatedAt = now }; + db.Roles.Add(role); } - if (!await db.Roles.AnyAsync(r => r.Name == "Member")) + var byScreen = role.Permissions.ToDictionary(p => p.Screen); + foreach (var screen in ScreenCatalog.Screens) { - var memberRole = new Role { Id = Guid.NewGuid(), Name = "Member", IsSystem = true, CreatedAt = now, UpdatedAt = now }; - memberRole.Permissions = ScreenCatalog.Screens - .Where(s => s.Key is not ("users" or "permissions" or "masterdata")) - .Select(s => new RolePermission - { - RoleId = memberRole.Id, Screen = s.Key, CanView = true, CanCreate = true, CanEdit = true, CanDelete = false, - }).ToList(); - db.Roles.Add(memberRole); - await db.SaveChangesAsync(); + if (!byScreen.TryGetValue(screen.Key, out var permission)) + { + permission = new RolePermission { RoleId = role.Id, Screen = screen.Key }; + role.Permissions.Add(permission); + } + + var (view, create, edit, delete) = rule(screen); + permission.CanView = view; + permission.CanCreate = create; + permission.CanEdit = edit; + permission.CanDelete = delete; } + + role.UpdatedAt = now; + await db.SaveChangesAsync(); } private static async Task EnsureUserAsync(AppDbContext db, IPasswordHasher passwordHasher, string username, string displayName, string roleName)