---
name: lis-permissions-granularity
description: Plan — split cost/pricing in Investigations into their own permissions + give each External-Lab feature its own permission. Current state map + proposal.
updated: 2026-06-28
status: DONE (BE + FE) + tested + shipped (2026-06-28)
plans:
  - https://moonui.elbaset.com/lis-permissions-granularity-plan.html
  - https://moonui.elbaset.com/lis-permissions-study.html
---

> 🧭 **تحليل استراتيجي — «طبقة القدرات» (2026-07-03، ⏳ قرار مالك: نبدأ MVP؟):** `lis-permissions-capabilities-analysis.html`. **المشكلة الجذرية اللي المالك رصدها:** 987 صلاحية ذرّية، والأدمن مش عارف «الطلب» محتاج أنهي حزمة (closure لـ`requests.create` بيجيب 9 بس، محتاج ~17 عبر LIS+Core+Accounting) → أدوار مكسورة تضرب 403 (حالة Front Desk: company/vat/document_settings/petty-cash/bank-accounts/insurance-contracts). **الحل (Fable):** كتالوج قدرات declarative فوق الذرّات (قدرة→أسئلة«هيعمل إيه؟»→حزمة تلقائية)، يمرّ على closure كشبكة أمان، **مخزَّن=ذرّات خام**+snapshot، تبويب «قدرات»+«متقدّم». **الموثوقية=اشتقاق من route↔middleware + بوابة Pest CI** (تضرب كل endpoints شاشات القدرة؛ أي 403=فشل بناء → Front Desk regression مستحيل). ambient (company/settings)=auth-only برّه النظام. cross-module=view/operate بس. **MVP 2-3ي** (سد الفجوات+6-8 قدرات بأسئلة+عرض شفّاف قبل الحفظ) → م2 (اشتقاق+CI) → م3 (presets فوق القدرات). **🔧 إصلاحات الجلسة (live+main):** `GET /core/company`→auth-only (كان 403→null→crash؛ `CompanyController` BE `71646db5e`) · `SettingController::show` whitelist (`lis.vat_rate_percentage`+`core.document_settings`) لأي مستخدم، الباقي أدمن (native review APPROVE). **⏳ follow-up (review IMPORTANT): أضف Pest test للحدّ الأمني** (whitelist allow/deny + company own-read isolation) في `Modules/Core/tests/Feature/SettingApiTest.php`+`CompanyApiTest.php`.

> ✅ **مُنفَّذ — فصل «الصلاحية» عن «المنيو» (2026-07-03، SHIPPED /app + main):** المالك اعتمد كل التوصيات (إخفاء منيو + قفل route FE · per-role · قالب الاستقبال يخفي المالي · المعمل الأول). **البناء (FE-only، صفر BE/migration):** تبويب **«القوائم»** في محرّر أدوار المعمل (`lis-roles`) يحوّل أي لينك لمخفي لكل دور (يُحفظ في `roles.<role>.nav_config.hiddenRoutes` عبر `PermissionService.saveNavConfig`) · استخراج كتالوج المنيو لـ `features/lis/config/lis-nav.config.ts` (مصدر واحد للسايدبار+التبويب) · `loadNavConfig` بقى **intersection** («الظهور يكسب» عبر الأدوار) · `LisLayout` يخفي اللينكات + **route-lock** بـ redirect (بعد تحميل الإعدادات، prefix-match، backstop لِـ loop الـ alias زي kanban→worklist) · **reset عند logout** (سينجلتون stale-user) · `PRESET_HIDDEN.reception`=10 شاشات مالية · تحقّق `home_page` مايتخفاش. راجعه native+Codex (اصطادوا: سباق stale-user، سباق getNavConfig، toast-stacking، alias-loop — كلها اتصلحت). FE main `a205c5d`/hazemdev `66f7ceb`، CHANGELOG BE `242c9cd31`، منشور /app. **🐛 fix (2026-07-03، بعد تست المالك):** الحفظ كان بيثبت بس القراءة-back بترجّع فاضي لأدوار باسم فيه كابيتال/مسافة/عربي (زي «Front Desk») → **السبب:** قيد route الـ GST `settings/{key}` كان `[a-z_.]+` بس فالـ GET بياخد 404 (الحفظ PUT ومفتاحه في الـ body فاشتغل). **الحل:** وسّعت القيد لـ`[^/]+` (BE main `1791fedde`، live على moonui_dev_be + مؤكّد end-to-end HTTP200) + `encodeURIComponent` للمفتاح في الـ FE (`loadNavConfig`+`getNavConfig`). يأثر كمان على runtime menu-hide لنفس الأسماء. **✨ enhancement (طلب المالك):** كل الـ8 presets بتيجي بقوائم إخفاء افتراضية (Fable حسبها من closure كل preset ∩ صلاحيات اللينكات): cashier[board,cash-flow] · phlebotomist[requests,board] · technician[requests,validation,device-catalog,machine-setup] · validator[requests] · pathologist[invoices] · reception+price-lists · courier/manager فاضي. FE main `f7115e2`، منشور /app. **🐛🐛 bug تانٍ أعمق (تست المالك، اتصلح):** حتى بعد إصلاح route القراءة، الإخفاء ماكانش بيشتغل للمستخدم المقيّد **حتى في incognito**. **الجذر:** `SettingController::show` متبوّب على `permission:core.settings.view` — والمستخدمين المقيّدين (Front Desk/reception/cashier = صلاحيات `lis.*` بس) **ملهمش الصلاحية دي** → `loadNavConfig` بتاخد **403** → صفر إخفاء. **الحفظ اشتغل** (الأدمن عنده `core.settings.manage`)، **القراءة وقت التشغيل بتـ403** → الإخفاء ماكانش بيوصل للناس المعمول عشانهم. **الحل النهائي:** نقل `hidden_routes` لبروفايل `/auth/me` (auth-only، `UserResource::computeHiddenRoutes` = تقاطع أدوار المستخدم سيرفر-سايد)؛ الـ FE `hiddenRoutesSet` بقى **computed من البروفايل** (مش fetch)، و`loadNavConfig`/`resetNavConfig` no-ops، `navConfigLoaded=userLoaded`. **مؤكّد end-to-end:** login كـ aghsan → `/me` بيرجّع 17 لينك مخفي. BE main `5ccff2134`/FE main `efe1345`، منشور /app. **🎓 الدرس:** أي قراءة runtime لكل مستخدم لبياناته (تفضيلات/إخفاء) **ماينفعش تعتمد على صلاحية أدمن** (`core.settings.view`) — سلّمها في البروفايل auth-only، مش عبر endpoint إعدادات متبوّب. **⏳ follow-up:** `nav_config` مفتاحه اسم الدور → rename تيتّم القديم (اربطه role-id). ورقة: `lis-permissions-menu-layering.html`.

