[CLAUDE] Auth: PRO role giu quyen Master/NCC qua deploy (seeder chay-sau-revoke) + scope-isolation test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m33s

Van de (UAT anh Kiet FDC, S118): user tra.bui (Bui Le Thuy Tra, Procurement TP) dang nhap khong
thay menu quan ly NCC/Suppliers. Nguyen nhan: seeder S92 RevokeTemporarilyHiddenModulesAsync (chay
moi startup) set CanRead=false cho Master/Suppliers/Projects/Departments/Catalog* voi moi role
non-Admin ("chi Admin thay").

Fix (owner chot mo ngoai-le CHI role Procurement, ben qua deploy):
- Them SeedProcurementMasterAccessAsync chay NGAY SAU revoke de THANG (mirror pattern Hrm S65 /
  Office S69). Chi dung role Procurement -- S92 "chi Admin thay" GIU NGUYEN cho moi role khac.
- Grant 9 key: Suppliers (R+C+U -- quan ly + cong bo NCC, S113 publish can Suppliers.Update);
  Master/Projects/Departments/Catalogs + 4 Catalog leaf (Read-only). KHONG Delete.
- Dashboard/Reports CO Y KHONG cap (reviewer bat: Reports lo tong gia tri HD + top NCC/du an theo
  gia tri, nguoc S92; owner chot bo).
- Upgrade-only idempotent: nang co desired len true, khong ha co, khong dung role khac.

Test: +4 regression scope-isolation (ProcurementMasterAccessSeedTests) -- Procurement duoc nang
dung ma tran, role khac (DeptManager rieng) VAN bi revoke. 501 -> 505.

Reviewer PASS (scope cach-ly sach, no over-grant). Grant tam da ap prod (tra.bui verified 20 menu);
deploy nay = ban ben. Follow-up: gate ReportsController thieu policy (task rieng).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-14 15:29:44 +07:00
parent b56659b6b7
commit c2135bae2b
2 changed files with 338 additions and 0 deletions

View File

