[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

- 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:
pqhuy1987
2026-06-29 14:58:59 +07:00
parent f98a743074
commit 1408d4eb03
4 changed files with 223 additions and 8 deletions

View File

@ -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 // (+ 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 // 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). // 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) // [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 // 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 + // để 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ừ // 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). // 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 // [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 // 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 // ở 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. // match prefix): PeWf_* (ký tự thứ 3 'W' ≠ '_'), AwV2_*, Ct_*, Bg_*, Wf_*, System.
static bool InReviewScope(string key) => // [S92 2026-06-29] Narrow: BỎ Master/Catalogs/Suppliers/Projects/Departments khỏi
key.StartsWith("Catalog") || key == MenuKeys.Master || // grant-all-roles (anh chốt "Danh mục" CHỈ Admin). GIỮ Pe_* (Duyệt NCC read+create
key == MenuKeys.Suppliers || key == MenuKeys.Projects || key == MenuKeys.Departments || // cho mọi role — KHÔNG đụng). Master-data GET = any-auth (Suppliers/Projects/Catalogs
key.StartsWith("Pe_"); // 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 '_' // 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). // → "PeWf_*"/"PeWorkflows" KHÔNG match (loại admin Designer).
@ -2128,7 +2136,14 @@ public static class DbInitializer
var rows = await db.Permissions var rows = await db.Permissions
.Where(p => p.RoleId != adminRoleId .Where(p => p.RoleId != adminRoleId
&& (p.MenuKey.StartsWith("Hrm") || p.MenuKey.StartsWith("Off") && (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)) && (p.CanRead || p.CanCreate || p.CanUpdate || p.CanDelete))
.ToListAsync(); .ToListAsync();
@ -2144,7 +2159,8 @@ public static class DbInitializer
{ {
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation( 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); rows.Count);
} }
} }

View File

@ -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");
}
}
}

View File

@ -9,6 +9,10 @@ using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application; 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). // 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): // DbInitializer permission-seed chain (SeedAsync order, line 2033-2046):
// 1. SeedAllRolesReviewReadPermissionsAsync (grant Master/Catalogs/Pe_* read) // 1. SeedAllRolesReviewReadPermissionsAsync (grant Master/Catalogs/Pe_* read)

View File

@ -9,6 +9,10 @@ using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application; 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). // 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 // 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. // mở READ + CREATE (self-service tạo phiếu) trên allow-list 16 key, KHÁC HRM chỉ READ 2 key.