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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user