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:
@@ -27,6 +27,13 @@ class ClinicDoctorPermission
|
||||
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'services' => ['view' => true, 'update' => false],
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'staff' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'discounts' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'sms' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* نقطهٔ واحدِ اعمالِ مجوزِ «پزشکِ عضوِ کلینیک» روی endpointهای چند-نقشه — قرینهٔ
|
||||
* SecretaryAccessChecker اما برای ClinicDoctorPermission.
|
||||
*
|
||||
* فقط پزشکی که در محیطِ فعالش (UserActiveContext.db_uuid = کلینیک) عضوِ همان کلینیک
|
||||
* است محدود میشود. بقیه — مالکِ کلینیک، ادمین، منشی، پزشکِ مطب شخصی، یا هر کاربری
|
||||
* که محیطش کلینیک نیست — دستنخورده عبور میکنند (این checker آنها را محدود نمیکند).
|
||||
*/
|
||||
class ClinicDoctorAccessChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicDoctorPermissionChecker $permissions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* idِ کلینیکی که این پزشک در محیطِ فعالش عضوِ آن است — برای کنترلرهایی که
|
||||
* tenant را نقشمحور حل میکنند و بدون این، پزشکِ عضو را به مطبِ شخصیاش میبردند
|
||||
* (نه دادهٔ کلینیک). null اگر محیط کلینیک نیست یا کاربر عضو نیست.
|
||||
*/
|
||||
public function memberClinicId(User $user): ?int
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $clinic->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* فقط پزشکِ عضوِ کلینیک را با ClinicDoctorPermission محدود کن؛ سایر کاربران true.
|
||||
*/
|
||||
public function canOrNonMember(User $user, string $resource, string $action): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic === null) {
|
||||
// محیطِ مطب شخصی (db_uuid پزشک است، نه کلینیک) → محدود نمیکنیم.
|
||||
return true;
|
||||
}
|
||||
|
||||
// مالکِ کلینیک هرگز با مجوزهای عضویت قفل نمیشود.
|
||||
if ($clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
// غیرعضو (مثلاً منشی) — این checker مالِ او نیست.
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->permissions->can($user, $clinic, $resource, $action);
|
||||
}
|
||||
|
||||
/** 403 اگر پزشکِ عضو مجاز نباشد؛ سایر کاربران بدون تغییر عبور میکنند. */
|
||||
public function denyUnlessGranted(User $user, string $resource, string $action): void
|
||||
{
|
||||
if (!$this->canOrNonMember($user, $resource, $action)) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user