route name => دلیل */ private const ALLOWED_200 = [ // ── عمومی: بدون توکن هم ۲۰۰ می‌دهند، پس مجوز نقشی معنا ندارد ────────── 'app_blog_blog_list' => 'فهرست عمومی مقالات', 'app_blog_blog_tags' => 'تگ‌های عمومی مقالات', 'app_clinic_clinic_list' => 'فهرست عمومی کلینیک‌ها', 'app_doctor_doctor_list' => 'فهرست عمومی پزشکان', 'app_specialty_specialty_list' => 'فهرست عمومی تخصص‌ها', 'app_specialty_specialty_doctorcounts' => 'شمارش عمومی پزشکان هر تخصص', 'app_tag_tag_list' => 'تگ‌های عمومی', 'app_shared_captcha_captcha_challenge' => 'کپچا پیش از لاگین لازم است', 'app_shared_captcha_captcha_config' => 'کپچا پیش از لاگین لازم است', // ── دادهٔ مرجع/ثابت: به هیچ tenant وابسته نیست ──────────────────────── 'app_location_location_provinces' => 'فهرست استان‌ها — دادهٔ مرجع', 'app_location_location_cities' => 'فهرست شهرها — دادهٔ مرجع', 'practice_domain_list' => 'حوزه‌های فعالیت — دادهٔ مرجع', 'app_subscription_subscription_plans' => 'پلن‌های اشتراک — کاتالوگ عمومی', 'app_payment_payment_config' => 'نام درگاه‌ها و کارمزد — بدون مقدار محرمانه', 'app_payment_payment_callback' => 'callback بانک — عمدا PUBLIC؛ بدون order_id معتبر فقط صفحهٔ «یافت نشد» می‌دهد', 'app_representation_sitecontext_resolve' => 'حل دامنه به شهر/نماینده — ورودی رندر سایت', 'resource_strategies' => 'فهرست ثابتِ استراتژی‌های تخصیص منبع', 'app_clinicservice_clinicservice_listservicecategories' => 'دسته‌های ثابت خدمت (سرپایی/بستری)', 'app_inventory_inventory_meta' => 'واحدها و enumهای ثابت انبار', 'api_permission_catalog' => 'شکلِ خودِ رجیستری، نه مقدار مجوز کسی', // ── دادهٔ «خودِ کاربر»: منبعِ رجیستری نیست و مجوزی رویش تعریف نشده ──── 'app_secretary_secretary_me' => 'پروفایل خودِ منشی', 'app_secretary_secretary_earningssummary' => 'درآمد خودِ منشی', 'app_secretary_secretary_earningsreport' => 'گزارش درآمد خودِ منشی', 'app_settlement_settlement_balance' => 'کیف پول خودِ کاربر', 'app_settlement_settlement_transactions' => 'تراکنش‌های کیف پول خودِ کاربر', 'app_settlement_settlement_listmine' => 'تسویه‌های خودِ کاربر', 'app_userprofile_tourprogress_seen' => 'راهنماهای دیده‌شدهٔ خودِ کاربر', 'app_dashboard_dashboard_secretary' => 'محیط و مجوزهای خودِ منشی — ورودی رندر پنل', // رفتار مستند: بدون مجوز فقط قابلیت‌های پلن می‌آید، نه وضعیت/تاریخ اشتراک. // تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly 'app_subscription_subscription_my' => 'نسخهٔ کاهش‌یافتهٔ عمدی', // نوبت‌های خودِ کاربر به‌عنوان بیمار (`a.user = خودِ او`)، نه دادهٔ محیط. // مصرف‌کننده‌اش داشبورد بیمار در nobat724_front است. گِیتِ appointments.view // اینجا یعنی منشی‌ای که جایی بیمار است نوبت‌های شخصی‌اش را نبیند. 'app_appointment_appointment_listbyuser' => 'نوبت‌های خودِ کاربر به‌عنوان بیمار', // کاتالوگ‌های سراسری: `findActive()` بدون هیچ فیلترِ محیط. هم‌ردهٔ // specialties و tags بالا؛ تنها فرقشان این است که پشت firewall نشسته‌اند. // گِیت‌زدنشان یک مجوز را با نبودِ مجوزِ دیگری می‌شکند: منشیِ دارای // patients.create فرمِ ثبت بیمار را با کمبوی خالیِ بیمه می‌گیرد. 'app_doctorservice_doctorservice_list' => 'کاتالوگ سراسری خدمات پزشک — دادهٔ مرجع', 'app_insurance_insurance_list' => 'کاتالوگ سراسری بیمه‌ها — دادهٔ مرجع', ]; /** * بدهیِ شناخته‌شده — روت‌هایی که **باید** گِیت داشته باشند و ندارند. * * **از ۲۰۲۶-۰۸-۰۸ خالی است.** ده ردیفِ اولیه‌اش در آدیت ۲۰۲۶-۰۸-۰۷ ثبت شده * بود؛ هفت‌تای‌شان گِیت گرفتند و سه‌تا پس از خواندنِ کد مثبت کاذب درآمدند و به * `ALLOWED_200` رفتند. * * فهرست عمداً باقی می‌ماند: نقشش baseline است، و خالی‌بودنش یعنی «همین حالا * بدهی‌ای نداریم»، نه «این مکانیزم لازم نیست». ردیفِ تازه فقط با تصمیمِ آگاهانه * اضافه شود. * * @var array route name => منبعِ مجوزی که باید enforce شود */ private const KNOWN_GAPS = []; /** * روت‌های نوشتنی‌ای که منشیِ بی‌مجوز حق دارد از گِیت ردشان کند. * * سه دسته: (۱) اندپوینتِ عمومی یا پیش‌ازلاگین؛ (۲) اکشنی روی دادهٔ خودِ کاربر که * منبعی در `PermissionCatalog` ندارد؛ (۳) رفتارِ عمدیِ مستند. * * @var array 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_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_userprofile_tourprogress_markseen' => 'ثبت دیده‌شدن راهنما برای خودِ کاربر', '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 ────────────────── 'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده می‌شود', ]; /** * روت‌هایی که پیش‌چکِ منشی **پیش از واکشی** دارند، پس حتی با uuidِ ناموجود هم * باید `403` بدهند نه `404`. * * این فهرست پیشرفتِ یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷ را قفل می‌کند: چکِ اصلیِ این روت‌ها * شیء‌محور است و بالا نمی‌رود، ولی سهمِ منشی از آن بالا برده شد. اگر کسی آن خط را * بردارد، پاسخ به `404` برمی‌گردد و همین تست قرمز می‌شود. * * @var list */ private const GATE_BEFORE_LOOKUP = [ 'app_appointment_appointment_updatestatus', 'app_appointment_appointment_confirm', 'app_appointment_appointment_update', 'app_appointment_appointment_servicereschedule', 'app_appointment_appointmentsettings_createschedule', 'app_appointment_appointmentsettings_updateschedule', 'app_appointment_appointmentsettings_deleteschedule', 'app_appointment_appointmentsettings_createoverride', 'app_appointment_appointmentsettings_updateoverride', 'app_appointment_appointmentsettings_deleteoverride', 'app_appointment_appointmentsettings_createholiday', 'app_appointment_appointmentsettings_updateholiday', 'app_appointment_appointmentsettings_deleteholiday', 'app_clinic_clinic_update', 'app_clinic_clinic_detachdoctor', 'app_clinic_clinicdoctorpermission_updatepermissions', 'app_clinicinvitation_clinicinvitation_invitedoctor', 'app_clinicinvitation_clinicinvitation_resendinvitation', 'app_clinicinvitation_clinicinvitation_changeinvitationstatus', 'app_clinicinvitation_clinicinvitation_deleteinvitation', 'resource_block_create', 'resource_block_delete', ]; /** * این تست ~۱۳۰ درخواست پشت‌سرهم می‌زند و هر درخواست کرنل را دوباره بالا * می‌آورد، پس `$this->em` تا انتهای تست به یک نمونهٔ کهنه اشاره می‌کند. بدون * ریست، همان نمونه به تست بعدی ارث می‌رسد و آنجا — نه اینجا — می‌شکند. * (دقیقاً همان دامی که ApiTestCase::setUp برای «EntityManager is closed» بست.) */ protected function tearDown(): void { static::getContainer()->get('doctrine')->resetManager(); parent::tearDown(); } /** منشی‌ای که هیچ مجوزی ندارد — همهٔ منابع رجیستری خاموش. */ private function makePowerlessSecretary(): \App\Auth\Entity\User { $owner = $this->createUser(['ROLE_CLINIC']); $clinic = new Clinic($owner); $this->em->persist($clinic); $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); $this->em->persist($doctor); $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); $rel = new DoctorSecretary($doctor, $secretary, $clinic); $off = []; foreach (PermissionCatalog::RESOURCES as $resource => $spec) { foreach (array_keys($spec['actions']) as $action) { $off[$resource][$action] = false; } } $rel->mergePermissions(['resources' => $off]); $this->em->persist($rel); $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); $this->em->flush(); return $secretary; } public function testNoApiRouteLeaksToASecretaryWithoutAnyPermission(): void { $secretary = $this->makePowerlessSecretary(); $router = self::getContainer()->get('router'); $leaks = []; foreach ($router->getRouteCollection() as $name => $route) { $path = $route->getPath(); if (!str_starts_with($path, '/api/') || str_contains($path, '{')) { continue; } $methods = $route->getMethods(); if ($methods !== [] && !in_array('GET', $methods, true)) { continue; } if (isset(self::ALLOWED_200[$name]) || isset(self::KNOWN_GAPS[$name])) { continue; } $this->authJson('GET', $path, $secretary); if ($this->responseCode() < 400) { $leaks[] = sprintf('%s %s → %d', $name, $path, $this->responseCode()); } } $this->assertSame([], $leaks, sprintf( "این روت‌ها به منشیِ بدونِ هیچ مجوزی پاسخ موفق دادند.\n" . "اگر گِیت مجوز ندارند، اضافه‌اش کن. اگر عمدی‌اند، با دلیل به ALLOWED_200 برو.\n%s", implode("\n", $leaks), )); } /** * مقدارِ جایگزینِ یک 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), )); } /** * قفلِ پیشرفت: هر روتِ `GATE_BEFORE_LOOKUP` با uuidِ ناموجود باید `403` بدهد. * * `404` یعنی گِیت دوباره پایین‌تر از واکشی رفته و enumeration oracle برگشته. */ public function testHoistedGatesAnswer403BeforeTheLookup(): void { $secretary = $this->makePowerlessSecretary(); $router = self::getContainer()->get('router'); $regressed = []; foreach (self::GATE_BEFORE_LOOKUP as $name) { $route = $router->getRouteCollection()->get($name); $this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — فهرست را به‌روز کن"); $method = array_values(array_intersect( $route->getMethods(), ['POST', 'PUT', 'PATCH', 'DELETE'], ))[0]; $this->authJson($method, self::probePath($route), $secretary); if ($this->responseCode() !== 403) { $regressed[] = sprintf('%s → %d', $name, $this->responseCode()); } } $this->assertSame([], $regressed, sprintf( "این روت‌ها دیگر پیش از واکشی گِیت نمی‌خورند:\n%s", implode("\n", $regressed), )); } /** * بدهی نباید بی‌صدا بماند: به‌محض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این * تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه * می‌ماند و کسی نمی‌فهمد بدهی تسویه شده. */ public function testKnownGapsAreStillGapsOrGetRemovedFromTheList(): void { $secretary = $this->makePowerlessSecretary(); $router = self::getContainer()->get('router'); $closed = []; foreach (self::KNOWN_GAPS as $name => $resource) { $route = $router->getRouteCollection()->get($name); $this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — ردیفش را از KNOWN_GAPS بردار"); $this->authJson('GET', $route->getPath(), $secretary); if ($this->responseCode() >= 400) { $closed[] = "{$name} ({$resource})"; } } $this->assertSame([], $closed, sprintf( "این گَپ‌ها بسته شده‌اند. ردیفشان را از KNOWN_GAPS بردار:\n%s", implode("\n", $closed), )); } }