---
name: s92-admin-only-modules-revoke
description: S92 PROD-security review — hide 5 menu-groups admin-only on eoffice via permission-revoke (Contracts+Master added to S58 Hrm/Off/Personal). PASS, 0 blocking, 1 awareness note.
metadata:
  type: project
---

# S92 (2026-06-29) Adversarial PROD-SECURITY: hide 5 menu-groups admin-only — PASS (genuine, 0 blocking)

Context: uncommitted/NOT-yet-deployed change to PROD permissions, 17 real users (go-live S91 on 26/06). S58-class permission-revoke EXTENDED to +Hợp đồng (Contracts+Ct_*) +Danh mục (Master/Suppliers/Projects/Departments/Catalog*) on top of S58 Hrm*/Off*/Personal. Mechanism: menu shows ⟺ Permission.CanRead=true (GetMyMenuTree filters, no admin-bypass); revoke sets 4 CRUD flags=false for non-Admin → menu auto-hides; Admin excluded.

## Attack A (seed-chain order) — UPHELD
All `db.Permissions` writes file-wide = grep lines 1948-2421, ALL inside `SeedAdminPermissionsAsync` (DbInitializer.cs:1943-2013) + its sub-callees. Revoke (:1993) is the LAST permission write in that method; the 2 old re-grants (HrmProfile :2003 / Office :2012) now COMMENTED. `SeedAdminPermissionsAsync` called at SeedAsync:110 with NO `if(demoSeedDisabled)` guard — genuinely UNGATED (the gated calls are :117/:120-124/:135-140) → revoke re-runs every restart = durable hide (gotcha #75 turned into a feature). Nothing after :110 (SeedMaster/Templates/Catalogs/RealMaster/Contracts/Workflows :117-164) touches Permissions.

## Attack B (under-revoke leak) — UPHELD CLEAN
Enumerated SeedMenuTreeAsync (:1704-1804 + loop :1809-1816): every Contracts child is `Ct_{code}[_List/Create/Pending]` (all `StartsWith("Ct_")`✓), root `Contracts`(`==`✓); Danh-mục children Suppliers/Projects/Departments(explicit✓)+Catalog*(`StartsWith`✓). Grep for stray `Contract`(no-underscore)/`Ct`-no-`_`/`Catalog`-outside-family = ZERO. GetMyMenuTree inherit (GetMyMenuTreeQuery.cs:66) makes row-less Ct_* leaves inherit `contractsFlags` → parent revoked false ⇒ inherited child false (consistent, no leak). `Forms`/`Reports`/`Dashboard` are top-level siblings (parent=null), NOT under Contracts/Master → correctly stay visible (spec didn't ask to hide).

## Attack C (over-revoke) — UPHELD
PE root `PurchaseEvaluations` is parent=null top-level (:1744, label only says "chọn Thầu phụ-NCC"), NOT a Master child; Pe_*/PeWorkflows/AwV2_*/PeWf_* match NONE of the predicate clauses (PeWf 3rd-char 'W'≠'_'; "Pe_" never starts "Ct_"/"Catalog"). `StartsWith("Ct_")`/`StartsWith("Catalog")` hit nothing PE/shared.

## Attack D (tests) — UPHELD
AdminOnlyModulesRevokeTests (NEW, 3 tests) invoke REAL `RevokeTemporarilyHiddenModulesAsync` via reflection NonPublic|Static (NOT re-impl), seed MenuItem+Role FK rows first (Cascade FK), assertions non-tautological (pre-grant CanRead=true → assert all-4-CRUD=false; Admin-excluded asserts true; Pe-kept asserts read+create stay true). 2 modified seed tests (HrmProfile + Office) = HEADER-COMMENT-ONLY diff (+4 each), still pass because grant methods kept `private static` invoked in ISOLATION. Ran 3 classes = 15/15 PASS; full suite 45+389=434 PASS 0-skip (386→389 Infra = +3, matches claim).

## Attack E (prod-impact) — 1 NOTE, intended-by-spec NOT a regression
Revoke flips EXISTING rows only (no row-create) — correct for "hide what users currently see". `SeedCatalogManagerPermissionsAsync` (:1980, grants Master/Suppliers/Projects/Departments/Catalog* FULL CRUD to CatalogManager role) runs BEFORE revoke (:1993) → CatalogManager's Danh-mục access IS stripped by the new predicate (CatalogManager is non-Admin). CatalogManager IS a seeded real role (:1462 "DM"/"Nhân viên Quản lý danh mục") but S89 deleted all demo/test users so currently assigned to ZERO users; documented purpose (:1978) is admin-assigns-on-demand. **Consequence: after S92, if admin assigns CatalogManager to a real user expecting Danh-mục CRUD, that user will NOT see/edit Danh mục (revoke wins, runs every restart).** Spec explicitly says "Danh mục CHỈ Admin" → intended consequence, FLAGGED for em-main awareness only, NOT blocking. Reversibility: admin can re-grant per-row via Permissions matrix UI (UpsertPermissionCommand PermissionFeatures.cs:119, only other runtime Permission-write path) but next restart's revoke re-strips → true re-enable = uncomment call + drop prefix from predicate (code comments state this correctly).

## Verify
build slnx 0-err · diff = exactly 5 files (DbInitializer +32/-10 surgical, 2 test headers +4, 1 new test class, +unrelated test-specialist MEMORY.md) · NO FE change needed (menu-hide data-driven via GetMyMenuTree, no menuKeys.ts edit) · no migration · mock-grep N-A.

## Lesson / anti-pattern positive-validation
The grant-then-revoke same-run ordering is SAFE because revoke is strictly last — narrowing InReviewScope to Pe_*-only (dropping the Master/Catalog grant) is belt-and-suspenders, not load-bearing; final state is revoked regardless. The reviewer-lens that mattered: seed-CHAIN-ordering (does any later writer undo the revoke?) NOT predicate-completeness alone. Mirror of S65 upgrade-path / S69 inherit-root lessons. Related: [[s89-pe-sectionb-review]] (authz-lens ≠ concurrency-lens — here authz-lens ≠ seed-chain-lens).
