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>
17 KiB
فاز ۵ — آدیت نقاط فرار، تأیید کلاینتها و مستندسازی
پرامپت پنجم و آخر سری. پیشنیاز: فازهای ۱ تا ۴ کامل و سبز. ۱.
tenant-01-entity-context-unify.md→ ۲.tenant-02-mark-booking-tables.md→ ۳.tenant-03-unify-owner-columns.md→ ۴.tenant-04-enforce-tenant-filter.md→ ۵. آدیت و مستندسازی (همین فایل)
زمینه
فاز ۴ فیلتر خودکار را روشن کرد، اما فیلتر Doctrine روی SQL خام DBAL اعمال نمیشود. پروژه هشت فایل دارد که مستقیم SQL میزنند:
src/Category/Service/CategoryImporter.php
src/Doctor/Command/PurgeDoctorsCommand.php
src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php
src/Admin/Controller/AdminApiController.php
src/Shared/Controller/HealthController.php
src/Shared/Command/SeedDemoDataCommand.php
src/Representation/Controller/RepresentationActionController.php
src/Billing/Repository/ClaimRepository.php
همزمان، تغییرات فاز ۲ و ۳ ممکن است شکل پاسخ برخی endpointها را عوض کرده باشند و build هیچکدام از سه کلاینت خطا نمیدهد — نه پنل ادمین، نه nobat724_front، نه clinic-pro-tauri.
مشکل / هدف
مشکل: سه دستهٔ ریسک باقیمانده که هیچکدام خودکار کشف نمیشوند:
- نقاط فرار از فیلتر — SQL خام،
EntityManager::find()،getReference() - رگرسیون خاموش کلاینتها — تغییر قرارداد API که فقط در رانتایم میشکند
- دانش از دست رفته — بدون سند، توسعهدهندهٔ بعدی repository جدیدی مینویسد که فرض میکند فیلتر همهجا کار میکند
هدف: آدیت کامل، تأیید دستی سه کلاینت با هر پنج نقش، سند معماری، و ابزار بکاپ per-tenant که مزیت عملی جداسازی را در دسترس بگذارد.
معیار پذیرش
- ✅ موفق: هر هشت فایل SQL خام آدیت و طبقهبندی شدهاند (نیاز به
WHEREدارد / سراسری است / ادمین است)؛ سایت عمومیnobat724_frontبا هر سه صفحهٔ اصلی (لیست پزشکان شهر، صفحهٔ پزشک، صفحهٔ کلینیک) داده برمیگرداند؛ پنل ادمین با هر پنج نقش ماتریس بدون خطای کنسول کار میکند. - ❌ خطا:
ddev exec php bin/console app:tenant:dump --tenant=clinic:999برای tenant ناموجود → پیام خطای واضح و خروج با کد غیرصفر، نه فایل خالی و نه stack trace. - ⚠️ مرزی:
app:tenant:dumpبرای tenantی که هیچ دادهای ندارد (کلینیک تازه ثبتنامشده) → فایل معتبر با ساختار جدولها و صفر ردیف، بدون خطا.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Category/Service/CategoryImporter.php |
SET FOREIGN_KEY_CHECKS + DELETE FROM روی جدولهای مرجع |
src/Doctor/Command/PurgeDoctorsCommand.php |
حذف انبوه با SQL خام |
src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php |
DELETE c FROM ... JOIN |
src/Admin/Controller/AdminApiController.php |
کوئریهای ادمین — عمداً cross-tenant |
src/Shared/Controller/HealthController.php |
SELECT 1 — بیخطر |
src/Shared/Command/SeedDemoDataCommand.php |
INSERT انبوه دادهٔ نمونه |
src/Representation/Controller/RepresentationActionController.php |
پنل نماینده |
src/Billing/Repository/ClaimRepository.php |
گزارشهای صورتحساب |
src/Shared/Tenant/GlobalTables.php |
فهرست TODOهای فاز ۴ که اینجا تعیین تکلیف میشوند |
docs/api/ |
مستندات endpointهای متأثر |
src/Shared/Command/TenantDumpCommand.php |
کامند بکاپ per-tenant — جدید |
وضعیت فعلی
نمونهٔ SQL خامی که فیلتر tenant نمیبیند:
// src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
// ...
$this->conn->executeStatement(
"DELETE c FROM {$t} c JOIN doctors d ON d.id = c.doctor_id WHERE {$where}",
$params,
);
$deleted = $this->conn->executeStatement("DELETE d FROM doctors d WHERE {$where}", $params);
// src/Category/Service/CategoryImporter.php
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
$this->db->executeStatement('DELETE FROM ' . $table);
foreach ($rows as $row) {
$this->db->insert($table, $row);
}
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
وظایف
۱. آدیت هشت فایل SQL خام
هر فایل را باز کن و هر دستور SQL را در یکی از این سه دسته بگذار، سپس در docblock همان متد دسته و دلیل را بنویس:
| دسته | معنی | اقدام |
|---|---|---|
| سراسری | فقط روی جدولهای GlobalTables کار میکند |
یادداشت در docblock، بدون تغییر کد |
| ادمین | عمداً cross-tenant است و پشت ROLE_ADMIN قفل است |
تأیید کن #[IsGranted('ROLE_ADMIN')] واقعاً هست |
| نیازمند دامنه | روی جدول tenant-دار کار میکند و کاربر غیرادمین به آن میرسد | WHERE entity_type/entity_id دستی اضافه کن |
خروجی این وظیفه یک جدول در گزارش پایانی است، نه فقط تغییر کد. برای هر فایل: مسیر، دسته، دلیل.
نکات از پیش معلوم:
HealthControllerفقطSELECT 1است → سراسری، بدون اقدام.CategoryImporterرویcategoriesکار میکند که درGlobalTablesاست → سراسری. اما تأیید کن که$tableواقعاً از یک allowlist میآید و از ورودی کاربر نمیآید — اگر میآید، این یک مسئلهٔ امنیتی جدا از tenant است و باید گزارش شود.AdminApiControllerوSeedDemoDataCommandو دوPurge*Commandمسیر ادمین/کنسولاند → دستهٔ ادمین، فقط تأیید گارد.ClaimRepositoryوRepresentationActionControllerرا با دقت بخوان — این دو محتملترین موارد دستهٔ «نیازمند دامنه» هستند.
نحوه تست: برای هر مورد دستهٔ «نیازمند دامنه»، یک تست بنویس که با کاربر tenant A فراخوانی شود و هیچ ردیف tenant B برنگردد. برای بقیه، تست لازم نیست ولی یادداشت docblock اجباری است.
۲. آدیت find() و getReference() روی entityهای tenant-دار
فیلتر روی این دو اعمال نمیشود. نقاط را پیدا کن:
ddev exec grep -rn "->find(\|getReference(" src --include="*.php" | grep -v "findBy\|findOneBy\|findAll"
برای هر مورد، تعیین کن روی entityِ tenant-دار است یا نه. اگر بله، یکی از دو کار:
- جایگزینی با repository + DQL (ترجیح) — فیلتر خودکار اعمال میشود
- چک صریح بعد از
find()اگر جایگزینی ممکن نبود:
$item = $this->repo->find($id);
if ($item === null || $item->getEntityId() !== $context->id || $item->getEntityType() !== $context->type) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, null, 403);
}
نحوه تست: برای هر نقطهٔ اصلاحشده، یک تست: کاربر tenant A با شناسهٔ رکورد tenant B → 403، نه 200 و نه 404 مبهم.
۳. تعیین تکلیف TODOهای GlobalTables
فاز ۴ فهرستی از entityهای طبقهبندینشده تولید کرد (احتمالاً جدولهای مالی و فرزندان aggregate). هر کدام را در یکی از سه دسته قطعی کن:
| دسته | مثال | اقدام |
|---|---|---|
| فرزند aggregate | patient_attachments, session_payments, claim_items, invoice_items |
در GlobalTables با دلیل «tenant از ریشه به ارث میرسد»؛ تأیید کن کوئریهایشان همیشه از ریشه JOIN میخورند |
| نیازمند tenant | آنچه مستقیم کوئری میشود و ریشهٔ tenant-دار ندارد | ستون اضافه شود — پرامپت فاز ۶ برایش نوشته شود، در این فاز پیاده نشود |
| سراسری | دادهٔ مرجع | ثبت با دلیل |
دستهٔ «نیازمند tenant» را در این فاز پیاده نکن. جدولهای مالی مالکیت دوگانه دارند (پرداختکننده در برابر دریافتکننده) و migration اشتباه روی دادههای مالی قابل برگشت نیست. خروجی این وظیفه برای آن دسته فقط یک فهرست مستند در گزارش پایانی است، بهعلاوهٔ پیشنهاد اینکه فاز ۶ لازم است یا نه.
نحوه تست: ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php سبز، و هیچ دلیلی در GlobalTables با متن TODO باقی نمانده باشد.
۴. کامند بکاپ per-tenant
مزیت عملیای که کاربر از «دیتابیس مجزا» میخواست، بدون هزینهٔ آن:
// src/Shared/Command/TenantDumpCommand.php
#[AsCommand(name: 'app:tenant:dump', description: 'خروجی SQL از دادهٔ یک محیط (پزشک یا کلینیک)')]
ورودی: --tenant=clinic:12 یا --tenant=doctor:5، خروجی: --output=/path/file.sql.
پیادهسازی: فهرست جدولهای tenant-دار را از metadata بگیر (همان منطق TenantSchemaCoverageTest)، و برای هر کدام mysqldump --where بزن:
mysqldump db <table> --where="entity_type='clinic' AND entity_id=12"
اعتبارسنجی ورودی اجباری است: entity_type باید یکی از doctor/clinic باشد و entity_id عدد صحیح — این مقادیر مستقیم داخل رشتهٔ --where میروند و بدون اعتبارسنجی، تزریق شل/SQL میشود. مقدار را با in_array() و (int) قفل کن، نه با escape.
اگر tenant وجود نداشت (نه کلینیک نه پزشک با آن شناسه)، پیام خطای فارسی و Command::FAILURE.
نحوه تست:
ddev exec php bin/console app:tenant:dump --tenant=clinic:1 --output=/tmp/c1.sql
ddev exec php bin/console app:tenant:dump --tenant=clinic:999999 --output=/tmp/x.sql # باید FAILURE بدهد
ddev exec php bin/console app:tenant:dump --tenant="clinic:1 OR 1=1" --output=/tmp/y.sql # باید رد شود
و بررسی محتوای /tmp/c1.sql: هیچ ردیفی با entity_id غیر از ۱ نباشد.
۵. تأیید دستی سه کلاینت
هیچ تست خودکاری این را پوشش نمیدهد (guidelines §۳). هر سه را دستی بررسی کن و نتیجه را گزارش بده.
الف) پنل ادمین ClinicPro — با هر پنج نقش ماتریس وارد شو (اعتبارنامهها از TEST_USERS.md / حسابهای QA محلی):
| نقش | چه باید ببیند |
|---|---|
| پزشک مستقل | فقط دادهٔ مطب شخصی |
| پزشک عضو کلینیک، محیط = کلینیک | دادهٔ کلینیک، محدود به بیماران خودش |
| پزشک عضو کلینیک، محیط = شخصی | فقط دادهٔ مطب شخصی |
| پزشک مالک کلینیک | همهٔ دادهٔ کلینیک، بدون محدودیت permission |
| مدیر کلینیک | همهٔ دادهٔ کلینیک |
برای هر نقش، صفحات نوبتها، بیماران، خدمات، انبار و تخفیفها را باز کن و کنسول مرورگر را برای خطا چک کن. برای این کار میتوانی از skill qa-clinicpro استفاده کنی.
ب) nobat724_front — مسیرهای عمومی نباید فیلتر بخورند:
- لیست پزشکان یک شهر
- صفحهٔ یک پزشک (
/doctor/{uuid}) - صفحهٔ یک کلینیک (
/clinic/{uuid}) - گرفتن نوبت بهعنوان بیمار
هر کدام که خالی برگشت، یعنی فیلتر روی مسیر عمومی روشن شده — باگ فاز ۴.
ج) clinic-pro-tauri — src/service/response.js همان /api/v1/... را صدا میزند. اگر شکل پاسخ نوبت یا خدمات عوض شده، اینجا در رانتایم میشکند. حداقل مسیر ورود و لیست نوبتها را بررسی کن.
نحوه تست: همان بالا — گزارش با ذکر اینکه هر مورد تأیید شد یا نه. اگر موردی بررسی نشد، صریح بنویس بررسی نشد؛ ننویس «احتمالاً سالم است».
۶. مستندسازی
الف) سند معماری — فایل docs/architecture/tenancy.md (جدید):
- تعریف tenant: جفت
(entity_type, entity_id)با مقادیرdoctor/clinic - ماتریس پنج نقش با نحوهٔ حل محیط هر کدام
EntityContextResolverتنها نقطهٔ تصمیمTenantFilterچه تضمین میکند و چه تضمین نمیکند (جدول محدودیتها از فاز ۴)GlobalTablesو قاعدهٔ افزودن به آن- «entity جدید میسازی؟ این چکلیست» — سه خط
ب) docs/api/ — هر فایلی که endpointش شکل پاسخ عوض کرده، با JSON واقعیِ خروجی اجرا بهروز شود، نه دستساز. حداقل: appointment.md، patient.md، discount.md، secretary.md.
ج) CLAUDE.md — یک بند کوتاه زیر «قواعد غیرقابلمذاکره» یا بخش الگوها:
## جداسازی محیط (tenant)
هر entity جدید یا `TenantOwnedTrait` میگیرد، یا با دلیل در `App\Shared\Tenant\GlobalTables`
ثبت میشود. تست `TenantSchemaCoverageTest` هر دو حالت را اجبار میکند.
محیط جاری همیشه از `EntityContextResolver` گرفته میشود، نه از نقش کاربر.
جزئیات: `docs/architecture/tenancy.md`
نحوه تست: سند را بخوان و مطمئن شو با کد واقعی میخواند — بهویژه جدول محدودیتهای فیلتر. سند ناهمخوان بدتر از نبود سند است (guidelines §۴).
۷. گزارش پایانی
یک خلاصه بنویس شامل:
- جدول آدیت هشت فایل SQL خام (مسیر، دسته، اقدام)
- فهرست نقاط
find()اصلاحشده - entityهای دستهٔ «نیازمند tenant» که به فاز بعدی موکول شدند + پیشنهاد اینکه فاز ۶ لازم است یا نه
- نتیجهٔ تأیید دستی سه کلاینت — با ذکر صریح هر موردی که بررسی نشد
- هر رفتاری که عمداً عوض شد (مثل رفع باگ یکتایی
doctor_secretariesدر فاز ۳)
نکات مهم
- این فاز کد کمی دارد و بررسی زیاد. وسوسهٔ رد شدن سریع از وظیفههای ۱ و ۵ همان چیزی است که نشتی را زنده نگه میدارد. اگر وقت کم آمد، وظیفهٔ ۴ (کامند بکاپ) را به تعویق بینداز، نه آدیت را.
- هیچ migration جدیدی در این فاز نیست. اگر لازم شد، یعنی فازهای قبلی ناقص ماندهاند — برگرد و همانجا درست کن، اینجا وصله نزن.
- اعتبارسنجی ورودی
app:tenant:dumpامنیتی است، نه سلیقهای. مقدار مستقیم داخل رشتهٔ shell و SQL میرود. SET FOREIGN_KEY_CHECKS=0در سه فایل: این الگو در MariaDB سراسری است و در طول اجرا تمام محافظت ارجاعی را برای همهٔ کانکشنها خاموش میکند. جزو دامنهٔ این فاز نیست، ولی اگر روی جدول tenant-دار اجرا میشود، در گزارش بهعنوان ریسک ذکرش کن.- کلاینتهای cross-repo تست خودکار ندارند (guidelines §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است.