[CLAUDE] Infra: ẩn 5 nhóm menu (Hợp đồng + Danh mục + HRM/Office/Cá nhân) chỉ Admin thấy trên eoffice (S92)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m24s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m24s
- RevokeTemporarilyHiddenModulesAsync mở rộng predicate: +Contracts/Ct_* +Master/Suppliers/Projects/Departments/Catalog* (cộng dồn S58 Hrm*/Off*/Personal). - Tắt 2 re-grant public cũ: SeedAllRolesHrmProfileReadPermissions (S65) + SeedAllRolesOfficeModulePermissions (S69). Revoke là bước permission CUỐI trong SeedAdminPermissionsAsync (ungated) → durable, re-ẩn mỗi restart prod (gotcha #75). - Thu hẹp InReviewScope → chỉ Pe_* (Duyệt NCC giữ nguyên cho user thường; master-data GET = any-auth nên form PE vẫn chọn được Dự án/NCC/Hạng mục dù menu ẩn). - 2 method grant giữ private static (reversibility) → 2 seed test cũ vẫn invoke cô-lập, pass. - +AdminOnlyModulesRevokeTests (3 test-before-merge, security): non-Admin revoked 4 cờ / Admin untouched / PE preserved. Test 431→434 PASS (45 Domain + 389 Infra). - Reviewer PASS (5 attack vector: seed-order/under-revoke/over-revoke/test-genuine/prod-impact). FYI: role CatalogManager (0 user hiện tại) cũng bị thu Danh mục — intended-by-spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1996,14 +1996,20 @@ public static class DbInitializer
|
||||
// (+ root nhóm "Nhân sự") cho MỌI role — anh chốt public module Nhân sự cho
|
||||
// user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog
|
||||
// Hrm_Config* VẪN ẨN (revoke che). Read-only (chỉ CanRead).
|
||||
await SeedAllRolesHrmProfileReadPermissionsAsync(db, roleManager, logger);
|
||||
// [S92 2026-06-29] TẮT re-grant "Hồ sơ Nhân sự" public (S65) — anh chốt nhóm
|
||||
// "Nhân sự" CHỈ Admin thấy. RevokeTemporarilyHiddenModulesAsync (chạy ngay trên,
|
||||
// S92 mở rộng phủ thêm Contracts/Master/Catalogs) là bước permission CUỐI → thắng
|
||||
// mọi grant. Bật lại module nào: bỏ comment call tương ứng + gỡ prefix khỏi revoke.
|
||||
// await SeedAllRolesHrmProfileReadPermissionsAsync(db, roleManager, logger); // S65 — TẮT S92
|
||||
|
||||
// [S69 2026-06-17] GOLIVE Văn phòng số: mở Read+Create module Office (self-service)
|
||||
// cho MỌI role — anh chốt "public văn phòng số cho all user eoffice". CHẠY SAU revoke
|
||||
// để THẮNG (mirror S65). Allow-list 16 key (loại Off_PhongHop_Manage admin-CRUD +
|
||||
// Off_AttendanceReport báo-cáo-riêng-tư + Off_ChamCong Cá-nhân — giữ ẩn). HRM (trừ
|
||||
// Hồ sơ NS) + Personal VẪN ẩn (anh chỉ mở Office).
|
||||
await SeedAllRolesOfficeModulePermissionsAsync(db, roleManager, logger);
|
||||
// [S92 2026-06-29] TẮT re-grant "Văn phòng số" public (S69) — anh chốt nhóm
|
||||
// "Văn phòng số" CHỈ Admin thấy.
|
||||
// await SeedAllRolesOfficeModulePermissionsAsync(db, roleManager, logger); // S69 — TẮT S92
|
||||
}
|
||||
|
||||
// [S57] Cấp CanRead (CHỈ xem) cho MỌI role trên menu HRM + Office + Master để mọi
|
||||
@ -2027,10 +2033,12 @@ public static class DbInitializer
|
||||
// số + Cá nhân với user thường (chưa golive các module này). Revoke tương ứng
|
||||
// ở RevokeTemporarilyHiddenModulesAsync (chạy sau). Loại trừ tự nhiên (không
|
||||
// match prefix): PeWf_* (ký tự thứ 3 'W' ≠ '_'), AwV2_*, Ct_*, Bg_*, Wf_*, System.
|
||||
static bool InReviewScope(string key) =>
|
||||
key.StartsWith("Catalog") || key == MenuKeys.Master ||
|
||||
key == MenuKeys.Suppliers || key == MenuKeys.Projects || key == MenuKeys.Departments ||
|
||||
key.StartsWith("Pe_");
|
||||
// [S92 2026-06-29] Narrow: BỎ Master/Catalogs/Suppliers/Projects/Departments khỏi
|
||||
// grant-all-roles (anh chốt "Danh mục" CHỈ Admin). GIỮ Pe_* (Duyệt NCC read+create
|
||||
// cho mọi role — KHÔNG đụng). Master-data GET = any-auth (Suppliers/Projects/Catalogs
|
||||
// controller class-[Authorize]) nên form PE vẫn chọn Dự án/NCC/Hạng mục dù menu ẩn.
|
||||
// Row Master/Catalogs cũ trên prod được RevokeTemporarilyHiddenModulesAsync (dưới) gỡ.
|
||||
static bool InReviewScope(string key) => key.StartsWith("Pe_");
|
||||
|
||||
// Phân biệt key Pe_* (read+create) vs read-only. Pe_* match cờ thứ-3 '_'
|
||||
// → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer).
|
||||
@ -2128,7 +2136,14 @@ public static class DbInitializer
|
||||
var rows = await db.Permissions
|
||||
.Where(p => p.RoleId != adminRoleId
|
||||
&& (p.MenuKey.StartsWith("Hrm") || p.MenuKey.StartsWith("Off")
|
||||
|| p.MenuKey == MenuKeys.Personal)
|
||||
|| p.MenuKey == MenuKeys.Personal
|
||||
// [S92 2026-06-29] +Hợp đồng (Contracts + Ct_*) + Danh mục (Master +
|
||||
// Suppliers + Projects + Departments + Catalog*) — anh chốt "chỉ Admin
|
||||
// thấy". Pe_* (Duyệt NCC) KHÔNG nằm đây → module PE giữ nguyên cho user.
|
||||
|| p.MenuKey == MenuKeys.Contracts || p.MenuKey.StartsWith("Ct_")
|
||||
|| p.MenuKey == MenuKeys.Master || p.MenuKey == MenuKeys.Suppliers
|
||||
|| p.MenuKey == MenuKeys.Projects || p.MenuKey == MenuKeys.Departments
|
||||
|| p.MenuKey.StartsWith("Catalog"))
|
||||
&& (p.CanRead || p.CanCreate || p.CanUpdate || p.CanDelete))
|
||||
.ToListAsync();
|
||||
|
||||
@ -2144,7 +2159,8 @@ public static class DbInitializer
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"Revoked {Count} permission rows — tạm ẩn HRM/Văn phòng số/Cá nhân khỏi non-Admin (S58)",
|
||||
"Revoked {Count} permission rows — ẩn HRM/Văn phòng số/Cá nhân/Hợp đồng/Danh mục " +
|
||||
"khỏi non-Admin (chỉ Admin thấy — S58 + S92)",
|
||||
rows.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,191 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// S92 test-before-merge (SECURITY — anh chốt 5 nhóm menu CHỈ Admin thấy trên eoffice).
|
||||
// Guard hành vi RevokeTemporarilyHiddenModulesAsync sau khi S92 MỞ RỘNG predicate phủ thêm
|
||||
// HỢP ĐỒNG (Contracts + Ct_*) + DANH MỤC (Master + Suppliers + Projects + Departments + Catalog*),
|
||||
// cộng dồn nhóm cũ S58 NHÂN SỰ (Hrm*) + VĂN PHÒNG SỐ (Off*) + CÁ NHÂN (Personal).
|
||||
//
|
||||
// Cơ chế ẩn: menu hiện ⟺ Permission.CanRead=true (GetMyMenuTree lọc CanRead, KHÔNG admin-bypass).
|
||||
// Revoke set 4 cờ CRUD=false cho MỌI role non-Admin trên các key match predicate → menu tự ẩn.
|
||||
// Admin loại trừ khỏi revoke (giữ full → vẫn thấy hết). Pe_* (Duyệt NCC) KHÔNG match predicate
|
||||
// → module PE giữ nguyên cho user (invariant bảo vệ tính năng đang dùng).
|
||||
//
|
||||
// S92 CŨNG TẮT 2 call re-grant trong SeedAsync: SeedAllRolesHrmProfileReadPermissionsAsync (S65)
|
||||
// + SeedAllRolesOfficeModulePermissionsAsync (S69) → revoke là bước permission CUỐI, thắng mọi grant.
|
||||
// 2 method grant đó vẫn `private static` (reversibility) → OfficeModulePermissionSeedTests +
|
||||
// HrmProfilePermissionSeedTests vẫn invoke-reflection method-cô-lập (assertion KHÔNG đổi, vẫn pass).
|
||||
//
|
||||
// Reflection harness: RevokeTemporarilyHiddenModulesAsync là `private static` →
|
||||
// BindingFlags.NonPublic|Static, signature (ApplicationDbContext, RoleManager<Role>, ILogger).
|
||||
// Test ĐÚNG behavior code thật, KHÔNG re-implement predicate.
|
||||
//
|
||||
// FK (PermissionConfiguration): Permission.MenuKey → MenuItem.Key (Cascade) + RoleId → Role
|
||||
// (Cascade) → PHẢI seed MenuItem rows + Role TRƯỚC khi seed Permission (nếu không SQLite FK Error 19).
|
||||
public class AdminOnlyModulesRevokeTests
|
||||
{
|
||||
// Đại diện 5 nhóm anh chốt admin-only — sau revoke PHẢI CanRead=false (non-Admin).
|
||||
// Dùng const/factory MenuKeys (KHÔNG hardcode string) để bắt đồng-bộ nếu key đổi.
|
||||
private static readonly string[] S92HiddenKeys =
|
||||
{
|
||||
// HỢP ĐỒNG (Contracts + Ct_*)
|
||||
MenuKeys.Contracts,
|
||||
MenuKeys.ContractTypeGroup("ThauPhu"), // "Ct_ThauPhu" — match StartsWith("Ct_")
|
||||
// DANH MỤC (Master + 3 leaf + Catalog*)
|
||||
MenuKeys.Master,
|
||||
MenuKeys.Suppliers,
|
||||
MenuKeys.Projects,
|
||||
MenuKeys.Departments,
|
||||
MenuKeys.Catalogs, // "Catalogs" — match StartsWith("Catalog")
|
||||
MenuKeys.CatalogWorkItems, // "CatalogWorkItems" — match StartsWith("Catalog")
|
||||
// Nhóm cũ S58 (regression — vẫn phải ẩn)
|
||||
MenuKeys.Hrm, // "Hrm" — match StartsWith("Hrm")
|
||||
MenuKeys.Off, // "Off" — match StartsWith("Off")
|
||||
MenuKeys.Personal, // "Personal" — match == Personal
|
||||
};
|
||||
|
||||
// Pe_* (Duyệt NCC) + root PurchaseEvaluations — KHÔNG bị revoke (giữ cho user thường).
|
||||
private static readonly string[] PeKeptKeys =
|
||||
{
|
||||
MenuKeys.PurchaseEvaluations, // "PurchaseEvaluations" root — không match predicate
|
||||
MenuKeys.PurchaseEvaluationGroup("DuyetNcc"), // "Pe_DuyetNcc" — không StartsWith Ct_/Catalog/Hrm/Off
|
||||
};
|
||||
|
||||
private static readonly string[] AllMenuKeys =
|
||||
S92HiddenKeys.Concat(PeKeptKeys).Distinct().ToArray();
|
||||
|
||||
// Reflection invoke private static (ApplicationDbContext, RoleManager<Role>, ILogger).
|
||||
private static async Task InvokeRevokeAsync(IdentityFixture fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
|
||||
var mi = typeof(DbInitializer).GetMethod(
|
||||
"RevokeTemporarilyHiddenModulesAsync", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
mi.Should().NotBeNull(
|
||||
"DbInitializer.RevokeTemporarilyHiddenModulesAsync phải tồn tại (private static) — đổi signature thì cập nhật test");
|
||||
var task = (Task)mi!.Invoke(null, new object[] { db, rm, NullLogger.Instance })!;
|
||||
await task;
|
||||
}
|
||||
|
||||
// Ensure roles tồn tại (Admin + non-Admin). Trả (adminId, nonAdminId).
|
||||
private static async Task<(Guid adminId, Guid nonAdminId)> SeedRolesAsync(IdentityFixture fix)
|
||||
{
|
||||
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
|
||||
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
|
||||
var drafter = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter };
|
||||
await rm.CreateAsync(admin);
|
||||
await rm.CreateAsync(drafter);
|
||||
return (admin.Id, drafter.Id);
|
||||
}
|
||||
|
||||
// Seed MenuItem rows (FK target cho Permission.MenuKey).
|
||||
private static async Task SeedMenuItemsAsync(TestApplicationDbContext db)
|
||||
{
|
||||
foreach (var key in AllMenuKeys)
|
||||
db.MenuItems.Add(new MenuItem { Key = key, Label = key });
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Seed 1 Permission row (CanRead mặc định + optional CanCreate — mô phỏng grant trước revoke).
|
||||
private static void AddPerm(
|
||||
TestApplicationDbContext db, Guid roleId, string menuKey, bool canRead, bool canCreate = false)
|
||||
=> db.Permissions.Add(new Permission
|
||||
{
|
||||
RoleId = roleId,
|
||||
MenuKey = menuKey,
|
||||
CanRead = canRead,
|
||||
CanCreate = canCreate,
|
||||
CanUpdate = false,
|
||||
CanDelete = false,
|
||||
});
|
||||
|
||||
private static async Task<Permission?> GetPermAsync(TestApplicationDbContext db, Guid roleId, string menuKey)
|
||||
=> await db.Permissions.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.RoleId == roleId && p.MenuKey == menuKey);
|
||||
|
||||
private static async Task<bool> CanReadAsync(TestApplicationDbContext db, Guid roleId, string menuKey)
|
||||
=> (await GetPermAsync(db, roleId, menuKey))?.CanRead ?? false;
|
||||
|
||||
// ⭐ LOAD-BEARING: non-Admin có read trên 5 nhóm → sau revoke TẤT CẢ 4 cờ CRUD=false (menu ẩn).
|
||||
[Fact]
|
||||
public async Task Revoke_NonAdmin_S92Modules_CanReadFalse()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (_, nonAdminId) = await SeedRolesAsync(fix);
|
||||
await SeedMenuItemsAsync(db);
|
||||
|
||||
// Pre-grant CanRead=true cho non-Admin (Drafter) trên đại diện 5 nhóm → revoke có gì thu hồi.
|
||||
foreach (var key in S92HiddenKeys)
|
||||
AddPerm(db, nonAdminId, key, canRead: true);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await InvokeRevokeAsync(fix);
|
||||
|
||||
foreach (var key in S92HiddenKeys)
|
||||
{
|
||||
var row = await GetPermAsync(db, nonAdminId, key);
|
||||
row.Should().NotBeNull($"{key} vẫn có row (revoke chỉ hạ cờ, KHÔNG xóa — giữ vết flip lại)");
|
||||
row!.CanRead.Should().BeFalse($"{key} bị revoke CanRead — menu ẩn khỏi non-Admin (chỉ Admin thấy)");
|
||||
row.CanCreate.Should().BeFalse($"{key} revoke CanCreate");
|
||||
row.CanUpdate.Should().BeFalse($"{key} revoke CanUpdate");
|
||||
row.CanDelete.Should().BeFalse($"{key} revoke CanDelete");
|
||||
}
|
||||
}
|
||||
|
||||
// Admin loại trừ khỏi revoke → giữ nguyên CanRead=true (vẫn thấy 5 nhóm).
|
||||
[Fact]
|
||||
public async Task Revoke_Admin_Untouched_KeepsRead()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (adminId, nonAdminId) = await SeedRolesAsync(fix);
|
||||
await SeedMenuItemsAsync(db);
|
||||
|
||||
// Cả Admin + non-Admin có read trên 5 nhóm — chỉ non-Admin bị revoke.
|
||||
foreach (var key in S92HiddenKeys)
|
||||
{
|
||||
AddPerm(db, adminId, key, canRead: true);
|
||||
AddPerm(db, nonAdminId, key, canRead: true);
|
||||
}
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await InvokeRevokeAsync(fix);
|
||||
|
||||
foreach (var key in S92HiddenKeys)
|
||||
(await CanReadAsync(db, adminId, key)).Should()
|
||||
.BeTrue($"Admin KHÔNG bị revoke trên {key} — quản trị vẫn thấy đủ");
|
||||
}
|
||||
|
||||
// ⭐ Invariant bảo vệ tính năng PE: Pe_* + root PurchaseEvaluations KHÔNG bị revoke (giữ cho user).
|
||||
[Fact]
|
||||
public async Task Revoke_DoesNotTouch_PeModule()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var (_, nonAdminId) = await SeedRolesAsync(fix);
|
||||
await SeedMenuItemsAsync(db);
|
||||
|
||||
// non-Admin có read (+create cho leaf Pe_*) — module Duyệt NCC đang dùng, KHÔNG được revoke.
|
||||
foreach (var key in PeKeptKeys)
|
||||
AddPerm(db, nonAdminId, key, canRead: true, canCreate: true);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await InvokeRevokeAsync(fix);
|
||||
|
||||
foreach (var key in PeKeptKeys)
|
||||
{
|
||||
var row = await GetPermAsync(db, nonAdminId, key);
|
||||
row!.CanRead.Should().BeTrue($"{key} KHÔNG bị revoke — module Duyệt NCC giữ cho user thường");
|
||||
row.CanCreate.Should().BeTrue($"{key} giữ CanCreate — user vẫn tạo phiếu Duyệt NCC");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,10 @@ using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [S92 2026-06-29] Call grant này (SeedAllRolesHrmProfileReadPermissionsAsync, S65) ĐÃ TẮT khỏi
|
||||
// SeedAsync — anh chốt nhóm "Nhân sự" CHỈ Admin thấy. Test giữ guard method-CÔ-LẬP cho reversibility
|
||||
// (method vẫn private static); hành vi prod THẬT = AdminOnlyModulesRevokeTests (revoke là bước cuối).
|
||||
// Assertion dưới KHÔNG đổi — vẫn pass vì invoke trực tiếp method cô lập.
|
||||
// S66 test-after (UAT mode — "public Hồ sơ NS nhưng ẩn Dashboard/Config" shipped S65).
|
||||
// DbInitializer permission-seed chain (SeedAsync order, line 2033-2046):
|
||||
// 1. SeedAllRolesReviewReadPermissionsAsync (grant Master/Catalogs/Pe_* read)
|
||||
|
||||
@ -9,6 +9,10 @@ using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [S92 2026-06-29] Call grant này (SeedAllRolesOfficeModulePermissionsAsync, S69) ĐÃ TẮT khỏi
|
||||
// SeedAsync — anh chốt nhóm "Văn phòng số" CHỈ Admin thấy. Test giữ guard method-CÔ-LẬP cho
|
||||
// reversibility (method vẫn private static); hành vi prod THẬT = AdminOnlyModulesRevokeTests
|
||||
// (revoke là bước cuối). Assertion dưới KHÔNG đổi — vẫn pass vì invoke trực tiếp method cô lập.
|
||||
// S69 test-after (UAT mode — GOLIVE "public Văn phòng số cho all user eoffice" shipped S69).
|
||||
// Mirror HrmProfilePermissionSeedTests (S65/S66) — same reflection harness, but Office grant
|
||||
// mở READ + CREATE (self-service tạo phiếu) trên allow-list 16 key, KHÁC HRM chỉ READ 2 key.
|
||||
|
||||
Reference in New Issue
Block a user