> 🧩 **(الأصل) تحليل فصل «الصلاحية» عن «المنيو»:** `lis-permissions-menu-layering.html` (https://moonui.elbaset.com/lis-permissions-menu-layering.html). **المشكلة:** إدخال `lis.requests.create` بيوسّع تلقائيًا لـ `lis.invoices.view` (المعالج بيفوتر — تبعية مقصودة في `LisPermissionDependencies.php`)، ولأن **نفس البِت** بيتحكم في القدرة والمنيو معًا (`LisLayoutComponent.filteredNavGroups` = `hasAnyPermission`) → منيو الفواتير بيظهر ومفيش طريقة تمنعه. **الحل الموصى به (Fable + نمط موجود):** طبقة «قوائم مخفية لكل دور» — الآلية **موجودة ومشحونة للـ ERP الرئيسي** (`roles.<role>.nav_config`→`hiddenRoutes[]` في `PermissionService.isRouteHidden` + `features/roles/roles.component.ts`) **بس مش موصّلة بشاشة المعمل `/lab`** ولا محرّر `lis-roles`. الشغل = مدّ النمط للمعمل (Phase1 توصيل LisLayout · Phase2 تبويب «القوائم» في محرّر أدوار المعمل · Phase3 اختياري قفل route — آمن: فلو الطلبات مابيعملش deep-link لـ/invoices، بس الداشبورد tiles). **⏳ 4 قرارات مالك:** مستوى الإخفاء (أ منيو/ب +route-lock[موصى]/ج فصل صلب مؤجّل) · per-role · presets افتراضية للاستقبال · نطاق (معمل الأول). **⚠️ فخ:** تعدد أدوار Spatie = الظهور يكسب (اخفِ لو مخفي في كل الأدوار المانحة).

> 📚 **دراسة شاملة لكل صلاحيات المعمل (2026-07-01):** `lis-permissions-study.html` — القائمة الكاملة (170 صلاحية / 39 مجموعة، من `RolePermissionSeeder`)، نظام الاعتماديات (`LisPermissionDependencies` EDGES + expand + lock + cascade)، الـ 8 presets، الأدوار المدمجة + قاعدة lab-confined، طبقات الحماية الـ6، وتاريخ التطوير.

# LIS permission granularity (cost/pricing + external-lab features)

**Ask:** in Investigations, anything about COST or PRICING must be a SEPARATE permission; in External Labs, every feature must have its own permission.

## Current state (analysis)
- **Investigations:** only `view/create/update/delete/approve/publish/transition`. **No cost/pricing perm** — `cost-breakdown`, consumables, `recalculate-costs`, and the `cost`+`price` resource fields all ride on `view`/`update`. So any "view investigations" user sees cost+price (margin leak).
- **External Labs:** already separate ✅ `external-labs.financial-summary`, `external-labs.transactions`, `external-lab-pricing.*`, `external-lab-normal-ranges.*`, payments via `lis.payments.*`. Still merged under generic `external-labs.update`: monthly-claim generate, couriers + pickup-config, portal credentials, credit_limit, claim_split_mode. 🔴 **Ungated (auth only):** `claim-detail`, `price-lists` (get), **`price-lists` sync (a WRITE!)**.

## Proposed new permissions
- `lis.investigations.cost.view` + `.cost.manage` (cost breakdown, consumables, recalculate, `cost` field)
- `lis.investigations.pricing.view` + `.pricing.manage` (`price` field + editing)
- `lis.external-labs.claims.view` (claim-detail) + `.claims.manage` (generate-monthly-claim, claim_split_mode)
- `lis.external-labs.couriers.manage`, `.portal-access.manage`, `.credit.manage`
- wire `price-lists` get→`external-lab-pricing.view`, sync→`external-lab-pricing.update` (closes the ungated write)

## To implement
RolePermissionSeeder + bridge migration · `LisPermissionDependencies` (grouping + auto-tick) · controller middleware · **gate `cost`/`price` FIELDS in the API Resource** (real protection, not just UI) · FE hides cost/pricing + external-lab features by perm · tests + CHANGELOG. Additive (owner/admin keep all). Open decisions: cost vs pricing separate or combined; view/manage split; external-lab granularity level.


## ✅ BACKEND IMPLEMENTED (2026-06-28, BE `15cfbd829`, hazemdev+main)
New perms (seeder + bridge migration `2026_06_28_120000` with **no-regression** grants + cache flush + `LisPermissionDependencies`):
- `lis.investigations.cost.{view,manage}` · `lis.investigations.pricing.{view,manage}`
- `lis.external-labs.{claims.view,claims.manage,couriers.manage,portal-access.manage,credit.manage}`
**Enforcement:** middleware (cost-breakdown/recalculate/consumables → cost.*; claim-detail→claims.view, generate-claim→claims.manage, couriers/pickup→couriers.manage; **closed the ungated `price-lists` get/sync** → external-lab-pricing.view/update). **Resource field-gating:** `cost` + cost inputs hidden when no `cost.view` (price STAYS — wizard needs it to bill). **Field-strip on save:** price needs pricing.manage; cost inputs need cost.manage; portal creds/credit/claim_split_mode need their perm. **+4 Pest green** (`LisCostPricingPermissionTest`). Migration RUN on moonui_dev_be (owner/admin/lab roles kept cost.view via no-regression).
**Gotcha (tests):** the first user in a company auto-gets the `owner` role → use `syncRoles([$role])` (not `assignRole`) to test a restricted user. And flush `PermissionRegistrar` cache after granting in tests.
**⏳ REMAINING — FE UI hiding (cosmetic; data already protected by BE):** hide the cost column + cost-breakdown dialog button + costing form fields in `lis-investigations` for users without `cost.view`/`cost.manage`; disable the price input without `pricing.manage`; hide claims/couriers/portal/credit tabs+buttons in `lis-external-labs` by the new perms. Decisions taken: cost & pricing SEPARATE, view/manage split, no-regression on existing roles.

## ✅ Code-review fixes (2026-06-28, BE `a621bef04`) — native code-reviewer (Codex blocked on this server, see [[codex-blocked-on-server]])
The review of `15cfbd829` found 3 real bypasses (all fixed + tested):
- **HIGH — CSV import** wrote price/cost from the file ignoring the gates → `ImportInvestigationsJob::handle()` now takes `allow_price`/`allow_cost` (from the caller's perms) and skips those columns.
- **HIGH — `LabExternalLabResource` READ leak**: portal email/username/is_active + credit_limit + claim_split_mode were returned to any `external-labs.view` user (write was gated, read wasn't) → now read-gated by portal-access.manage / credit.manage / claims.manage. **Lesson: gate the READ side too, not just the write.**
- **MEDIUM — `duplicate()`** carried price/cost to the copy → those columns are now EXCLUDED from `replicate()` when the user lacks the perm (use DB default — don't set NULL into NOT-NULL cost columns like `calculated_cost`; consumables copy guarded by cost.manage).
- +3 Pest (import/duplicate/resource-read); full external-lab+perm suite 27 green.


## ✅ FE UI-hiding DONE (2026-06-28, FE `35c5d3f`, deployed /app)
`PermissionService.can()` computeds gate the UI (BE already protects the data):
- **Investigations** (`lis-investigations`): cost column + margin + cost-breakdown button + «Consumables & Cost» tab + cost form fields hidden without `cost.view`/`cost.manage`; price column + price input hidden without `pricing.view`/`pricing.manage`.
- **External labs** (`lis-external-labs`): portal email/username/password + portal-active + credit limit + claim-mode selector + «Print Claim» button hidden without `portal-access.manage` / `credit.manage` / `claims.manage` / `claims.view`.
The granular-permissions feature is now COMPLETE end-to-end (BE enforcement + 3 review-fix bypasses closed + FE hiding). ⏳ optional later: couriers/pricing tab UI gating (BE already gates those endpoints).
