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
@@ -29,6 +29,17 @@ use OpenApi\Attributes as OA;
#[OA\Tag(name: 'My Appointments')]
class MyAppointmentsController extends BaseController
{
use \App\Shared\Controller\PermissionGateTrait;
/**
* همهٔ روت‌های این کنترلر زیرِ توگلِ «مدیریت نوبت‌ها»ی پنل‌اند: فهرست، آمار روز،
* تب‌های پزشک و خودِ فرمِ ثبت نوبت. `patient-lookup` هم بخشی از همان فرم است.
*/
private function permissionResource(): string
{
return 'appointments';
}
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppointmentRepository $appointmentRepo,
@@ -63,6 +74,8 @@ class MyAppointmentsController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function myClinicDoctors(#[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$roles = $user->getRoles();
$doctors = [];
@@ -116,6 +129,8 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::FORBIDDEN, 'دسترسی ندارید', 403);
}
$this->denyUnlessGranted($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
@@ -394,6 +409,10 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::FORBIDDEN, 'دسترسی ندارید', 403);
}
// بخشی از فرمِ ثبت نوبت است، پس همان `appointments.create` — نه `patients.view`.
// با گیتِ پرونده، منشی‌ای که فقط اجازهٔ نوبت‌دهی دارد فرمش را از دست می‌داد.
$this->denyUnlessGranted($user, 'create');
// Lookup by mobile OR national code — the booking form lets the user
// search either way. National code takes precedence when both are sent.
$mobile = InputValidator::toEnglishDigits(trim((string) $request->query->get('mobile', '')));
@@ -430,6 +449,8 @@ class MyAppointmentsController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(500, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
@@ -620,6 +641,8 @@ class MyAppointmentsController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function todayStats(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$date = trim((string) $request->query->get('date', date('Y-m-d')));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
@@ -26,6 +26,38 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class AppointmentPlanController extends BaseController
{
use \App\Shared\Controller\PermissionGateTrait;
/**
* بخش‌بندی یک خاصیتِ `ServiceItem` است و صفحه‌اش داخل کاتالوگ خدمات، پس همان
* منبعِ `services` — نه `appointments`. دقیقاً همان استدلالِ پروتکل درمان در
* آدیت ۲۰۲۶-۰۸-۰۷.
*/
private function permissionResource(): string
{
return 'services';
}
/**
* پیش‌نمایشِ برنامهٔ یک نوبت — ورودیِ فرمِ ثبت نوبت است، نه پیکربندیِ سرویس.
*
* قرینهٔ `ResourcePermissionTrait::denyUnlessGrantedForBooking`: منشی‌ای که
* اجازهٔ ثبت نوبت دارد ولی کاتالوگ خدمات برایش بسته است، وگرنه نمی‌توانست همان
* نوبتی را که مجاز است ثبت کند.
*/
private function denyUnlessGrantedForPlanning(User $user): void
{
$allowed =
($this->secretaryAccess->canOrNonSecretary($user, 'services', 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, 'services', 'view'))
|| ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view'));
if (!$allowed) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
public function __construct(
private readonly SegmentTemplateRepository $templates,
private readonly ServiceItemRepository $items,
@@ -38,6 +70,8 @@ class AppointmentPlanController extends BaseController
#[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$service = $this->requireItem($user, $uuid);
return $this->success(array_map(
@@ -55,6 +89,8 @@ class AppointmentPlanController extends BaseController
#[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_replace', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['segments'] ?? null)) {
@@ -198,6 +234,8 @@ class AppointmentPlanController extends BaseController
#[Route('/api/v1/appointment-plan/preview', name: 'appointment_plan_preview', methods: ['POST'])]
public function preview(#[CurrentUser] User $user, Request $request): JsonResponse
{
$this->denyUnlessGrantedForPlanning($user);
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) {
@@ -16,6 +16,7 @@ use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Security\PatientRecordScopeResolver;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Controller\PermissionGateTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -27,6 +28,17 @@ use OpenApi\Attributes as OA;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class BillingController extends BaseController
{
use PermissionGateTrait;
/**
* صورتحساب و مطالبهٔ بیمه هر دو زیرِ «مدیریت پرداخت‌ها»ی پنل نشسته‌اند و توگل
* جداگانه‌ای ندارند، پس منبعِ مجوزشان یکی است.
*/
private function permissionResource(): string
{
return 'payments';
}
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly InvoiceRepository $invoiceRepo,
@@ -108,6 +120,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/invoices', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -132,6 +146,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/invoices/{uuid}', methods: ['GET'])]
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
$invoice = $this->invoiceRepo->findByUuid($uuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
@@ -144,6 +160,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/invoices/{uuid}/finalize', methods: ['POST'])]
public function finalize(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$invoice = $this->invoiceRepo->findByUuid($uuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
@@ -164,6 +182,8 @@ class BillingController extends BaseController
#[Route('/api/v1/my/billing/payments', methods: ['GET'])]
public function listPayments(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -190,6 +210,8 @@ class BillingController extends BaseController
#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])]
public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -223,6 +245,8 @@ class BillingController extends BaseController
#[Route('/api/v1/my/billing/patients/{patientUuid}/invoices', methods: ['GET'])]
public function listPatientInvoices(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -262,6 +286,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims', methods: ['POST'])]
public function createClaim(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -286,6 +312,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims', methods: ['GET'])]
public function listClaims(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -324,6 +352,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims/by-patient', methods: ['GET'])]
public function claimsByPatient(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -349,6 +379,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims/by-patient/{patientUuid}', methods: ['GET'])]
public function claimsForPatient(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -439,6 +471,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims/{uuid}/{action}', methods: ['POST'], requirements: ['action' => 'submit|approve|reject|pay'])]
public function transitionClaim(string $uuid, string $action, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$claim = $this->claimRepo->findByUuid($uuid);
if ($claim === null || $claim->getEntityType() !== $entityType || $claim->getEntityId() !== $entityId) {
@@ -483,6 +517,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/reports/insurance-debt', methods: ['GET'])]
public function insuranceDebt(#[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -3,11 +3,9 @@
namespace App\Resource\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\PermissionGateTrait;
use App\Shared\Exception\AppException;
use Symfony\Contracts\Service\Attribute\Required;
/**
* گِیتِ مشترک کنترلرهای این دامنه.
@@ -18,20 +16,13 @@ use Symfony\Contracts\Service\Attribute\Required;
*
* تعطیلات از این قاعده مستثناست و `appointment_settings` می‌ماند — صفحه‌اش
* زیرمجموعهٔ تنظیمات نوبت‌دهی است، نه فهرست دستگاه‌ها.
*
* تزریق checkerها و خودِ `denyUnlessGranted` در `PermissionGateTrait` مشترک است؛
* اینجا فقط منبعِ پیش‌فرض و استثنای نوبت‌دهی می‌ماند.
*/
trait ResourcePermissionTrait
{
private SecretaryAccessChecker $secretaryAccess;
private ClinicDoctorAccessChecker $clinicDoctorAccess;
#[Required]
public function setResourceAccessCheckers(
SecretaryAccessChecker $secretaryAccess,
ClinicDoctorAccessChecker $clinicDoctorAccess,
): void {
$this->secretaryAccess = $secretaryAccess;
$this->clinicDoctorAccess = $clinicDoctorAccess;
}
use PermissionGateTrait;
/** کنترلری که منبعِ دیگری را گِیت می‌کند این را بازنویسی می‌کند. */
private function permissionResource(): string
@@ -39,15 +30,6 @@ trait ResourcePermissionTrait
return 'resources';
}
/** @param 'view'|'create'|'update'|'delete' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$resource = $this->permissionResource();
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
}
/**
* خواندنِ منبع **برای نوبت‌دهی** — نه برای پیکربندی‌اش.
*
@@ -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);
}
}