fix(permissions): apply the patient and tag read gates to invited clinic doctors

PatientController::resolveScope and TenantTagController::guardTagView only ever
checked the secretary, while every write in both controllers already ran through
both checkers. So an invited clinic doctor with patients.view off got 200 with an
empty list where a secretary got 403 — one permission, two behaviours. No data
was exposed either way; tenant scoping emptied the result.

The fix is not canOrNonMember. That collapses two different situations: a
membership row switched to active=false means the collaboration ended, and
ClinicDoctorPermission::can() returns false for everything in that case too.
Routing it through the permission gate turned the existing 404 on a single record
into a 403, which confirms the record exists to someone who just lost access.
ClinicRecordAccessTest caught it.

isActiveMemberDenied() answers the narrower question — active member, permission
off — and leaves a deactivated row to the data scope, which closes it with a 404
and discloses nothing. A test now pins that distinction so it cannot be collapsed
again.

Tags keep the tags.view OR patients.view rule, now for both roles.

Verified live in three states: active with both off 403/403, deactivated not 403,
active with patients.view on 200/200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 19:30:52 +03:30
co-authored by Claude Opus 5
parent 6a6852379d
commit d6de746938
7 changed files with 152 additions and 4 deletions
+2
View File
@@ -421,6 +421,8 @@ The envelope is always returned in full (`{version, resources}`); it is never fl
Unknown resources and unknown actions in a PATCH body are silently ignored (اعتبارسنجی از `PermissionCatalog::filterPatch`)، so a client cannot invent permission keys. `subscription` و `clinic_doctors` حالا در رجیستری هستند ولی پیش‌فرضشان برای پزشکِ عضو خاموش است — عملیاتِ مالکِ کلینیک‌اند.
**خواندنِ پرونده و تگ.** `PatientController::resolveScope` و `TenantTagController::guardTagView` تا پیش از این فقط منشی را بررسی می‌کردند، پس پزشکِ عضو با `patients.view` خاموش به‌جای `403` یک `200` با فهرست **خالی** می‌گرفت (نشتی نبود — tenant scoping خالی‌اش می‌کرد — ولی یک مجوز دو رفتار داشت). حالا هر دو نقش قرینه‌اند. تگ‌ها مثل قبل با `tags.view` **یا** `patients.view` باز می‌شوند، برای هر دو نقش.
**اعمال (enforcement):** همهٔ منابع در بک‌اند enforce می‌شوند. نقطهٔ واحد `App\Clinic\Security\ClinicDoctorAccessChecker` (`denyUnlessGranted` / `memberClinicId`) که **فقط پزشکِ عضوِ کلینیک در محیطِ فعالِ کلینیک** را محدود می‌کند؛ مالک/ادمین/منشی/پزشکِ مطبِ شخصی دست‌نخورده عبور می‌کنند. کنترلرهایی که tenant را نقش‌محور حل می‌کنند (Inventory/Tag/Staff/Discount/Sms) با `memberClinicId` پزشکِ عضو را به دادهٔ کلینیک می‌برند (نه مطبِ شخصی). نبودِ مجوز → `403`. در پنل، سایدبار/Route/دکمه‌های CRUD با `usePermissions().can` برای محیطِ `scope=clinic` گِیت می‌شوند.
---
+1 -1
View File
@@ -145,7 +145,7 @@ Create a secretary for a doctor.
| Resource | Enforced in | Action → endpoint |
| --- | --- | --- |
| `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status |
| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای؛ افزودن/ویرایشِ زیرآیتم‌ها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete |
| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای — همین قاعده برای پزشکِ عضوِ **فعالِ** کلینیک هم اعمال می‌شود؛ افزودن/ویرایشِ زیرآیتم‌ها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete |
| `payments` | `PaymentController::myPayments`, `PaymentMethodController` (bank/pos), `PatientController` (کیف‌پول + پرداختِ جلسه) | view/create/update/delete |
| `addresses` | `AddressController::list` (`GET /api/v1/addresses`). فقط `view`؛ نوشتن‌ها owner-only‌اند. تا پیش از این این فهرست روی `appointment_settings.view` سوار بود و توگلِ آدرس‌ها بی‌اثر بود | view |
| `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete |
@@ -68,6 +68,24 @@ class ClinicDoctorAccessChecker
return $this->permissions->can($user, $clinic, $resource, $action);
}
/**
* پزشکِ عضوی که همکاری‌اش **فعال** است ولی این مجوز را ندارد.
*
* ردیفِ غیرفعال عمداً `false` می‌دهد: «پایان همکاری» با «مجوز خاموش» یکی نیست.
* اولی را دامنهٔ داده با ۴۰۴ می‌بندد (پرونده اصلاً در محیط او نیست و وجودش هم
* فاش نمی‌شود)؛ دومی ۴۰۳ است. جمع‌کردنشان در یک پاسخ، وجودِ رکورد را لو می‌دهد.
*/
public function isActiveMemberDenied(User $user, string $resource, string $action): bool
{
$context = $this->contextResolver->resolve($user);
if (!$context->isClinic()) {
return false;
}
return $this->permissions->isActiveMember($user, $context->clinic)
&& !$this->permissions->can($user, $context->clinic, $resource, $action);
}
/** 403 اگر پزشکِ عضو مجاز نباشد؛ سایر کاربران بدون تغییر عبور می‌کنند. */
public function denyUnlessGranted(User $user, string $resource, string $action): void
{
@@ -21,6 +21,26 @@ class ClinicDoctorPermissionChecker
private readonly DoctorRepository $doctorRepo,
) {}
/**
* پزشکی که ردیفِ همکاری‌اش با این کلینیک **فعال** است.
*
* مالک و ادمین «عضو» حساب نمی‌شوند — آن‌ها اصلاً با این مجوزها سنجیده نمی‌شوند.
* ردیفِ غیرفعال یعنی پایان همکاری، که با «مجوز خاموش» یکی نیست.
*/
public function isActiveMember(User $user, Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return false;
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
return false;
}
return $this->permRepo->getOrCreate($clinic, $doctor)->isActive();
}
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
@@ -1284,6 +1284,16 @@ class PatientController extends BaseController
return PatientRecordScope::unknown();
}
// قرینهٔ همان قاعده برای پزشکِ عضوِ کلینیک. تا پیش از این فقط منشی اینجا
// بررسی می‌شد، پس پزشکِ عضو با `patients.view` خاموش به‌جای ۴۰۳، ۲۰۰ با
// فهرست خالی می‌گرفت — یک مجوز، دو رفتار.
//
// isActiveMemberDenied و نه canOrNonMember: ردیفِ غیرفعال یعنی پایان
// همکاری، که باید مثل قبل ۴۰۴ بدهد نه ۴۰۳ — وگرنه وجودِ پرونده لو می‌رود.
if ($this->clinicDoctorAccess->isActiveMemberDenied($user, 'patients', 'view')) {
return PatientRecordScope::unknown();
}
return $this->scopeResolver->resolve($user);
}
+16 -3
View File
@@ -38,11 +38,24 @@ class TenantTagController extends BaseController
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
/** تگ‌ها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است. */
/**
* تگ‌ها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است.
*
* هر نقش با مجوزهای خودش سنجیده می‌شود. تا پیش از این فقط منشی بررسی می‌شد و
* پزشکِ عضوِ کلینیک — برخلاف نوشتن‌ها که هر دو checker را دارند — از خواندن رد
* می‌شد؛ یک مجوز با دو رفتار.
*/
private function guardTagView(User $user): void
{
if (!$this->secretaryAccess->canOrNonSecretary($user, 'tags', 'view')
&& !$this->secretaryAccess->canOrNonSecretary($user, 'patients', 'view')) {
$secretaryOk = $this->secretaryAccess->canOrNonSecretary($user, 'tags', 'view')
|| $this->secretaryAccess->canOrNonSecretary($user, 'patients', 'view');
// ردیفِ غیرفعال (پایان همکاری) اینجا محدود نمی‌شود؛ دامنهٔ داده خودش
// خالی‌اش می‌کند. فقط عضوِ فعالِ بی‌مجوز رد می‌شود.
$memberOk = !$this->clinicDoctorAccess->isActiveMemberDenied($user, 'tags', 'view')
|| !$this->clinicDoctorAccess->isActiveMemberDenied($user, 'patients', 'view');
if (!$secretaryOk || !$memberOk) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
@@ -89,4 +89,89 @@ class ClinicDoctorPermissionEnforcementTest extends ApiTestCase
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
$this->assertSame(200, $this->responseCode());
}
// ── خواندنِ پرونده و تگ — قرینهٔ منشی ────────────────────────────────────
/**
* تا پیش از این فقط منشی در PatientController::resolveScope بررسی می‌شد، پس
* پزشکِ عضو با `patients.view` خاموش به‌جای ۴۰۳، ۲۰۰ با فهرست خالی می‌گرفت.
*/
public function testPatientListDeniedWhenPatientsViewOff(): void
{
[$doctorUser, $perm] = $this->makeMemberDoctor();
$perm->mergePermissions(['resources' => ['patients' => ['view' => false]]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/patients', $doctorUser);
$this->assertSame(403, $this->responseCode());
}
/** پیش‌فرضِ پزشکِ عضو `patients.view = true` است. */
public function testPatientListAllowedByDefault(): void
{
[$doctorUser] = $this->makeMemberDoctor();
$this->em->flush();
$this->authJson('GET', '/api/v1/patients', $doctorUser);
$this->assertSame(200, $this->responseCode());
}
/** تگ‌ها با tags.view یا patients.view باز می‌شوند — همان قاعدهٔ منشی. */
public function testTagListAllowedViaPatientsViewEvenWhenTagsViewOff(): void
{
[$doctorUser, $perm] = $this->makeMemberDoctor();
$perm->mergePermissions(['resources' => [
'patients' => ['view' => true],
'tags' => ['view' => false],
]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/tenant-tags', $doctorUser);
$this->assertSame(200, $this->responseCode());
}
public function testTagListDeniedWhenNeitherTagsNorPatientsViewGranted(): void
{
[$doctorUser, $perm] = $this->makeMemberDoctor();
$perm->mergePermissions(['resources' => [
'patients' => ['view' => false],
'tags' => ['view' => false],
]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/tenant-tags', $doctorUser);
$this->assertSame(403, $this->responseCode());
}
public function testTagListAllowedWithTagsViewAlone(): void
{
[$doctorUser, $perm] = $this->makeMemberDoctor();
$perm->mergePermissions(['resources' => [
'patients' => ['view' => false],
'tags' => ['view' => true],
]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/tenant-tags', $doctorUser);
$this->assertSame(200, $this->responseCode());
}
/**
* «پایان همکاری» با «مجوز خاموش» یکی نیست: ردیفِ غیرفعال نباید ۴۰۳ بدهد،
* وگرنه وجودِ پرونده لو می‌رود. دامنهٔ داده خودش آن را می‌بندد.
*/
public function testDeactivatedMemberIsNotAnswered403OnTheList(): void
{
[$doctorUser, $perm] = $this->makeMemberDoctor();
$perm->setActive(false);
$this->em->flush();
$this->authJson('GET', '/api/v1/patients', $doctorUser);
$this->assertNotSame(
403,
$this->responseCode(),
'ردیفِ غیرفعال باید از مسیرِ دامنهٔ داده بسته شود، نه با ۴۰۳ مجوز',
);
}
}