feat(secretary): add services permission resource + panel gating (phase A)
Secretaries could reach neither the services module (EntityContextResolver does not recognise a secretary as clinic owner, so they resolved to `unknown` → 403) nor had any toggle to grant it. Add `services` as a first-class secretary permission resource, enforced end-to-end. Backend - DoctorSecretary::DEFAULT_PERMISSIONS: new `services` resource (default-deny). - SecretaryAccessChecker::resolveOwnerEntity(): reusable owner (clinic/doctor) resolution from the secretary's active context, for controllers whose data is fetched by [entityType, entityId] and whose generic resolver is not secretary-aware. - ClinicServiceController: resolveEntity() is now secretary-aware; every action (sections, items, tariffs — 13 total) guards with `services` view/create/ update/delete via denyUnlessGranted, ahead of the subscription gate. Frontend - SecretaryPermissions type + MySecretariesPage + SecretariesPage: `services` section so owners can grant it. - Sidebar (secretary branch): services / inventory / tags menu items gated by can(resource, 'view'). - RoleRoute: a secretary now needs the page's `permission` to open it (direct URL entry included); clinic-services, inventory, tags-settings routes accept secretary + permission gate. Tests - SecretaryResourceEnforcementTest: services denied-by-default, allowed-when- granted, create-denied-while-view-granted. - Sidebar.test: secretary menu gating for services/inventory/tags. Docs: secretary.md + clinic-services.md updated with the `services` resource and the resolveOwnerEntity note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
@@ -49,6 +50,7 @@ class ClinicServiceController extends BaseController
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -138,6 +140,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-sections', methods: ['GET'])]
|
||||
public function listSections(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -155,6 +158,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section', methods: ['POST'])]
|
||||
public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -174,6 +178,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -198,6 +203,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -217,6 +223,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items', methods: ['GET'])]
|
||||
public function listAllItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
// A user with neither a doctor profile nor a clinic (admin, secretary,
|
||||
@@ -235,6 +242,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($sectionUuid);
|
||||
@@ -248,6 +256,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
|
||||
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -262,6 +271,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])]
|
||||
public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -278,6 +288,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -332,6 +343,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -386,6 +398,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -416,6 +429,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -436,6 +450,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -499,6 +514,12 @@ class ClinicServiceController extends BaseController
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
// EntityContextResolver منشی را مالکِ محیط نمیشناسد؛ محیطِ فعالِ او را
|
||||
// جداگانه به owner (کلینیک/پزشک) حل میکنیم. مجوز با denyUnlessGranted جداست.
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
return $this->secretaryAccess->resolveOwnerEntity($user);
|
||||
}
|
||||
|
||||
return $this->contextResolver->resolve($user, $this->requestedClinicUuid())->toEntityPair();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class DoctorSecretary
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -57,6 +57,34 @@ class SecretaryAccessChecker
|
||||
return $relation !== null && $this->permissions->can($relation, $resource, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* جفتِ [entityType, entityId] مالکِ محیطِ فعالِ منشی — کلینیک یا پزشک.
|
||||
* برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی میکنند و resolverِ
|
||||
* عمومی (EntityContextResolver) منشی را نمیشناسد. مجوز جدا با
|
||||
* denyUnlessGranted بررسی میشود؛ این متد فقط owner را حل میکند.
|
||||
*
|
||||
* @return array{0: string, 1: int|null} ['clinic'|'doctor'|'unknown', id|null]
|
||||
*/
|
||||
public function resolveOwnerEntity(User $user): array
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* برای مسیرهایی که چند نقش دارند: فقط منشی را محدود کن. سایر نقشها true.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user