Przejdź do głównej zawartości

Wyszukiwanie jest dostępne tylko w buildach produkcyjnych. Spróbuj zbudować i uruchomić aplikację, aby przetestować lokalnie.

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).

apps/crm-api/config/iam/permissions.yaml

To 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.

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 }
  • 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).

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 }
  1. Otwórz apps/crm-api/config/iam/permissions.yaml.
  2. Dodaj wpis w odpowiedniej groups.<group_id>.permissions:
    invoices.export:
    label:
    en: Export invoices
    pl: Eksport faktur
  3. Jeśli to nowa grupa — dodaj całą gałąź groups.<new_id> z label i co najmniej jednym permissions.<code>.
  4. Commit + deployPermissionCatalog lazy-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.

PermissionCatalog::normalize(array $codes):

  • Sprawdza że każdy kod istnieje w katalogu (InvalidArgumentException jeś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.

Per Membership:

effective = ⋃ (role.permissions ∪ transitive inheritance baz)
∪ membership.permission_adds
∖ membership.permission_blocks

Liczone w DoctrineIamFinder::calculateEffectivePermissions. Wynik zwracany w MembershipView.effectivePermissions (lista kodów) i propagowany na AuthenticatedUser::memberships.

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.

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:

  1. ?locale=pl w URL endpointu (debug)
  2. Accept-Language header z requestu (browser default)
  3. en fallback

Frontend nie wysyła explicit Accept-Language — browser robi to automatycznie. Polski browser dostanie polskie labelki, angielski — angielskie.

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)
  • effectivePermissions dla applicationContext.organizationId zawiera 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ń.

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_at

department_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.

  • Backend:
    • Role/Membership entity ctors → walidacja przy WRITE.
    • DoctrineIamFinder::calculateEffectivePermissions → flatten dla auth.
    • JwtAuthenticator → flatten na Symfony role ROLE_PERMISSION_<X> (gotowe do voter’a, nikt ich aktualnie nie używa).
  • Frontend:
    • usePermissionsCatalog() w roles-tab.tsx, role-detail-view.tsx, user-detail-view.tsx (UI configuration ról i permission override per user).
    • useMe() (jeśli istnieje) zwróci memberships[].effectivePermissions — frontend może warunkowo ukryć przycisk, ale prawda jest enforce’owana server-side (gdy będzie attribute).