Перейти до змісту

Архітектура системи прав

Доменна модель

Три моделі у 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. Використовується у двох точках:

  1. UniversalViewSet — explicit permission_classes = [IsAuthenticated, HasRolePermission]. Покриває всі CRUD по /api/v1/data/<entity>/.
  2. TenantFilterMixin — default permission_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):

  1. view.kwargs['entity_code'] — UniversalViewSet (URL /data/<entity_code>/).
  2. view.entity_code атрибут — explicit override на класі. Використовується у обгорнутих APIView (reports, print, audit, settings).
  3. view.queryset.modelEntityRegistry.code_for_model(model) — reverse lookup. Для більшості ModelViewSet — automatic. EntityRegistry будує _code_by_model map при кожному register().
  4. 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/unpostPOST (для 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 DB
  • PATCH /admin/roles/<code>/permissions/ — replace usability set
  • POST /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.pyBAFSyncSettingsView, BAFSyncRunView - backend/essentials/views/essentials_settings.pyEssentialsSettingsView

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

Усе що поки не зроблено:

  1. Custom (non-system) ролі через UI — поки лише edit існуючих. Створення нових ролей доступно лише через Python-каталог.
  2. Полевий-рівень — приховати поле User.salary від не-HR-ролі. Потребує middleware на serializer-level.
  3. Скоупи own / department / organization — наприклад "Sales Manager бачить лише своїх клієнтів". Зараз усе tenant-wide. Інфраструктура є (UserRole — explicit through-model, можна додати scope_* поля), але реалізації ще немає.
  4. Mobile API (/api/v1/mobile/, /api/v1/sales-mobile/) — ViewSet'и з [IsAuthenticated], не покриті HasRolePermission. Native додатки — окремий фронт, мають інші вимоги до auth.
  5. Custom @action endpoints на ViewSet'ах — частково покриті HTTP-method fallback'ом, але деякі action-назви (recalculate_status, bulk_print) можуть не мапитись точно. Аудит окремий sprint.
  6. Audit trail змін у каталозі — коли тенант-адмін править матрицю через UI, ми не пишемо в AuditLog хто що змінив. Потребує перевизначення RolePermissionsView.patch з audit-логуванням.
  7. Field-level UI hints — frontend бачить тільки entity/action grain. Кнопка "Видалити" не сховається на основі canDelete(entity) — це треба зробити явно у компонентах. Hook useActivePermissions дає API, не auto-binding.

Усе вище — backlog, не баги. Кожне можна додати інкрементально, не ламаючи поточний контракт.