feat: Implement permission gate for appointment and billing controllers

- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController.
- Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments.
- Updated existing methods in both controllers to utilize the new permission checks.
- Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management.
- Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
This commit is contained in:
hamed
2026-08-08 10:27:13 +03:30
parent c452150a83
commit 934405c42d
14 changed files with 830 additions and 67 deletions
@@ -0,0 +1,58 @@
<?php
namespace App\Shared\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use Symfony\Contracts\Service\Attribute\Required;
/**
* گِیتِ مجوزِ مشترکِ کنترلرها.
*
* هر دو checker با هم صدا زده می‌شوند چون هرکدام یک نقش را می‌بندد و بقیه را
* دست‌نخورده رد می‌کند: منشی با `SecretaryAccessChecker`، پزشکِ عضوِ کلینیک با
* `ClinicDoctorAccessChecker`. ادمین، مالک کلینیک و پزشک مطب شخصی از هیچ‌کدام
* اثر نمی‌گیرند.
*
* تزریق با `#[Required]` است نه constructor، تا کنترلری که constructor پرِ خودش
* را دارد برای گرفتن گِیت مجبور به بازنویسی امضایش نشود.
*/
trait PermissionGateTrait
{
private SecretaryAccessChecker $secretaryAccess;
private ClinicDoctorAccessChecker $clinicDoctorAccess;
#[Required]
public function setPermissionGateCheckers(
SecretaryAccessChecker $secretaryAccess,
ClinicDoctorAccessChecker $clinicDoctorAccess,
): void {
$this->secretaryAccess = $secretaryAccess;
$this->clinicDoctorAccess = $clinicDoctorAccess;
}
/**
* منبعِ پیش‌فرضِ این کنترلر در `PermissionCatalog`. کنترلری که فقط یک منبع را
* گِیت می‌کند این را بازنویسی می‌کند و بعد `denyUnlessGranted($user, $action)`
* صدا می‌زند؛ کنترلری که چند منبع دارد `denyUnlessGrantedOn()` را مستقیم می‌زند.
*/
abstract private function permissionResource(): string;
/** @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$this->denyUnlessGrantedOn($user, $this->permissionResource(), $action);
}
/**
* گِیت روی یک منبعِ صریح — برای کنترلری که بیش از یک منبع را پوشش می‌دهد.
*
* @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action
*/
private function denyUnlessGrantedOn(User $user, string $resource, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
}
}