refactor(tenant): make EntityContextResolver the single context resolver

Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 10:59:22 +03:30
co-authored by Claude Opus 5
parent 748dc81ea9
commit 1a7bf53577
18 changed files with 2185 additions and 216 deletions
@@ -8,7 +8,6 @@ use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
@@ -35,7 +34,7 @@ class MyAppointmentsController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly \App\Shared\Context\EntityContextResolver $contextResolver,
private readonly SlotCalculatorService $slotCalculator,
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
@@ -552,21 +551,19 @@ class MyAppointmentsController extends BaseController
private function secretaryCanBookForDoctor(User $user, Doctor $doctor): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($context->isClinic()) {
// منشی فقط برای پزشکانِ تخصیص‌یافته‌ی خودش می‌تواند رزرو کند، نه کل کلینیک
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
$rel = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
$scopeDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($scopeDoctor !== null && $scopeDoctor->getId() === $doctor->getId()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $scopeDoctor);
if ($context->id === $doctor->getId()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
@@ -581,35 +578,28 @@ class MyAppointmentsController extends BaseController
*/
private function resolveSecretaryFilter(User $user): ?array
{
$activeCtx = $this->contextRepo->findByUser($user);
$dbUuid = $activeCtx?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return null;
}
// بررسی scope کلینیک — فقط پزشکانِ تخصیص‌یافته به این منشی، نه کل کلینیک
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
if ($context->isClinic()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
$doctorIds = array_map(
fn(Doctor $d) => $d->getId(),
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $context->clinic)
);
return ['clinic', $doctorIds, $canView];
}
// بررسی scope مطب شخصی
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return ['doctor', $doctor, $canView];
}
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return null;
return ['doctor', $context->doctor, $canView];
}
}
@@ -4,12 +4,11 @@ namespace App\Appointment\Security;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Clinic\Security\ClinicDoctorPermissionChecker;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Secretary\Security\SecretaryPermissionChecker;
use App\Shared\Context\EntityContextResolver;
/**
* تنها تصمیم‌گیرندهٔ دسترسی روی «یک نوبت مشخص».
@@ -34,9 +33,8 @@ class AppointmentAccessChecker
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
private readonly SecretaryPermissionChecker $secretaryPermissions,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityContextResolver $contextResolver,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
) {}
public function canView(Appointment $appointment, User $user): bool
@@ -108,29 +106,23 @@ class AppointmentAccessChecker
*/
private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
if ($ctxClinic !== null) {
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
if ($context->isClinic()) {
if ($clinic === null || $context->id !== $clinic->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
}
// محیطِ مطب شخصی: نوبت هم باید در همان مطب شخصی باشد (clinic == null).
if ($clinic !== null) {
return false;
}
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
if ($clinic !== null || $context->id !== $doctor->getId()) {
return false;
}
@@ -145,12 +137,12 @@ class AppointmentAccessChecker
*/
public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
$context = $this->contextResolver->resolve($user);
if ($clinic === null) {
$clinic = $this->clinicRepo->findByUser($user);
}
// بیرون از محیط کلینیک، کلینیکِ تحتِ مالکیت بازمی‌گردد: کاربری که هم پزشک
// است و هم مالک کلینیک، بدون محیط فعال به‌عنوان پزشک حل می‌شود ولی هنوز
// باید لیست نوبت‌های کلینیک خودش را ببیند.
$clinic = $context->isClinic() ? $context->clinic : $this->clinicRepo->findByUser($user);
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
return null;
@@ -168,24 +160,23 @@ class AppointmentAccessChecker
*/
private function secretaryCan(Appointment $appointment, User $user, string $action): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($appointment->getClinic()?->getId() !== $clinic->getId()) {
if ($context->isClinic()) {
if ($appointment->getClinic()?->getId() !== $context->id) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $appointment->getDoctor());
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $appointment->getDoctor());
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) {
$doctor = $appointment->getDoctor();
if ($context->id !== $doctor->getId()) {
return false;
}
@@ -13,6 +13,11 @@ use App\Shared\Exception\AppException;
*
* نبودِ clinic_uuid هرگز به معنی «هر محلی که پیدا شد» نیست. با چند برنامهٔ هم‌زمان،
* حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای اشتباه — پس یا محل صریح است، یا شخصی.
*
* چرا جدا از {@see \App\Shared\Context\EntityContextResolver}: ورودی آن User است و
* محیطِ کاربرِ پنل را حل می‌کند؛ اینجا ورودی Doctor است چون رزرو عمومی را بیمار
* انجام می‌دهد و کاربرِ درخواست هیچ محیط کاری ندارد. به همین دلیل هم null اینجا
* معنای صریحِ «مطب شخصی» دارد، نه «نامشخص».
*/
class BookingContextResolver
{
@@ -3,10 +3,9 @@
namespace App\Clinic\Security;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Exception\AppException;
/**
@@ -20,8 +19,7 @@ use App\Shared\Exception\AppException;
class ClinicDoctorAccessChecker
{
public function __construct(
private readonly UserActiveContextRepository $contextRepo,
private readonly ClinicRepository $clinicRepo,
private readonly EntityContextResolver $contextResolver,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicDoctorPermissionChecker $permissions,
) {}
@@ -33,22 +31,14 @@ class ClinicDoctorAccessChecker
*/
public function memberClinicId(User $user): ?int
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return null;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isClinic()) {
return null;
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
return null;
}
return $clinic->getId();
return $doctor !== null && $context->clinic->hasDoctor($doctor) ? $context->id : null;
}
/**
@@ -56,16 +46,13 @@ class ClinicDoctorAccessChecker
*/
public function canOrNonMember(User $user, string $resource, string $action): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isClinic()) {
// محیطِ مطب شخصی یا نامشخص → این checker مالِ او نیست.
return true;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic === null) {
// محیطِ مطب شخصی (db_uuid پزشک است، نه کلینیک) → محدود نمی‌کنیم.
return true;
}
$clinic = $context->clinic;
// مالکِ کلینیک هرگز با مجوزهای عضویت قفل نمی‌شود.
if ($clinic->getUser()->getId() === $user->getId()) {
@@ -3,7 +3,6 @@
namespace App\Dashboard\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Repository\PatientRecordRepository;
@@ -33,7 +32,6 @@ class DashboardController extends BaseController
private readonly SmsWalletService $smsWalletService,
private readonly PatientRecordRepository $patientRecordRepo,
private readonly PatientSessionRepository $patientSessionRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityContextResolver $contextResolver,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
@@ -532,29 +530,22 @@ class DashboardController extends BaseController
#[IsGranted('ROLE_SECRETARY')]
public function secretary(#[CurrentUser] User $user): JsonResponse
{
$activeCtx = $this->contextRepo->findByUser($user);
$dbUuid = $activeCtx?->getDbUuid();
$context = $this->contextResolver->resolve($user);
if ($dbUuid === null) {
if (!$context->isResolved()) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
}
// تعیین scope بر اساس db_uuid فعال
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
return $this->secretaryClinicDashboard($user, $clinic);
if ($context->isClinic()) {
return $this->secretaryClinicDashboard($user, $context->clinic);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
if ($rel === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
}
return $this->secretaryDoctorDashboard($user, $rel);
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
if ($rel === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
}
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403);
return $this->secretaryDoctorDashboard($user, $rel);
}
private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse
@@ -286,7 +286,14 @@ class InventoryController extends BaseController
return $package;
}
/** @return array{0: string, 1: int|null} [entityType, entityId] */
/**
* عمداً از EntityContextResolver استفاده نمی‌کند: اینجا نقشِ پزشک بی‌قید اول
* بررسی می‌شود، پس پزشکِ عضو حتی در محیط کلینیک هم انبارِ شخصی خودش را می‌بیند.
* رزولور مشترک محیط فعال را مقدم می‌داند و انتقال به آن، دادهٔ نمایش‌داده‌شده به
* پزشکِ عضو را عوض می‌کند — تصمیمی محصولی که به فاز نشانه‌گذاری tenant موکول شد.
*
* @return array{0: string, 1: int|null} [entityType, entityId]
*/
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
@@ -3,18 +3,20 @@
namespace App\Patient\Security;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Security\ClinicDoctorPermissionChecker;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Context\EntityContext;
use App\Shared\Context\EntityContextResolver;
/**
* «پرونده‌های کدام محیط را این کاربر می‌بیند؟»
* «پرونده‌های کدام محیط را این کاربر می‌بیند، و درون آن محیط محدود به بیمارانِ
* کدام پزشکان؟»
*
* پیش از این، نگاشت تک‌مقصدی بود: نقش پزشک همیشه به پروندهٔ مطب شخصی می‌رسید، پس
* پزشکِ دعوت‌شده به کلینیک پرونده‌های بیمارانش در آن کلینیک را اصلاً نمی‌دید. محیط
* فعال (UserActiveContext) تعیین‌کننده است، دقیقاً مثل EntityContextResolver.
* پرسش اول را EntityContextResolver جواب می‌دهد؛ مسئولیت منحصربه‌فرد این کلاس
* فقط پرسش دوم است. پروندهٔ کلینیکی per-بیمار است نه per-پزشک، پس محدودسازیِ
* پزشکِ عضو و منشی نمی‌تواند روی خودِ محیط باشد.
*
* «پایان همکاری» منبع حقیقتِ جدا ندارد: ClinicDoctorPermission/DoctorSecretary با
* active=false خودشان رد می‌کنند.
@@ -24,79 +26,52 @@ class PatientRecordScopeResolver
private const RESOURCE = 'patients';
public function __construct(
private readonly EntityContextResolver $contextResolver,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
) {}
public function resolve(User $user): PatientRecordScope
{
if ($user->hasRole('ROLE_DOCTOR')) {
return $this->forDoctorUser($user);
$context = $this->contextResolver->resolve($user);
if ($context->isClinic()) {
return $this->restrictionInClinic($user, $context->clinic);
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return PatientRecordScope::forClinic($clinic?->getId());
}
if ($user->hasRole('ROLE_SECRETARY')) {
return $this->forSecretary($user);
}
return PatientRecordScope::unknown();
// پزشکی که هنوز رکورد Doctor ندارد، محیطش «doctor» با شناسهٔ تهی است —
// assertPatientGate همان را ۴۰۳ می‌کند.
return $context->type === EntityContext::TYPE_DOCTOR
? PatientRecordScope::forDoctor($context->id)
: PatientRecordScope::unknown();
}
/**
* پزشک در محیط کلینیکِ فعالش پرونده‌های همان کلینیک را می‌بیند — محدود به
* بیمارانِ خودش. بیرون از آن محیط، فقط پروندهٔ مطب شخصی.
* مالک کلینیک همهٔ پرونده‌ها را می‌بیند؛ پزشکِ عضو فقط بیمارانِ خودش؛ منشی
* فقط بیمارانِ پزشکانِ تخصیص‌یافته به او — عضویت در کلینیک به‌تنهایی یعنی
* منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند.
*/
private function forDoctorUser(User $user): PatientRecordScope
private function restrictionInClinic(User $user, Clinic $clinic): PatientRecordScope
{
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null) {
return PatientRecordScope::forDoctor(null);
}
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
return PatientRecordScope::forDoctor($doctor->getId());
}
// مالک کلینیکی که خودش پزشک هم هست، محدود نمی‌شود.
if ($clinic->getUser()->getId() === $user->getId()) {
return PatientRecordScope::forClinic($clinic->getId());
}
if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) {
return PatientRecordScope::forDoctor($doctor->getId());
}
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]);
}
/**
* منشی در محیط فعالش. در کلینیک، فقط بیمارانِ پزشکانِ تخصیص‌یافته به او —
* عضویت در کلینیک به‌تنهایی یعنی منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند.
*/
private function forSecretary(User $user): PatientRecordScope
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return PatientRecordScope::unknown();
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) === null) {
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null) {
return PatientRecordScope::unknown();
}
if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) {
return PatientRecordScope::forDoctor($doctor->getId());
}
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]);
}
if ($user->hasRole('ROLE_SECRETARY')) {
$doctorIds = array_map(
fn($d) => $d->getId(),
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic),
@@ -105,11 +80,6 @@ class PatientRecordScopeResolver
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), $doctorIds);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null && $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null) {
return PatientRecordScope::forDoctor($doctor->getId());
}
return PatientRecordScope::unknown();
return PatientRecordScope::forClinic($clinic->getId());
}
}
@@ -3,28 +3,23 @@
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\Context\EntityContextResolver;
use App\Shared\Exception\AppException;
/**
* Single entry point that answers «آیا این منشی مجاز به resource/action هست؟».
*
* منبع حقیقت، ستون JSON `permission` روی ردیف فعالِ DoctorSecretary در محیطِ
* فعال کاربر (UserActiveContext.db_uuid) است — دقیقاً مثل PatientRecordScopeResolver
* و DashboardController::secretary. کنترلرهایی که چند نقش می‌گیرند فقط وقتی کاربر
* ROLE_SECRETARY دارد این checker را صدا می‌زنند؛ نقش‌های دیگر دست‌نخورده می‌مانند.
* فعالِ کاربر است. محیط از EntityContextResolver می‌آید — همان نقطه‌ای که همهٔ
* نقش‌ها از آن می‌گذرند؛ اینجا فقط مجوزها بررسی می‌شوند، نه محیط.
*/
class SecretaryAccessChecker
{
public function __construct(
private readonly UserActiveContextRepository $contextRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly EntityContextResolver $contextResolver,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly SecretaryPermissionChecker $permissions,
) {}
@@ -32,22 +27,14 @@ class SecretaryAccessChecker
/** ردیف فعالِ منشی در محیط فعال؛ null اگر محیط تنظیم نشده یا رابطه‌ای نیست. */
public function activeRelation(User $user): ?DoctorSecretary
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
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;
return $context->isClinic()
? $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic)
: $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
}
public function can(User $user, string $resource, string $action): bool
@@ -59,30 +46,14 @@ class SecretaryAccessChecker
/**
* جفتِ [entityType, entityId] مالکِ محیطِ فعالِ منشی — کلینیک یا پزشک.
* برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی می‌کنند و resolverِ
* عمومی (EntityContextResolver) منشی را نمی‌شناسد. مجوز جدا با
* برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی می‌کنند. مجوز جدا با
* 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];
return $this->contextResolver->resolve($user)->toEntityPair();
}
/**
@@ -97,28 +68,23 @@ class SecretaryAccessChecker
string $resource,
string $action
): bool {
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
if ($ctxClinic !== null) {
if ($context->isClinic()) {
// محیطِ کلینیک: تنظیم باید در همان کلینیک باشد و پزشکش جزو پزشکانِ منشی.
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
if ($clinic === null || $context->id !== $clinic->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
return $relation !== null && $this->permissions->can($relation, $resource, $action);
}
// محیطِ مطب شخصی: تنظیم هم باید شخصی باشد (clinic == null).
if ($clinic !== null) {
return false;
}
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
if ($clinic !== null || $context->id !== $doctor->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
+33 -5
View File
@@ -6,7 +6,9 @@ use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
@@ -18,6 +20,12 @@ use App\Shared\Exception\AppException;
* نقش به‌تنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمی‌دهد: چنین
* کاربری همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید.
* UserActiveContext تعیین‌کننده است و نقش فقط fallback آخر.
*
* این کلاس فقط به «کدام محیط» جواب می‌دهد. «چه کاری در آن محیط مجاز است» کار
* ClinicDoctorPermissionChecker و SecretaryPermissionChecker است و اینجا بررسی نمی‌شود.
*
* برای مسیر رزرو عمومی (که کاربر پنل ندارد و ورودی‌اش Doctor است، نه User)
* قرینه‌ای جدا وجود دارد: {@see \App\Appointment\Service\BookingContextResolver}.
*/
class EntityContextResolver
{
@@ -25,6 +33,7 @@ class EntityContextResolver
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $activeContextRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
) {}
/**
@@ -64,7 +73,7 @@ class EntityContextResolver
}
}
/** مالک کلینیک، ادمین، یا پزشکِ عضو همان کلینیک. */
/** مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، یا منشیِ دارای رابطهٔ فعال در آن. */
public function canActInClinic(User $user, Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
@@ -72,8 +81,11 @@ class EntityContextResolver
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor !== null && $clinic->hasDoctor($doctor)) {
return true;
}
return $doctor !== null && $clinic->hasDoctor($doctor);
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null;
}
public function assertCanActInClinic(User $user, Clinic $clinic): void
@@ -100,11 +112,24 @@ class EntityContextResolver
}
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) {
return EntityContext::forDoctor($doctor);
return $doctor !== null && $this->canActForDoctor($user, $doctor)
? EntityContext::forDoctor($doctor)
: null;
}
/**
* محیطِ مطب شخصی: خودِ پزشک، یا منشیِ دارای رابطهٔ فعال با او.
* قرینهٔ canActInClinic برای شاخهٔ غیرکلینیکی؛ ادمین عمداً اینجا نیست چون
* مسیرهای ادمین سراسری‌اند و از محیط فعالِ یک پزشک عبور نمی‌کنند.
*/
private function canActForDoctor(User $user, Doctor $doctor): bool
{
if ($doctor->getUser()->getId() === $user->getId()) {
return true;
}
return null;
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null;
}
private function fromRole(User $user): EntityContext
@@ -119,6 +144,9 @@ class EntityContextResolver
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
// منشی fallback نقشی ندارد: محیطش فقط از UserActiveContext می‌آید، چون یک
// منشی می‌تواند هم‌زمان به چند پزشک و کلینیک وصل باشد و نقش تنها، انتخاب
// بین آن‌ها را تعیین نمی‌کند.
return EntityContext::unknown();
}
}
@@ -257,6 +257,13 @@ class SubscriptionController extends BaseController
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* عمداً از EntityContextResolver استفاده نمی‌کند: اشتراک به مالکِ حقوقی تعلق
* دارد، نه به محیطِ کاری لحظه‌ای — پزشکِ عضو در محیط کلینیک همچنان اشتراک
* خودش را می‌بیند، نه اشتراک کلینیک. انتقال به رزولور این را عوض می‌کند.
*
* @return array{0: string, 1: int|null} [entityType, entityId]
*/
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
+8 -1
View File
@@ -154,7 +154,14 @@ class TenantTagController extends BaseController
return $tag;
}
/** @return array{0: string, 1: int|null} [entityType, entityId] */
/**
* عمداً از EntityContextResolver استفاده نمی‌کند: اینجا نقشِ پزشک بی‌قید اول
* بررسی می‌شود، پس پزشکِ عضو حتی در محیط کلینیک هم برچسب‌های شخصی خودش را
* می‌بیند. رزولور مشترک محیط فعال را مقدم می‌داند و انتقال به آن، دادهٔ
* نمایش‌داده‌شده را عوض می‌کند — به فاز نشانه‌گذاری tenant موکول شد.
*
* @return array{0: string, 1: int|null} [entityType, entityId]
*/
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {