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