# آدیت امنیتی دلتایی — سطح حملهٔ ساخته‌شده بعد از ۲۰۲۶-۰۷-۱۹ ## زمینه آخرین آدیت امنیتی `clinicpro` در `docs/security/AUDIT-2026-07-19.md` ثبت شده. از آن تاریخ تا امروز (۲۰۲۶-۰۸-۰۷) روی این repo **۳۵۴ کامیت** زده شده و تمرکز غالب آن‌ها دقیقاً روی سطحی است که آدیت قبلی ندیده بود: - رجیستری واحد مجوزها (`PermissionCatalog`) و اندپوینت `GET /api/v1/permission-catalog` - بازنویسی گیت‌های منشی و پزشکِ مهمانِ کلینیک - دامنهٔ کاملاً جدید `Treatment` — پروندهٔ درمان، پروتکل، اجرای جلسه توسط پرسنل - نقش/پنل `staff` با گاردِ متمرکز `StaffRouteGuardSubscriber` - `PatientRecordScopeResolver` برای محدودکردن دید پرونده‌ها آدیت قبلی خودش در بخش «محدودیت پوشش» نوشته بود که ماتریس authz ناقص مانده، چون فقط کاربر `admin` و `doctor` در DB بود. آن محدودیت حالا برطرف‌شدنی است. این پرامپت **آدیت کامل از صفر نیست**. عمداً دلتایی است: یافته‌های قبلی فقط regression می‌شوند، و بودجهٔ اصلی صرف کدی می‌شود که هرگز آدیت نشده. ## مشکل / هدف **مشکل:** بزرگ‌ترین سطح حملهٔ فعلی پروژه — authorization چندنقشی و دامنهٔ `Treatment` — هیچ‌وقت تست امنیتی نشده. یک رجیستری مجوز که در UI رندر می‌شود ولی در backend enforce نشود، یعنی هر منشی/پزشکِ مهمان می‌تواند با یک درخواست مستقیم به API از مجوزش فرار کند. **هدف:** پیدا کردن و رفع آسیب‌پذیری‌های این سطح، به‌علاوهٔ تأیید اینکه یافته‌های آدیت قبلی برنگشته‌اند. **سیاست رفع (تصمیم کاربر):** - 🟥 Critical و 🟧 High: **همان جلسه خودکار رفع شود** + تست رگرسیون نوشته شود. - 🟨 Medium و پایین‌تر: **اول گزارش، بعد تأیید کاربر، بعد رفع.** بدون تأیید دست نزن. **خارج از محدوده (تصمیم کاربر):** مهاجرت CKEditor از `@ckeditor/ckeditor5-build-classic` به پکیج umbrella `ckeditor5` v45+. فقط به‌عنوان «risk پذیرفته‌شده» در گزارش ثبت شود، پیاده نشود. ## معیار پذیرش - ✅ **موفق:** گزارش `docs/security/AUDIT-2026-08-07.md` تولید شده و برای **هر** یافته یک بازتولید اجراشده دارد (دستور + خروجی واقعی). هر ردیف `PermissionCatalog::RESOURCES` یک تست دارد که ثابت می‌کند خاموش‌بودن آن مجوز، درخواستِ متناظر API را با **403** رد می‌کند — نه اینکه فقط دکمه را در UI پنهان کند. - ❌ **خطا:** درخواست به هر اندپوینت `/api/v1/treatment-*` با توکن کاربری از tenant دیگر → **403 یا 404** با envelope خطای `BaseController` و کد از `ErrorCodes`؛ هرگز 200 با دادهٔ tenant دیگر و هرگز 500 با stack trace. - ⚠️ **مرزی:** کاربر چندنقشی (مثلاً هم `ROLE_STAFF` هم `ROLE_SECRETARY`) پس از `POST /api/v1/auth/switch-context` دقیقاً دسترسی همان context فعال را دارد، نه اجتماع دو نقش. همچنین کاربری که **فقط** `ROLE_STAFF` است، روی هر مسیر خارج از allowlist ــ از جمله مسیرهایی که بعد از نوشتن گارد اضافه شده‌اند ــ 403 می‌گیرد. - ✅ تست‌ها سبزند: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`. - ✅ اگر رفتار یا قرارداد هر endpoint عوض شد، فایل متناظر در `docs/api/` همان جلسه به‌روز شد. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `../.claude/skills/symfony-security-audit/driver.mjs` | درایور آدیت؛ **در روت workspace است، نه در `clinicpro/`** | | `.claude/skills/qa-clinicpro/driver.mjs` | ساخت/بررسی اکانت نقش‌ها (`roles`) | | `docs/security/AUDIT-2026-07-19.md` | یافته‌های قبلی برای regression | | `docs/security-audit.md` | آدیت ۲۰۲۶-۰۶-۰۹ (قدیمی‌تر) | | `src/Shared/Security/PermissionCatalog.php` | منبع واحد منابع/اکشن‌های مجوزدهی | | `src/Shared/Controller/PermissionCatalogController.php` | `GET /api/v1/permission-catalog` | | `src/Secretary/Security/SecretaryPermissionChecker.php` | enforce مجوز منشی | | `src/Secretary/Security/SecretaryAccessChecker.php` | حل tenant منشی | | `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | enforce مجوز پزشکِ مهمان | | `src/Clinic/Security/ClinicDoctorAccessChecker.php` | حل tenant پزشکِ مهمان | | `src/Staff/Security/StaffRouteGuardSubscriber.php` | allowlist مسیرهای پرسنل | | `src/Staff/Security/StaffPermissions.php` | مجوزهای پرسنل | | `src/Patient/Security/PatientRecordScopeResolver.php` | محدودسازی دید پروندهٔ بیمار | | `src/Treatment/Controller/TreatmentCaseController.php` | پروندهٔ درمان | | `src/Treatment/Controller/TreatmentProtocolController.php` | پروتکل سرویس | | `src/Treatment/Controller/SessionExecutionController.php` | اجرای جلسه توسط پرسنل | | `src/Resource/Controller/ResourcePermissionTrait.php` | گیت منابع/دستگاه‌ها | | `src/Shared/Tenant/TenantFilter.php` | فیلتر Doctrine جداسازی محیط | | `src/Shared/Tenant/TenantOwnershipChecker.php` | بررسی مالکیت محیط | | `src/Shared/Tenant/GlobalTables.php` | entityهای عمداً غیر-tenant | | `config/packages/security.yaml` | firewall و مسیرهای public | | `assets/admin/` | پنل ادمین؛ مجوزهای UI | | `docs/architecture/tenancy.md` | «چه تضمین می‌دهد و چه نمی‌دهد» | ## وضعیت فعلی ### ۱. عدم تقارن گیت در دامنهٔ Treatment — قوی‌ترین lead `TreatmentCaseController` کلاسش فقط با احراز هویت گارد شده و مجوز را متد‌به‌متد می‌سنجد: ```php // src/Treatment/Controller/TreatmentCaseController.php:31 #[IsGranted('IS_AUTHENTICATED_FULLY')] class TreatmentCaseController extends BaseController { #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])] public function show(#[CurrentUser] User $user, string $uuid): JsonResponse { $this->denyUnlessGranted($user, 'view'); $case = $this->requireCase($user, $uuid); // ... } #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])] public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $this->denyUnlessGranted($user, 'update'); // ... } } ``` ولی `TreatmentProtocolController` **هیچ** `denyUnlessGranted` ندارد — نه روی خواندن، نه روی `PUT`، نه روی `DELETE`: ```php // src/Treatment/Controller/TreatmentProtocolController.php:24 #[IsGranted('IS_AUTHENTICATED_FULLY')] class TreatmentProtocolController extends BaseController { #[Route('/api/v1/service-item/{uuid}/treatment-protocol', methods: ['PUT'])] public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); if (!is_array($data)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } $protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data); return $this->success($protocol->toArray()); } } ``` یعنی تنها دفاع، `requireItem($user, $uuid)` است. اگر آن فقط مالکیت tenant را بسنجد و نه مجوز اکشن، هر کاربرِ داخل همان کلینیک — از جمله منشی‌ای که مجوز `services` ندارد — می‌تواند پروتکل درمان یک سرویس را بازنویسی یا حذف کند. **باید بازتولید شود، نه فرض.** ### ۲. گاردِ پرسنل مبتنی بر allowlist مسیر ```php // src/Staff/Security/StaffRouteGuardSubscriber.php private const ALLOWED_PREFIXES = [ '/api/v1/dashboard/staff', '/api/v1/auth/switch-context', '/api/v1/user/change-password', ]; private const OVERRIDING_ROLES = [ 'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION', ]; public function onKernelRequest(RequestEvent $event): void { // ... $path = $event->getRequest()->getPathInfo(); if (!str_starts_with($path, '/api/')) { return; } // ... foreach (self::ALLOWED_PREFIXES as $prefix) { if (str_starts_with($path, $prefix)) { return; } } throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403); } ``` دو ریسک ساختاری که باید تست شوند: - گارد روی `getPathInfo()` و `str_starts_with` کار می‌کند. مسیر نرمال‌نشده (`/api/v1/../v1/patients`، دابل‌اسلش، درصد-انکود) ممکن است هم از شرط `/api/` رد شود هم از allowlist بیفتد یا برعکس، از روتر عبور کند ولی از گارد نه. - `OVERRIDING_ROLES` گارد را برای کاربر چندنقشی **کاملاً** کنار می‌گذارد. اگر کاربری هم پرسنل و هم منشی باشد و context فعالش پرسنل باشد، این گارد اجرا نمی‌شود. ### ۳. یافته‌های باز از ۲۰۲۶-۰۷-۱۹ | # | یافته | شدت | وضعیت ثبت‌شده | |---|-------|-----|----------------| | 2 | `lodash` code injection via `_.template` | 🟧 High | نیمه‌رفع | | 2b | ۶۱ moderate در CKEditor build-classic (deprecated) | 🟨 Medium | باز — خارج از محدودهٔ این پرامپت | | 5 | پسورد sandbox درگاه ملت هاردکد در `MellatGateway.php:23` | ⬜ Info | باز | ### ۴. وضعیت اکانت‌های تست `TEST_USERS.md` بی‌اعتبار است و `create_test_users.php` که به آن ارجاع می‌دهد در repo نیست. **وضعیت واقعی DB لوکال، سنجیده‌شده در ۲۰۲۶-۰۸-۰۷** — نه از حافظه، خروجی `driver.mjs roles` و `ddev mysql`: ``` admin 09120671756 ROLE_USER,ROLE_ADMIN ✓ login موفق representation 09124000001 ROLE_USER,ROLE_REPRESENTATION ✓ login موفق doctor 09390039833 ROLE_USER,ROLE_CLINIC ⚠ نقشش عوض شده — دیگر DOCTOR نیست clinic 09127000000 — ✗ کاربر در DB نیست secretary 09123456778 — ✗ کاربر در DB نیست ``` DB از زمان آدیت قبلی دوباره seed شده. کاربران قابل استفاده که واقعاً وجود دارند: ``` id=47 09128726723 ROLE_USER,ROLE_STAFF ← کاربرِ «فقط پرسنل»، موجود است id=11 0912000201 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC ← کاربر چندنقشی، موجود است id=24 0912000301 ROLE_USER,ROLE_CLINIC id=5 0912000109 ROLE_USER,ROLE_SECRETARY id=15 0912000209 ROLE_USER,ROLE_SECRETARY id=28 0912000309 ROLE_USER,ROLE_SECRETARY ``` پس کاربر پرسنل و کاربر چندنقشی **ساخته نمی‌شوند** — فقط پسوردشان باید معلوم/ست شود. پسورد سری `0912000xxx` نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش. --- ## وظایف ### ۱. آماده‌سازی ماتریس نقش‌ها اول وضعیت واقعی اکانت‌ها را بسنج، بعد کمبود را بساز: ```bash ddev start node .claude/skills/qa-clinicpro/driver.mjs roles ``` کاربر «فقط پرسنل» (`09128726723`) و کاربر چندنقشی (`0912000201`) از قبل در DB هستند — ساخته نمی‌شوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن: ```bash ddev exec php bin/console security:hash-password 'QaTest@1234' ddev mysql -e "UPDATE users SET password_hash='' WHERE mobile_number='09128726723';" ``` این تغییر فقط روی DB لوکال است و در گزارش ثبت می‌شود. علاوه بر آن، برای تست IDOR لازم است: - **tenant دوم** (کلینیک B) با حداقل یک پروندهٔ درمان، یک سرویس با پروتکل، و یک بیمار. اول بگرد ببین از قبل هست؛ فقط اگر نبود بساز. خروجی این وظیفه یک جدول creds در ابتدای گزارش است. **نحوه تست:** برای هر کاربر ساخته‌شده، `POST /api/v1/auth/login` باید 200 و توکن بدهد؛ در گزارش، `roles` هر توکن decode‌شده ثبت شود. --- ### ۲. اجرای درایور به‌عنوان baseline **با درایور شروع کن، نه با grep دستی.** اسکیل `symfony-security-audit` را طبق `.claude/skills/symfony-security-audit/SKILL.md` اجرا کن (white-box: deps/sinks/guards/secrets/config — black-box: authz/headers/cors/injection). خروجی خام درایور **یافته نیست، lead است**. هر lead باید با خواندن کد تأیید یا رد شود، و leadهای ردشده با دلیل در بخش «رد شد» گزارش بیایند — دقیقاً همان قانونی که آدیت ۲۰۲۶-۰۷-۱۹ رعایت کرده بود. **نحوه تست:** خروجی درایور با تعداد lead به تفکیک شدت در گزارش ثبت شود. --- ### ۳. regression یافته‌های آدیت قبلی برای هر یافتهٔ `docs/security/AUDIT-2026-07-19.md` که «رفع شد» علامت خورده، بازتولیدِ همان گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است: ```bash # CSP روی SPA ادمین curl -sI https://clinic-pro.ddev.site/admin | grep -i content-security-policy # APP_SECRET در فایل env تحت git git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET=' # فلگ‌های session cookie ddev exec php bin/console debug:config framework session # وابستگی‌های npm npm audit --json | python3 -c "import json,sys; m=json.load(sys.stdin)['metadata']['vulnerabilities']; print(m)" ``` هر کدام برگشته بود، **رگرسیون** است و شدتش یک درجه بالاتر ثبت می‌شود — چون قبلاً رفع شده بوده و دوباره شکسته. **نحوه تست:** جدول «یافتهٔ قبلی / وضعیت امروز / خروجی بازتولید» در گزارش. --- ### ۴. ماتریس enforcement مجوزها — هستهٔ این آدیت `PermissionCatalog::RESOURCES` منبع واحد است. برای **هر** جفت `(resource, action)` در آن، این سه سؤال جواب داده شود: 1. کدام اندپوینت(ها) این مجوز را نمایندگی می‌کنند؟ 2. آیا backend واقعاً enforce می‌کند، یا فقط UI دکمه را پنهان می‌کند؟ 3. آیا منشی و پزشکِ مهمان **هر دو** enforce می‌شوند، یا فقط یکی؟ روش: با اکانت منشی، مجوز X را در DB خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن. ```bash TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"mobile":"09123456778","password":"QaTest@1234"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["token"])') # مجوز treatment.update خاموش است → باید 403 بدهد curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \ https://clinic-pro.ddev.site/api/v1/treatment-case/ \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"status":"closed"}' ``` هر جفتی که **200** بدهد یک یافتهٔ 🟧 High است (bypass مجوز)، مگر اینکه با خواندن کد ثابت شود عمداً باز است — که آن‌وقت باید در `docs/api/` مستند باشد. خروجی: جدول کامل `resource × action × role × HTTP status` در گزارش. **نحوه تست:** خودِ جدول تست است. هر ردیف باید دستور و کد وضعیت واقعی داشته باشد. --- ### ۵. آدیت دامنهٔ Treatment — IDOR و عدم تقارن گیت سه کنترلر `src/Treatment/Controller/*` کامل خوانده شوند. مشخصاً: الف) **`TreatmentProtocolController` بدون `denyUnlessGranted`** (بخش «وضعیت فعلی ۱»). بررسی کن `requireItem()` دقیقاً چه می‌سنجد — فقط tenant، یا مجوز اکشن هم؟ ```bash # با توکن منشیِ بدون مجوز services curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \ https://clinic-pro.ddev.site/api/v1/service-item//treatment-protocol \ -H "Authorization: Bearer $SECRETARY_TOKEN" # انتظار: 403 — اگر 200/204 داد، یافتهٔ High ``` ب) **IDOR بین tenant.** با توکن کلینیک A، uuid منابع کلینیک B را صدا بزن. برای هر مسیر: ``` GET /api/v1/treatment-case/{uuid} PATCH /api/v1/treatment-case/{uuid} GET /api/v1/treatment-case/{uuid}/plan GET /api/v1/treatment-session/{uuid} GET /api/v1/treatment-session/{uuid}/slot-suggestions GET /api/v1/service-item/{uuid}/treatment-protocol PUT /api/v1/service-item/{uuid}/treatment-protocol DELETE /api/v1/service-item/{uuid}/treatment-protocol POST /api/v1/dashboard/staff/treatment-session/{uuid}/start POST /api/v1/dashboard/staff/treatment-session/{uuid}/finish POST /api/v1/dashboard/staff/session-area/{uuid}/start POST /api/v1/dashboard/staff/session-area/{uuid}/complete POST /api/v1/dashboard/staff/session-area/{uuid}/skip POST /api/v1/dashboard/staff/session-area/{uuid}/reopen ``` انتظار: 403 یا 404. هر 200 یا 500 یافته است. ج) **پرسنلِ کلینیک A روی جلسهٔ کلینیک B.** `SessionExecutionController` با `ROLE_STAFF` گارد شده، ولی نقش ≠ مالکیت. تست کن که `start`/`finish` روی جلسهٔ tenant دیگر رد می‌شود. د) **دستکاری وضعیت.** `finish` روی جلسه‌ای که `start` نشده، `reopen` روی ناحیهٔ جلسهٔ بسته، `complete` دو بار پشت سر هم. اگر منجر به وضعیت ناسازگار یا 500 شود، ثبت شود. **نحوه تست:** هر خط بالا با `curl` و کد وضعیت واقعی. برای هر یافتهٔ تأییدشده، یک تست PHPUnit در `tests/` که آن مسیر را با کاربر بیگانه می‌زند و 403 انتظار دارد. --- ### ۶. آدیت فرار از tenant `docs/architecture/tenancy.md` صریح می‌گوید `TenantFilter` یک تور ایمنی است، نه authorization، و روی سه چیز اعمال **نمی‌شود**: SQL خام، `getReference()`، و فرزندان aggregate. ```bash # SQL خام grep -rnE "createNativeQuery|->getConnection\(\)|executeQuery\(|executeStatement\(" src/ --include=*.php # getReference grep -rn "getReference(" src/ --include=*.php # entityهای بدون طبقه‌بندی tenant ddev exec php bin/phpunit --filter TenantSchemaCoverageTest ``` هر hit را بخوان و جواب بده: ورودی کاربر مستقیم وارد کوئری می‌شود؟ tenant دستی چک شده؟ **نحوه تست:** `TenantSchemaCoverageTest` سبز باشد. برای هر SQL خامی که ورودی کاربر می‌گیرد، یک تست تزریق با payload واقعی (`' OR '1'='1`, `1; DROP`) و تأیید اینکه پارامتری‌سازی شده. --- ### ۷. آدیت گاردِ پرسنل الف) **نرمال‌سازی مسیر** (بخش «وضعیت فعلی ۲»): ```bash for P in \ '/api/v1/patients' \ '/api//v1/patients' \ '/api/v1/dashboard/staff/../../patients' \ '/api/v1/%2e%2e/v1/patients' \ '/API/v1/patients' ; do printf '%s -> ' "$P" curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \ "https://clinic-pro.ddev.site$P" -H "Authorization: Bearer $STAFF_TOKEN" done ``` هر چیزی جز 403/404 روی این مسیرها یافتهٔ 🟧 High است. ب) **کشف مسیرهای تازه‌ای که گارد نمی‌بیند.** فهرست کامل روت‌ها را بگیر و همه را با توکن پرسنل بزن: ```bash ddev exec php bin/console debug:router --format=json > /tmp/routes.json ``` اندازهٔ واقعی (سنجیده‌شده در ۲۰۲۶-۰۸-۰۷): **۴۹۳** روت زیر `/api/`، از این تعداد **۲۲۲** روت `GET` و **۱۲۸** روت `GET` بدون path parameter. **سقف این وظیفه همان ۱۲۸ روتِ بدون پارامتر است** — چون روت پارامتردار به uuid معتبر نیاز دارد و 404 آن با 403 قابل تفکیک نیست. روت‌های پارامتردار در وظیفهٔ ۵ (IDOR) با uuid واقعی پوشش داده می‌شوند. اگر به هر دلیل کمتر از ۱۲۸ روت زده شد، تعداد و دلیلش در گزارش بیاید — سکوت ممنوع. برای هر روت درخواست بزن و کد وضعیت را ثبت کن. هر 200 خارج از allowlist یافته است. ج) **کاربر چندنقشی.** با کاربر `staff + secretary`: بعد از `switch-context` به پرسنل، آیا هنوز به مسیرهای منشی دسترسی دارد؟ اگر بله، تصمیم بگیر این طراحی است یا نشت — و در گزارش با دلیل بنویس. اگر نشت است، گارد باید به **context فعال** نگاه کند نه صرفاً به مجموعهٔ نقش‌ها. **نحوه تست:** جدول `مسیر → کد وضعیت` برای هر سه بخش. --- ### ۸. آدیت پنل ادمین مجوزهای UI نباید تنها لایهٔ دفاع باشند. برای هر جایی که `assets/admin/` بر اساس مجوز چیزی را پنهان می‌کند، تأیید کن endpoint متناظر هم بسته است — نتیجهٔ وظیفهٔ ۴ همین را می‌دهد؛ اینجا فقط نگاشت UI به endpoint ثبت شود. علاوه بر آن: - توکن JWT در `localStorage['clinicpro-auth']` است. تأیید کن هیچ مسیر جدیدی HTML کاربرساخته را بدون sanitize رندر نمی‌کند (`dangerouslySetInnerHTML`). - CSP روی `/admin` هنوز فعال است (وظیفهٔ ۳) و صفحات جدید (treatment، staff، permissions) خطای CSP در کنسول نمی‌دهند. ```bash grep -rn "dangerouslySetInnerHTML" assets/admin/ ``` **نحوه تست:** لود هر صفحهٔ جدید پنل و ثبت خطاهای کنسول؛ اگر درایور `qa-clinicpro` این را می‌دهد، از همان استفاده کن. --- ### ۹. رفع طبق سیاست تعیین‌شده: - **Critical/High:** همان جلسه رفع + تست رگرسیون در `tests/`. هر رفع کوچک و جدا باشد، نه یک دیف بزرگ. - **Medium و پایین‌تر:** فهرست پیشنهاد با دیف پیشنهادی به کاربر نشان بده و **منتظر تأیید بمان**. قواعد پروژه هنگام رفع: - گیت جدید در همان لایه‌ای که بقیه هستند — `denyUnlessGranted` در کنترلر یا checker موجود، نه یک مکانیزم موازی جدید. - منطق در Service، کوئری در Repository، کنترلر نازک. وابستگی با constructor injection. - خطا با `AppException(ErrorCodes::ERR_XXX, null, $status)` و پیام فارسی از `src/Shared/Constant/ErrorCodes.php`. کد جدید لازم شد، همان‌جا اضافه شود. - اگر Entity عوض شد: `doctrine:migrations:diff` سپس `migrate`. **نحوه تست:** ```bash ddev exec php bin/phpunit ddev exec php vendor/bin/phpstan analyse ``` --- ### ۱۰. گزارش فایل `docs/security/AUDIT-2026-08-07.md` با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹: 1. خلاصهٔ وضعیت — جدول `# / یافته / شدت / وضعیت` 2. جدول creds نقش‌ها که آدیت با آن اجرا شد 3. برای هر یافته: فایل و خط، ریسک، شرح، **بازتولید با خروجی واقعی**، رفع اعمال‌شده، علت انتخاب راه‌حل 4. جدول کامل ماتریس مجوز (وظیفهٔ ۴) 5. «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند 6. «leadهایی که رد شدند» با دلیل 7. «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا 8. «risk پذیرفته‌شده» — مهاجرت CKEditor، با ارجاع به تصمیم امروز --- ## نکات مهم - **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نمی‌شود. خروجی درایور lead است، نه یافته. این قانون از گزارش قبلی می‌آید و باید حفظ شود. - **دلتا یعنی دلتا.** آدیت کامل OWASP از صفر تکرار نشود. اگر یک ناحیه در ۲۰۲۶-۰۷-۱۹ سبز بود و کدش عوض نشده، فقط اشاره شود که regression شد؛ عمیق نشو. - **نقش ≠ مالکیت.** `#[IsGranted('ROLE_STAFF')]` فقط می‌گوید کاربر پرسنل است، نه اینکه این جلسه مال اوست. هر جا فقط نقش چک شده و مالکیت نه، یک lead است. - **`TenantFilter` را authorization فرض نکن.** روی SQL خام، `getReference()` و فرزندان aggregate اعمال نمی‌شود. `docs/architecture/tenancy.md` مرجع است. - **پیام خطا نشت ندهد.** روی منبع tenant دیگر، تفاوت پیام «یافت نشد» و «دسترسی ندارید» خودش یک enumeration oracle است. رفتار فعلی را ثبت کن؛ اگر ناسازگار بود، یکدست کن. - **رفع نباید رفتار مجاز را بشکند.** قبل از هر گیت جدید، مسیر مجازِ همان اندپوینت با نقش درست تست شود که هنوز 200 می‌دهد. - **`docs/api/`** — هر تغییر در قرارداد، وضعیت یا مجوز یک endpoint، همان جلسه در فایل متناظر ثبت شود. این قانون ایستادهٔ پروژه است. - **`nobat724_front`** کلاینت همین API است. اگر گیتی روی endpointی اضافه شد که سایت عمومی صرف می‌کند، در گزارش هشدار بده — build کلاینت خطا نمی‌دهد. - **بدون DoS.** آدیت روی محیط لوکال ddev اجرا می‌شود. تست rate limit با چند درخواست شمارشی، نه با سیل ترافیک. - **secret واقعی در گزارش ننویس.** مقدار را ماسک کن و فقط فایل و خط را بده.