feat(clinic-doctor): full permission coverage + enforcement, parity with secretary
The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the secretary system: only 6 resources, enforced in ~6 places, dead toggles (services.update never checked), and a sidebar showing just appointments+patients. Bring it to parity so a clinic owner can control exactly what each member doctor does — while an independent doctor stays completely unrestricted. Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal (subscription/clinic_doctors stay owner-only by design). New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to SecretaryAccessChecker): - denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor in the clinic context; owner/admin/secretary/independent-doctor pass through. - memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them their personal tenant in clinic context. Enforcement wired into 10 controllers alongside the existing secretary gates: ClinicService (services), Insurance (insurances), Patient (patients+payments), Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod. Frontend: the guest-doctor sidebar branch now exposes every permitted resource (gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor, not just secretaries; my-payments route gets the missing payments permission. CRUD-button gating already applies (usePermissions is role-agnostic). Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed + independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass, frontend 503 pass. docs/api/clinic.md updated with the full resource set + enforcement notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Clinic;
|
||||
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* مجوزهای پزشکِ عضوِ کلینیک (ClinicDoctorPermission) باید مثل منشی در API enforce
|
||||
* شوند: منبعی که کلینیک به پزشکِ عضو نداده ۴۰۳؛ منبعِ دادهشده ۲۰۰. پزشکِ مستقل
|
||||
* (بدون محیطِ کلینیک) هرگز محدود نمیشود.
|
||||
*/
|
||||
class ClinicDoctorPermissionEnforcementTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: ClinicDoctorPermission} */
|
||||
private function makeMemberDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر عضو');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$perm = new ClinicDoctorPermission($clinic, $doctor);
|
||||
$this->em->persist($perm);
|
||||
// محیطِ فعالِ پزشک = کلینیک، تا memberClinicId او را به کلینیک ببرد.
|
||||
$this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid()));
|
||||
|
||||
return [$doctorUser, $perm];
|
||||
}
|
||||
|
||||
public function testInventoryDeniedByDefault(): void
|
||||
{
|
||||
// DEFAULT_PERMISSIONS: inventory.* = false
|
||||
[$doctorUser] = $this->makeMemberDoctor();
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInventoryAllowedWhenGranted(): void
|
||||
{
|
||||
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
||||
$perm->mergePermissions(['resources' => ['inventory' => ['view' => true]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testStaffDeniedByDefault(): void
|
||||
{
|
||||
[$doctorUser] = $this->makeMemberDoctor();
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/staff', $doctorUser);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testStaffCreateDeniedButViewGranted(): void
|
||||
{
|
||||
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
||||
$perm->mergePermissions(['resources' => ['staff' => ['view' => true, 'create' => false]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/staff', $doctorUser);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', '/api/v1/staff', $doctorUser, ['full_name' => 'خانم تست']);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testIndependentDoctorIsNotRestricted(): void
|
||||
{
|
||||
// پزشکِ مستقل: نه عضوِ کلینیک، نه محیطِ کلینیک → روی دادهٔ شخصیِ خودش آزاد.
|
||||
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$this->em->persist(new Doctor($doctorUser, 'دکتر مستقل'));
|
||||
$this->em->flush();
|
||||
|
||||
// inventory برای منابعِ ClinicDoctorPermission پیشفرض false است، اما این پزشک
|
||||
// اصلاً عضوِ کلینیک نیست، پس ClinicDoctorAccessChecker او را محدود نمیکند.
|
||||
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user