Autorizace v DDD na Symfony
Kam patří otázka „smí to tento uživatel udělat?“ – do controlleru, do voteru, nebo do agregátu? Kapitola ji rozkládá do čtyř vrstev: Edge, Use Case, Aggregate a Field. Každá odpovídá na jinou otázku a má v Symfony vlastní nástroj.
Obsah kapitoly
Kapitola ve videokurzu
Celý videokurzPřehrávač YouTube se načte až po kliknutí.
Předchozí kapitola, Implementace v Symfony, postavila v Symfony 8 agregáty, repozitáře a Application Services. Autorizaci záměrně nechala stranou a otevřená tak zůstala otázka, kterou projekty obvykle řeší případ od případu: kdo smí který use case zavolat a za jakých podmínek. Odpovědí je čtyřvrstvý rámec, který každé autorizační rozhodnutí umístí na jednu vrstvu: od HTTP firewallu přes Symfony Voter, který aplikační vrstva volá z handleru, až po doménové invarianty v agregátu. Volání z Command Handleru ukazuje sekce 11.04; kapitola o CQRS na to navazuje middleware vrstvou, kterou lze autorizaci vytáhnout před handler.
Autentizaci (Symfony firewall, JWT, OAuth) tým většinou postaví bez větších potíží. Otázka „kdo smí udělat co s konkrétní entitou v konkrétním stavu“ je ale jiná disciplína. Bez rámce se odpověď rozptýlí mezi controllery, listenery, Twig šablony a Doctrine query buildery. Podle rámce poznáte, kam které pravidlo patří a jak ho v Symfony 8 zapsat tak, aby Security komponenta nepronikla do doménového jádra.
Na kapitolu navazují CQRS (kde sedí ověření v Command Handleru) a Testování (jak otestovat každou ze čtyř vrstev samostatně). DDD v praxi – kde to bolí autorizaci zmiňuje jen letmo.
11.01 Tři chyby s autorizací, které se v review opakovaně objevují#
Tři vzory níže spojuje jedna příčina: chybí rozhodovací rámec, kam které pravidlo patří. Pořadí odpovídá odhadované četnosti v code review; měřená data k tomu nejsou, jde o autorský odhad. Že téma unese vlastní kapitolu, ale doložit lze: OWASP posunul Broken Access Control v Top 10 pro rok 2021 z pátého místa na první.
Chyba 1: Vše v controlleru
Nejčastější vzor. Controller přijme HTTP požadavek, načte entitu z repository a inline porovná atributy uživatele s atributy entity:
1<?php2 3// src/Controller/OrderController.php (anti-vzor)4namespace App\Controller;5 6use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;7use Symfony\Component\HttpFoundation\Response;8use App\Ordering\Domain\Repository\OrderRepository;9 10final class OrderController extends AbstractController11{12 #[Route('/order/{id}/cancel', methods: ['POST'])]13 public function cancel(string $id, OrderRepository $orders): Response14 {15 $order = $orders->find($id);16 $user = $this->getUser();17 18 // Anti-vzor: autorizační logika rozsypaná v controlleru19 if ($user->getId() !== $order->getCustomerId()) {20 throw $this->createAccessDeniedException('Not your order');21 }22 if ($order->getStatus() !== 'placed') {23 throw new \LogicException('Cannot cancel a non-placed order');24 }25 26 $order->setStatus('cancelled');27 $orders->save($order);28 29 return $this->redirectToRoute('order_detail', ['id' => $id]);30 }31}
Co je špatně: stejný use case se volá i z konzolového commandu (cron, batch), ze Symfony Messenger handleru (asynchronní fronta) a z administračního panelu. Každý vstupní bod musí tutéž podmínku zopakovat a stačí, aby na ni jeden zapomněl. Pravidlo „zrušit smí jen vlastník“ má mít jedno místo definice a vynucovat se v use case, ne v každém vstupním bodu zvlášť.
Chyba 2: Vše ve Voteru, doména nezná autorizaci
Druhý extrém. Tým objeví Symfony Voter a přesune do něj všechna pravidla, včetně doménových invariantů. Agregát má veřejné API setStatus(), setTotal(), setCustomerId() a Voter „natáhne“ autorizaci přes ně:
1<?php2 3// src/Security/OrderVoter.php (anti-vzor)4namespace App\Security;5 6use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;7use Symfony\Component\Security\Core\Authorization\Voter\Vote;8use Symfony\Component\Security\Core\Authorization\Voter\Voter;9 10final class OrderVoter extends Voter11{12 protected function voteOnAttribute(13 string $attribute,14 mixed $subject,15 TokenInterface $token,16 ?Vote $vote = null,17 ): bool18 {19 $user = $token->getUser();20 21 // Anti-vzor: doménové pravidlo (storno lhůta) ve Voteru22 if ($attribute === 'CANCEL') {23 if ($user->getId() !== $subject->getCustomerId()) { return false; }24 if ($subject->getStatus() !== 'placed') { return false; }25 $age = (new \DateTimeImmutable())->getTimestamp() - $subject->getPlacedAt()->getTimestamp();26 if ($age > 86400) { return false; }27 return true;28 }29 return false;30 }31}
Co je špatně: setter Order::setStatus('cancelled') dál existuje a je veřejný. Stačí, aby ho kdokoli (test, fixture, migrační skript, jiný vývojář) zavolal mimo Voter, a invariant „storno do 24 h“ přestane platit. Voter je jen volitelný filtr před vstupem, doména žádnou pojistku nemá. Storno lhůta je doménové pravidlo, ne pravidlo use case.
Chyba 3: Autorizace na úrovni databázových řádků
Tým objeví Doctrine SQLFilter a rozhodne, že autorizaci vyřeší v perzistentní vrstvě: entity, ke kterým uživatel nemá přístup, se z databáze nevrátí. Pro read dotazy to funguje, v doménové logice ne:
- Když handler dostane
$orderIda entitu nenajde, neví, jestli neexistuje, nebo jen není dostupná pro daného uživatele. Hláška „Order not found“ pak mate. - Doctrine filtry se nevztahují na entity už načtené v identity map, na nativní SQL ani na Redis cache.
- Filtr se neuplatní ani při načtení neowning strany asociace one-to-one. Ověřeno na ORM 3.6:
find()i DQL cizí záznam skryjí, ale průchod z entity na druhý konec vztahu ho vrátí. Kdo staví oddělení tenantů jen na filtru, má tudy díru. - Doménová pravidla typu „objednávka patří zákazníkovi“ ztrácejí jedno závazné místo: zapsaná jsou v SQL filtru, ve Voteru se na ně zapomíná a v agregátu chybí. Při volání mimo HTTP vrstvu se nevynutí.
11.02 Čtyři vrstvy autorizace#
Nejdřív strategický kontext. Identita a oprávnění tvoří vlastní Bounded Context, kterému Vernon v Implementing Domain-Driven Design říká Identity and Access Context. V referenční implementaci IDDD_Samples stojí jako samostatný modul vedle Collaboration a Agile PM a ostatní kontexty ho konzumují jako službu. Typologicky jde o generickou subdoménu: kupuje se (Keycloak, Auth0, OIDC provider), nemodeluje se vlastními silami. Autorizační rozhodnutí přitom zůstává v konzumujícím kontextu, protože závisí na jeho entitách a stavech. Zdroj identit a rolí leží mimo něj a vazbu mezi obojím popisuje Context Mapping jako Open Host Service.
Uvnitř konzumujícího kontextu padá rozhodnutí ve čtyřech postupných vrstvách. Každá má vlastní otázku, Symfony nástroj i granularitu. Vrstvy fungují jako filtry: další odpovídá na jemnější otázku a předpokládá, že předchozí už řekla „ano“.
| Vrstva | Otázka | Symfony nástroj | Příklad |
|---|---|---|---|
| Edge | Je přihlášený? Smí na tuhle URL? | access_control, JWT firewall |
/admin/* jen pro ROLE_ADMIN |
| Use Case | Smí vykonat use case na tomto objektu? | Voter |
„Smí Petr zrušit objednávku #42?“ |
| Aggregate | Dá se to vůbec teď udělat? | doménová kontrola + výjimka | „Objednávku lze zrušit jen do 24 h od potvrzení“ |
| Field | Smí vidět konkrétní pole? | Twig + Voter, query filter | „Sloupec audit_log vidí jen admin“ |
Každé autorizační pravidlo má právě jedno místo definice. Vynutit ho lze na více vrstvách, pokud všechny čtou tutéž definici. OWASP to formuluje jako požadavek implementovat kontrolu jednou a znovu ji používat. Duplicitou je až přepis téhož pravidla druhými slovy na druhé vrstvě; typické případy ukazuje sekce o anti-vzorech.
Metafora filtrů má jednu podmínku. Uvnitř use-case vrstvy platí jen při rozhodovací strategii unanimous. Výchozí affirmative ji obrací: stačí jeden souhlasící Voter a nesouhlas ostatních se ignoruje. Podrobnosti v sekci o rozhodovací strategii.
Citace: Symfony Security komponenta dokumentuje vícevrstvý přístup v sekci „Authorization“ [1]; obecné principy ABAC vs. RBAC najdete v NIST SP 800-162 [2]. Vernon v Implementing Domain-Driven Design (kap. 14, „Application“) umisťuje autorizační kontrolu do Application Services: aplikační služba se podle něj stará o bezpečnost a překlad objektů. Čtyřvrstvý rámec této kapitoly u něj nenajdete – jde o autorské rozšíření, ne o Vernonův model.
11.03 Edge – Symfony firewall a access_control#
Edge je nejhrubší vrstva a leží mimo doménový kód. Odpovídá jen na otázku „kdo je vůbec na druhém konci socketu?“: anonymní, přihlášený, případně s rolí pro hrubě dělené sekce (/admin/*, /api/v1/*). Pravidla typu „zákazník X smí na tuto objednávku“ patří o vrstvu výš (use case).
Uživatelský provider ukazuje na třídu SecurityUser z infrastruktury, ne na doménovou entitu. Na tom stojí celý zbytek kapitoly: provider vyžaduje implementaci Symfony\Component\Security\Core\User\UserInterface, a kdyby ji nesla doménová třída, doména by se svázala se Security komponentou. Přesně to zakazuje anti-vzor 4. SecurityUser je navíc read model pro autentizaci: nese identifikátor, hash hesla, role a doménové ID (CustomerId, TenantId). Mění se z jiných důvodů než doménový model uživatele [3].
1# config/packages/security.yaml2security:3 # Recept security-bundle tenhle blok vygeneruje sám. Kdo výpis níž4 # zkopíruje jako celý soubor, přijde o něj – a první přihlášení pak5 # skončí na „No password hasher has been configured“.6 password_hashers:7 Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'8 9 providers:10 app_user_provider:11 entity:12 # Infrastrukturní třída implementující UserInterface,13 # mapovaná na tabulku app_user. Doména o ní neví.14 class: App\Identity\Infrastructure\Security\SecurityUser15 property: email16 17 firewalls:18 # Stateless API – JWT. Klíč `jwt` registruje19 # lexik/jwt-authentication-bundle; bez něj konfigurace neprojde20 # („Unrecognized option jwt“), takže celý blok nechte zakomentovaný,21 # dokud balíček nemáte. Nativní ekvivalent je `access_token`.22 # api:23 # pattern: ^/api/24 # stateless: true25 # jwt: ~26 # provider: app_user_provider27 28 # Web – session29 main:30 pattern: ^/31 lazy: true32 provider: app_user_provider33 form_login:34 login_path: login35 check_path: login36 # Šablona posílá `_csrf_token` s id `authenticate`, bez této37 # volby ho ale form_login nečte ani neověřuje.38 enable_csrf: true39 # Bez tohohle řádku míří Symfony po přihlášení na `/`,40 # kterou kniha nikde nedefinuje – první proklik po loginu41 # by skončil na 404.42 default_target_path: app_profile43 # Bez `target` platí totéž co u přihlášení: odhlášení44 # přesměruje na `/` a skončí na 404.45 logout: { target: login }46 47 access_control:48 # Veřejné endpointy49 - { path: ^/login, roles: PUBLIC_ACCESS }50 # Pozor na tvar cesty: ^/register nepokrývá /api/register.51 # JSON endpoint by jinak skončil přesměrováním na login dřív,52 # než se kontroler vůbec spustí.53 - { path: ^/register, roles: PUBLIC_ACCESS }54 - { path: ^/api/register, roles: PUBLIC_ACCESS }55 - { path: ^/health, roles: PUBLIC_ACCESS }56 # Hrubá role-based separace57 - { path: ^/admin, roles: ROLE_ADMIN }58 - { path: ^/api/internal, roles: ROLE_SERVICE_ACCOUNT }59 # Vše ostatní za autentizací60 - { path: ^/, roles: IS_AUTHENTICATED_FULLY }
form_login odkazuje na routu login. Firewall ji sám nezaloží a první chráněný request
spadne na chybějící routě:
1<?php2 3declare(strict_types=1);4 5namespace App\Identity\Infrastructure\Http;6 7use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;8use Symfony\Component\HttpFoundation\Response;9use Symfony\Component\Routing\Attribute\Route;10use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;11 12final class LoginController extends AbstractController13{14 // Přihlášení samo zpracovává firewall. Kontroler jen vykreslí15 // formulář a poslední chybu – proto tu není žádná logika.16 #[Route('/login', name: 'login', methods: ['GET', 'POST'])]17 public function __invoke(AuthenticationUtils $utils): Response18 {19 return $this->render('security/login.html.twig', [20 'last_username' => $utils->getLastUsername(),21 'error' => $utils->getLastAuthenticationError(),22 ]);23 }24}
Šablona je krátká, ale dvě jména v ní nejsou volná: _username a _password očekává
form_login autentikátor a přejmenovat je znamená přenastavit firewall.
1{% extends 'base.html.twig' %}2 3{% block body %}4 {% if error %}5 <p class="error">{{ error.messageKey|trans(error.messageData, 'security') }}</p>6 {% endif %}7 8 <form method="post">9 <label for="username">E-mail</label>10 <input type="email" id="username" name="_username" value="{{ last_username }}" required>11 12 <label for="password">Heslo</label>13 <input type="password" id="password" name="_password" required>14 15 <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">16 <button type="submit">Přihlásit</button>17 </form>18{% endblock %}
Třída SecurityUser tvoří most mezi Symfony a doménou. Kromě UserInterface vystavuje doménové identifikátory pro aplikační vrstvu:
1<?php2 3// src/Identity/Infrastructure/Security/SecurityUser.php4declare(strict_types=1);5 6namespace App\Identity\Infrastructure\Security;7 8use App\Ordering\Domain\ValueObject\CustomerId;9use App\Identity\Domain\TenantId;10use Doctrine\ORM\Mapping as ORM;11use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;12use Symfony\Component\Security\Core\User\UserInterface;13 14#[ORM\Entity]15#[ORM\Table(name: 'app_user')]16class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface17{18 public function __construct(19 #[ORM\Id, ORM\Column(type: 'string')]20 private string $email,21 #[ORM\Column(type: 'string')]22 private string $passwordHash,23 /** @var list<string> */24 #[ORM\Column(type: 'json')]25 private array $roles,26 #[ORM\Column(type: 'string')]27 private string $customerId,28 #[ORM\Column(type: 'string')]29 private string $tenantId,30 ) {}31 32 public function getUserIdentifier(): string { return $this->email; }33 public function getPassword(): string { return $this->passwordHash; }34 35 /** @return list<string> */36 public function getRoles(): array { return $this->roles; }37 38 // Most do domény – Voter i handler pracují s doménovým typem39 public function customerId(): CustomerId { return CustomerId::fromString($this->customerId); }40 public function tenantId(): TenantId { return TenantId::fromString($this->tenantId); }41}
TenantId je obyčejný hodnotový objekt stejného tvaru jako OrderId; bez něj SecurityUser
neprojde ani autoloadem a firewall zůstane bez uživatelů:
1<?php2 3declare(strict_types=1);4 5namespace App\Identity\Domain;6 7final readonly class TenantId8{9 public function __construct(10 public string $value,11 ) {12 if ($value === '') {13 throw new \InvalidArgumentException('TenantId must not be empty.');14 }15 }16 17 public static function fromString(string $value): self18 {19 return new self($value);20 }21 22 public function __toString(): string23 {24 return $this->value;25 }26}
Principy edge vrstvy:
- Žádná doménová znalost. Edge nezná pojmy „objednávka“, „zákazník“ ani „storno lhůta“. Pracuje jen se vzorem URL, rolemi a stavem autentizace.
- Default deny. Poslední pravidlo v
access_controlje „všechno ostatní vyžaduje přihlášení“. Bez něj stačí přidat nový endpoint, zapomenout ho zařadit, a je veřejný. - Role-based, ne attribute-based. ROLE_ADMIN je hrubá kategorie; jemnější rozhodnutí jako „admin tenantu T1, ne T2“ patří do Voteru.
- JWT firewall vs. session. API bývá stateless (
jwtautentikátor), web pracuje se session. Pro JWT v Symfony existuje balíčeklexik/jwt-authentication-bundlenebo nativníaccess_tokenautentikátor sOidcTokenHandlerpro OpenID Connect provider [4].
Matcherů má access_control víc než jen path a roles. K dispozici jsou host, port, ips, methods, attributes, route, request_matcher, a k tomu allow_if a requires_channel [5]. Uplatní se první shodné pravidlo a nespecifikovaný matcher odpovídá čemukoli. Pravidlo { path: ^/api, methods: [POST] } tedy nechrání GET na téže cestě, pokud dřív v seznamu není obecnější záznam.
11.04 Use Case – Symfony Voter#
Use case vrstva odpovídá na otázku „smí tento uživatel vykonat tento use case na tomto objektu?“. Přesně na to je Symfony Voter navržený. Pravidlo: 1 use case = 1 atribut Voteru; jeden Voter může pokrývat N atributů, pokud se týkají stejné entity (typicky operace nad jedním agregátem).
Voter zná dvě věci: identitu uživatele (přes TokenInterface) a cílový subjekt (typicky kořen agregátu). Nesmí načítat subjekt, o kterém rozhoduje, a nesmí znát doménové invarianty – ty vynucuje agregát. Storno lhůtu tedy Voter nepřebírá zvenku; patří ke stavu agregátu.
1<?php2 3// src/Ordering/Infrastructure/Security/OrderVoter.php4declare(strict_types=1);5 6namespace App\Ordering\Infrastructure\Security;7 8use App\Identity\Infrastructure\Security\SecurityUser;9use App\Ordering\Domain\Model\Order;10use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;11use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;12use Symfony\Component\Security\Core\Authorization\Voter\Vote;13use Symfony\Component\Security\Core\Authorization\Voter\Voter;14 15final class OrderVoter extends Voter16{17 public const VIEW = 'order.view';18 public const CANCEL = 'order.cancel';19 public const REFUND = 'order.refund';20 21 public function __construct(22 private readonly AccessDecisionManagerInterface $decisions,23 ) {}24 25 protected function supports(string $attribute, mixed $subject): bool26 {27 return in_array($attribute, [self::VIEW, self::CANCEL, self::REFUND], true)28 && $subject instanceof Order;29 }30 31 public function supportsAttribute(string $attribute): bool32 {33 return in_array($attribute, [self::VIEW, self::CANCEL, self::REFUND], true);34 }35 36 public function supportsType(string $subjectType): bool37 {38 return $subjectType === Order::class;39 }40 41 protected function voteOnAttribute(42 string $attribute,43 mixed $subject,44 TokenInterface $token,45 ?Vote $vote = null,46 ): bool47 {48 $user = $token->getUser();49 if (!$user instanceof SecurityUser) {50 return false;51 }52 53 \assert($subject instanceof Order);54 55 return match ($attribute) {56 self::VIEW => $this->canView($subject, $user, $token),57 self::CANCEL => $this->canCancel($subject, $user),58 self::REFUND => $this->decisions->decide($token, ['ROLE_REFUND_AGENT']),59 default => false,60 };61 }62 63 private function canView(Order $order, SecurityUser $user, TokenInterface $token): bool64 {65 // Vlastnictví definuje agregát, Voter se jen ptá66 return $order->isOwnedBy($user->customerId())67 || $this->decisions->decide($token, ['ROLE_ADMIN']);68 }69 70 private function canCancel(Order $order, SecurityUser $user): bool71 {72 return $order->isOwnedBy($user->customerId());73 }74}
Pět implementačních detailů:
- Konstanty atributů s prefixem entity (
order.cancel, ne jenCANCEL). Nekolidují s atributy jiných Voterů (invoice.cancel,shipment.cancel) a z audit logu je hned vidět, kterého subjektu se rozhodnutí týkalo. - Match expression místo stromu if-else. Bez default větve PHPStan ohlásí nepokrytý případ;
default => falsevolí tiché zamítnutí (fail-closed) a tuto kontrolu obětuje. - Privátní metody
canView,canCancel. Každý use case má vlastní metodu a test ověří výsledek každého z nich zvlášť. Bez extrakce by Voter přerostl v nečitelný switch-case. - Role se uvnitř Voteru kontrolují přes
AccessDecisionManagerInterface::decide(), ne dotazem na uživatelskou třídu. Dotazin_array('ROLE_ADMIN', $user->getRoles(), true)obejde hierarchii rolí zesecurity.yaml: uživatel sROLE_SUPER_ADMINbyROLE_ADMINnedostal, přestože ho hierarchie zahrnuje. Před ručnímgetRoles()varuje dokumentace Security [1],decide()ve Voteru doporučuje dokumentace k Voterům [7]. supportsAttribute()asupportsType()pocházejí zCacheableVoterInterface, které abstraktníVoterimplementuje. Obě ve výchozím stavu vracejítrue, takže bez override nic neušetří. Seznam s 200 řádky a pěti Votery znamená tisíc zbytečných volánísupports(); s override jich většina odpadne už v rozhodovacím manažeru.
Rozhodovací strategie a AccessDecisionManager
Voterů bývá v aplikaci víc a jejich hlasy skládá dohromady AccessDecisionManager. Strategie, kterou použije, mění výsledek víc než cokoli uvnitř samotných Voterů:
| Strategie | Chování | Kdy se hodí |
|---|---|---|
affirmative |
výchozí; stačí jeden souhlas | jednoduché aplikace s jedním Voterem na subjekt |
unanimous |
zamítne, jakmile nesouhlasí kdokoli | vrstvená autorizace, multi-tenancy |
consensus |
rozhoduje většina | zřídka; výsledek se hůř zdůvodňuje |
priority |
rozhodne první nezdržující se volič | explicitní pořadí přes #[AsTaggedItem(priority: …)] |
Pro rámec této kapitoly je výchozí affirmative špatná volba. Kdo si vedle OrderVoter postaví TenantVoter, dostane opak toho, co čekal: TenantVoter cizí tenant zamítne, OrderVoter řekne ano podle vlastnictví a přístup projde. Vrstvená autorizace proto potřebuje unanimous.
Blok patří do stejného security.yaml jako firewall výše, ne místo něj. YAML má jeden kořenový klíč security: a druhý dokument by ten první přepsal.
1# config/packages/security.yaml2security:3 access_decision_manager:4 strategy: unanimous5 allow_if_all_abstain: false # nikdo nehlasoval = zamítnuto
Hodnota allow_if_all_abstain: false je výchozí, ale patří do konfigurace explicitně. Je to poslední fail-closed pojistka: atribut, ke kterému se žádný Voter nepřihlásí, skončí zamítnutím, ne tichým povolením.
#[IsGranted] na controlleru
Autoritativní je kontrola v handleru. Na hranici HTTP se ale vyplatí odmítnout požadavek dřív, než vznikne command. K tomu slouží atribut Symfony\Component\Security\Http\Attribute\IsGranted. Funguje na metodě i na celé třídě controlleru a parametrem subject určuje argument akce, o kterém se rozhoduje:
1<?php2 3// src/Ordering/Infrastructure/Http/OrderController.php4declare(strict_types=1);5 6namespace App\Ordering\Infrastructure\Http;7 8use App\Ordering\Application\Command\CancelOrder;9use App\Ordering\Domain\Model\Order;10use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;11use Symfony\Component\HttpFoundation\Request;12use Symfony\Component\HttpFoundation\Response;13use Symfony\Component\Messenger\MessageBusInterface;14use Symfony\Component\Routing\Attribute\Route;15use Symfony\Component\Security\Http\Attribute\IsGranted;16 17#[IsGranted('IS_AUTHENTICATED_FULLY')]18final class OrderController extends AbstractController19{20 #[Route('/order/{id}/cancel', name: 'order_cancel', methods: ['POST'])]21 #[IsGranted('order.cancel', subject: 'order', statusCode: 404)]22 public function cancel(Order $order, Request $request, MessageBusInterface $bus): Response23 {24 // Voter už rozhodl; controller jen přeloží vstup na command25 $bus->dispatch(new CancelOrder(26 orderId: $order->id,27 reason: (string) $request->request->get('reason', ''),28 actorId: $this->getUser()->customerId(),29 ));30 31 return $this->redirectToRoute('order_detail', ['id' => $order->id->value]);32 }33 34 #[Route('/order/{id}', name: 'order_detail', methods: ['GET'])]35 #[IsGranted('order.view', subject: 'order', statusCode: 404)]36 public function detail(Order $order): Response37 {38 // isCancellable() si aktuální čas nebere sama, takže ho šablona39 // musí dostat odsud – jinak Twig hlásí, že proměnná neexistuje.40 return $this->render('order/detail.html.twig', [41 'order' => $order,42 'now' => new \DateTimeImmutable(),43 ]);44 }45}
Parametr statusCode: 404 mění odpověď z 403 na 404. Rozdíl není kosmetický: 403 útočníkovi potvrdí, že objednávka s daným ID existuje, a otevře cestu k enumeraci cizích identifikátorů. Téma rozvádí callout o 403 vs. 409.
Atribut má dvě omezení. První: potřebuje subjekt už jako objekt, takže se neobejde bez převodu z parametru routy a dotaz do databáze se přesouvá do controlleru. #[MapEntity] na to sám nestačí. EntityValueResolver předá find() řetězec z URL, jenže identita je namapovaná vlastním typem, který řetězec odmítne. Resolver výjimku spolkne a vrátí 404, takže se chyba hledá na špatném místě:
Could not convert PHP value '01a074c3-…' to type OrderIdType.
Expected one of the following types: null, OrderId.
Řešením je vlastní resolver, který řetězec převede na hodnotový objekt dřív, než sáhne do repozitáře:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Http;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\Repository\OrderRepository;9use App\Ordering\Domain\ValueObject\OrderId;10use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;11use Symfony\Component\HttpFoundation\Request;12use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;13use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;14use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;15use Symfony\Component\Uid\Uuid;16 17// Vyšší priorita než EntityValueResolver, jinak se k slovu nedostane.18#[AutoconfigureTag('controller.argument_value_resolver', ['priority' => 150])]19final readonly class OrderValueResolver implements ValueResolverInterface20{21 public function __construct(private OrderRepository $orders) {}22 23 public function resolve(Request $request, ArgumentMetadata $argument): iterable24 {25 if ($argument->getType() !== Order::class) {26 return [];27 }28 29 $id = $request->attributes->get('id');30 31 if (!is_string($id) || !Uuid::isValid($id)) {32 throw new NotFoundHttpException('Invalid order ID.');33 }34 35 yield $this->orders->get(OrderId::fromString($id));36 }37}
Druhé omezení: jakmile controller command jen odešle na asynchronní bus, #[IsGranted] chrání pouze vstup do fronty. Zpracování ve workeru běží bez tokenu a řeší ho následující sekce. Atribut proto kontrolu v handleru nenahrazuje, jen ji doplňuje na hranici.
Proč ne Symfony ACL
Starší materiály nabízejí pro oprávnění na jednotlivé objekty komponentu ACL: tabulky acl_entries, acl_object_identities a MaskBuilder. Pro Symfony 8 to volba není. Podpora ACL zmizela ze SecurityBundle ve verzi 4.0 a samostatný symfony/acl-bundle deklaruje ve svém posledním vydání (2.4.0 z dubna 2024) podporu Symfony 4.4 až 7.x. Symfony 8 mezi nimi není. Ani technicky by nešlo o dobrou náhradu. ACL ukládá rozhodnutí jako data v databázi, takže pravidlo „vlastník smí zrušit do 24 hodin“ se promítne do řádků, které musí někdo držet v synchronizaci se stavem agregátu. Voter tutéž věc spočítá z aktuálního stavu a nic synchronizovat nemusí. Kde jsou potřeba explicitně přidělovaná oprávnění na jednotlivé objekty (sdílení dokumentu, delegace), sahá se po vlastní tabulce vazeb nebo po ReBAC modelu z pozdější sekce.
Použití ve Command Handleru
Voter někdo musí zavolat. Místo pro to je Application Service / Command Handler, kde se autorizace ověří před doménovou operací. Handler injektuje AuthorizationCheckerInterface, tedy rozhraní Security komponenty. V aplikační vrstvě je taková závislost v pořádku, doménová by ji mít nesměla.
1<?php2 3// src/Ordering/Application/Handler/CancelOrderHandler.php4declare(strict_types=1);5 6namespace App\Ordering\Application\Handler;7 8use App\Ordering\Application\Command\CancelOrder;9use App\Ordering\Application\Exception\AccessDeniedDomainException;10use App\Ordering\Domain\Repository\OrderRepository;11use Symfony\Component\Messenger\Attribute\AsMessageHandler;12use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;13 14#[AsMessageHandler(bus: 'command.bus')]15final readonly class CancelOrderHandler16{17 public function __construct(18 private OrderRepository $orders,19 private AuthorizationCheckerInterface $auth,20 ) {}21 22 public function __invoke(CancelOrder $command): void23 {24 $order = $this->orders->get($command->orderId);25 26 // Atribut jako řetězec, stejně jako v controlleru: konstanta27 // OrderVoter::CANCEL by handler svázala s infrastrukturou.28 if (!$this->auth->isGranted('order.cancel', $order)) {29 throw new AccessDeniedDomainException(30 sprintf('Cancel not allowed for order %s', $command->orderId->value)31 );32 }33 34 $order->cancel(reason: $command->reason, when: new \DateTimeImmutable());35 $this->orders->save($order);36 37 // Publikaci událostí ukázka vynechává, aby zůstala čitelná otázka38 // autorizace. V úplném tvaru ji doplňuje async varianta níže –39 // a bez ní se agregát a read model rozejdou.40 }41}
Obě výjimky, které v kapitole padají, jsou obyčejné doménové třídy. Dělí je vrstva: autorizační patří aplikaci, časové okno doméně, a aplikační vrstva každou překládá na jiný HTTP status.
1<?php2 3// --- src/Ordering/Application/Exception/AccessDeniedDomainException.php ---4 5declare(strict_types=1);6 7namespace App\Ordering\Application\Exception;8 9/** Aktér na operaci nemá právo. Aplikační vrstva to překládá na 403. */10final class AccessDeniedDomainException extends \DomainException11{12}13 14// --- src/Ordering/Domain/Exception/CancellationWindowExpiredException.php ---15 16namespace App\Ordering\Domain\Exception;17 18use App\Ordering\Domain\ValueObject\OrderId;19 20/** Právo je v pořádku, jen uplynula lhůta. Odtud 409, ne 403. */21final class CancellationWindowExpiredException extends \DomainException22{23 public function __construct(24 public readonly OrderId $orderId,25 public readonly \DateTimeImmutable $placedAt,26 public readonly \DateTimeImmutable $attemptedAt,27 ) {28 parent::__construct(sprintf(29 'Order "%s" confirmed at %s can no longer be cancelled (attempted at %s).',30 $orderId->value,31 $placedAt->format('Y-m-d H:i'),32 $attemptedAt->format('Y-m-d H:i'),33 ));34 }35}
Po kontrole zavolá handler doménovou operaci $order->cancel(...) a ta uvnitř agregátu ověří invarianty (stav, storno lhůtu). Vznikají tak dvě nezávislé bariéry: Voter řekne „smí Petr“, agregát řekne „dá se to teď vůbec udělat“. Agregátovou vrstvu rozebírá sekce 11.06. Háček: handler nese atribut #[AsMessageHandler] a v asynchronním workeru žádný token neexistuje. Tomu se věnuje následující sekce.
Voter v Twig šabloně
Tentýž Voter pokrývá i rozhodnutí ve view, třeba skrýt tlačítko „Zrušit objednávku“ tomu, kdo objednávku nevlastní. Funkce is_granted() v Twigu volá stejný AuthorizationCheckerInterface. Proměnnou now (\DateTimeImmutable) předává do šablony controller, protože isCancellable() si aktuální čas nebere sama. Šablona čte agregát přímo a u detailu jedné objednávky to stačí. Jakmile obrazovka potřebuje jméno zákazníka nebo data z jiného kontextu, patří jí read model, ne další getter na agregátu:
1{# templates/order/detail.html.twig #}2{# Šablona sahá jen na to, co agregát opravdu má: identitu zákazníka,3 ne objekt Customer, a hodnotu enumu, ne vymyšlený label. #}4<h1>Objednávka {{ order.id.value }}</h1>5 6{% if is_granted('order.view', order) %}7 <dl>8 <dt>Zákazník</dt><dd>{{ order.customerId.value }}</dd>9 <dt>Celkem</dt> <dd>{{ (order.totalAmount.amountInCents / 100)|number_format(2, ',', ' ') }} Kč</dd>10 <dt>Stav</dt> <dd>{{ order.status.value }}</dd>11 </dl>12{% endif %}13 14{% if is_granted('order.cancel', order) and order.isCancellable(now) %}15 <form method="post" action="{{ path('order_cancel', {id: order.id.value}) }}">16 <button type="submit">Zrušit objednávku</button>17 </form>18{% endif %}19 20{# Refund je operace pro obsluhu, ne pro zákazníka – atribut ji pustí21 jen roli z Voteru. Odkaz vede na routu, kterou kniha nedodává, proto22 je zakomentovaný: path() na neexistující routu shodí celou šablonu,23 a to až ve chvíli, kdy se přihlásí první uživatel s tou rolí. #}24{# {% if is_granted('order.refund', order) %}25 <a href="{{ path('order_refund', {id: order.id.value}) }}">Vrátit platbu</a>26{% endif %} #}
{% if is_granted(...) %} v Twigu jen schová tlačítko. Request poslaný ručně (curl, Postman, dev tools prohlížeče) nezastaví. Kontrola ve view je UX, ne bezpečnostní bariéra; ta stojí v handleru.
11.06 Aggregate-level – doména sama rozhoduje#
Některá pravidla do Voteru nepatří. Vyžadují znalost doménového stavu, kterou Voter nemá přebírat zvenku: časové okno, předchozí stav objednávky, invarianty napříč entitami uvnitř agregátu. Patří do kořene agregátu, který je vynutí doménovou výjimkou.
Praktická heuristika:
- Pravidlo, které vyžaduje stav agregátu („objednávka nesmí být odeslaná a nesmí být starší než 24 h“), patří do agregátu.
- Pravidlo o vztahu mezi aktérem a agregátem (vlastnictví, členství, hierarchie) definuje také agregát. Zná ho a nepřestává ho znát, ani když se command zpracuje asynchronně. Voter se na něj ptá, neopisuje ho.
- Pravidlo o atributech aktéra („refund smí jen
ROLE_REFUND_AGENT“, „mimo pracovní dobu ne“) patří do Voteru. Agregát o rolích nic neví a vědět nemá.
Na prostředním bodě se týmy rozcházejí nejčastěji. „Zrušit smí jen vlastník“ zní jako typické use-case pravidlo, ve skutečnosti jde o invariant vztahu mezi Order a CustomerId. Agregát na něj proto odpovídá metodou isOwnedBy() a Voter i asynchronní handler ji volají místo vlastního porovnání. Definice zůstane jedna, vynucení může být na obou místech.
1<?php2 3// Výřez kanonického agregátu z kapitoly Návrh agregátu. Doplňuje jen to,4// co přidává autorizace; konstruktor, továrny ani mapování se nemění.5declare(strict_types=1);6 7namespace App\Ordering\Domain\Model;8 9use App\Ordering\Domain\Event\OrderCancelled;10use App\Ordering\Domain\Exception\CancellationWindowExpiredException;11use App\Ordering\Domain\Exception\InvalidOrderStateTransitionException;12use App\Ordering\Domain\Exception\OrderLockedBySagaException;13use App\Ordering\Domain\ValueObject\CustomerId;14use App\Ordering\Domain\ValueObject\OrderStatus;15use App\SharedKernel\Domain\AggregateRoot;16 17final class Order extends AggregateRoot18{19 private const CANCELLATION_WINDOW_SECONDS = 86_400; // 24 h20 21 public function cancel(string $reason, \DateTimeImmutable $when): void22 {23 // Zámek drží proces – viz kapitola o ságách, sekce Izolace ság.24 // Tahle podmínka je v kanonické verzi z Návrhu agregátu taky;25 // tenhle výpis metodu nahrazuje celou, ne po částech.26 if ($this->sagaInProgress) {27 throw new OrderLockedBySagaException($this->id);28 }29 30 // Odeslanou ani doručenou zásilku storno nevrátí – tam nastupuje31 // kompenzace v ságe.32 if (in_array($this->status, [OrderStatus::Shipped, OrderStatus::Delivered], true)) {33 throw InvalidOrderStateTransitionException::cannotTransition(34 $this->status->value,35 OrderStatus::Cancelled->value,36 );37 }38 39 // Opakované storno není chyba volajícího, jen už není co dělat.40 if ($this->status === OrderStatus::Cancelled) {41 return;42 }43 44 // Lhůta běží od potvrzení. Draft ji ještě nemá a rozpracovaný45 // košík taky nikdo neruší na čas.46 if ($this->placedAt !== null) {47 $age = $when->getTimestamp() - $this->placedAt->getTimestamp();48 49 if ($age > self::CANCELLATION_WINDOW_SECONDS) {50 throw new CancellationWindowExpiredException(51 $this->id,52 $this->placedAt,53 $when,54 );55 }56 }57 58 $this->status = OrderStatus::Cancelled;59 $this->record(new OrderCancelled(60 orderId: $this->id,61 customerId: $this->customerId,62 reason: $reason,63 occurredAt: $when,64 ));65 }66 67 public function isCancellable(\DateTimeImmutable $now): bool68 {69 // Šablona se ptá právě téhle metody, takže musí znát i zámek.70 // Jinak nabídne tlačítko, jehož příkaz skončí v dead-letter frontě.71 if ($this->sagaInProgress) {72 return false;73 }74 75 if (in_array($this->status, [76 OrderStatus::Shipped,77 OrderStatus::Delivered,78 OrderStatus::Cancelled,79 ], true)) {80 return false;81 }82 83 return $this->placedAt === null84 || $now->getTimestamp() - $this->placedAt->getTimestamp()85 <= self::CANCELLATION_WINDOW_SECONDS;86 }87 88 // Vztahový invariant: vlastnictví zná agregát, ne Voter89 public function isOwnedBy(CustomerId $customerId): bool90 {91 return $this->customerId->equals($customerId);92 }93}
Agregát nemá žádnou závislost na Symfony. Používá jen standardní typy PHP a vlastní doménové třídy: žádný TokenInterface, AuthorizationChecker ani UserInterface. Proto ho lze testovat unit testem bez Symfony Kernelu. Selhání hlásí výjimkami InvalidOrderStateTransitionException a CancellationWindowExpiredException z App\Ordering\Domain\Exception. Nesou doménový kontext (kdy byla objednávka potvrzena, kdy přišel pokus o storno) a aplikační vrstva je překládá na 409 Conflict, ne na 403 Forbidden. Nejde o autorizační selhání, ale o doménový stav.
Pomocná metoda isCancellable() je dotaz bez vedlejších efektů. UI podle ní skrývá tlačítko: Twig šablona ji volá s proměnnou now z controlleru a kombinuje ji s is_granted. Lhůtu sdílí s cancel() přes konstantu CANCELLATION_WINDOW_SECONDS, takže nevzniká duplicita. Zbývají doménové události: po úspěšné operaci agregát zaznamená OrderCancelled voláním record(), handler je po repository->save() vyzvedne přes releaseEvents() a publikuje (typicky přes Outbox). Agregát sám EventDispatcher nikdy nevolá.
Otázku „smí Petr“ zde agregát neřeší; tu zodpověděl Voter v sekci 11.04. Agregát odpovídá na „dá se to teď vůbec udělat?“, a jeho „ne“ platí i tehdy, když Voter řekl „ano“: Petr je vlastník, ale objednávka už odešla. Obě bariéry jsou nezávislé a obě nutné.
cancel() výše nahrazuje verzi z Návrhu agregátu celou, ne po částech: nese tytéž
stavové podmínky i zámek a přidává k nim lhůtu. Nová je jen metoda isCancellable();
isOwnedBy() je ve výpisu pro úplnost a shoduje se s verzí z Návrhu agregátu. Konstruktor,
továrny i markPaid() zůstávají tak, jak je zavádí Návrh agregátu. Stavová podmínka je proto stejná jako tam: blokuje odeslanou a doručenou objednávku, ne všechno kromě Confirmed. Zúžení na Confirmed by vypadalo přísněji, ale rozbilo by kompenzaci: sága ruší objednávku zaplacenou, handler by jí storno odmítl a objednávka by zůstala viset.
End-to-end průchod: žádost o storno
Co se stane, když zákazník Petr v rozhraní klikne na „Zrušit objednávku #42“:
- Edge (firewall). Symfony ověří JWT nebo session. Bez ověření → 401. Petr je přihlášený, pokračuje se.
- Edge (access_control). URL
/order/42/cancelspadá podIS_AUTHENTICATED_FULLY. Petr je přihlášený, pokračuje se. - Controller validuje vstup (CSRF token, tělo požadavku), vytvoří
CancelOrder(orderId: 42, reason: 'changed mind', actorId: <Petrovo CustomerId>)a předá ho na message bus. - Application Handler (
CancelOrderHandler) načte agregát z repozitáře:$order = $repo->get($orderId). - Use Case. Synchronní handler volá
$auth->isGranted('order.cancel', $order)aOrderVoterse zeptá agregátu přes$order->isOwnedBy($user->customerId()); asynchronní varianta voláisOwnedBy()přímo sactorIdz commandu. Petr je vlastník → pokračuje se. Kdyby nebyl →AccessDeniedDomainException→ HTTP 403. - Aggregate. Handler volá
$order->cancel('changed mind', $now). Agregát ověří, že objednávka není odeslaná ani doručená a že od potvrzení neuplynulo víc než 24 h. Petr ji potvrdil před 30 minutami → stav se změní naCancelleda vznikne událostOrderCancelled. Kdyby už byla odeslaná →InvalidOrderStateTransitionException→ HTTP 409. - Persistence + outbox. Handler zavolá
$repo->save($order); v jedné transakci se uloží stav agregátu a událostOrderCancelleddo outbox tabulky. - Field-level (odpověď). Controller přesměruje na detail objednávky. Kdyby detail obsahoval
audit_loga Petr nebyl admin, read model by ho vynechal: u vlastní objednávky vidí stav, ale ne kdo a kdy ji upravoval.
Každá vrstva selže po svém: jiný HTTP status, jiná chybová hláška, jiné logy. Generické „Access denied“ nestačí.
11.07 Field-level – read model filtrace#
Předchozí tři vrstvy řešily akce a přípustnost operace. Field-level řeší viditelnost konkrétního pole při jinak povoleném čtení. Klasický příklad: detail objednávky vidí zákazník i admin, ale sloupec audit_log (kdo a kdy objednávku upravoval) jen admin.
Nabízejí se dva přístupy s odlišnými kompromisy.
Přístup 1: Twig if (view-level)
Nejjednodušší, ale s únikem dat: z databáze se načte všechno a ve view se část jen zahodí. Pro většinu UI to stačí, pro citlivá data ne – unikají přes HTML komentáře, JSON serializaci v JS aplikaci nebo ETag hashing.
1{# Jiná varianta téže šablony než v 11.04. Tam čte agregát, zde2 OrderDetailDto z read modelu níže – obrazovka potřebuje audit log,3 který agregát nenese. Do projektu jde jedna z nich, ne obě. #}4<dl>5 <dt>Zákazník</dt> <dd>{{ order.customerId }}</dd>6 <dt>Celkem</dt> <dd>{{ (order.totalAmount / 100)|number_format(2, ',', ' ') }} Kč</dd>7 <dt>Stav</dt> <dd>{{ order.status }}</dd>8 9 {% if is_granted('order.audit_log', order) %}10 <dt>Audit log</dt>11 <dd>12 <ul class="audit">13 {% for entry in order.auditLog %}14 <li>{{ entry.at|date }}: {{ entry.action }} ({{ entry.actor }})</li>15 {% endfor %}16 </ul>17 </dd>18 {% endif %}19</dl>
Přístup 2: Query filter (read model)
Citlivá pole se z databáze vůbec nenačtou a read model vrací různá DTO podle role. Data neunikají, cenou je duplicita (dva dotazy, dvě struktury DTO). Hodí se pro PII, finanční data a audit logy.
1<?php2 3// src/Ordering/Infrastructure/ReadModel/OrderDetailReadModel.php4declare(strict_types=1);5 6namespace App\Ordering\Infrastructure\ReadModel;7 8use App\Ordering\Application\ReadModel\OrderDetailDto;9use App\Ordering\Domain\Exception\OrderNotFoundException;10use App\Ordering\Domain\ValueObject\OrderId;11use Doctrine\DBAL\Connection;12use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;13use Symfony\Component\Security\Core\User\UserInterface;14 15final readonly class OrderDetailReadModel16{17 public function __construct(18 private Connection $db,19 private UserAuthorizationCheckerInterface $auth,20 ) {}21 22 public function forUser(string $orderId, UserInterface $user): OrderDetailDto23 {24 // Read model čte projekci order_dashboard z kapitoly o CQRS, ne25 // tabulku agregátu. Celková částka je tam předpočítaná; nad `orders`26 // by se musela dopočítat joinem přes položky.27 $columns = 'order_id, customer_id, total_amount, status, placed_at';28 $seesAudit = $this->auth->isGrantedForUser($user, 'ROLE_ADMIN');29 30 $sql = "SELECT {$columns} FROM order_dashboard WHERE order_id = :id";31 32 $row = $this->db->fetchAssociative($sql, ['id' => $orderId]);33 if ($row === false) {34 throw OrderNotFoundException::withId(OrderId::fromString($orderId));35 }36 37 // Audit log je vlastní tabulka, ne sloupec projekce – dotaz se38 // pro něj vůbec nepoloží, když ho aktér nesmí vidět.39 $row['audit_log'] = $seesAudit40 ? $this->db->fetchAllAssociative(41 'SELECT at, action, actor FROM order_audit_log42 WHERE order_id = :id ORDER BY at',43 ['id' => $orderId],44 )45 : null;46 47 return OrderDetailDto::fromRow($row, includeAudit: $seesAudit);48 }49}
Tabulku zakládá migrace. Do schema_filter z kapitoly o CQRS
patří ze stejného důvodu jako ostatní projekce, jinak ji migrations:diff navrhne zahodit:
1CREATE TABLE order_audit_log (2 id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,3 order_id UUID NOT NULL,4 at TIMESTAMP(0) NOT NULL,5 action VARCHAR(64) NOT NULL,6 actor VARCHAR(255) NOT NULL7);8 9CREATE INDEX idx_audit_order ON order_audit_log (order_id, at);
# config/packages/doctrine.yaml – výčet je nutné rozšířit
schema_filter: '~^(?!order_dashboard|reporting_orders|order_audit_log)~'
Read model sahá přímo na DBAL, proto leží v infrastruktuře, stejně jako read modely v kapitole o CQRS.
DTO, které vrací, patří aplikační vrstvě. Drží jen to, co obrazovka opravdu dostala. auditLog je null, pokud ho dotaz
nevybral. Oproti prázdnému poli to nese jiný význam: log se nenačetl, ne že by
neobsahoval žádné záznamy.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\ReadModel;6 7final readonly class OrderDetailDto8{9 /** @param list<array<string, mixed>>|null $auditLog */10 public function __construct(11 public string $orderId,12 public string $customerId,13 public int $totalAmount,14 public string $status,15 public \DateTimeImmutable $placedAt,16 public ?array $auditLog = null,17 ) {}18 19 /** @param array<string, mixed> $row */20 public static function fromRow(array $row, bool $includeAudit): self21 {22 return new self(23 orderId: (string) $row['order_id'],24 customerId: (string) $row['customer_id'],25 totalAmount: (int) $row['total_amount'],26 status: (string) $row['status'],27 placedAt: new \DateTimeImmutable((string) $row['placed_at']),28 auditLog: $includeAudit ? (array) ($row['audit_log'] ?? []) : null,29 );30 }31}
Volba mezi přístupy:
| Kritérium | Twig if | Query filter |
|---|---|---|
| Únik dat | Riziko (data v paměti; u API/SPA unikají do odpovědi) | Ne |
| Implementační složitost | Triviální | Vyžaduje různé DTO / read modely |
| Vhodné pro | skrytí v UI, neostrá ochrana | PII, finance, audit log, GDPR |
| Testování | Twig integrační test | Unit + integrační test read modelu |
| Soulad s OWASP A01:2021 | Nedostatečný – viz [8] | Ano (vynucení na serveru) |
Pro necitlivá data Twig if stačí a šetří čas, pro citlivá data vždy query filter. Hodnocení v posledním řádku je výklad, ne citace. OWASP v kategorii „A01 Broken Access Control“ uznává jen kontrolu v důvěryhodném serverovém kódu a mezi slabinami uvádí obejití kontroly úpravou HTML stránky nebo požadavku na API. Twig if sice běží na serveru, data ale už načetl. Kdo k nim přijde jinou cestou (API, export), podmínku v šabloně obejde.
Seznamy: Voter na otázku „které objekty smí?“ neodpoví
Voter odpovídá na uzavřenou otázku: smí tento uživatel tento konkrétní objekt? Endpoint se seznamem ale potřebuje otázku opačnou: které objekty z deseti tisíc smí vidět? Rozdíl vypadá formálně, v praxi jde o strop celého rámce postaveného na Voterech.
Naivní řešení načte stránku výsledků a přefiltruje ji v PHP:
1{# templates/order/list.html.twig (anti-vzor) #}2{% for order in orders %}3 {% if is_granted('order.view', order) %}4 <tr><td>{{ order.id.value }}</td><td>{{ order.status.value }}</td></tr>5 {% endif %}6{% endfor %}
Rozbijí se dvě věci naráz. Stránkování přestane sedět: dotaz vrátí 20 řádků, filtr jich zahodí 7 a uživatel uvidí stránku o třinácti položkách. Celkový počet nikdo nespočítá, dokud nenačte všechno. Výkon navíc klesá lineárně: každý řádek projde rozhodováním přes všechny registrované Votery, takže dvacet řádků a pět Voterů znamená sto rozhodnutí na jedno vykreslení. Bez override supportsAttribute() a supportsType() (viz implementační detaily Voteru) se z toho počtu neubere nic.
Autorizace proto patří do dotazu. Read model dostane identitu aktéra a promítne ji do WHERE:
1<?php2 3// src/Ordering/Infrastructure/ReadModel/OrderListReadModel.php4declare(strict_types=1);5 6namespace App\Ordering\Infrastructure\ReadModel;7 8use App\Ordering\Domain\ValueObject\CustomerId;9use Doctrine\DBAL\Connection;10 11final readonly class OrderListReadModel12{13 public function __construct(private Connection $db) {}14 15 /** @return list<array<string, mixed>> */16 public function visibleTo(CustomerId $actor, bool $isAdmin, int $limit, int $offset): array17 {18 $sql = 'SELECT order_id, status, total_amount, placed_at FROM order_dashboard';19 $params = ['limit' => $limit, 'offset' => $offset];20 21 // Autorizace je součástí dotazu, ne postprocessingu22 if (!$isAdmin) {23 $sql .= ' WHERE customer_id = :actor';24 $params['actor'] = $actor->value;25 }26 27 $sql .= ' ORDER BY placed_at DESC LIMIT :limit OFFSET :offset';28 29 return $this->db->fetchAllAssociative($sql, $params);30 }31}
Podmínka ve WHERE je ale druhý zápis pravidla, které už zná OrderVoter. Jednu definici tu udržet nelze, protože SQL a PHP jsou různé jazyky; vazbu ale lze pojmenovat. Osvědčuje se držet obojí v jedné třídě nebo aspoň v jednom adresáři a doplnit komentář s odkazem na Voter. Hlavní pojistkou je test: projde objednávky vrácené read modelem a u každé ověří, že Voter řekne ano. Když se rozejdou, test spadne.
Modely vzniklé kolem Zanzibaru toto rozdělení pojmenovávají přímo: Check je otázka na jeden objekt, ListObjects vrací množinu. Pro druhou z nich Symfony 8 nativní podporu nemá. Voter je dobrý Policy Enforcement Point a víc si nenárokuje. Detail v sekci o ReBAC.
11.08 Policy-based přístup (ABAC)#
RBAC (Role-Based Access Control) se ptá na roli. ABAC (Attribute-Based Access Control) vyhodnocuje kombinaci atributů subjektu, akce, prostředku a kontextu proti policy a vrátí povoleno / zakázáno. O přechodu od prvního ke druhému nerozhoduje počet pravidel, ale tři kvalitativní signály: policy musí být čitelná pro někoho mimo vývojový tým, mění se v jiném rytmu než kód, nebo ji sdílí víc aplikací. Dokud neplatí ani jeden, Votery stačí a další abstrakce je jen práce navíc.
NIST SP 800-162 dává pro tuto vrstvu slovník, který používají i externí enginy [2]. PEP (Policy Enforcement Point) je místo, kde se rozhodnutí vynutí: v Symfony access_control, #[IsGranted] a volání isGranted() v handleru. Rozhodnutí samo padne v PDP (Policy Decision Point), u nás v rozhodovacím manažeru s Votery, případně ve vzdáleném enginu. PIP (Policy Information Point) dodává atributy, PAP (Policy Administration Point) policy spravuje. Rámec čtyř vrstev z 11.02 je tedy rozmístění PEP; PDP zůstává jeden.
Následující ukázka staví ABAC model explicitně: Policy jako kolekce objektů Rule, které se vyhodnotí proti trojici subject/user/context. Slouží k tomu, aby byl model vidět. Zda se takový kód vyplatí psát, řeší závěr sekce; ve většině Symfony projektů ne.
1<?php2 3// src/SharedKernel/Authorization/Policy.php4declare(strict_types=1);5 6namespace App\SharedKernel\Authorization;7 8// Tři třídy, tři soubory – PSR-4 jinak najde jen tu první.9interface Policy10{11 public function name(): string;12 13 /** @return list<Rule> */14 public function rules(): array;15}16 17final readonly class Rule18{19 public function __construct(20 public string $expression,21 public string $description,22 ) {}23}24 25final readonly class PolicyContext26{27 public function __construct(28 public object $subject,29 public object $user,30 public \DateTimeImmutable $now,31 ) {}32}
1<?php2 3// src/Ordering/Authorization/CancelOrderPolicy.php4declare(strict_types=1);5 6namespace App\Ordering\Authorization;7 8use App\SharedKernel\Authorization\Policy;9use App\SharedKernel\Authorization\Rule;10 11final class CancelOrderPolicy implements Policy12{13 public function name(): string14 {15 return 'order.cancel';16 }17 18 /** @return list<Rule> */19 public function rules(): array20 {21 return [22 new Rule(23 // user.customerId je privátní – ExpressionLanguage k němu24 // getter nedohledá, volá se metoda.25 expression: 'subject.customerId == user.customerId()',26 description: 'Pouze vlastník objednávky',27 ),28 new Rule(29 expression: 'subject.status.value == "confirmed"',30 description: 'Objednávka musí být potvrzená',31 ),32 new Rule(33 expression: 'subject.placedAt.getTimestamp() >= now - 86400',34 description: 'Storno lhůta 24 h ještě neuplynula',35 ),36 ];37 }38}
Zápis výrazů má svá úskalí a chyby se projeví až za běhu. ExpressionLanguage čte veřejné properties a volá veřejné metody. Gettery k privátním polím nedohledá, takže subjekt politiky musí mít stav čitelný zvenku: buď snapshot s veřejnými poli, nebo agregát s public private(set) jako kanonický Order. Odečíst DateTimeImmutable od čísla komponenta neumí: datum se převádí na unixový timestamp metodou objektu (subject.placedAt.getTimestamp()) a now přichází z proměnných evaluatoru jako číslo, ne jako objekt. Backed enum se neporovnává přímo: subject.status == "confirmed" selže, porovnává se až hodnota subject.status.value. A protože výrazy jsou řetězce, statická analýza je nevidí; každé pravidlo musí krýt test, viz tabulkové testy policy.
Pravidla subject.status.value == "confirmed" a lhůta 24 h stojí v politice jen pro ilustraci ABAC zápisu. Jak popisuje sekce 11.06, doménové invarianty patří do agregátu a politika je ověřuje nanejvýš jako předběžnou kontrolu (obrana do hloubky). Zdrojem pravdy zůstává agregát, který neplatný příkaz odmítne i bez autorizační vrstvy.
Jednoduchý PolicyEvaluator vyhodnocuje pravidla v daném kontextu pomocí Symfony ExpressionLanguage. Balíček v základní instalaci není, prvním krokem je tedy composer require symfony/expression-language:
1<?php2 3// src/SharedKernel/Authorization/PolicyEvaluator.php4declare(strict_types=1);5 6namespace App\SharedKernel\Authorization;7 8use Symfony\Component\ExpressionLanguage\ExpressionLanguage;9 10final class PolicyEvaluator11{12 public function __construct(private readonly ExpressionLanguage $expr = new ExpressionLanguage()) {}13 14 /**15 * Vrací první porušené pravidlo, nebo null pokud všechna prošla.16 */17 public function evaluate(Policy $policy, PolicyContext $ctx): ?Rule18 {19 $vars = [20 'subject' => $ctx->subject,21 'user' => $ctx->user,22 'now' => $ctx->now->getTimestamp(),23 ];24 foreach ($policy->rules() as $rule) {25 if (!$this->expr->evaluate($rule->expression, $vars)) {26 return $rule;27 }28 }29 return null;30 }31}
Co tento přístup přináší a co stojí:
| Policy nad ExpressionLanguage | Voter | |
|---|---|---|
| Důvod zamítnutí | vrací porušené pravidlo | Vote::addReason() od Symfony 7.3 |
| Verzování | třída v repu, git historie | totéž |
| Statická analýza | výrazy jsou stringy, PHPStan je nevidí | plná |
| Subjekt | musí mít veřejně čitelný stav | libovolný objekt |
| Kompozice hlasů | vlastní kód | AccessDecisionManager a strategie |
Poslední dva řádky jsou skrytá cena, kterou tabulky výhod obvykle zamlčují. ExpressionLanguage vidí jen veřejné properties a metody. Kanonický Order s public private(set) to splní, agregát se stavem čitelným jen přes privátní pole ne. Pak vzniká snapshot navíc, který se musí držet v synchronizaci s doménou. Hlasy navíc skládá vlastní evaluátor místo rozhodovacího manažeru, takže strategie z 11.04 se neuplatní.
Vlastní evaluátor, nebo Voter s Vote?
Pro vlastní PolicyEvaluator býval jediný silný argument: vědět, které pravidlo selhalo, ne jen že přístup nebyl povolen. Od Symfony 7.3 to umí Security komponenta sama. Voter přijímá volitelný parametr ?Vote $vote a může do něj zapsat důvod. Aplikační vrstva pak čte celé rozhodnutí přes Security::getAccessDecision(), které přibylo ve verzi 7.4:
1// src/Ordering/Infrastructure/Security/OrderVoter.php (s důvody, jen větev CANCEL)2protected function voteOnAttribute(3 string $attribute,4 mixed $subject,5 TokenInterface $token,6 ?Vote $vote = null,7): bool {8 $user = $token->getUser();9 if (!$user instanceof SecurityUser) {10 $vote?->addReason('Aktér není přihlášený uživatel aplikace.');11 return false;12 }13 14 if (!$subject->isOwnedBy($user->customerId())) {15 $vote?->addReason('Objednávku smí zrušit pouze její vlastník.');16 return false;17 }18 19 if (!$subject->isCancellable(new \DateTimeImmutable())) {20 $vote?->addReason('Lhůta 24 h pro zrušení objednávky uplynula.');21 return false;22 }23 24 return true;25}
Důvody se čtou z veřejné vlastnosti $vote->reasons (pole stringů); getter třída Vote nemá. Stejně je na tom AccessDecision, kde je výsledek vlastnost $decision->isGranted. Aplikační vrstva je vytáhne z AccessDecision a předá do chybové odpovědi:
1// src/Ordering/Infrastructure/Http/ExplainedAccessDenied.php2$decision = $this->security->getAccessDecision(OrderVoter::CANCEL, $order);3 4if (!$decision->isGranted) {5 $reasons = [];6 foreach ($decision->votes as $vote) {7 // Vote::$reasons je veřejná vlastnost, ne getter8 $reasons = array_merge($reasons, $vote->reasons);9 }10 11 throw new AccessDeniedDomainException(implode(' ', $reasons));12}
Ukázka záměrně kontroluje i stav agregátu, aby bylo vidět, co se získá. Pravidlo ale zůstává definované v Order::isCancellable(); Voter ho volá, neopisuje.
Závěr pro Symfony 8: vlastní vrstva Policy/Rule se nevyplatí. Dá tytéž odpovědi jako Votery, ale bez statické analýzy, bez rozhodovacích strategií a s modelem navíc. ABAC zůstává užitečný jako způsob uvažování o pravidlech, implementace ale vzniká z Voterů. Externí engine přichází na řadu, až když policy musí žít mimo aplikaci: sdílí ji víc služeb, spravuje ji jiný tým nebo ji auditor kontroluje nezávisle na deploy cyklu. Tehdy se nabízí OPA s jazykem Rego nebo Cerbos a Voter se stane tenkým PEP, který se ptá vzdáleného PDP. Rozhraní mezi nimi standardizuje AuthZEN Authorization API 1.0, schválené v lednu 2026 [9].
ReBAC: když je oprávnění vztah, ne atribut
OPA není poslední stanice. Posun posledních let míří k ReBAC (Relationship-Based Access Control), kde se přístup odvozuje ze vztahů mezi uživateli a objekty i mezi objekty navzájem. Typické pravidlo: „uživatel vidí dokument, pokud má přístup k jeho nadřazené složce“. Na hierarchiích, sdílení a multi-tenancy RBAC selhává: buď vznikají role pro každou kombinaci, nebo se logika roztříští do Voterů.
Referenčním modelem je Zanzibar, autorizační systém Googlu popsaný na USENIX ATC '19. Ukládá vztahy jako trojice objekt#relace@uživatel, konfiguraci vztahů popisuje vlastním jazykem namespace a konzistenci řeší tokeny zvanými zookies. Provozní čísla z paperu dávají měřítko: biliony ACL záznamů, miliony autorizačních dotazů za sekundu, p95 latence pod 10 ms. Otevřené implementace téhož modelu jsou dnes dvě: OpenFGA (projekt CNCF) a SpiceDB od Authzed se schema jazykem, který rozlišuje zapsané vztahy a počítaná oprávnění.
11.09 Multi-tenancy – tenant kontext#
Multi-tenancy (vícenájemnost) je speciální případ ABAC: jedna aplikace obsluhuje více oddělených zákazníků (organizací, mandantů, tenantů) a žádný nesmí vidět data jiného. Architektonické strategie jsou tři:
- Row-based – sdílená databáze, sdílené tabulky, sloupec
tenant_idvšude. Nejlevnější, nejméně izolace, vyžaduje pečlivé filtry. - Schema-based – sdílená databáze, samostatné schema per tenant (PostgreSQL
SET search_path). Střední izolace. Výkon závisí na počtu tenantů: menší tabulky pomohou velkým tenantům, tisíce schémat ale zatíží katalog, migrace i connection pooling. - Database-based – samostatná databáze per tenant. Nejvyšší izolace, nejnákladnější (DB connection per tenant, migrations × N).
Volba mezi nimi souvisí s velikostí instalace. Row-based se hodí pro SaaS s velkým počtem malých tenantů, schema-based tam, kde se mají oddělit zálohy a migrace per tenant, database-based pro regulované domény. Kritérium je pokaždé stejné: jak drahá je chyba, když se dva tenanty potkají v jedné odpovědi. Pro row-based je v Symfony zavedeným nástrojem Doctrine SQLFilter.
To vypadá jako rozpor s chybou 3, která filtrování v perzistentní vrstvě označila za anti-vzor. Rozpor je jen zdánlivý a rozdíl spočívá v tom, na co filtr odpovídá. Tenant je kontext dotazu, ne autorizační rozhodnutí o akci: dimenze, kterou nese každý dotaz v požadavku, stejně jako jazyk nebo časová zóna. Rozhodnutí „Petr smí zrušit objednávku #42“ do SQL nepatří, protože handler pak nerozezná neexistující záznam od cizího. Otázka „ke kterému tenantovi tento request patří“ do SQL patří, protože odpověď je pro celý request jediná a neměnná.
1<?php2 3// src/SharedKernel/Infrastructure/Doctrine/TenantFilter.php4declare(strict_types=1);5 6namespace App\SharedKernel\Infrastructure\Doctrine;7 8use App\SharedKernel\Domain\TenantAware;9use Doctrine\ORM\Mapping\ClassMetadata;10use Doctrine\ORM\Query\Filter\SQLFilter;11 12final class TenantFilter extends SQLFilter13{14 public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string15 {16 if (!$targetEntity->reflClass->implementsInterface(TenantAware::class)) {17 return '';18 }19 20 return sprintf(21 '%s.tenant_id = %s',22 $targetTableAlias,23 $this->getParameter('tenant_id'),24 );25 }26}
Marker rozhraní je prázdné a říká jen „tahle entita patří tenantovi“. Bez něj filtr
spadne u prvního dotazu na Interface … does not exist, a protože je zapnutý globálně,
shodí každý dotaz v aplikaci:
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain;6 7/**8 * Značka pro entity, které patří konkrétnímu tenantovi. Metody nemá –9 * filtr se ptá jen na to, jestli ji entita implementuje.10 */11interface TenantAware12{13}
Filtr přidá klauzuli tenant_id = ? do každého dotazu nad entitou, která implementuje TenantAware. Dokud ho neimplementuje žádná, je filtr no-op: zapnutý, ale bez účinku. Značka nepatří na SecurityUser. Toho načítá provider uvnitř firewallu, tedy dřív, než listener stihne nastavit parametr, a přihlášení by spadlo na Parameter 'tenant_id' does not exist. Tenantní jsou doménové entity za firewallem, ne třída, kterou firewall používá k autentizaci. Aktivace filtru v config/packages/doctrine.yaml:
1# config/packages/doctrine.yaml2doctrine:3 orm:4 filters:5 tenant:6 class: App\SharedKernel\Infrastructure\Doctrine\TenantFilter7 enabled: true # fail-closed: filtr běží vždy, parametr dodá listener
Pozor na výchozí stav. Vypnutý nebo nenakonfigurovaný filtr nepřidá do SQL žádné WHERE a dotaz vrátí data všech tenantů. SQLFilter je ze své podstaty fail-open a v tom je hlavní riziko celého přístupu. Konfigurace výše proto filtr zapíná globálně (enabled: true): běží pro každý dotaz a chybějící tenant_id skončí výjimkou, ne únikem dat.
Jedna mezera zůstane i pak. Filtr nepokrývá načtení neowning strany asociace one-to-one.
Měřeno na ORM 3.6 vrátí druhý konec vztahu i záznam cizího tenanta, přestože přes find()
nebo DQL týž záznam nedostanete. Kde na oddělení tenantů závisí bezpečnost, patří kontrola
tenanta i do doménové vrstvy, ne jen do filtru.
Hodnotu parametru dodává kernel event listener po autentizaci:
1<?php2 3// src/SharedKernel/Infrastructure/Http/TenantContextListener.php4declare(strict_types=1);5 6namespace App\SharedKernel\Infrastructure\Http;7 8use Doctrine\ORM\EntityManagerInterface;9use Symfony\Component\EventDispatcher\Attribute\AsEventListener;10use Symfony\Component\HttpKernel\Event\RequestEvent;11use Symfony\Component\HttpKernel\KernelEvents;12use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;13 14#[AsEventListener(event: KernelEvents::REQUEST, priority: 7)]15final readonly class TenantContextListener16{17 public function __construct(18 private EntityManagerInterface $em,19 private TokenStorageInterface $tokens,20 ) {}21 22 public function __invoke(RequestEvent $event): void23 {24 if (!$event->isMainRequest()) {25 return;26 }27 28 $token = $this->tokens->getToken();29 $user = $token?->getUser();30 if ($user === null || !method_exists($user, 'tenantId')) {31 return; // veřejný endpoint, anonymní požadavek32 }33 34 $tenantId = $user->tenantId()->value;35 $filter = $this->em->getFilters()->enable('tenant');36 $filter->setParameter('tenant_id', $tenantId);37 }38}
Volání enable('tenant') v listeneru je u globálně zapnutého filtru neškodné: vrátí existující instanci, na kterou stačí nastavit parametr.
Tři detaily:
- Priorita 7 v
AsEventListener. V Symfony platí vyšší priorita = dřívější vykonání. Firewall registruje svůjonKernelRequests prioritou 8, takže listener, který potřebuje už autentizovaného uživatele, musí mít prioritu nižší než 8 (typicky 7 nebo 0). Detail v dokumentaci EventDispatcheru. - Main request guard. Bez
$event->isMainRequest()by se filtr nastavoval i pro dílčí požadavky (ESI, render fragments), kde token obvykle není a listener by spadl. - Anonymní požadavek parametr nedostane. U veřejných endpointů (login, register, health) listener skončí na guardu a
tenant_idzůstane nenastavené. První dotaz nadTenantAwareentitou pak vyhodí výjimku, protože globálně zapnutý filtr bez parametru dotaz nepustí. Hlučné selhání je tu záměr: veřejný endpoint nemá tenantní data co číst. Pokud je přesto čte, patří požadavek odmítnout už na firewallu.
PostgreSQL Row-Level Security
Díra popsaná výše má u PostgreSQL řešení o patro níž. Row-Level Security posouvá filtrování do databáze, takže platí i pro nativní SQL, pro konzolové skripty i pro připojení mimo aplikaci:
1-- migrations/tenant_rls.sql2ALTER TABLE orders ENABLE ROW LEVEL SECURITY;3-- Bez FORCE se politika neuplatní na vlastníka tabulky4ALTER TABLE orders FORCE ROW LEVEL SECURITY;5 6CREATE POLICY tenant_isolation ON orders7 USING (tenant_id = current_setting('app.tenant_id', true))8 WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
Aplikace pak před dotazy nastaví proměnnou spojení příkazem SET app.tenant_id = '…', a to ve stejném listeneru, který plní Doctrine filtr. Proti SQLFilteru se RLS liší výchozím chováním, a to rozhoduje. Po ENABLE ROW LEVEL SECURITY platí na tabulce default-deny: bez politiky se nevrátí nic. SQLFilter je naopak fail-open a fail-closed chování se musí vyrobit ručně, jak popisuje předchozí callout. Cenou za RLS je vázanost na PostgreSQL, obtížnější ladění (dotaz vrátí prázdno a nikde není proč) a role s atributem BYPASSRLS, kterou potřebují migrace a zálohy. Obě vrstvy se nevylučují: filtr drží čitelné chování v ORM, RLS je poslední záchytná síť.
11.10 Testovací pyramida pro autorizaci#
Každá ze čtyř vrstev se testuje jiným druhem testu. Kdo se snaží pokrýt všechno end-to-end, skončí u pomalé a křehké sady. Dělení odpovídá klasické testovací pyramidě: hodně rychlých unit testů, méně integračních, pár end-to-end.
Aggregate-level: čistý unit test
Doménová pravidla v agregátu jsou čisté PHP bez frameworku a databáze, takže test je rychlý a deterministický.
Testy sahají po OrderFactory, která drží sestavení agregátu na jednom místě. Podle
taxonomie z Testování DDD jde o Object Mother: pojmenované
statické metody vracejí hotové scénáře (placed(), shipped()). S Foundry továrnou
stejného jména z téže kapitoly nemá nic společného. Vyplatí se, jakmile ji potřebuje víc
testovacích souborů:
1<?php2 3declare(strict_types=1);4 5namespace App\Tests\Ordering\Domain;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\ValueObject\CustomerId;9use App\Ordering\Domain\ValueObject\ProductId;10use App\Shipping\Domain\ValueObject\ShipmentId;11use App\SharedKernel\Domain\Currency;12use App\SharedKernel\Domain\Money;13 14final class OrderFactory15{16 private const AT = '2026-04-29 10:00:00';17 18 public static function placed(19 string $at = self::AT,20 ?CustomerId $customerId = null,21 ): Order {22 return self::build($customerId ?? CustomerId::generate(), $at);23 }24 25 public static function placedFor(CustomerId $customerId): Order26 {27 return self::build($customerId, self::AT);28 }29 30 // Vlastník je parametr i zde. Bez něj by testy policy hlásily31 // porušení vlastnictví místo pravidla, které chtěly ověřit.32 public static function shipped(?CustomerId $customerId = null): Order33 {34 $order = self::build($customerId ?? CustomerId::generate(), self::AT);35 $order->markPaid();36 $order->ship(ShipmentId::generate());37 38 return $order;39 }40 41 /**42 * Továrna jde přes veřejné API agregátu, ne přes reflexi. Konstruktor43 * je privátní a stav se mění jen přechody – kdyby si test sahal dovnitř,44 * přestal by hlídat právě ta pravidla, kvůli kterým existuje.45 */46 private static function build(CustomerId $customerId, string $at): Order47 {48 // Poslední parametr je čas potvrzení. Bez něj by se scénář49 // „potvrzeno v 10:00, stornováno ve 12:00“ nedal postavit jinak50 // než reflexí – a test by přestal hlídat pravidla agregátu.51 $order = Order::placeWithFirstItem(52 $customerId,53 ProductId::generate(),54 1,55 new Money(10_000, Currency::CZK),56 new \DateTimeImmutable($at),57 );58 59 $order->releaseEvents(); // fronta událostí patří testu, ne továrně60 61 return $order;62 }63}
1<?php2 3// tests/Ordering/Domain/OrderCancelTest.php4declare(strict_types=1);5 6namespace App\Tests\Ordering\Domain;7 8use App\Ordering\Domain\Event\OrderCancelled;9use App\Ordering\Domain\Exception\CancellationWindowExpiredException;10use App\Ordering\Domain\Exception\InvalidOrderStateTransitionException;11use App\Ordering\Domain\Model\Order;12use PHPUnit\Framework\TestCase;13 14final class OrderCancelTest extends TestCase15{16 public function testCancelWithinWindowSucceeds(): void17 {18 $order = OrderFactory::placed(at: '2026-04-29 10:00:00');19 $order->releaseEvents(); // vyprázdní eventy z fáze vytvoření20 21 $order->cancel('changed mind', new \DateTimeImmutable('2026-04-29 12:00:00'));22 23 // Stav se ověří přes chování: úspěšný cancel zaznamená OrderCancelled24 $events = $order->releaseEvents();25 self::assertCount(1, $events);26 self::assertInstanceOf(OrderCancelled::class, $events[0]);27 }28 29 public function testCancelOfShippedOrderThrows(): void30 {31 $order = OrderFactory::shipped();32 33 $this->expectException(InvalidOrderStateTransitionException::class);34 $order->cancel('changed mind', new \DateTimeImmutable());35 }36 37 public function testCancelAfter24hThrows(): void38 {39 $order = OrderFactory::placed(at: '2026-04-29 10:00:00');40 41 $this->expectException(CancellationWindowExpiredException::class);42 $order->cancel('too late', new \DateTimeImmutable('2026-04-30 11:00:00'));43 }44}
Voter: unit test se stubem TokenInterface
Voter dostává TokenInterface; v testu stačí jeho stub, reálný subjekt a stub rozhodovacího manažeru pro role. Symfony Kernel test nepotřebuje:
1<?php2 3// tests/Ordering/Infrastructure/Security/OrderVoterTest.php4declare(strict_types=1);5 6namespace App\Tests\Ordering\Infrastructure\Security;7 8use App\Identity\Infrastructure\Security\SecurityUser;9use App\Ordering\Domain\ValueObject\CustomerId;10use App\Ordering\Infrastructure\Security\OrderVoter;11use App\Tests\Identity\SecurityUserFixture;12use App\Tests\Ordering\Domain\OrderFactory;13use PHPUnit\Framework\TestCase;14use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;15use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;16use Symfony\Component\Security\Core\Authorization\Voter\Vote;17use Symfony\Component\Security\Core\Authorization\Voter\Voter;18 19final class OrderVoterTest extends TestCase20{21 // Identifikátory jsou UUID – CustomerId jinou hodnotu nepřijme.22 private const OWNER = '018f4d2e-7a31-7c9e-b4d0-6f2a1c8e5b03';23 private const STRANGER = '02b5e8c1-9d44-7f10-a8b7-3e5c9d21f746';24 25 public function testOwnerCanCancelOwnOrder(): void26 {27 $order = OrderFactory::placedFor(CustomerId::fromString(self::OWNER));28 29 self::assertSame(30 Voter::ACCESS_GRANTED,31 $this->voteCancel($order, actor: self::OWNER)32 );33 }34 35 public function testStrangerCannotCancelOrder(): void36 {37 $order = OrderFactory::placedFor(CustomerId::fromString(self::OWNER));38 39 self::assertSame(40 Voter::ACCESS_DENIED,41 $this->voteCancel($order, actor: self::STRANGER)42 );43 }44 45 private function voteCancel(object $order, string $actor): int46 {47 // Bez očekávání jde o stuby, ne mocky – createMock() by na PHPUnit 1348 // hlásil „No expectations were configured“ a ve 14 přestane fungovat.49 $decisions = $this->createStub(AccessDecisionManagerInterface::class);50 $decisions->method('decide')->willReturn(false); // aktér nemá žádnou roli navíc51 52 $token = $this->createStub(TokenInterface::class);53 $token->method('getUser')->willReturn(SecurityUserFixture::for($actor));54 55 return (new OrderVoter($decisions))->vote($token, $order, [OrderVoter::CANCEL]);56 }57}
1<?php2 3declare(strict_types=1);4 5namespace App\Tests\Identity;6 7use App\Identity\Infrastructure\Security\SecurityUser;8 9final class SecurityUserFixture10{11 /** Aktér pro test Voteru. Zajímá ho jen customerId, zbytek je výplň. */12 public static function for(string $customerId, string ...$roles): SecurityUser13 {14 // E-mail je primární klíč, takže musí být pro každého aktéra jiný –15 // dvě fixture se stejným by při ukládání kolidovaly.16 return new SecurityUser(17 email: $customerId . '@example.test',18 passwordHash: 'irrelevant',19 roles: $roles ?: ['ROLE_USER'],20 customerId: $customerId,21 tenantId: 'tenant-test',22 );23 }24}
Stub AccessDecisionManagerInterface vrací záměrně false. Test tak ověřuje vlastnictví bez vlivu rolí; pro scénář s adminem stačí druhý test s návratovou hodnotou true.
End-to-end: WebTestCase
Celou pipeline (firewall → controller → handler → Voter → agregát) pokrývá Symfony WebTestCase. To už je integrační test s kernelem a databází. Rozumná míra: jeden e2e test na use case, který pokryje hlavní scénář a jeden až dva nejdůležitější chybové stavy. Okrajové případy patří do unit testů na nižších vrstvách.
Přihlášení v takovém testu neprobíhá přes formulář. KernelBrowser::loginUser() vloží uživatele rovnou do session a ušetří jeden request i závislost na podobě login stránky. Jednu vazbu ale neodstraní: s entity providerem firewall uživatele při každém dalším requestu načítá znovu, takže fixture musí být v databázi. Jinak test skončí přesměrováním na /login a tváří se jako chyba autorizace.
1<?php2 3// tests/Ordering/Http/CancelOrderE2eTest.php4declare(strict_types=1);5 6namespace App\Tests\Ordering\Http;7 8use App\Ordering\Domain\Model\Order;9use App\Ordering\Domain\Repository\OrderRepository;10use App\Ordering\Domain\ValueObject\CustomerId;11use App\Tests\Identity\SecurityUserFixture;12use App\Tests\Ordering\Domain\OrderFactory;13use Doctrine\ORM\EntityManagerInterface;14use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;15 16final class CancelOrderE2eTest extends WebTestCase17{18 private const OWNER = '018f4d2e-7a31-7c9e-b4d0-6f2a1c8e5b03';19 private const STRANGER = '02b5e8c1-9d44-7f10-a8b7-3e5c9d21f746';20 21 protected function setUp(): void22 {23 // Fixture se zapisuje do databáze, takže se musí uklidit – jinak24 // druhý běh spadne na unique indexu, ne na testovaném chování.25 // Pohodlnější alternativa je dama/doctrine-test-bundle, který26 // každý test obalí transakcí a na konci ji vrátí zpět.27 $connection = static::getContainer()28 ->get(EntityManagerInterface::class)29 ->getConnection();30 31 foreach (['order_items', 'orders', 'app_user'] as $table) {32 $connection->executeStatement('DELETE FROM ' . $table);33 }34 35 // getContainer() kernel nabootuje, ale createClient() ho chce36 // nastartovat znovu. Bez tohohle řádku test spadne na37 // „Booting the kernel before calling createClient() is not supported“.38 self::ensureKernelShutdown();39 }40 41 public function testStrangerGetsNotFound(): void42 {43 $client = static::createClient();44 $order = $this->givenOrderOf(self::OWNER);45 46 // loginUser() vloží uživatele do session, ale při dalším requestu47 // ho firewall obnovuje přes entity provider – uživatel proto musí48 // v databázi být, jinak se token zahodí a test skončí na /login.49 $stranger = SecurityUserFixture::for(self::STRANGER);50 $container = static::getContainer();51 $container->get(EntityManagerInterface::class)->persist($stranger);52 $container->get(EntityManagerInterface::class)->flush();53 54 $client->loginUser($stranger);55 $client->request('POST', '/order/' . $order->id->value . '/cancel');56 57 // #[IsGranted(..., statusCode: 404)] brání enumeraci cizích ID58 self::assertResponseStatusCodeSame(404);59 }60 61 private function givenOrderOf(string $customerId): Order62 {63 $container = static::getContainer();64 $order = OrderFactory::placedFor(CustomerId::fromString($customerId));65 66 $container->get(OrderRepository::class)->save($order);67 $container->get(EntityManagerInterface::class)->flush();68 69 return $order;70 }71}
Architektonický test: doména bez Security komponenty
Anti-vzor 4 zakazuje závislost domény na Symfony\Component\Security. Pravidlo, které hlídá jen code review, se dřív nebo později poruší, proto ho vynucuje test. S PHPArkitect stačí jedno pravidlo:
1// tests/Architecture/DomainRules.php2Rule::allClasses()3 ->that(new ResideInOneOfTheseNamespaces('App\*\Domain\*'))4 ->should(new NotDependsOnTheseNamespaces('Symfony\Component\Security'))5 ->because('doménový model nesmí znát autorizační infrastrukturu');
Test běží v CI vedle unit testů a selže při prvním nepovoleném importu, ne až při refaktoringu za rok. Pyramidu, fixture buildery i další architektonická pravidla rozebírá kapitola o testování.
Policy: tabulkový unit test
U policy-based přístupu je každé pravidlo jeden test case. Vyhodnocení výrazů stojí na balíčku symfony/expression-language, který v základní instalaci není. Nejlépe poslouží tabulkový test s data providerem: jeden řádek = jeden scénář, čitelný i pro netechnického reviewera:
1<?php2 3// tests/Ordering/Authorization/CancelOrderPolicyTest.php4declare(strict_types=1);5 6namespace App\Tests\Ordering\Authorization;7 8use App\Ordering\Authorization\CancelOrderPolicy;9use App\Ordering\Domain\ValueObject\CustomerId;10use App\SharedKernel\Authorization\PolicyContext;11use App\SharedKernel\Authorization\PolicyEvaluator;12use App\Tests\Identity\SecurityUserFixture;13use App\Tests\Ordering\Domain\OrderFactory;14use PHPUnit\Framework\Attributes\DataProvider;15use PHPUnit\Framework\TestCase;16 17final class CancelOrderPolicyTest extends TestCase18{19 private const OWNER = '018f4d2e-7a31-7c9e-b4d0-6f2a1c8e5b03';20 private const STRANGER = '02b5e8c1-9d44-7f10-a8b7-3e5c9d21f746';21 22 public static function scenarios(): iterable23 {24 yield 'happy path' => [25 'subject' => OrderFactory::placedFor(CustomerId::fromString(self::OWNER)),26 'user' => SecurityUserFixture::for(self::OWNER),27 'expected' => null,28 ];29 yield 'wrong customer' => [30 'subject' => OrderFactory::placedFor(CustomerId::fromString(self::OWNER)),31 'user' => SecurityUserFixture::for(self::STRANGER),32 'expected' => 'Pouze vlastník objednávky',33 ];34 yield 'shipped order' => [35 'subject' => OrderFactory::shipped(CustomerId::fromString(self::OWNER)),36 'user' => SecurityUserFixture::for(self::OWNER),37 'expected' => 'Objednávka musí být potvrzená',38 ];39 yield 'window expired' => [40 'subject' => OrderFactory::placed('2026-04-28 09:00:00', CustomerId::fromString(self::OWNER)),41 'user' => SecurityUserFixture::for(self::OWNER),42 'expected' => 'Storno lhůta 24 h ještě neuplynula',43 ];44 }45 46 #[DataProvider('scenarios')]47 public function testEvaluate(object $subject, object $user, ?string $expected): void48 {49 $evaluator = new PolicyEvaluator();50 // Čas vyhodnocení je pevný, jinak by scénář se lhůtou po roce51 // začal padat sám od sebe.52 $context = new PolicyContext($subject, $user, new \DateTimeImmutable('2026-04-29 12:00:00'));53 54 $violation = $evaluator->evaluate(new CancelOrderPolicy(), $context);55 56 self::assertSame($expected, $violation?->description);57 }58}
Oproti testu s metodou na každý případ má tabulka dvě výhody. Nové pravidlo znamená jeden řádek navíc v scenarios(). A celý test slouží jako spustitelná dokumentace policy: reviewer mimo vývojový tým vidí všechny případy v jedné tabulce a může doménová pravidla schválit.
11.11 Anti-vzory#
Čtyři anti-vzory mají strukturu „symptom – důsledek – náprava“. První dva shrnují, co kapitola už rozebrala, aby šel v code review projít celý seznam na jednom místě.
Anti-vzor 1: Autorizace v controlleru
Rozebírá ho sekce 11.01. Symptom: stejná autorizační podmínka opakovaná ve třech a více controllerech, která chybí ve verzích volaných z konzolového commandu nebo Messenger handleru. Náprava: přesun do Voteru a volání AuthorizationCheckerInterface v Application Service. Souvisí: obecné anti-vzory v DDD.
Anti-vzor 2: Voter, který si načte vlastní subjekt
Rozebráno v calloutu u Voteru. Symptom: konstruktor Voteru přijímá repository a voteOnAttribute() volá find($subject) nad ID, které dostal místo objektu. Důsledek: druhý dotaz na tutéž entitu a rozhodování nad stavem, který se mezitím mohl změnit. Náprava: handler načte entitu jednou a předá ji do isGranted(). Doplňková data mimo subjekt (členství, delegace) si Voter načíst smí; zákaz míří na subjekt, ne na všechny dotazy.
Anti-vzor 3: Voter == Aggregate logic
Symptom: storno lhůta („objednávka ne starší než 24 h“) je zapsaná jak ve Voteru, tak v Order::cancel(). Když se pravidlo změní (lhůta se prodlouží na 48 h), musí se upravit obě místa a na jedno se typicky zapomene.
Náprava: pravidlo patří do agregátu, protože jde o doménový invariant. Voter doménový stav agregátu neověřuje; odpovídá jen na identitu a role uživatele a na vlastnictví subjektu. Pro skrytí tlačítka ve view se v Twigu kombinuje {% if is_granted(...) and order.isCancellable(now) %}: Voter pro oprávnění, doménová metoda pro stav.
Anti-vzor 4: Symfony User natažený do doménového Aggregate
Symptom:
1<?php2 3// src/Ordering/Domain/Model/Order.php (anti-vzor)4namespace App\Ordering\Domain\Model;5 6use Symfony\Component\Security\Core\User\UserInterface;7 8final class Order9{10 // Anti-vzor: doména závisí na Symfony Security komponentě11 public function cancel(UserInterface $user, string $reason): void12 {13 if ($user->getUserIdentifier() !== $this->customerEmail) {14 throw new \DomainException('Not your order');15 }16 // ...17 }18}
Technický důsledek: doména závisí na Symfony\Component\Security, takže stejný kód nespustíte z konzolového commandu, z Messenger workeru ani z unit testu bez Kernelu. Modelový důsledek váží víc. Role a oprávnění jsou slovník jiné subdomény, Identity & Access kontextu z 11.02. Jakmile se objeví v Order::cancel(), mluví Ordering kontext cizím Ubiquitous Language a hranice mezi kontexty se stírá.
Náprava: doména pracuje s vlastním typem (CustomerId, TenantId), aplikační handler překládá SecurityUser na doménový identifikátor a vynucuje to architektonický test. Opačný směr téhož porušení vrstev, tedy doménovou logiku v infrastruktuře, rozebírá kapitola o anti-vzorech.
11.12 Shrnutí#
Autorizace v DDD aplikaci na Symfony 8 sedí na čtyřech vrstvách, každá s vlastním Symfony nástrojem a vlastní granularitou:
- Edge – Symfony firewall +
access_control. Anonymní vs. přihlášený, hrubé dělení podle rolí. Žádná doménová znalost. - Use Case – Symfony Voter. „Smí Petr zrušit objednávku #42?“ Aplikační handler ověří oprávnění před doménovou operací: synchronně přes
isGranted(), ve workeru protiactorIdz commandu. Doména Security komponentu nezná. - Aggregate – doménový invariant + doménová výjimka. „Objednávku nelze stornovat po odeslání ani po 24 h od potvrzení.“ Agregát vyhazuje
InvalidOrderStateTransitionExceptionneboCancellationWindowExpiredException; aplikační vrstva je mapuje na HTTP 409. - Field – Twig
is_grantedve view (s rizikem úniku dat) nebo query filter / read model pro citlivá data (PII, audit log). Seznamy potřebují filtr v dotazu, ne Voter nad každým řádkem.
Hrubá oprávnění pokryje RBAC. Jakmile pravidla závisí na vztazích mezi entitami, nastupuje uvažování v ABAC pojmech, implementované ale z Voterů, ne z vlastní policy vrstvy. Vícenájemnost řeší Doctrine SQLFilter s kernel listenerem, nastavený fail-closed, a v PostgreSQL k tomu RLS jako záchytná síť pod aplikací. Doménové stavové pravidlo patří do agregátu; vztah aktéra k agregátu (vlastnictví) definuje rovněž agregát a Voter se ho ptá.
Kdy z Voterů odejít, neurčuje počet pravidel, ale tři otázky: musí být policy čitelná mimo vývojový tým, mění se v jiném rytmu než kód, sdílí ji víc aplikací? Dokud zní odpověď třikrát ne, jsou Votery s Vote::addReason() levnější varianta. Při prvním ano přichází externí engine (OPA, Cerbos) a Voter se stane tenkým vynucovacím bodem.
Praktický checklist před deploy
Než commitnete autorizační změnu, projděte těchto devět bodů:
- Existuje v
access_controldefault-deny pravidlo na konci? Pokud ne: nový endpoint bez explicitní role je veřejný. - Ověřuje Application Handler oprávnění (
isGranted(), ve workeruactorIdz commandu) před doménovou operací? Pokud ne: autorizace se může obejít přes alternativní vstupní bod (CLI, Messenger). - Je doménový invariant zapsaný v aggregate, ne ve Voteru? Pokud ne: pravidlo se obejde přímým voláním aggregate metody mimo handler.
- Je rozhodovací strategie nastavená na
unanimous? Pokud ne: při výchozíaffirmativepřebije jeden souhlasící Voter všechny nesouhlasící. - Vrací aplikace 403, 404 nebo 409 podle typu selhání? Pokud ne: uživatel dostane matoucí hlášku, nebo lze enumerovat cizí identifikátory.
- Mají citlivá pole (PII, audit) query filter, ne jen Twig if? Pokud ne: data unikají přes JSON API, dev tools, ETag.
- Filtruje endpoint se seznamem v dotazu, ne přes
is_granted()nad každým řádkem? Pokud ne: rozpadne se stránkování a výkon klesá lineárně. - Pokud je aplikace multi-tenant: má Doctrine SQLFilter fail-closed default? Pokud ne: chybějící tenant context vrátí všechna data.
- Existuje na každé vrstvě alespoň jeden test, včetně architektonického? Aggregate test, Voter test, e2e test a zákaz importu Security v doméně.
Časté otázky
Mám psát jeden Voter na entitu, nebo víc?
Jeden Voter na entitu, který pokrývá N atributů (VIEW, CANCEL, REFUND, …). V supports() se filtruje podle $subject instanceof Order a podle whitelistu atributů; v voteOnAttribute() se atributy mapují přes match expression na privátní metody. Více Voterů na jednu entitu se vyplatí jen tehdy, když oprávnění potřebují úplně jiné závislosti (typicky vlastnictví vs. role) a chcete je testovat nezávisle. Detail v sekci o Voteru.
Smí Voter načítat aggregate z databáze?
Subjekt, o kterém rozhoduje, ne. Voter ho dostává jako $subject; handler ho už načetl a předává v paměti. Druhé načtení je anti-vzor (11.11): vede k duplicate query a k rozhodování nad stavem, který se mezitím mohl změnit. Doplňková data, která na subjektu nejsou (členství v týmu, delegace, hierarchie tenantů), si Voter načíst musí a Symfony s injektovanými službami ve Voteru počítá. Takové dotazy patří za cache platnou po dobu requestu.
Kdy stačí ROLE_USER a kdy je třeba attribute-based přístup?
RBAC (role) stačí, dokud platí „role popisuje oprávnění sama o sobě“: ROLE_ADMIN smí všechno, ROLE_REFUND_AGENT smí refundy bez ohledu na konkrétní entitu. Jakmile oprávnění závisí na vztazích (vlastnictví, tenant, časové okno, stav agregátu), RBAC se rozroste a vznikají úzce specifické role typu ROLE_TENANT_42_ORDER_AGENT. Tehdy nastupuje uvažování v ABAC pojmech (11.08): rozhodnutí vyhodnocuje atributy subjektu, uživatele a kontextu. Neznamená to psát vlastní policy engine; v Symfony 8 se totéž postaví z Voterů, které umí i vrátit důvod zamítnutí.
Co když máme 100 různých rolí?
To je obvykle příznak, že role replikují data, která patří do entit. Místo ROLE_TENANT_42_ADMIN, ROLE_TENANT_43_ADMIN, … stačí atribut user.tenantId, jedna generická role ROLE_TENANT_ADMIN a Voter, který ověří user.tenantId == subject.tenantId. Zjednoduší se tím správa uživatelů, audit i delegace. Detail v sekci o multi-tenancy.
Smí doménový Aggregate záviset na Symfony Security komponentě?
Ne. Doména musí být nezávislá na frameworku. Jinak ji nelze unit-testovat bez Kernelu, sdílet mezi webem a CLI ani převést na jiný framework. Modelový důvod je ale silnější než technický: role a oprávnění jsou slovník Identity & Access kontextu, ne toho, ve kterém agregát žije. Potřebuje-li agregát „znát“ uživatele, dostane vlastní doménový typ (CustomerId). Překlad ze SecurityUser obstará aplikační handler. Detail v anti-vzoru 4 v 11.11.
Kam ukládat audit log autorizačních rozhodnutí?
Tři možnosti, podle compliance požadavků: (1) Symfony Monolog s vlastním channelem authorization, což stačí pro většinu aplikací, log do souboru / ELK / Loki; (2) doménová tabulka authorization_decisions s parametry (user_id, attribute, subject_id, decision, policy_version), vhodné pro regulované domény (PCI DSS, zdravotnictví); (3) externí audit služba (AWS CloudTrail, Datadog) pro multi-tenant SaaS. Nejjednodušší je dekorátor nad AuthorizationCheckerInterface, který každé volání zaloguje; hlasy jednotlivých voličů ale vidí až vlastní AccessDecisionStrategyInterface nebo Security::getAccessDecision(). Detail v sekci Audit log autorizačních rozhodnutí.
11.13 Další četba#
- Symfony Security komponenta – oficiální dokumentace
- Symfony Voters – Custom Authorization
- OWASP Top 10 (2021): A01 – Broken Access Control
- NIST SP 800-162 – Guide to Attribute-Based Access Control
- OpenID Connect Core 1.0 – autentizační vrstva nad OAuth 2.0
- Stripe API keys – restricted keys s explicitním scope
- Symfony – jak funguje access_control
- Open Policy Agent (OPA) – externí policy engine
- OpenFGA – Authorization Concepts (ReBAC, model Zanzibaru)
- PostgreSQL – Row Security Policies
- Noback, M.: Decoupling your security user from your user model (2022)
- Pang, R. a kol.: Zanzibar: Google's Consistent, Global Authorization System. USENIX ATC '19, Renton, WA, 2019.
- Vernon, V.: Implementing Domain-Driven Design. Addison-Wesley, 2013. Kapitola 14 „Application“ a Identity and Access Context.