@ -2007,6 +2007,12 @@ public static class DbInitializer
// Mở lại sau golive: gỡ prefix khỏi revoke + thêm lại vào InReviewScope. // Mở lại sau golive: gỡ prefix khỏi revoke + thêm lại vào InReviewScope.
await RevokeTemporarilyHiddenModulesAsync(db, roleManager, logger); await RevokeTemporarilyHiddenModulesAsync(db, roleManager, logger);
// [S118 2026-07-14 — anh Kiệt FDC] CHẠY SAU revoke để THẮNG: NGOẠI-LỆ role Procurement
// (Phòng Cung ứng) — mở lại Danh mục (Master/Suppliers/Projects/Departments/Catalog*) RIÊNG
// cho PRO (cần quản lý + CÔNG BỐ NCC — Suppliers R+C+U). S92 "chỉ Admin thấy" GIỮ NGUYÊN cho
// MỌI role khác (seeder chỉ đụng role Procurement). Dashboard/Reports KHÔNG cấp (anh chốt bỏ).
await SeedProcurementMasterAccessAsync(db, roleManager, logger);
// [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự" // [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự"
// (+ 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
@ -2180,6 +2186,83 @@ public static class DbInitializer
} }
} }
// [S118 2026-07-14 — anh Kiệt FDC] NGOẠI-LỆ role Procurement (Phòng Cung ứng): mở lại quyền
// Danh mục (Master + Suppliers + Projects + Departments + Catalog*) RIÊNG cho role Procurement
// — anh chốt PRO cần quản lý + CÔNG BỐ NCC (Suppliers R+C+U; S113 publish cần Suppliers.Update).
// S92 "chỉ Admin thấy" GIỮ NGUYÊN cho MỌI role khác. CHẠY SAU RevokeTemporarilyHiddenModulesAsync
// để THẮNG revoke (mirror pattern S65/S69). Dashboard/Reports CỐ Ý KHÔNG cấp (anh chốt bỏ —
// Reports lộ tổng giá trị HĐ + top NCC/dự án theo giá trị, ngược S92; reviewer S118 bắt).
// - Suppliers: Read+Create+Update (quản lý + công bố NCC). KHÔNG Delete.
// - Master + Catalogs (group) + Projects + Departments + 4 Catalog leaf: Read-only.
// - UPGRADE-ONLY (mirror Hrm S65 / Office S69): row đã tồn tại (revoke vừa set false) → NÂNG
// cờ desired lên true. Row chưa có (DB mới) → tạo. KHÔNG hạ cờ + KHÔNG đụng role khác.
// Thu hồi khi cần: xóa call ở SeedAsync — revoke sẽ tự che Master/Suppliers lại lần seed kế.
private static async Task SeedProcurementMasterAccessAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
var role = await roleManager.FindByNameAsync(AppRoles.Procurement);
if (role is null)
{
logger.LogWarning("SeedProcurementMasterAccessAsync: skip — Procurement role chưa seed.");
return;
}
// (MenuKey, CanRead, CanCreate, CanUpdate) — CanDelete luôn giữ nguyên (KHÔNG cấp Delete).
var grants = new (string Key, bool R, bool C, bool U)[]
{
(MenuKeys.Master, true, false, false),
(MenuKeys.Suppliers, true, true, true), // quản lý + CÔNG BỐ NCC (S113 cần Update)
(MenuKeys.Projects, true, false, false),
(MenuKeys.Departments, true, false, false),
(MenuKeys.Catalogs, true, false, false),
(MenuKeys.CatalogUnits, true, false, false),
(MenuKeys.CatalogMaterials, true, false, false),
(MenuKeys.CatalogServices, true, false, false),
(MenuKeys.CatalogWorkItems, true, false, false),
};
var keys = grants.Select(g => g.Key).ToArray();
var existing = (await db.Permissions
.Where(p => p.RoleId == role.Id && keys.Contains(p.MenuKey))
.ToListAsync())
.ToDictionary(p => p.MenuKey);
var added = 0;
var upgraded = 0;
foreach (var g in grants)
{
if (existing.TryGetValue(g.Key, out var row))
{
var changed = false;
if (g.R && !row.CanRead) { row.CanRead = true; changed = true; }
if (g.C && !row.CanCreate) { row.CanCreate = true; changed = true; }
if (g.U && !row.CanUpdate) { row.CanUpdate = true; changed = true; }
if (changed) upgraded++;
continue;
}
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = g.Key,
CanRead = g.R,
CanCreate = g.C,
CanUpdate = g.U,
CanDelete = false,
});
added++;
}
if (added > 0 || upgraded > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Seeded Procurement master access: {Added} added + {Upgraded} upgraded " +
"(Master/Suppliers/Projects/Departments/Catalog* — PRO exception to S92, S118)",
added, upgraded);
}
}
// [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt // [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt
// public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự. // public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự.
// CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke // CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke

View File

