From 5211b34d0e08d0b472a64dababa0796af5da5b56 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 17:39:45 +0330 Subject: [PATCH] feat(permissions): expose the registry over GET /api/v1/permission-catalog Both permission forms in the admin panel can now render from the backend registry instead of their own hardcoded lists. Resources come back as an array so display order is part of the contract, each carrying its Persian label, its actions, and the clinic_only flag that used to live in the frontend. contextPermissions() normalizes the no-row branch through the registry too, so a doctor whose permission row was never provisioned sees the same shape as one who has it. Two existing assertions compared the API response against DEFAULT_PERMISSIONS by identity. The values are unchanged; only key order moved to the registry's, so both now compare through PermissionCatalog::merge. Co-Authored-By: Claude Opus 5 --- docs/api/README.md | 1 + docs/api/permission.md | 111 ++++++++++++++++++ docs/api/secretary.md | 8 +- src/Auth/Controller/AuthController.php | 3 +- .../PermissionCatalogController.php | 28 +++++ tests/Clinic/ClinicDoctorPermissionTest.php | 2 +- tests/Secretary/SecretaryFieldsTest.php | 54 +++++++++ tests/Shared/PermissionCatalogApiTest.php | 60 ++++++++++ 8 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 docs/api/permission.md create mode 100644 src/Shared/Controller/PermissionCatalogController.php create mode 100644 tests/Shared/PermissionCatalogApiTest.php diff --git a/docs/api/README.md b/docs/api/README.md index f65dfd50..cda624cc 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -96,6 +96,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [settlement.md](settlement.md) | Wallet & settlement requests | 7 | | [rating.md](rating.md) | Ratings, comments, likes | 9 | | [secretary.md](secretary.md) | Doctor secretaries | 5 | +| [permission.md](permission.md) | Permission catalog — single registry of permissionable resources | 1 | | [representation.md](representation.md) | Representations (agents) | 6 | | [sms.md](sms.md) | SMS send & templates | 10 | | [blog.md](blog.md) | Blog posts | 6 | diff --git a/docs/api/permission.md b/docs/api/permission.md new file mode 100644 index 00000000..383117b1 --- /dev/null +++ b/docs/api/permission.md @@ -0,0 +1,111 @@ +# Permission Catalog API + +> **Prefix:** `/api/v1/permission-catalog` + +## چرا این اندپوینت هست + +فهرستِ منابعِ قابل‌مجوزدهی تا پیش از این شش جا تکرار شده بود — دو Entity، سه فایل UI و +یک interface تایپ‌اسکریپت — و از هم واگرا شده بودند. نتیجه‌اش این بود که صفحهٔ تازه +به‌جای منبعِ خودش، مجوزِ نزدیک‌ترین صفحهٔ موجود را قرض می‌گرفت. + +حالا `App\Shared\Security\PermissionCatalog` تنها جایی است که می‌گوید چه منابع و +اکشن‌هایی وجود دارند. این اندپوینت همان رجیستری را به پنل می‌دهد تا هر دو فرمِ +مجوز — منشی و پزشکِ عضو کلینیک — به‌جای فهرستِ هاردکد از آن رندر شوند. + +**افزودن صفحهٔ تازه = یک ردیف در `PermissionCatalog::RESOURCES`.** فرانت تغییری لازم ندارد. + +رجیستری فقط **ساختار** می‌دهد. مقدارِ پیش‌فرضِ هر نقش سیاست است و در Entity خودش +می‌ماند: `DoctorSecretary::DEFAULT_PERMISSIONS` و `ClinicDoctorPermission::DEFAULT_PERMISSIONS`. + +--- + +## GET `/api/v1/permission-catalog` + +فهرستِ کاملِ منابع و اکشن‌ها با برچسب فارسی. + +**Permission:** `IS_AUTHENTICATED_FULLY` + +پاسخ به کاربر بستگی ندارد و تا وقتی رجیستری عوض نشود ثابت است، پس کلاینت می‌تواند +بلندمدت cache کند (`staleTime: Infinity`). مقدارِ واقعیِ مجوزِ هر رابطه از +اندپوینت‌های خودِ منشی/پزشک می‌آید، نه از اینجا. + +`resources` آرایه است نه object، تا ترتیبِ نمایش بخشی از قرارداد باشد؛ ترتیبِ +کلیدهای JSON قرارداد نیست. + +### Response `200` (خروجی واقعی، بریده‌شده) + +```json +{ + "success": true, + "data": { + "version": 1, + "resources": [ + { + "key": "appointments", + "label": "مدیریت نوبت‌ها", + "clinic_only": false, + "actions": [ + { "key": "view", "label": "مشاهده نوبت‌ها" }, + { "key": "create", "label": "ایجاد نوبت" }, + { "key": "cancel", "label": "لغو نوبت" }, + { "key": "update_status", "label": "تغییر وضعیت نوبت" } + ] + }, + { + "key": "clinic_doctors", + "label": "مدیریت پزشکان کلینیک", + "clinic_only": true, + "actions": [ + { "key": "view", "label": "مشاهده پزشکان" }, + { "key": "create", "label": "افزودن پزشک" }, + { "key": "update", "label": "ویرایش پزشک" }, + { "key": "delete", "label": "حذف پزشک" } + ] + } + ] + } +} +``` + +| فیلد | نوع | توضیح | +| --- | --- | --- | +| `version` | int | نسخهٔ شِمای مجوزها. همان چیزی که در envelope ذخیره می‌شود | +| `resources[].key` | string | کلیدِ منبع — همان چیزی که در `permission` JSON می‌نشیند | +| `resources[].label` | string | برچسب فارسی برای نمایش | +| `resources[].clinic_only` | bool | فقط در محیطِ کلینیک معنا دارد؛ پزشکِ مستقل نباید ببیندش | +| `resources[].actions[].key` | string | `view` / `create` / `update` / `delete` / `cancel` / `update_status` | +| `resources[].actions[].label` | string | برچسب فارسیِ همان اکشن | + +### منابعِ فعلی (۱۷) + +`appointments` · `patients` · `treatment` · `payments` · `insurances` · `addresses` · +`clinic_info` · `services` · `inventory` · `staff` · `tags` · `discounts` · `sms` · +`appointment_settings` · `resources` · `clinic_doctors` · `subscription` + +اکشن‌ها یکسان نیستند: `subscription` فقط `view/create` دارد، و +`clinic_info` / `appointment_settings` / `treatment` فقط `view/update`. + +### Errors + +| Status | شرایط | +| --- | --- | +| `401` | بدون توکن یا توکنِ نامعتبر | + +--- + +## سازگاری با دادهٔ موجود + +`getPermissions()` روی هر دو Entity، JSONِ ذخیره‌شده را روی پیش‌فرضِ نقش merge می‌کند: + +- منبعی که در رجیستری هست و در JSONِ ردیف نیست (یعنی بعد از ساختِ آن ردیف اضافه شده) + **پیش‌فرضِ نقش** را می‌گیرد، نه `false`. بدون این، هر منبع تازه برای همهٔ ردیف‌های + موجود خاموش می‌ماند. +- مقدارِ صریحِ ذخیره‌شده هرگز بازنویسی نمی‌شود — حتی `false`. +- هیچ migration دادهٔ انبوهی لازم نیست؛ ستون `permission` از نوع `json` است و + schema عوض نمی‌شود. + +نوشتن‌ها از `PermissionCatalog::filterPatch()` عبور می‌کنند: منبع یا اکشنِ خارج از +رجیستری **بی‌صدا** کنار گذاشته می‌شود (نه `422`) — همان رفتاری که کلاینت‌های فعلی روی +آن حساب کرده‌اند. بقیهٔ کلیدهای همان درخواست اعمال می‌شوند. + +مصرف‌کننده‌ها: [secretary.md](secretary.md) · [clinic.md](clinic.md) diff --git a/docs/api/secretary.md b/docs/api/secretary.md index ed5d792b..664869c2 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -132,7 +132,13 @@ Create a secretary for a doctor. **Permissions Structure:** -مجموعهٔ منابع (resources) بر اساس صفحات و ماژول‌های در دسترسِ منشی است: `appointments`, `patients`, `payments`, `insurances`, `addresses`, `clinic_info`, `inventory`, `tags`, `services`, `staff`, `discounts`, `sms`, `appointment_settings`, `clinic_doctors`, `subscription`. منبعِ `subscription` فقط `view/create` دارد. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند. منابعِ `inventory`, `tags`, `services`, `staff`, `discounts`, `sms`, `appointment_settings`, `clinic_doctors` به‌صورت پیش‌فرض همه `false`‌اند (default-deny)؛ بقیه طبق `DEFAULT_PERMISSIONS`. منبعِ `appointment_settings` فقط `view/update` دارد. منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو). +مجموعهٔ منابع را دیگر این فایل تعیین نمی‌کند: منبعِ واحد `App\Shared\Security\PermissionCatalog` است و از `GET /api/v1/permission-catalog` هم خوانده می‌شود — [permission.md](permission.md). فهرستِ فعلی: `appointments`, `patients`, `treatment`, `payments`, `insurances`, `addresses`, `clinic_info`, `services`, `inventory`, `staff`, `tags`, `discounts`, `sms`, `appointment_settings`, `resources`, `clinic_doctors`, `subscription`. + +منبعِ `subscription` فقط `view/create` دارد؛ `clinic_info`, `appointment_settings` و `treatment` فقط `view/update`. منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو) و در کاتالوگ با `clinic_only: true` علامت خورده. + +`mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند و **هر دو شکلِ ورودی** را می‌پذیرد: با envelope (`{version, resources:{…}}`) و نقشهٔ تخت (`{patients:{…}}`). تا پیش از این فقط شکلِ اول خوانده می‌شد و صفحهٔ ادمین که تخت می‌فرستد بی‌صدا بی‌اثر بود. منبع یا اکشنِ خارج از رجیستری بی‌صدا کنار گذاشته می‌شود؛ بقیهٔ کلیدهای همان درخواست اعمال می‌شوند. + +پیش‌فرض‌ها (`DEFAULT_PERMISSIONS`) سیاستِ نقشِ منشی‌اند، نه ساختار: `appointments`, `patients`, `treatment`, `payments`, `insurances`, `addresses`, `clinic_info` با `view` روشن؛ بقیه default-deny. منبعی که بعد از ساختِ یک ردیف به رجیستری اضافه شود، هنگام خواندن **پیش‌فرضِ نقش** را می‌گیرد نه `false` — پس نیازی به migration داده نیست. **اعمال (enforcement):** همهٔ منابع در بک‌اند enforce می‌شوند، نه فقط `appointments`. منبعِ حقیقت، ستون JSON `permission` روی ردیفِ فعالِ `DoctorSecretary` در محیطِ فعالِ کاربر (`UserActiveContext.db_uuid`) است؛ نقطهٔ مرکزی `App\Secretary\Security\SecretaryAccessChecker` (`can` / `canOrNonSecretary` / `denyUnlessGranted`). نبودِ مجوز → `403 ERR_FORBIDDEN_001`. نقشه: diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 71c8f9b0..511af59a 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -16,6 +16,7 @@ use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Captcha\CaptchaGuard; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use App\Shared\Security\PermissionCatalog; use App\Staff\Repository\ClinicStaffRepository; use App\Staff\Security\StaffPermissions; use Doctrine\ORM\EntityManagerInterface; @@ -816,7 +817,7 @@ class AuthController extends BaseController private function contextPermissions(?ClinicDoctorPermission $perm): array { if ($perm === null) { - return ClinicDoctorPermission::DEFAULT_PERMISSIONS; + return PermissionCatalog::merge([], ClinicDoctorPermission::DEFAULT_PERMISSIONS); } return $perm->isActive() diff --git a/src/Shared/Controller/PermissionCatalogController.php b/src/Shared/Controller/PermissionCatalogController.php new file mode 100644 index 00000000..2b1b1858 --- /dev/null +++ b/src/Shared/Controller/PermissionCatalogController.php @@ -0,0 +1,28 @@ +success([ + 'version' => PermissionCatalog::VERSION, + 'resources' => PermissionCatalog::toApiArray(), + ]); + } +} diff --git a/tests/Clinic/ClinicDoctorPermissionTest.php b/tests/Clinic/ClinicDoctorPermissionTest.php index ff8d7273..188d8319 100644 --- a/tests/Clinic/ClinicDoctorPermissionTest.php +++ b/tests/Clinic/ClinicDoctorPermissionTest.php @@ -166,7 +166,7 @@ class ClinicDoctorPermissionTest extends ApiTestCase self::assertNotEmpty($member); self::assertNull($personal[0]['permissions'] ?? null, 'own practice is unrestricted'); self::assertSame( - ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'], + PermissionCatalog::merge([], ClinicDoctorPermission::DEFAULT_PERMISSIONS)['resources'], $member[0]['permissions']['resources'], ); } diff --git a/tests/Secretary/SecretaryFieldsTest.php b/tests/Secretary/SecretaryFieldsTest.php index bfb45f3f..1e2c0414 100644 --- a/tests/Secretary/SecretaryFieldsTest.php +++ b/tests/Secretary/SecretaryFieldsTest.php @@ -106,4 +106,58 @@ class SecretaryFieldsTest extends ApiTestCase $this->assertSame(422, $this->responseCode()); } + /** + * صفحهٔ ادمین نقشهٔ تخت می‌فرستد — بدون envelope. تا پیش از رجیستری، کنترلر + * آن را می‌پذیرفت ولی Entity فقط $patch['resources'] را می‌خواند، پس ویرایش + * دسترسی بی‌صدا هیچ اثری نداشت. + */ + public function testUpdateAcceptsFlatPermissionMapFromAdminPage(): void + { + [$owner, $doctor] = $this->makeDoctor(); + $mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT); + + $created = $this->authJson('POST', '/api/v1/secretary', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'mobile_number' => $mobile, + 'name' => 'منشی تخت', + ]); + $uuid = ($created['data']['data'] ?? $created['data'])['uuid']; + + $res = $this->authJson('PATCH', "/api/v1/secretary/{$uuid}", $owner, [ + 'permissions' => ['staff' => ['view' => true, 'create' => true]], + ]); + + $this->assertSame(200, $this->responseCode()); + $perms = ($res['data']['data'] ?? $res['data'])['permissions']; + $this->assertTrue($perms['staff']['view']); + $this->assertTrue($perms['staff']['create']); + $this->assertTrue($perms['appointments']['view'], 'بقیهٔ منابع نباید دست بخورند'); + } + + /** منشی تا پیش از این هیچ اعتبارسنجی نداشت و هر کلیدی را ذخیره می‌کرد. */ + public function testUpdateDropsResourcesOutsideTheRegistry(): void + { + [$owner, $doctor] = $this->makeDoctor(); + $mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT); + + $created = $this->authJson('POST', '/api/v1/secretary', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'mobile_number' => $mobile, + 'name' => 'منشی ناشناخته', + ]); + $uuid = ($created['data']['data'] ?? $created['data'])['uuid']; + + $res = $this->authJson('PATCH', "/api/v1/secretary/{$uuid}", $owner, [ + 'permissions' => ['resources' => [ + 'ghost_resource' => ['view' => true], + 'tags' => ['view' => true], + ]], + ]); + + $this->assertSame(200, $this->responseCode()); + $perms = ($res['data']['data'] ?? $res['data'])['permissions']; + $this->assertArrayNotHasKey('ghost_resource', $perms); + $this->assertTrue($perms['tags']['view']); + } + } diff --git a/tests/Shared/PermissionCatalogApiTest.php b/tests/Shared/PermissionCatalogApiTest.php new file mode 100644 index 00000000..b240336d --- /dev/null +++ b/tests/Shared/PermissionCatalogApiTest.php @@ -0,0 +1,60 @@ +createUser(['ROLE_USER']); + + $res = $this->authJson('GET', '/api/v1/permission-catalog', $user); + + self::assertSame(200, $this->responseCode()); + self::assertSame( + array_keys(PermissionCatalog::RESOURCES), + array_column($res['data']['resources'], 'key'), + 'کاتالوگ باید همان منابع رجیستری و با همان ترتیب باشد', + ); + } + + public function testEveryResourceCarriesLabelAndActions(): void + { + $user = $this->createUser(['ROLE_USER']); + + $res = $this->authJson('GET', '/api/v1/permission-catalog', $user); + + foreach ($res['data']['resources'] as $resource) { + self::assertNotSame('', $resource['label'], "منبع {$resource['key']} برچسب ندارد"); + self::assertNotEmpty($resource['actions'], "منبع {$resource['key']} اکشن ندارد"); + foreach ($resource['actions'] as $action) { + self::assertArrayHasKey('key', $action); + self::assertNotSame('', $action['label']); + } + } + } + + public function testClinicOnlyFlagIsExposed(): void + { + $user = $this->createUser(['ROLE_USER']); + + $res = $this->authJson('GET', '/api/v1/permission-catalog', $user); + + $byKey = array_column($res['data']['resources'], 'clinic_only', 'key'); + self::assertTrue($byKey['clinic_doctors'], 'مدیریت پزشکان کلینیک فقط برای مالکِ کلینیک است'); + self::assertFalse($byKey['appointments']); + } + + public function testAnonymousIsRejected(): void + { + $this->client->request('GET', '/api/v1/permission-catalog'); + + self::assertSame(401, $this->responseCode()); + } +}