feat: Implement permission gate for appointment and billing controllers

- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController.
- Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments.
- Updated existing methods in both controllers to utilize the new permission checks.
- Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management.
- Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
This commit is contained in:
hamed
2026-08-08 10:27:13 +03:30
parent c452150a83
commit 934405c42d
14 changed files with 830 additions and 67 deletions
+188 -24
View File
@@ -70,39 +70,102 @@ class ApiLeastPrivilegeTest extends ApiTestCase
// رفتار مستند: بدون مجوز فقط قابلیت‌های پلن می‌آید، نه وضعیت/تاریخ اشتراک.
// تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly
'app_subscription_subscription_my' => 'نسخهٔ کاهش‌یافتهٔ عمدی',
// فهرست پزشکانِ تخصیص‌یافته به همین منشی — تستش
// SecretaryResourceEnforcementTest::testDoctorListReturnsOnlyAssignedDoctors
'app_appointment_myappointments_myclinicdoctors' => 'فقط پزشکانِ تخصیص‌یافته به خودِ منشی',
// نوبت‌های خودِ کاربر به‌عنوان بیمار (`a.user = خودِ او`)، نه دادهٔ محیط.
// مصرف‌کننده‌اش داشبورد بیمار در nobat724_front است. گِیتِ appointments.view
// اینجا یعنی منشی‌ای که جایی بیمار است نوبت‌های شخصی‌اش را نبیند.
'app_appointment_appointment_listbyuser' => 'نوبت‌های خودِ کاربر به‌عنوان بیمار',
// کاتالوگ‌های سراسری: `findActive()` بدون هیچ فیلترِ محیط. هم‌ردهٔ
// specialties و tags بالا؛ تنها فرقشان این است که پشت firewall نشسته‌اند.
// گِیت‌زدنشان یک مجوز را با نبودِ مجوزِ دیگری می‌شکند: منشیِ دارای
// patients.create فرمِ ثبت بیمار را با کمبوی خالیِ بیمه می‌گیرد.
'app_doctorservice_doctorservice_list' => 'کاتالوگ سراسری خدمات پزشک — دادهٔ مرجع',
'app_insurance_insurance_list' => 'کاتالوگ سراسری بیمه‌ها — دادهٔ مرجع',
];
/**
* بدهیِ شناخته‌شده — روت‌هایی که **باید** گِیت داشته باشند و ندارند.
*
* این‌ها در آدیت ۲۰۲۶-۰۸-۰۷ کشف شدند و عمداً همان جلسه رفع **نشدند**: هر سه
* کنترلرشان (`BillingController`، `MyAppointmentsController`،
* `DoctorServiceController`) هیچ checker مجوزی تزریق‌شده ندارند، و بستنشان
* بدون دانستن نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد.
* **از ۲۰۲۶-۰۸-۰۸ خالی است.** ده ردیفِ اولیه‌اش در آدیت ۲۰۲۶-۰۸-۰۷ ثبت شده
* بود؛ هفت‌تای‌شان گِیت گرفتند و سه‌تا پس از خواندنِ کد مثبت کاذب درآمدند و به
* `ALLOWED_200` رفتند.
*
* در DB تست، tenant خالی است پس پاسخشان خالی می‌آید؛ در tenant واقعی دادهٔ
* واقعی می‌دهند. نبودِ نشت در تست، دلیلِ امن‌بودن نیست.
*
* نقشِ این فهرست مثل baseline است: تست اجازه می‌دهد این‌ها ۲۰۰ بدهند، ولی
* **بزرگ‌ترشدنش** را نمی‌پذیرد. هر روتِ تازه‌ای که بدون گِیت اضافه شود، تست را
* قرمز می‌کند. حذف هر ردیف از اینجا یعنی آن گَپ بسته شد.
* فهرست عمداً باقی می‌ماند: نقشش baseline است، و خالی‌بودنش یعنی «همین حالا
* بدهی‌ای نداریم»، نه «این مکانیزم لازم نیست». ردیفِ تازه فقط با تصمیمِ آگاهانه
* اضافه شود.
*
* @var array<string, string> route name => منبعِ مجوزی که باید enforce شود
*/
private const KNOWN_GAPS = [
'app_appointment_appointment_listbyuser' => 'appointments.view',
'app_appointment_myappointments_myappointments' => 'appointments.view',
'app_appointment_myappointments_todaystats' => 'appointments.view',
'app_billing_billing_listpayments' => 'payments.view',
'app_billing_billing_paymentssummary' => 'payments.view',
'app_billing_billing_listclaims' => 'payments.view',
'app_billing_billing_claimsbypatient' => 'payments.view',
'app_billing_billing_insurancedebt' => 'payments.view',
'app_doctorservice_doctorservice_list' => 'services.view',
'app_insurance_insurance_list' => 'insurances.view',
private const KNOWN_GAPS = [];
/**
* روت‌های نوشتنی‌ای که منشیِ بی‌مجوز حق دارد از گِیت ردشان کند.
*
* سه دسته: (۱) اندپوینتِ عمومی یا پیش‌ازلاگین؛ (۲) اکشنی روی دادهٔ خودِ کاربر که
* منبعی در `PermissionCatalog` ندارد؛ (۳) رفتارِ عمدیِ مستند.
*
* @var array<string, string> route name => دلیل
*/
private const ALLOWED_WRITE = [
// ── پیش‌ازلاگین: اصلاً نقشی وجود ندارد که مجوز داشته باشد ─────────────
'app_auth_auth_login' => 'لاگین',
'app_auth_auth_sendcode' => 'ارسال کد تأیید',
'app_auth_auth_verifycode' => 'بررسی کد تأیید',
'app_auth_auth_register' => 'ثبت‌نام',
'app_auth_auth_otplogin' => 'ورود با رمز یک‌بارمصرف',
'app_auth_auth_resetpassword' => 'بازیابی رمز',
'app_auth_preregistration_submit' => 'پیش‌ثبت‌نام عمومی',
'app_payment_payment_callback' => 'کال‌بک درگاه — بدون توکن فراخوانی می‌شود',
'app_payment_payment_subscriptioncallback' => 'کال‌بک درگاه اشتراک',
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',
'app_auth_auth_switchcontext' => 'سوییچ محیطِ خودِ کاربر',
'app_auth_notificationmobile_requestotp' => 'موبایل اعلانِ خودِ کاربر',
'app_auth_notificationmobile_verify' => 'موبایل اعلانِ خودِ کاربر',
'app_auth_notificationmobile_remove' => 'موبایل اعلانِ خودِ کاربر',
'app_userprofile_userprofile_create' => 'پروفایل خودِ کاربر',
'app_userprofile_userprofile_update' => 'پروفایل خودِ کاربر',
'app_userprofile_userprofile_uploadavatar' => 'آواتار خودِ کاربر',
'app_settlement_settlement_request' => 'تسویهٔ کیف‌پول خودِ کاربر',
'app_secretary_secretary_addiban' => 'شبای خودِ منشی',
'app_secretary_secretary_removeiban' => 'شبای خودِ منشی',
'app_doctor_doctor_create' => 'ساخت پروفایل پزشکِ خودِ کاربر — ۴۰۹ اگر قبلاً دارد',
'app_clinic_clinic_create' => 'ساخت کلینیکِ خودِ کاربر',
'app_doctor_doctorclaim_claim' => 'ادعای مالکیتِ پروفایل پزشک توسط خودِ فرد',
'app_clinicinvitation_clinicinvitation_acceptinvitation' => 'پذیرشِ دعوتِ خودِ فرد',
'app_clinicinvitation_clinicinvitation_rejectinvitation' => 'ردِ دعوتِ خودِ فرد',
// ── اکشن بیمار روی محتوای عمومی ──────────────────────────────────────
'app_rating_rating_rate' => 'امتیازدهی بیمار',
'app_rating_rating_createcomment' => 'ثبت نظر بیمار',
'app_rating_rating_deletecomment' => 'حذف نظرِ خودِ فرد',
'app_rating_rating_togglelike' => 'لایک بیمار',
// ── جریان رزرو عمومی: مصرف‌کننده‌اش nobat724_front است، نه پنل ────────
'app_appointment_appointment_book' => 'رزرو نوبت توسط خودِ بیمار',
'appointment_availability' => 'وقت‌های آزاد — ورودی رزرو عمومی',
'appointment_hold_create' => 'نگه‌داشتن موقت اسلات در جریان رزرو',
'appointment_hold_release' => 'آزادکردن اسلاتِ نگه‌داشته‌شده',
'appointment_confirm' => 'تأیید نهایی رزرو عمومی',
'appointment_rebook' => 'رزرو مجدد توسط خودِ بیمار',
'pricing_quote' => 'محاسبهٔ قیمت پیش از رزرو',
'app_payment_payment_initiateappointment' => 'پرداختِ نوبتِ خودِ بیمار',
// ── رفتار عمدیِ مستند ────────────────────────────────────────────────
// حذف سرویس/بخش اصلاً ممکن نیست و کنترلر بی‌قیدوشرط ۴۰۹ می‌دهد، پس هرگز
// به لایهٔ مجوز نمی‌رسد. دلیلش در خودِ ClinicServiceController نوشته شده:
// نوبت و فاکتور و سوابق پرداخت به سرویس ارجاع دارند.
'app_clinicservice_clinicservice_deleteitem' => 'حذف ممنوع — همیشه ۴۰۹',
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
// ── گِیت دارند ولی هدفشان از بدنه می‌آید، نه از path ──────────────────
// با بدنهٔ خالی روی uuidِ ناموجودِ داخلِ بدنه ۴۰۴ می‌دهند. مثل روت‌های
// پارامتردار، ولی چون path parameter ندارند سطح اولِ قاعده شاملشان می‌شد.
'app_appointment_appointmentsettings_createschedule' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createoverride' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createholiday' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده می‌شود',
];
/**
@@ -180,6 +243,107 @@ class ApiLeastPrivilegeTest extends ApiTestCase
));
}
/**
* مقدارِ جایگزینِ یک path parameter — طوری که روت **match شود** ولی رکوردی
* پیدا نشود.
*
* اگر مقدار با requirement نخواند، Symfony قبل از رسیدن به کنترلر ۴۰۴ می‌دهد و
* تست بی‌دلیل قرمز می‌شود. پس alternationهای ساده (`submit|approve|…`) اولین
* شاخه‌شان برداشته می‌شود، عددی‌ها `999999999` می‌گیرند و بقیه uuidِ صفر.
*/
private static function sampleValueFor(string $name, ?string $requirement): string
{
$nilUuid = '00000000-0000-0000-0000-000000000000';
if (preg_match('/^[a-z_]+(\|[a-z_]+)+$/i', (string) $requirement)) {
return explode('|', $requirement)[0];
}
if (preg_match('/^(\\\\d\+?|\[0-9\]\+)$/', (string) $requirement)) {
return '999999999';
}
// بدون requirement، نامِ پارامتر تنها سرنخِ نوعِ آرگومانِ کنترلر است. uuid
// فرستادن به `int $id` قبل از رسیدن به کنترلر ۵۰۰ می‌دهد، نه ۴۰۴.
if ($name === 'id' || str_ends_with($name, 'Id') || str_ends_with($name, '_id')) {
return '999999999';
}
return $nilUuid;
}
/**
* روتِ نوشتنی با پارامترهای جایگزین‌شده؛ null اگر پارامتری داشت که نمی‌شد
* مقدارِ مطمئنی برایش ساخت.
*/
private static function probePath(\Symfony\Component\Routing\Route $route): string
{
$path = $route->getPath();
return preg_replace_callback(
'/\{!?(\w+)\}/',
static fn(array $m) => self::sampleValueFor($m[1], $route->getRequirement($m[1])),
$path,
);
}
/**
* قرینهٔ تستِ بالا برای `POST`/`PUT`/`PATCH`/`DELETE`.
*
* بدنهٔ معتبر لازم نیست و عمداً فرستاده نمی‌شود. استدلالش همان یافتهٔ ۱ آدیت
* ۲۰۲۶-۰۸-۰۷ است، وارونه: آنجا `422` شاهدِ **عبور** از لایهٔ authorization بود،
* چون کد خطا از داخلِ writer می‌آمد. پس `2xx` یا `422` برای منشیِ بی‌مجوز یعنی
* گِیت نخورده و فقط اعتبارسنجی جلویش را گرفته.
*
* قاعده دو سطحی است، چون `404` دو معنای متفاوت دارد:
*
* - **روتِ بدون path parameter** باید دقیقاً `403` بدهد. چیزی برای واکشی وجود
* ندارد، پس هیچ توجیهی برای پاسخِ دیگر نیست.
* - **روتِ پارامتردار** `403` یا `404` هر دو قبول است. uuidِ ناموجود می‌فرستیم و
* بیشترِ کنترلرهای این پروژه اول رکورد را واکشی می‌کنند و بعد مجوز را
* می‌سنجند، پس `404` می‌دهند بی‌آنکه بی‌گِیت باشند.
*
* محدودیتِ صادقانهٔ سطح دوم: با `404` نمی‌شود «گِیت بعد از واکشی» را از «اصلاً
* گِیت ندارد» تفکیک کرد. تفکیکش رکوردِ واقعی در tenantِ همین منشی می‌خواهد،
* یعنی fixture به ازای هر روت. فهرستِ ۳۴ روتی که گِیتشان بعد از واکشی است در
* `docs/security/AUDIT-2026-08-07.md` (یافتهٔ ۱۰) ثبت شده تا بدهی گم نشود.
*/
public function testNoApiWriteRouteSkipsItsPermissionGate(): void
{
$secretary = $this->makePowerlessSecretary();
$router = self::getContainer()->get('router');
$ungated = [];
foreach ($router->getRouteCollection() as $name => $route) {
if (!str_starts_with($route->getPath(), '/api/')) {
continue;
}
$writeMethods = array_values(array_intersect(
$route->getMethods(),
['POST', 'PUT', 'PATCH', 'DELETE'],
));
if ($writeMethods === [] || isset(self::ALLOWED_WRITE[$name])) {
continue;
}
$hasPathParam = str_contains($route->getPath(), '{');
$probe = self::probePath($route);
$this->authJson($writeMethods[0], $probe, $secretary);
$code = $this->responseCode();
$accepted = $hasPathParam ? in_array($code, [403, 404], true) : $code === 403;
if (!$accepted) {
$ungated[] = sprintf('%s %s %s → %d', $name, $writeMethods[0], $probe, $code);
}
}
$this->assertSame([], $ungated, sprintf(
"این روت‌های نوشتنی به منشیِ بدونِ هیچ مجوزی گِیت مجوز را رد کردند.\n"
. "روتِ بدون parameter باید ۴۰۳ بدهد؛ روتِ پارامتردار ۴۰۳ یا ۴۰۴.\n"
. "اگر عمدی‌اند، با دلیل به ALLOWED_WRITE برو.\n%s",
implode("\n", $ungated),
));
}
/**
* بدهی نباید بی‌صدا بماند: به‌محض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
* تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه