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()); + } +}