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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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`. نقشه:
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Controller;
|
||||
|
||||
use App\Shared\Security\PermissionCatalog;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* فهرستِ منابع قابلمجوزدهی برای فرمهای مجوزِ پنل.
|
||||
*
|
||||
* پاسخ به کاربر بستگی ندارد — همان رجیستری است — پس کلاینت میتواند بلندمدت
|
||||
* cache کند. مقدارِ واقعیِ مجوزِ هر رابطه از اندپوینتهای خودِ منشی/پزشک میآید.
|
||||
*/
|
||||
#[Route('/api/v1')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PermissionCatalogController extends BaseController
|
||||
{
|
||||
#[Route('/permission-catalog', name: 'api_permission_catalog', methods: ['GET'])]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return $this->success([
|
||||
'version' => PermissionCatalog::VERSION,
|
||||
'resources' => PermissionCatalog::toApiArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Shared\Security\PermissionCatalog;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* اندپوینتِ کاتالوگ — منبعی که هر دو فرمِ مجوز از آن رندر میشوند.
|
||||
*/
|
||||
class PermissionCatalogApiTest extends ApiTestCase
|
||||
{
|
||||
public function testAuthenticatedUserGetsEveryRegistryResource(): void
|
||||
{
|
||||
$user = $this->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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user