From 0a22cf98fb910a4adbf18f8818e40a0b7692711f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 18:20:49 +0330 Subject: [PATCH] docs(api): document the permission registry and correct two enforcement claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clinic.md's default envelope is regenerated from the running app, so it now shows all 17 resources instead of 13, including services with its full create/delete actions. Both role docs point at permission.md for the shared registry and spell out the merge rule that makes new resources work on existing rows: deleting a key means "take the default", not "deny" — denying requires an explicit false. A systematic sweep over every gated route with all permissions off found two places where the docs claimed enforcement that does not exist: - GET /api/v1/subscription/my returns 200 with every permission off. Only trial is gated. - ServiceCatalogController has no gate at all. Both are pre-existing and both are left as-is rather than half-fixed: their endpoints are also consumed by the booking and subscription flows, where a hard gate would break secretaries who legitimately need them. The docs now say so. Co-Authored-By: Claude Opus 5 --- docs/api/clinic.md | 30 +++++++++++++++++++----------- docs/api/secretary.md | 4 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 527db35d..590770b4 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -386,32 +386,40 @@ Detach a doctor from a clinic. This removes the clinic↔doctor link (the `clini Each doctor attached to a clinic has a permission envelope scoped to **that clinic only** — the doctor's own practice is never affected. Rows live in `clinic_doctor_permissions` (one per clinic+doctor) and are created lazily with defaults for doctors who joined before this feature existed. +مجموعهٔ منابع را `App\Shared\Security\PermissionCatalog` تعیین می‌کند و از `GET /api/v1/permission-catalog` هم خوانده می‌شود — [permission.md](permission.md). این کلاس با منشی مشترک است، پس هر دو نقش دقیقاً یک فهرست از منابع و اکشن‌ها دارند؛ فقط **پیش‌فرض‌ها** فرق می‌کنند. تا پیش از این `services` برای پزشکِ عضو فقط `view/update` داشت و `create`/`delete` اصلاً قابل ذخیره نبود. + +منبعی که بعد از ساختِ یک ردیف به رجیستری اضافه شود، هنگام خواندن **پیش‌فرضِ نقش** را می‌گیرد نه `false`، پس migration داده لازم نیست. توجه: حذفِ یک کلید از JSON یعنی «پیش‌فرض را بگیر»، نه «ممنوع» — برای ممنوع‌کردن باید `false` صریح ذخیره شود. + The envelope is always returned in full (`{version, resources}`); it is never flattened. ```json { "version": 1, "resources": { - "appointments": { "view": true, "create": true, "cancel": true, "update_status": true }, - "appointment_settings": { "view": true, "update": true }, - "patients": { "view": true, "create": true, "update": true, "delete": false }, - "payments": { "view": true, "create": false, "update": false, "delete": false }, - "services": { "view": true, "update": false }, - "clinic_info": { "view": true, "update": false }, - "insurances": { "view": true, "create": false, "update": false, "delete": false }, - "addresses": { "view": true, "create": false, "update": false, "delete": false }, + "appointments": { "view": true, "create": true, "cancel": true, "update_status": true }, + "patients": { "view": true, "create": true, "update": true, "delete": false }, + "treatment": { "view": true, "update": true }, + "payments": { "view": true, "create": false, "update": false, "delete": false }, + "insurances": { "view": true, "create": false, "update": false, "delete": false }, + "addresses": { "view": true, "create": false, "update": false, "delete": false }, + "clinic_info": { "view": true, "update": false }, + "services": { "view": true, "create": false, "update": false, "delete": false }, "inventory": { "view": false, "create": false, "update": false, "delete": false }, - "tags": { "view": false, "create": false, "update": false, "delete": false }, "staff": { "view": false, "create": false, "update": false, "delete": false }, + "tags": { "view": false, "create": false, "update": false, "delete": false }, "discounts": { "view": false, "create": false, "update": false, "delete": false }, - "sms": { "view": false, "create": false, "update": false, "delete": false } + "sms": { "view": false, "create": false, "update": false, "delete": false }, + "appointment_settings": { "view": true, "update": true }, + "resources": { "view": true, "create": true, "update": true, "delete": true }, + "clinic_doctors": { "view": false, "create": false, "update": false, "delete": false }, + "subscription": { "view": false, "create": false } } } ``` `active: false` revokes everything at once regardless of the individual flags. The clinic owner and `ROLE_ADMIN` bypass all checks and can never be locked out. -Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys. `subscription` و `clinic_doctors` عمداً منبع نیستند — عملیاتِ مالکِ کلینیک‌اند، نه پزشکِ عضو. +Unknown resources and unknown actions in a PATCH body are silently ignored (اعتبارسنجی از `PermissionCatalog::filterPatch`)، so a client cannot invent permission keys. `subscription` و `clinic_doctors` حالا در رجیستری هستند ولی پیش‌فرضشان برای پزشکِ عضو خاموش است — عملیاتِ مالکِ کلینیک‌اند. **اعمال (enforcement):** همهٔ منابع در بک‌اند enforce می‌شوند. نقطهٔ واحد `App\Clinic\Security\ClinicDoctorAccessChecker` (`denyUnlessGranted` / `memberClinicId`) که **فقط پزشکِ عضوِ کلینیک در محیطِ فعالِ کلینیک** را محدود می‌کند؛ مالک/ادمین/منشی/پزشکِ مطبِ شخصی دست‌نخورده عبور می‌کنند. کنترلرهایی که tenant را نقش‌محور حل می‌کنند (Inventory/Tag/Staff/Discount/Sms) با `memberClinicId` پزشکِ عضو را به دادهٔ کلینیک می‌برند (نه مطبِ شخصی). نبودِ مجوز → `403`. در پنل، سایدبار/Route/دکمه‌های CRUD با `usePermissions().can` برای محیطِ `scope=clinic` گِیت می‌شوند. diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 664869c2..529dd937 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -150,13 +150,13 @@ Create a secretary for a doctor. | `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete | | `inventory` | `InventoryController` (items + packages) | view/create/update/delete | | `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتن‌ها با `tags.*`) | view/create/update/delete | -| `services` | `ClinicServiceController` (sections + items). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل می‌شود چون `EntityContextResolver` منشی را نمی‌شناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا می‌شود | view/create/update/delete | +| `services` | `ClinicServiceController` (sections + items). ⚠ `ServiceCatalogController` (دسته‌بندی سرویس‌ها، گروه‌ها، روابط) گِیت **ندارد** — اندپوینت‌هایش در جریانِ ثبت نوبت هم مصرف می‌شوند و بستنِ یکجا نوبت‌دهی منشی را می‌شکند. owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل می‌شود چون `EntityContextResolver` منشی را نمی‌شناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا می‌شود | view/create/update/delete | | `staff` | `StaffController` (resolveEntity منشی‌آگاه) | view/create/update/delete | | `discounts` | `DiscountController` (CRUD؛ `suggestions` جزو flowِ جلسه است و با discounts گِیت نمی‌شود) | view/create/update/delete | | `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update | | `appointment_settings` | `AppointmentSettingsController::denyDoctorAccess` → `SecretaryAccessChecker::canForDoctor` (اسکوپِ پزشکِ تخصیص‌یافته + توگل). `clinic_uuid` برای محیطِ کلینیک لازم است | view/update | | `clinic_doctors` (فقط کلینیک) | `ClinicController::detachDoctor` (delete)، `ClinicDoctorPermissionController` (view/update)، `ClinicInvitationController` (create/view/update/delete) via `SecretaryAccessChecker::canForClinic` | view/create/update/delete | -| `subscription` | `SubscriptionController::my` (view) و `trial` (create)، `PaymentController::initiateSubscription` (create). resolveEntity از قبل منشی‌آگاه است | view/create | +| `subscription` | `SubscriptionController::trial` (create)، `PaymentController::initiateSubscription` (create). ⚠ خواندنِ `GET /api/v1/subscription/my` گِیت **ندارد** — با همهٔ مجوزها خاموش هم ۲۰۰ می‌دهد؛ چون FeatureGate و useSubscription همه‌جا صداش می‌زنند، بستنش نیاز به بررسی جداگانه دارد | create | نقش‌های غیرمنشی (`ROLE_CLINIC`/`ROLE_DOCTOR`/`ROLE_ADMIN`) از این چک عبور می‌کنند (`canOrNonSecretary` برایشان `true`). منشیِ بدون رابطهٔ فعال/context هیچ مجوزی ندارد → همه‌چیز `403`.