IAM: Uprawnienia (permissions)
System uprawnień DealerCRM opiera się na katalogu kodów uprawnień
zdefiniowanym w jednym pliku YAML i konsumowanym przez backend (PermissionCatalog)
oraz frontend (/api/iam/permissions/catalog endpoint).
Source of truth
Dział zatytułowany „Source of truth”apps/crm-api/config/iam/permissions.yamlTo jedyne miejsce gdzie definiujemy kody i ich labelki. Frontend NIE ma hardcoded listy — pobiera ją z API. Drift PHP↔TS jest niemożliwy z definicji.
Struktura YAML
Dział zatytułowany „Struktura YAML”groups: opportunities: # group_id, snake_case ascii label: en: Opportunities # EN wymagane (fallback) pl: Szanse # PL aktualnie, kolejne języki dorzucisz tu permissions: opp.view: # permission_code, kropki + ASCII label: en: View opportunities pl: Przegląd szans opp.create: label: { en: Create opportunities, pl: Tworzenie szans }Konwencja kodów
Dział zatytułowany „Konwencja kodów”- Lowercase, ASCII, separator
.lub_(preferowany.). - Format:
{group_prefix}.{action}(np.opp.view,customers.export). - Permissions nie znikają — kod raz dodany do produkcji nie może być usunięty bez migracji danych (Role i Membership trzymają listy kodów w JSON kolumnach).
Label localization
Dział zatytułowany „Label localization”Każdy label to mapa { locale: text }. Wymagane jest co najmniej en
(default fallback). PL jest aktualnie supportowany — dodanie kolejnego języka
to dorzucenie wpisu we WSZYSTKICH label w pliku + rejestracja w
PermissionsCatalogController::SUPPORTED_LOCALES.
Shorthand: jeśli string zamiast mapy → traktowane jako EN.
label: View opportunities # == { en: View opportunities }Jak dodać nowe uprawnienie
Dział zatytułowany „Jak dodać nowe uprawnienie”- Otwórz
apps/crm-api/config/iam/permissions.yaml. - Dodaj wpis w odpowiedniej
groups.<group_id>.permissions:invoices.export:label:en: Export invoicespl: Eksport faktur - Jeśli to nowa grupa — dodaj całą gałąź
groups.<new_id>zlabeli co najmniej jednympermissions.<code>. - Commit + deploy —
PermissionCataloglazy-loaduje YAML, Symfony container cache’uje wynik. Po deploy nowe permissions są dostępne natychmiast.
Brak migracji DB jest wymagany — iam_role.permissions to JSON list, dorzucenie
kodu w roli to UPDATE setu, nie schema change.
Walidacja
Dział zatytułowany „Walidacja”PermissionCatalog::normalize(array $codes):
- Sprawdza że każdy kod istnieje w katalogu (
InvalidArgumentExceptionjeśli nie). - Deduplikuje + sortuje (canonical form).
Wszystkie ścieżki write idą przez to:
Role::__construct/Role::update()→Permission::normalize()→PermissionCatalog::normalize()Membership::__construct/Membership::updatePermissionAdds()/…Blocks→ analogicznie
Stare role/memberships ze skasowanymi kodami: deserialize z Doctrine nie waliduje, bo to byłoby breaking. Walidacja dzieje się tylko przy WRITE — przy READ trzymamy co jest w DB, frontend gracefully ignoruje kody których nie zna.
Effective permissions
Dział zatytułowany „Effective permissions”Per Membership:
effective = ⋃ (role.permissions ∪ transitive inheritance baz) ∪ membership.permission_adds ∖ membership.permission_blocksLiczone w DoctrineIamFinder::calculateEffectivePermissions. Wynik zwracany w
MembershipView.effectivePermissions (lista kodów) i propagowany na
AuthenticatedUser::memberships.
Dziedziczenie ról
Dział zatytułowany „Dziedziczenie ról”iam_role.inherits_from_role_ids (JSON list) wskazuje role-bazy. Algorytm
expandWithInheritance robi BFS po grafu — Kierownik salonu może
dziedziczyć z Handlowiec i automatycznie dostaje jego permissions plus
swoje własne nadwyżki.
Cykl detection: Role::update() rzuca przy próbie podpięcia siebie jako bazy
(direct cycle). Pośrednie cykle nie są na razie wykrywane — staff musi
być uważny przy konfiguracji łańcuchów.
Frontend — pobieranie katalogu
Dział zatytułowany „Frontend — pobieranie katalogu”import { usePermissionsCatalog } from "@/lib/iam/permissions-catalog";
const { data, isPending } = usePermissionsCatalog();// data: { locale: "pl", groups: [{ id, label, permissions: [{ code, label }] }] }React Query cache: staleTime 5 min, gcTime 30 min. Lista zmienia się tylko przy deploy — agresywny cache OK.
Locale wybierany w priorytecie:
?locale=plw URL endpointu (debug)Accept-Languageheader z requestu (browser default)enfallback
Frontend nie wysyła explicit Accept-Language — browser robi to automatycznie.
Polski browser dostanie polskie labelki, angielski — angielskie.
Enforce permissions (TBD, Etap N+1)
Dział zatytułowany „Enforce permissions (TBD, Etap N+1)”Aktualnie: endpointy CRM API sprawdzają tylko IS_AUTHENTICATED_FULLY
(firewall-level) + ręcznie if (!$user->isStaff) w paru controllerach.
Permission codes są PUBLISHED na AuthenticatedUser::memberships.effectivePermissions
ale żaden endpoint ich nie egzekwuje.
Plan:
#[Route('/api/opportunities', methods: ['POST'])]#[RequiresPermission('opp.create')]public function create(...) { ... }IamPermissionVoter sprawdza:
isStaff = true→ allow (bypass)- bieżący
applicationContext.kind === 'staff'→ allow (staff app) effectivePermissionsdlaapplicationContext.organizationIdzawiera kod → allow- inaczej 403
Default policy = opt-in (endpoint bez attribute nadal działa jak dziś). Migracja
stopniowa per kontroler. Implementacja jeszcze nie ruszona — patrz commit message
refactor(iam): YAML jako source of truth dla katalogu uprawnień.
Tabele danych
Dział zatytułowany „Tabele danych”iam_role├─ id uuid PK├─ owner_organization_id uuid? NULL = system role, UUID = role własna org├─ name varchar├─ description text├─ permissions JSON list<code> — bezpośrednie permissions roli├─ inherits_from_role_ids JSON list<role_id> — bazy dziedziczenia├─ is_system bool└─ created_at, updated_at
iam_membership (user × organization, UNIQUE(user_id, organization_id))├─ id uuid PK├─ user_id uuid → iam_user├─ organization_id uuid → organization├─ role_ids JSON list<role_id>├─ permission_adds JSON list<code> — dodaj poza rolami├─ permission_blocks JSON list<code> — odejmij mimo roli├─ department_ids JSON list<dep_id> — salon scoping (osobny wymiar)├─ status varchar(20)└─ active, created_atdepartment_ids to osobny wymiar od permissions — scope na poziomie zasobów
(które salony user widzi). Permissions decydują CO user może robić; departments
decydują NA CZYM. Future iteracja: dorzucenie tego do voter’a żeby zwracał 403
np. dla opp.edit jeśli opportunity należy do salonu spoza department_ids.
Konsumenci
Dział zatytułowany „Konsumenci”- Backend:
Role/Membershipentity ctors → walidacja przy WRITE.DoctrineIamFinder::calculateEffectivePermissions→ flatten dla auth.JwtAuthenticator→ flatten na Symfony roleROLE_PERMISSION_<X>(gotowe do voter’a, nikt ich aktualnie nie używa).
- Frontend:
usePermissionsCatalog()wroles-tab.tsx,role-detail-view.tsx,user-detail-view.tsx(UI configuration ról i permission override per user).useMe()(jeśli istnieje) zwrócimemberships[].effectivePermissions— frontend może warunkowo ukryć przycisk, ale prawda jest enforce’owana server-side (gdy będzie attribute).