@ -0,0 +1,255 @@
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;
// [S118 2026-07-14 — anh Kiệt FDC] Regression test SECURITY cho seeder MỚI
// DbInitializer.SeedProcurementMasterAccessAsync (private static). Đây là thay đổi
// security-sensitive → guard thuộc-tính-an-ninh CỐT LÕI: grant KHÔNG rò sang role khác.
//
// Bối cảnh chain (SeedAsync, order thực tế):
// 1. RevokeTemporarilyHiddenModulesAsync (S58+S92) → set 4 cờ CRUD=false cho MỌI role
// non-Admin trên Master/Suppliers/Projects/Departments/Catalog*/Contracts/Ct_*/Hrm*/
// Off*/Personal ("chỉ Admin thấy").
// 2. SeedProcurementMasterAccessAsync (S118) → CHẠY NGAY SAU để THẮNG revoke, mở NGOẠI-LỆ
// CHỈ role Procurement (Phòng Cung ứng):
// - Suppliers = Read + Create + Update (quản lý + CÔNG BỐ NCC, S113). Delete=false.
// - Master/Projects/Departments/Catalogs/4-Catalog-leaf = Read-only.
// UPGRADE-ONLY: row đã có → nâng cờ desired lên true; row chưa có → insert; KHÔNG hạ cờ;
// KHÔNG cấp Delete. (Dashboard/Reports CỐ Ý bị gỡ khỏi seeder — KHÔNG test 2 key này.)
//
// Thuộc-tính an ninh test bảo vệ:
// T1 upgrade-path — Procurement sau revoke→seed có ĐÚNG ma trận grant (Suppliers R+C+U D=false;
// 8 key read-only chỉ R). Chứng seeder THẮNG revoke đúng phạm vi.
// T2 SCOPE ISOLATION (quan trọng nhất) — role non-Admin KHÁC (DeptManager, đứng RIÊNG) VẪN bị
// ẩn hết sau cùng chuỗi → grant CHỈ đụng Procurement, S92 giữ nguyên role khác.
// Kèm CONTRAST assert Procurement THẬT được nâng (chống vacuous-pass).
// T3 insert-path — DB mới (chưa có row, không revoke) → seeder INSERT 9 row; Delete=false (an ninh).
// T4 idempotent — chạy lần 2 → end-state bất biến + KHÔNG tạo row trùng + KHÔNG chạm role khác.
//
// Kỹ thuật: 2 method là `private static` (KHÔNG sửa prod đổi visibility) → gọi qua REFLECTION
// (BindingFlags.NonPublic|Static). Signature cả 2: (ApplicationDbContext, RoleManager<Role>, ILogger).
// Test ĐÚNG behavior code thật, KHÔNG re-implement predicate/grant-set.
//
// 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).
// Revoke skip nếu Admin role chưa seed → luôn seed Admin dù test không assert Admin.
public class ProcurementMasterAccessSeedTests
{
// Suppliers = key DUY NHẤT được Read+Create+Update (quản lý + công bố NCC — S113). Delete=false.
private const string SuppliersKey = MenuKeys.Suppliers;
// 8 key CHỈ đọc (Read-only) — seeder chỉ nâng CanRead.
private static readonly string[] ReadOnlyKeys =
{
MenuKeys.Master,
MenuKeys.Projects,
MenuKeys.Departments,
MenuKeys.Catalogs,
MenuKeys.CatalogUnits,
MenuKeys.CatalogMaterials,
MenuKeys.CatalogServices,
MenuKeys.CatalogWorkItems,
};
// Toàn bộ 9 key seeder đụng (Suppliers + 8 read-only). Dùng const/factory MenuKeys
// (KHÔNG hardcode string) để bắt đồng-bộ nếu key đổi.
private static readonly string[] AllGrantKeys =
ReadOnlyKeys.Append(SuppliersKey).ToArray();
// ── Reflection invoke private static (ApplicationDbContext, RoleManager<Role>, ILogger) ──
private static async Task InvokePrivateStaticAsync(IdentityFixture fix, string methodName)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var mi = typeof(DbInitializer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);
mi.Should().NotBeNull(
$"DbInitializer.{methodName} 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;
}
private static Task InvokeRevokeAsync(IdentityFixture fix) =>
InvokePrivateStaticAsync(fix, "RevokeTemporarilyHiddenModulesAsync");
private static Task InvokeSeedProcurementAsync(IdentityFixture fix) =>
InvokePrivateStaticAsync(fix, "SeedProcurementMasterAccessAsync");
// Seed roles: Admin (để Revoke KHÔNG skip) + Procurement (target grant) + DeptManager
// (control isolation — đứng RIÊNG, KHÔNG kèm Procurement). Trả (adminId, procurementId, otherId).
private static async Task<(Guid adminId, Guid procurementId, Guid otherId)> SeedRolesAsync(IdentityFixture fix)
{
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
var procurement = new Role { Id = Guid.NewGuid(), Name = AppRoles.Procurement };
var other = new Role { Id = Guid.NewGuid(), Name = AppRoles.DeptManager };
await rm.CreateAsync(admin);
await rm.CreateAsync(procurement);
await rm.CreateAsync(other);
return (admin.Id, procurement.Id, other.Id);
}
// Seed MenuItem rows (FK target cho Permission.MenuKey — cả pre-grant lẫn seeder-insert cần).
private static async Task SeedMenuItemsAsync(TestApplicationDbContext db)
{
foreach (var key in AllGrantKeys)
db.MenuItems.Add(new MenuItem { Key = key, Label = key });
await db.SaveChangesAsync(CancellationToken.None);
}
private static void AddPerm(
TestApplicationDbContext db, Guid roleId, string menuKey,
bool canRead = false, bool canCreate = false, bool canUpdate = false, bool canDelete = false)
=> db.Permissions.Add(new Permission
{
RoleId = roleId,
MenuKey = menuKey,
CanRead = canRead,
CanCreate = canCreate,
CanUpdate = canUpdate,
CanDelete = canDelete,
});
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 AssertFlagsAsync(
TestApplicationDbContext db, Guid roleId, string key,
bool r, bool c, bool u, bool d, string because)
{
var row = await GetPermAsync(db, roleId, key);
row.Should().NotBeNull($"{key}: {because}");
row!.CanRead.Should().Be(r, $"{key}.CanRead — {because}");
row.CanCreate.Should().Be(c, $"{key}.CanCreate — {because}");
row.CanUpdate.Should().Be(u, $"{key}.CanUpdate — {because}");
row.CanDelete.Should().Be(d, $"{key}.CanDelete — {because}");
}
// Ma trận grant kỳ vọng cho Procurement sau khi seeder chạy: Suppliers R+C+U (D=false),
// 8 key còn lại chỉ Read. Dùng chung cho T1/T2-contrast/T3/T4.
private static async Task AssertProcurementGrantMatrixAsync(TestApplicationDbContext db, Guid procurementId)
{
await AssertFlagsAsync(db, procurementId, SuppliersKey,
r: true, c: true, u: true, d: false,
"Suppliers phải R+C+U cho Procurement (quản lý + CÔNG BỐ NCC — S113), Delete=false");
foreach (var key in ReadOnlyKeys)
await AssertFlagsAsync(db, procurementId, key,
r: true, c: false, u: false, d: false,
"read-only grant — CHỈ CanRead, KHÔNG Create/Update/Delete");
}
// T1 — UPGRADE PATH (chain prod-realistic): Procurement từng có CanRead → revoke S92 hạ false
// → seeder S118 nâng lại đúng ma trận. Chứng seeder THẮNG revoke, đúng phạm vi cờ.
[Fact]
public async Task Procurement_AfterRevokeThenSeed_HasExactGrantMatrix()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, procurementId, _) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// Procurement từng có CanRead trên cả 9 key (grant cũ trước S92) → revoke có gì để hạ.
foreach (var key in AllGrantKeys)
AddPerm(db, procurementId, key, canRead: true);
await db.SaveChangesAsync(CancellationToken.None);
await InvokeRevokeAsync(fix); // S92: 9 key CanRead=false cho non-Admin
await InvokeSeedProcurementAsync(fix); // S118: NGOẠI-LỆ Procurement — nâng lại
await AssertProcurementGrantMatrixAsync(db, procurementId);
}
// T2 — ⭐ SCOPE ISOLATION (thuộc-tính an ninh QUAN TRỌNG NHẤT): role non-Admin KHÁC
// (DeptManager, đứng RIÊNG — KHÔNG kèm Procurement) VẪN có Master/Suppliers CanRead=false
// sau cùng chuỗi seed → grant S118 CHỈ đụng Procurement, S92 "chỉ Admin thấy" giữ nguyên.
[Fact]
public async Task SeedProcurement_DoesNotLeakToOtherNonAdminRole()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, procurementId, otherId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// CẢ Procurement + DeptManager từng có CanRead trên 9 key (grant cũ) → revoke hạ CẢ HAI.
foreach (var key in AllGrantKeys)
{
AddPerm(db, procurementId, key, canRead: true);
AddPerm(db, otherId, key, canRead: true);
}
await db.SaveChangesAsync(CancellationToken.None);
await InvokeRevokeAsync(fix); // revoke CẢ HAI role về false
await InvokeSeedProcurementAsync(fix); // CHỈ nâng lại Procurement
// ⭐ ISOLATION: DeptManager row TỒN TẠI (revoke giữ vết) NHƯNG mọi cờ = false — seeder
// Procurement KHÔNG chạm role khác. Assert cả Master + Suppliers (2 key task chốt) + 7 key kia.
foreach (var key in AllGrantKeys)
await AssertFlagsAsync(db, otherId, key,
r: false, c: false, u: false, d: false,
"DeptManager KHÔNG được Procurement-grant chạm — S92 'chỉ Admin thấy' giữ nguyên role khác");
// CONTRAST (chống vacuous-pass): Procurement THẬT SỰ được nâng → chứng seeder CÓ chạy,
// nên isolation ở trên KHÔNG phải "false vì seeder no-op".
await AssertProcurementGrantMatrixAsync(db, procurementId);
}
// T3 — INSERT PATH (DB mới): chưa có Permission row + KHÔNG revoke → seeder phải INSERT 9 row.
// Chốt security: hàng insert KHÔNG cấp Delete (CanDelete=false trên cả 9).
[Fact]
public async Task SeedProcurement_InsertPath_FreshDb_CreatesRowsWithoutDelete()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, procurementId, _) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
// KHÔNG pre-grant + KHÔNG revoke → nhánh insert (db.Permissions.Add) của seeder.
await InvokeSeedProcurementAsync(fix);
await AssertProcurementGrantMatrixAsync(db, procurementId);
var count = await db.Permissions.AsNoTracking().CountAsync(p => p.RoleId == procurementId);
count.Should().Be(AllGrantKeys.Length, "seeder insert đúng 9 row (Suppliers + 8 read-only), không dư");
}
// T4 — IDEMPOTENT: chạy seeder lần 2 → end-state bất biến + KHÔNG tạo row trùng + KHÔNG chạm role khác.
[Fact]
public async Task SeedProcurement_Idempotent_SecondRunNoChange()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (_, procurementId, otherId) = await SeedRolesAsync(fix);
await SeedMenuItemsAsync(db);
foreach (var key in AllGrantKeys)
{
AddPerm(db, procurementId, key, canRead: true);
AddPerm(db, otherId, key, canRead: true);
}
await db.SaveChangesAsync(CancellationToken.None);
await InvokeRevokeAsync(fix);
await InvokeSeedProcurementAsync(fix);
await InvokeSeedProcurementAsync(fix); // ⭐ chạy lần 2 — end-state phải bất biến
await AssertProcurementGrantMatrixAsync(db, procurementId);
// Không nhân đôi row Procurement.
var procCount = await db.Permissions.AsNoTracking().CountAsync(p => p.RoleId == procurementId);
procCount.Should().Be(AllGrantKeys.Length, "idempotent — chạy lại KHÔNG tạo row trùng");
// Role khác vẫn bị ẩn sau lần 2 (isolation bền vững).
foreach (var key in AllGrantKeys)
await AssertFlagsAsync(db, otherId, key,
r: false, c: false, u: false, d: false,
"idempotent — lần seed thứ 2 KHÔNG rò sang DeptManager");
}
}