feat: Implement secretary permissions enforcement across multiple resources

- Added SecretaryAccessChecker to manage resource access for secretaries.
- Integrated permission checks for payments, inventory, and tags in relevant controllers.
- Updated PaymentController and PaymentMethodController to enforce secretary permissions.
- Enhanced TenantTagController to check permissions for tag management actions.
- Introduced tests for secretary resource enforcement, ensuring proper access control.
- Updated DoctorSecretary entity to include inventory and tags permissions.
- Created a comprehensive audit document for secretary permissions coverage and enforcement.
- Fixed potential crashes in SecretaryDashboard when rendering without doctor data.
This commit is contained in:
hamed
2026-07-23 16:36:35 +03:30
parent f00ed23f00
commit 5c4976d65f
24 changed files with 790 additions and 87 deletions
@@ -18,6 +18,7 @@ use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Shared\Constant\ErrorCodes;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -44,6 +45,7 @@ class InsuranceController extends BaseController
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly string $projectDir,
) {}
@@ -252,6 +254,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
@@ -301,6 +304,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -370,6 +374,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
@@ -400,6 +405,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -435,6 +441,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -480,6 +487,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deactivateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'update');
if ($err !== null) {
return $err;
@@ -499,6 +507,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
@@ -529,6 +538,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -574,6 +584,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function addDoctorInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
$data = json_decode($request->getContent(), true) ?? [];
$doctorId = $data['doctor_id'] ?? null;
$insuranceId = $data['insurance_id'] ?? null;
@@ -614,6 +625,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function showDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
@@ -630,6 +642,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateDoctorInsurance(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
@@ -652,6 +665,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
@@ -11,6 +11,7 @@ use App\Inventory\Entity\InventoryPackage;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Inventory\Service\InventoryService;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
@@ -37,6 +38,7 @@ class InventoryController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
) {}
// ── Items ────────────────────────────────────────────────────────────────
@@ -44,6 +46,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-items', methods: ['GET'])]
public function listItems(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -60,6 +63,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-categories', methods: ['GET'])]
public function listCategories(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -80,6 +84,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-item', methods: ['POST'])]
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -101,6 +106,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-item/{uuid}', methods: ['PATCH'])]
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
@@ -123,6 +129,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-item/{uuid}', methods: ['DELETE'])]
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
@@ -138,6 +145,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-packages', methods: ['GET'])]
public function listPackages(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -152,6 +160,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-package', methods: ['POST'])]
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -173,6 +182,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-package/{uuid}', methods: ['PATCH'])]
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
@@ -199,6 +209,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-package/{uuid}', methods: ['DELETE'])]
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
+34 -1
View File
@@ -13,6 +13,7 @@ use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Security\PatientRecordScope;
use App\Patient\Security\PatientRecordScopeResolver;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Patient\Service\PatientService;
use App\UserProfile\Entity\UserProfile;
use App\Shared\Constant\ErrorCodes;
@@ -57,6 +58,7 @@ class PatientController extends BaseController
private readonly \App\Discount\Repository\DiscountRuleRepository $discountRuleRepo,
private readonly \App\Patient\Repository\SessionPaymentRepository $sessionPaymentRepo,
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly LoggerInterface $logger,
) {}
@@ -141,6 +143,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/wallet/charge', methods: ['POST'])]
public function chargeWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -176,6 +179,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -235,6 +239,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/call', methods: ['POST'])]
public function createCall(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -271,6 +276,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/call/{uuid}', methods: ['DELETE'])]
public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$call = $this->callRepo->findByUuid($uuid);
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) {
@@ -302,6 +308,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/message', methods: ['POST'])]
public function createMessage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -327,6 +334,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/message/{uuid}', methods: ['DELETE'])]
public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$message = $this->messageRepo->findByUuid($uuid);
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) {
@@ -361,6 +369,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/note', methods: ['POST'])]
public function createNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -383,6 +392,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/note/{uuid}', methods: ['PATCH'])]
public function updateNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -408,6 +418,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/note/{uuid}', methods: ['DELETE'])]
public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -439,6 +450,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/medical-record', methods: ['POST'])]
public function createMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -463,6 +475,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['PATCH'])]
public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -493,6 +506,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])]
public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -524,6 +538,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/attachment', methods: ['POST'])]
public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -546,6 +561,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])]
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$attachment = $this->attachmentRepo->findByUuid($uuid);
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) {
@@ -683,6 +699,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -776,6 +793,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -985,6 +1003,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1028,6 +1047,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1103,6 +1123,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}/payments', methods: ['POST'])]
public function addSessionPayment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1128,6 +1149,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['PATCH'])]
public function updateSessionPayment(string $uuid, string $paymentUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1149,6 +1171,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['DELETE'])]
public function deleteSessionPayment(string $uuid, string $paymentUuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1186,7 +1209,17 @@ class PatientController extends BaseController
private function scope(User $user): PatientRecordScope
{
return $this->scopeCache[$user->getId()] ??= $this->scopeResolver->resolve($user);
return $this->scopeCache[$user->getId()] ??= $this->resolveScope($user);
}
/** منشیِ بدون مجوز patients.view هیچ پرونده‌ای نمی‌بیند؛ سایر نقش‌ها از رزولور. */
private function resolveScope(User $user): PatientRecordScope
{
if ($user->hasRole('ROLE_SECRETARY') && !$this->secretaryAccess->can($user, 'patients', 'view')) {
return PatientRecordScope::unknown();
}
return $this->scopeResolver->resolve($user);
}
/** @return array{0: string, 1: int|null} */
@@ -10,6 +10,7 @@ use App\Config\Repository\SiteConfigRepository;
use App\Payment\Gateway\GatewayFactory;
use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\PaymentManager;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
@@ -35,6 +36,7 @@ class PaymentController extends BaseController
private readonly GatewayFactory $gateways,
private readonly PaymentManager $paymentManager,
private readonly SiteConfigRepository $configRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -505,6 +507,7 @@ class PaymentController extends BaseController
#[Route('/api/v1/my/payments', methods: ['GET'])]
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'view');
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
$status = $request->query->get('status');
@@ -4,6 +4,7 @@ namespace App\PaymentMethod\Controller;
use App\Auth\Entity\User;
use App\PaymentMethod\Service\PaymentMethodService;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -25,15 +26,23 @@ class PaymentMethodController extends BaseController
private const ALLOWED_ROLES = ['ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
public function __construct(
private readonly PaymentMethodService $service,
private readonly PaymentMethodService $service,
private readonly SecretaryAccessChecker $secretaryAccess,
) {}
/** نقش مجاز + مجوز منشی روی منبع payments (روش‌های پرداخت زیرمجموعهٔ مالی است). */
private function guard(User $user, string $action): void
{
$this->assertRole($user);
$this->secretaryAccess->denyUnlessGranted($user, 'payments', $action);
}
// ---- Bank accounts -----------------------------------------------------
#[Route('/bank-accounts', methods: ['GET'])]
public function listBankAccounts(#[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'view');
return $this->success($this->service->listBankAccounts($user));
}
@@ -41,7 +50,7 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts', methods: ['POST'])]
public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createBankAccount($user, $data), 201);
@@ -50,7 +59,7 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts/{uuid}', methods: ['PUT'])]
public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updateBankAccount($user, $uuid, $data));
@@ -59,7 +68,7 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])]
public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
return $this->success($this->service->toggleBankAccountStatus($user, $uuid));
}
@@ -69,7 +78,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos', methods: ['GET'])]
public function listPos(#[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'view');
return $this->success($this->service->listPos($user));
}
@@ -77,7 +86,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos', methods: ['POST'])]
public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createPos($user, $data), 201);
@@ -86,7 +95,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos/{uuid}', methods: ['PUT'])]
public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updatePos($user, $uuid, $data));
@@ -95,7 +104,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos/{uuid}/status', methods: ['PATCH'])]
public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
return $this->success($this->service->togglePosStatus($user, $uuid));
}
+2
View File
@@ -26,6 +26,8 @@ class DoctorSecretary
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'clinic_info' => ['view' => true, 'update' => false],
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
],
];
@@ -0,0 +1,79 @@
<?php
namespace App\Secretary\Security;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* Single entry point that answers «آیا این منشی مجاز به resource/action هست؟».
*
* منبع حقیقت، ستون JSON `permission` روی ردیف فعالِ DoctorSecretary در محیطِ
* فعال کاربر (UserActiveContext.db_uuid) است — دقیقاً مثل PatientRecordScopeResolver
* و DashboardController::secretary. کنترلرهایی که چند نقش می‌گیرند فقط وقتی کاربر
* ROLE_SECRETARY دارد این checker را صدا می‌زنند؛ نقش‌های دیگر دست‌نخورده می‌مانند.
*/
class SecretaryAccessChecker
{
public function __construct(
private readonly UserActiveContextRepository $contextRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly SecretaryPermissionChecker $permissions,
) {}
/** ردیف فعالِ منشی در محیط فعال؛ null اگر محیط تنظیم نشده یا رابطه‌ای نیست. */
public function activeRelation(User $user): ?DoctorSecretary
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return null;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
}
return null;
}
public function can(User $user, string $resource, string $action): bool
{
$relation = $this->activeRelation($user);
return $relation !== null && $this->permissions->can($relation, $resource, $action);
}
/**
* برای مسیرهایی که چند نقش دارند: فقط منشی را محدود کن. سایر نقش‌ها true.
*/
public function canOrNonSecretary(User $user, string $resource, string $action): bool
{
if (!$user->hasRole('ROLE_SECRETARY')) {
return true;
}
return $this->can($user, $resource, $action);
}
/** 403 اگر منشی مجاز نباشد؛ نقش‌های دیگر بدون تغییر عبور می‌کنند. */
public function denyUnlessGranted(User $user, string $resource, string $action): void
{
if (!$this->canOrNonSecretary($user, $resource, $action)) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
}
@@ -6,8 +6,10 @@ use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Tag\Entity\TenantTag;
use App\Tag\Repository\TenantTagRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -32,11 +34,22 @@ class TenantTagController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
) {}
/** تگ‌ها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است. */
private function guardTagView(User $user): void
{
if (!$this->secretaryAccess->canOrNonSecretary($user, 'tags', 'view')
&& !$this->secretaryAccess->canOrNonSecretary($user, 'patients', 'view')) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
#[Route('/api/v1/tenant-tags', methods: ['GET'])]
public function list(#[CurrentUser] User $user): JsonResponse
{
$this->guardTagView($user);
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -51,6 +64,7 @@ class TenantTagController extends BaseController
#[Route('/api/v1/tenant-tag', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -79,6 +93,7 @@ class TenantTagController extends BaseController
#[Route('/api/v1/tenant-tag/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'update');
$tag = $this->ownedTag($uuid, $user);
if ($tag === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404);
@@ -111,6 +126,7 @@ class TenantTagController extends BaseController
#[Route('/api/v1/tenant-tag/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'delete');
$tag = $this->ownedTag($uuid, $user);
if ($tag === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404);