Files
clinicpro/.claude/prompt/tenant-05-audit-and-docs.md
T
hamedandClaude Opus 5 1a7bf53577 refactor(tenant): make EntityContextResolver the single context resolver
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>
2026-07-28 10:59:22 +03:30

17 KiB
Raw Blame History

فاز ۵ — آدیت نقاط فرار، تأیید کلاینت‌ها و مستندسازی

پرامپت پنجم و آخر سری. پیش‌نیاز: فازهای ۱ تا ۴ کامل و سبز. ۱. 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.

مشکل / هدف

مشکل: سه دستهٔ ریسک باقی‌مانده که هیچ‌کدام خودکار کشف نمی‌شوند:

  1. نقاط فرار از فیلتر — SQL خام، EntityManager::find()، getReference()
  2. رگرسیون خاموش کلاینت‌ها — تغییر قرارداد API که فقط در رانتایم می‌شکند
  3. دانش از دست رفته — بدون سند، توسعه‌دهندهٔ بعدی 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-taurisrc/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 §۴).


۷. گزارش پایانی

یک خلاصه بنویس شامل:

  1. جدول آدیت هشت فایل SQL خام (مسیر، دسته، اقدام)
  2. فهرست نقاط find() اصلاح‌شده
  3. entityهای دستهٔ «نیازمند tenant» که به فاز بعدی موکول شدند + پیشنهاد اینکه فاز ۶ لازم است یا نه
  4. نتیجهٔ تأیید دستی سه کلاینت — با ذکر صریح هر موردی که بررسی نشد
  5. هر رفتاری که عمداً عوض شد (مثل رفع باگ یکتایی doctor_secretaries در فاز ۳)

نکات مهم

  • این فاز کد کمی دارد و بررسی زیاد. وسوسهٔ رد شدن سریع از وظیفه‌های ۱ و ۵ همان چیزی است که نشتی را زنده نگه می‌دارد. اگر وقت کم آمد، وظیفهٔ ۴ (کامند بکاپ) را به تعویق بینداز، نه آدیت را.
  • هیچ migration جدیدی در این فاز نیست. اگر لازم شد، یعنی فازهای قبلی ناقص مانده‌اند — برگرد و همان‌جا درست کن، اینجا وصله نزن.
  • اعتبارسنجی ورودی app:tenant:dump امنیتی است، نه سلیقه‌ای. مقدار مستقیم داخل رشتهٔ shell و SQL می‌رود.
  • SET FOREIGN_KEY_CHECKS=0 در سه فایل: این الگو در MariaDB سراسری است و در طول اجرا تمام محافظت ارجاعی را برای همهٔ کانکشن‌ها خاموش می‌کند. جزو دامنهٔ این فاز نیست، ولی اگر روی جدول tenant-دار اجرا می‌شود، در گزارش به‌عنوان ریسک ذکرش کن.
  • کلاینت‌های cross-repo تست خودکار ندارند (guidelines §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است.