Implement Care RBAC role→permission→app matrix.
Deploy Ladill Care / deploy (push) Successful in 57s

Replace broad doctor/nurse specialty access with granular roles and primary
apps, permission inheritance for lab/BB managers, and cannot-rules for
discharge, lab approve, and cashier vs billing officer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 00:24:09 +00:00
co-authored by Cursor
parent 55a1288d30
commit ac870bcf33
21 changed files with 913 additions and 239 deletions
+83 -9
View File
@@ -200,10 +200,10 @@ class SpecialtyModuleService
/**
* Whether the sidebar should show the Specialty nav group.
*
* Desk specialists only receive their own matching module(s), so a
* Specialty Dentistry (etc.) group is redundant they use dashboard
* cards / direct routes instead. GPs, nurses, and other multi-module
* roles keep the group for referral and cross-module access.
* Single-app specialists (dentist, radiologist, ) and legacy desk
* specialists only receive their own module(s), so a Specialty group is
* redundant they use dashboard cards / direct routes instead.
* Multi-app clinical roles keep the group.
*/
public function shouldShowSpecialtyNav(Organization $organization, ?Member $member): bool
{
@@ -216,6 +216,11 @@ class SpecialtyModuleService
return false;
}
$permissions = app(CarePermissions::class);
if ($permissions->isSingleAppSpecialist($member)) {
return false;
}
if ($this->isDeskSpecialist($organization, $member)) {
return false;
}
@@ -283,6 +288,9 @@ class SpecialtyModuleService
/**
* Full clinical / queue edit access for the module.
*
* Role primary apps (CarePermissions) are the source of truth for the
* RBAC matrix. Legacy `doctor` desk specialists still use keyword matching.
*/
public function memberCanManage(Organization $organization, ?Member $member, string $key): bool
{
@@ -290,7 +298,8 @@ class SpecialtyModuleService
return false;
}
if (app(CarePermissions::class)->isAdmin($member)) {
$permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member)) {
return true;
}
@@ -299,6 +308,24 @@ class SpecialtyModuleService
return false;
}
// Department managers get analytics view, not clinical manage.
if ((string) $member->role === 'department_manager') {
return false;
}
$primaryApps = $permissions->primaryAppsFor($member);
// Matrix-driven roles (including GP / EP / specialists with app lists).
if (is_array($primaryApps)) {
// Legacy doctor + specialty desk: only matching modules.
if ((string) $member->role === 'doctor'
&& $this->isDeskSpecialist($organization, $member)) {
return $this->specialistBelongsToModule($organization, $member, $key);
}
return in_array($key, $primaryApps, true);
}
// Desk specialists only manage modules that match their specialty / assignment.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -314,7 +341,7 @@ class SpecialtyModuleService
if ($this->roleListed($member, $supportRoles)) {
return true;
}
if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) {
if (in_array($role, ['doctor', 'general_physician'], true) && $this->roleListed($member, $manageRoles)) {
return $this->specialistBelongsToModule($organization, $member, $key);
}
@@ -339,6 +366,33 @@ class SpecialtyModuleService
return false;
}
$permissions = app(CarePermissions::class);
if ((string) $member->role === 'department_manager') {
return true;
}
$primaryApps = $permissions->primaryAppsFor($member);
if (is_array($primaryApps)) {
// Matrix roles: view only what they can manage (no cross-module GP view
// unless listed — general_physician gets cardiology manage via apps).
if ((string) $member->role === 'doctor'
&& $this->isDeskSpecialist($organization, $member)) {
return $this->specialistBelongsToModule($organization, $member, $key);
}
// Legacy doctor (non-desk) / GP: keep refer/view on restricted modules
// outside primary manage via config view_roles when role is doctor/GP.
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
&& ! in_array($key, $primaryApps, true)) {
$definition = $this->definition($key);
if ($definition && $this->roleListed($member, $definition['view_roles'] ?? [])) {
return true;
}
}
return in_array($key, $primaryApps, true);
}
// Desk specialists do not get cross-module view; only their matching desks.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -366,6 +420,25 @@ class SpecialtyModuleService
return false;
}
$permissions = app(CarePermissions::class);
$primaryApps = $permissions->primaryAppsFor($member);
if (is_array($primaryApps)) {
if ((string) $member->role === 'doctor'
&& $this->isDeskSpecialist($organization, $member)) {
return $this->specialistBelongsToModule($organization, $member, $key);
}
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
&& ! in_array($key, $primaryApps, true)) {
$definition = $this->definition($key);
if ($definition && $this->roleListed($member, $definition['refer_roles'] ?? [])) {
return true;
}
}
return in_array($key, $primaryApps, true);
}
// Desk specialists do not get cross-module referral nav; GPs keep refer_roles.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -381,8 +454,9 @@ class SpecialtyModuleService
}
/**
* Doctor with a specialty desk: non-GP specialty matching a catalog module, or
* assigned to a provisioned specialty department. GPs and non-doctors are false.
* Legacy doctor with a specialty desk: non-GP specialty matching a catalog
* module, or assigned to a provisioned specialty department.
* Dedicated specialist roles use primary apps instead.
*/
public function isDeskSpecialist(Organization $organization, Member $member): bool
{
@@ -391,7 +465,7 @@ class SpecialtyModuleService
return $this->deskSpecialistCache[$cacheKey];
}
if ((string) $member->role !== 'doctor') {
if (! in_array((string) $member->role, ['doctor'], true)) {
return $this->deskSpecialistCache[$cacheKey] = false;
}