Files
clinicpro/src/Secretary/Security/SecretaryAccessChecker.php
T
hamedandClaude Opus 4.8 9d46577181 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>
2026-07-23 17:10:54 +03:30

108 lines
4.2 KiB
PHP

<?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);
}
/**
* جفتِ [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.
*/
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);
}
}
}