Архітектура системи прав¶
Доменна модель¶
Три моделі у backend/core/models/role.py:
Role
├── tenant FK → Tenant (per-tenant isolation)
├── code unique per tenant ('accountant', 'gate_operator', ...)
├── name, name_ua, description
├── is_system bool (catalog-managed; UI editor allowed but warns)
└── permissions ← RolePermission[]
RolePermission
├── role FK → Role
├── entity_code string ('invoices', 'reports', '*')
├── action enum (view/create/update/delete/post/all)
└── unique (role, entity_code, action)
UserRole
├── user FK → User
├── role FK → Role (must be in same tenant as user.tenant)
├── granted_at, granted_by
└── unique (user, role)
Міграція: core/migrations/0004_add_role_permission_user_role.py. Викликається apply_system_roles(tenant) після створення тенанта.
DRF permission class¶
HasRolePermission — основний gate. Використовується у двох точках:
UniversalViewSet— explicitpermission_classes = [IsAuthenticated, HasRolePermission]. Покриває всі CRUD по/api/v1/data/<entity>/.TenantFilterMixin— defaultpermission_classes = [IsAuthenticated, HasRolePermission]. Усі ViewSet'и, що його наслідують (Invoice/GoodsReceipt/Lead/PayrollSlip/etc.) — автоматично захищені.
Логіка has_permission:
1. Не авторизований / без тенанта → deny
2. is_superuser або is_tenant_admin → allow (short-circuit)
3. Не вдалося резолвити entity_code → deny (closed-by-default)
4. ('*', 'all') у наборі юзера → allow
5. (entity_code, 'all') у наборі юзера → allow
6. (entity_code, required_action) → allow
7. otherwise → deny
Набір (entity, action) юзера кешується на request.user._perm_set_cache для тривалості одного запиту.
Entity code resolution¶
Для кожного запиту HasRolePermission має визначити, яку сутність він захищає. Порядок резолву (roles.py:_resolve_entity_code):
view.kwargs['entity_code']— UniversalViewSet (URL/data/<entity_code>/).view.entity_codeатрибут — explicit override на класі. Використовується у обгорнутих APIView (reports, print, audit, settings).view.queryset.model→EntityRegistry.code_for_model(model)— reverse lookup. Для більшості ModelViewSet — automatic.EntityRegistryбудує_code_by_modelmap при кожномуregister().view.serializer_class.Meta.model→ registry — fallback для viewset'ів без queryset.
Якщо жоден шлях не дав результату — deny. Це closed-by-default.
Action mapping¶
DRF action → RolePermission action (roles.py:_required_action):
EXPLICIT_MAP = {
'list': VIEW,
'retrieve': VIEW,
'create': CREATE,
'update': UPDATE,
'partial_update': UPDATE,
'destroy': DELETE,
'post_document': POST,
'unpost_document': POST,
}
Якщо action не в мапі — HTTP-method fallback:
- Action містить post/unpost → POST (для custom action назв на кшталт force_post)
- HTTP GET → VIEW
- HTTP DELETE → DELETE
- HTTP POST/PUT/PATCH → UPDATE
Це покриває custom @action endpoints (form-data, form-refs, kanban, recalculate-status, etc.) без необхідності в exhaustive map.
Materialisation flow¶
role_catalog.py (Python)
│ apply_system_roles(tenant)
▼
Role + RolePermission rows (БД, per-tenant)
│ assign_role(user, code) / unassign_role
▼
UserRole links
│ /auth/me/ aggregates entries
▼
Frontend: Set<"entity:action">
│ useActivePermissions
▼
useFilteredSections (sidebar) + HasRolePermission (backend gate)
apply_system_roles(tenant) виконує update_or_create(Role) + delete-all + bulk_create(RolePermission). Це drift-free перезапис — будь-які локальні правки через UI або Django admin зникають на наступному apply.
Editable matrix vs catalog¶
Через UI (Admin Tools → Roles Matrix) можна правити permissions усіх системних ролей за виключенням tenant_admin (бо wildcard *:all не виражається в матриці). Backend endpoint:
GET /admin/roles/— повертає catalog as it is in DBPATCH /admin/roles/<code>/permissions/— replace usability setPOST /admin/roles/reapply/— викликаєapply_system_roles(tenant)→ відкочує локальні правки до коду
Системні ролі is_system=True свідомо лишені редагованими — це гнучкість для тенант-адмінів. Кнопка "Скинути до каталогу" — escape hatch.
Backwards-compatibility shortcuts¶
is_superuser=Trueабоis_tenant_admin=True→ завжди allow (короткозамикання уHasRolePermission). Це зберігає роботу існуючих admin-flow без явного призначенняtenant_adminролі.- Existing
[IsAuthenticated]overrides у TenantFilterMixin-наслідуючих ViewSet'ах прибрано через AST-cleanup (см. commit history). Без override — спрацьовує default міксіну. IsTenantAdminяк permission class збережено — використовується для admin-only endpoints (settings, baf-sync, scheduled-tasks).
Multi-tenancy¶
- Кожен тенант — свій набір
Role+RolePermission. Колиapply_system_roles(eswf)створюєaccountantдля eswf, у dry_port залишається свійaccountant(з тими самими правами, але інший FK). UserRoleлінк перевіряється на консистентність:role.tenant_idмає дорівнюватиuser.tenant_id. Це гарантуєassign_role()сервіс.- Призначення ролей між тенантами заборонено — користувач прив'язаний до одного тенанта (
User.tenant), і його_user_permission_setфільтрується поrole__tenant_id=user.tenant_id.
Pseudo-entities¶
APIView без queryset.model (звіти, друк, audit, custom analytics) не мають реального entity. Для них вводиться псевдо-entity — рядок, який ставиться як entity_code атрибут на класі:
class ReportRunView(APIView):
permission_classes = [IsAuthenticated, HasRolePermission]
entity_code = 'reports'
У каталозі ролей псевдо-entity треба явно додати у _PSEUDO_* константи й роздати ролям. Додано at scale через AST-script (див. історію):
- 'reports' — усі views у core/views/reports.py, essentials/views/reports.py, production/reports/api.py
- 'print' — core/views/print.py, core/views/print_list.py
- 'audit' — core/views/audit.py
- 'pricing', 'reconciliation', 'bankExchange', 'crmAnalytics', 'exchangeRates' — відповідні модулі
Перевагу маємо одночасно: гнучкість грануляції (можна ввести 'reports:executive_payroll' для топ-секретного звіту) і простоту дефолту.
Settings — окрема історія¶
Settings-endpoints містять секрети (auth-token для BAF Sync, конфіг базової валюти у Essentials). Вони обгорнуті [IsAuthenticated, IsTenantAdmin], не HasRolePermission. Це свідоме рішення: жоден catalog-grant не дає доступу — тільки tenant_admin.
Файли:
- backend/baf_sync/views.py → BAFSyncSettingsView, BAFSyncRunView
- backend/essentials/views/essentials_settings.py → EssentialsSettingsView
Performance¶
- Permission set юзера тягнеться одним запитом (RolePermission JOIN UserRole) і кешується на request lifetime. У межах одного HTTP-запиту — O(1) check на кожен
has_permission. - Каталог тримається у пам'яті (Python module-level dict). Зміна каталогу = редеплой бекенду; зміна БД = миттєва.
- Frontend
/auth/me/вертає flat list[(entity, action), ...]— сумарно ~250 рядків для accountant+sales_manager (найбільший набір). React використовуєSet<string>для O(1) лукапів.
Gaps¶
Усе що поки не зроблено:
- Custom (non-system) ролі через UI — поки лише edit існуючих. Створення нових ролей доступно лише через Python-каталог.
- Полевий-рівень — приховати поле
User.salaryвід не-HR-ролі. Потребує middleware на serializer-level. - Скоупи
own / department / organization— наприклад "Sales Manager бачить лише своїх клієнтів". Зараз усе tenant-wide. Інфраструктура є (UserRole — explicit through-model, можна додатиscope_*поля), але реалізації ще немає. - Mobile API (
/api/v1/mobile/,/api/v1/sales-mobile/) — ViewSet'и з[IsAuthenticated], не покритіHasRolePermission. Native додатки — окремий фронт, мають інші вимоги до auth. - Custom
@actionendpoints на ViewSet'ах — частково покриті HTTP-method fallback'ом, але деякі action-назви (recalculate_status,bulk_print) можуть не мапитись точно. Аудит окремий sprint. - Audit trail змін у каталозі — коли тенант-адмін править матрицю через UI, ми не пишемо в
AuditLogхто що змінив. Потребує перевизначенняRolePermissionsView.patchз audit-логуванням. - Field-level UI hints — frontend бачить тільки entity/action grain. Кнопка "Видалити" не сховається на основі
canDelete(entity)— це треба зробити явно у компонентах. HookuseActivePermissionsдає API, не auto-binding.
Усе вище — backlog, не баги. Кожне можна додати інкрементально, не ламаючи поточний контракт.