docs(api): document the permission registry and correct two enforcement claims

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 18:20:49 +03:30
co-authored by Claude Opus 5
parent a0fd85d910
commit 0a22cf98fb
2 changed files with 21 additions and 13 deletions
+14 -6
View File
@@ -386,6 +386,10 @@ 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
@@ -393,25 +397,29 @@ The envelope is always returned in full (`{version, resources}`); it is never fl
"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 },
"treatment": { "view": true, "update": true },
"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 },
"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` گِیت می‌شوند.
+2 -2
View File
@@ -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`.