Kapitola 11 · Architektura · Autorizace v DDD na Symfony

Autorizace v DDD na Symfony

V DDD aplikacích se opakovaně objevuje stejná otázka: „smí to ten uživatel udělat?“ – patří do controlleru, do voteru, do aggregate, nebo někam jinam? Kapitola dává konkrétní čtyřvrstvý rámec: Edge, Use Case, Aggregate, Field. Každá vrstva odpovídá na jinou otázku a používá jiný Symfony nástroj.

Autor M. Katuščák
Doba čtení ≈ 34 min
Náročnost pokročilá
Publikováno · Aktualizováno ·
Obsah kapitoly

V předchozí kapitole jsme implementovali agregáty, repozitáře a Application Services v Symfony 8. Otevřená 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. V této kapitole zavedeme čtyřvrstvý rámec, který autorizační rozhodnutí umístí na správnou vrstvu: od HTTP firewallu přes Symfony Voter v aplikační vrstvě 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ěď rozpadne mezi controllery, listenery, twig šablony a Doctrine query buildery. Kapitola dává čtyřvrstvý rámec: podle něj poznáte, kam které pravidlo patří a jak ho v Symfony 8 implementovat idiomaticky. Security komponenta přitom nepronikne do doménového jádra.

Kapitola navazuje na Implementaci v Symfony, která autorizaci záměrně ponechala stranou a odkazuje sem. Doplňuje praktický pohled k tématům CQRS (kde sedí ověření Command Handleru), Testování (jak otestovat každou ze 4 vrstev samostatně) a DDD v praxi – kde to bolí (která 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:

php src/Controller/OrderController.php (anti-vzor)
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í queue) a z administračního panelu. Při každém volání musí někdo tutéž podmínku zopakovat. Stačí, aby jeden vstupní bod selhal, a celá ochrana padá. Pravidlo „zrušit smí jen vlastník“ patří do use-case vrstvy. Zde je rozeseté po infrastruktuře, ne na jednom místě.

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ů. Aggregate má veřejné API setStatus(), setTotal(), setCustomerId() a Voter „natáhne“ autorizaci přes ně:

php src/Security/OrderVoter.php (anti-vzor)
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 (cancellation window) 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ě: Aggregate Order::setStatus(OrderStatus::Cancelled) stále existuje a je veřejné. Stačí, aby kdokoli (test, fixture, migration script, jiný vývojář) zavolal setter mimo Voter, a invariant „24h cancellation window“ přestane platit. Voter je jen volitelný filtr před vstupem; doména nemá žádnou pojistku. Pravidlo „cancellation window“ je doménové, ne use-case-level.

Chyba 3: Autorizace na úrovni databázových řádků

Tým objeví Doctrine SQLFilter a rozhodne, že autorizaci vyřeší v perzistentní vrstvě: entity se z databáze nevrátí, pokud k nim uživatel nemá přístup. Funguje to pro read dotazy, ale rozpadá se v doménové logice:

  • Když handler dostane $orderId a entita se nenajde, neví, jestli neexistuje, nebo jen není dostupná pro daného uživatele. Chybová hláška „Order not found“ je matoucí.
  • 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 „order patří customerovi“ ztrácejí jedno závazné místo: zapsaná jsou v SQL filtru, ve Voteru se na ně zapomíná a v aggregate chybí. Při volání mimo HTTP vrstvu se nevynutí.

11.02 Čtyři vrstvy autorizace#

Vrstvy dávají smysl až ve strategickém kontextu. Identita a oprávnění tvoří vlastní Bounded Context. Vernon mu v Implementing Domain-Driven Design říká Identity and Access Context. V referenční implementaci IDDD_Samples stojí jako samostatný modul vedle Ordering a Collaboration a ostatní kontexty ho konzumují jako službu. Typologicky spadá pod 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á autorizační rozhodnutí ve čtyřech postupných vrstvách. Každá vrstva má vlastní otázku, Symfony nástroj i granularitu. Vrstvy fungují jako filtry: každá další odpovídá na jemnější otázku a předpokládá, že předchozí vrstva už řekla „ano“.

FIG. 11.2-A 4 vrstvy autorizace v DDD aplikaci
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 cancelnout order #42?“
Aggregate Dá se to vůbec teď udělat? doménový check + výjimka „Order lze cancelnout jen 24 h od vytvoření“
Field Smí vidět konkrétní pole? Twig + Voter, query filter „Sloupec audit_log vidí jen admin“

Formulace toho pravidla je přesnější takto: každé autorizační pravidlo má právě jedno místo definice. Vynucení může proběhnout 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ů přitom stojí na jedné podmínce. Uvnitř use-case vrstvy platí jen tehdy, když je rozhodovací strategie nastavená na unanimous. Výchozí affirmative ji obrací naruby: 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á pouze na otázku „kdo je vůbec na druhém konci socketu?“, tedy anonymous, authenticated, případně role-based pro hrubě dělené sekce (/admin/*, /api/v1/*). Doménová 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. Důvod je zásadní pro 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].

yaml config/packages/security.yaml (výřez: firewall)
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                # Bez tohohle řádku míří Symfony po přihlášení na `/`,37                # kterou kniha nikde nedefinuje – první proklik po loginu38                # by skončil na 404.39                default_target_path: app_profile40            # Bez `target` platí totéž co u přihlášení: odhlášení41            # přesměruje na `/` a skončí na 404.42            logout: { target: login }43 44    access_control:45        # Veřejné endpointy46        - { path: ^/login,        roles: PUBLIC_ACCESS }47        # Pozor na tvar cesty: ^/register nepokrývá /api/register.48        # JSON endpoint by jinak skončil přesměrováním na login dřív,49        # než se kontroler vůbec spustí.50        - { path: ^/register,     roles: PUBLIC_ACCESS }51        - { path: ^/api/register, roles: PUBLIC_ACCESS }52        - { path: ^/health,       roles: PUBLIC_ACCESS }53        # Hrubá role-based separace54        - { path: ^/admin,        roles: ROLE_ADMIN }55        - { path: ^/api/internal, roles: ROLE_SERVICE_ACCOUNT }56        # Vše ostatní za autentizací57        - { path: ^/,             roles: IS_AUTHENTICATED_FULLY }

form_login odkazuje na routu login, kterou musí někdo vytvořit. Firewall ji sám nezaloží a po prvním chráněném requestu spadne na chybějící routě:

php src/Identity/Infrastructure/Http/LoginController.php
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 k němu je krátká, ale dvě jména v ní nejdou vymyslet: _username a _password očekává form_login autentikátor a přejmenovat je znamená přenastavit firewall.

twig templates/security/login.html.twig
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 %}

Doprovodná třída SecurityUser drží most mezi Symfony a doménou. Kromě UserInterface vystavuje doménové identifikátory, které si z ní vezme aplikační vrstva:

php src/Identity/Infrastructure/Security/SecurityUser.php
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ů:

php src/Identity/Domain/TenantId.php
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 nesmí být prázdné.');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á pojem „order“, „customer“, „cancellation window“. Pracuje jen s URL pattern + roles + autentizační stav.
  • Default deny. Poslední pravidlo v access_control je „všechno ostatní vyžaduje přihlášení“. Bez tohoto fallbacku stačí přidat nový endpoint a zapomenout ho zařadit; bude automaticky veřejný.
  • Role-based, ne attribute-based. ROLE_ADMIN je hrubá kategorizace; jemnější rozhodnutí jako „admin tenantu T1, ne T2“ patří do Voteru, ne do access_control.
  • JWT firewall vs. session. API typicky stateless (jwt autentikátor), web typicky session-based. Pro JWT v Symfony existuje balíček lexik/jwt-authentication-bundle nebo nativní access_token autentikátor s OidcTokenHandler pro 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?“. Symfony Voter je přesně k tomu navržený nástroj. Pravidlo: 1 use case = 1 atribut Voteru; jeden Voter může pokrývat N atributů, pokud se týkají stejné entity (typicky CRUD operace nad agregátem).

Voter zná dvě věci: identitu uživatele (přes TokenInterface) a cílový subjekt (typicky aggregate root). Co Voter nesmí dělat: načítat subjekt, o kterém rozhoduje, a znát doménové invarianty (to je práce aggregate). Pravidla typu „cancellation window“ Voter nesmí natáhnout zvenku; patří ke stavu agregátu.

php src/Ordering/Infrastructure/Security/OrderVoter.php
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 jen CANCEL). Vyhne se kolizi s atributy jiných Voterů (invoice.cancel, shipment.cancel) a v audit logu je hned jasné, kterého subjektu se rozhodnutí týkalo.
  • Match expression místo if-else stromu. Bez default větve PHPStan ohlásí nepokrytý case; default => false naopak volí tiché zamítnutí (fail-closed) výměnou za ztrátu této kontroly.
  • Privátní metody canView, canCancel. Každý use case má vlastní privátní metodu: test namockuje token i subjekt a asserce na výsledek je explicitní. Bez extrakce by se voter rozrostl do nečitelného switch-case.
  • Role se uvnitř Voteru kontrolují přes AccessDecisionManagerInterface::decide(), ne dotazem na uživatelskou třídu. Volání $user->hasRole('ROLE_ADMIN') obejde hierarchii rolí nakonfigurovanou v security.yaml: uživatel s ROLE_SUPER_ADMIN by ROLE_ADMIN nedostal, přestože ho hierarchie zahrnuje. Doporučuje to dokumentace k Voterům [7].
  • supportsAttribute() a supportsType() pocházejí z CacheableVoterInterface, které abstraktní Voter implementuje. Výchozí návratová hodnota obou je true, takže bez override žádnou optimalizaci nepřinášejí. Seznam s 200 řádky a pěti Votery znamená tisíc zbytečných volání supports(); s override odpadne většina z nich už na úrovni rozhodovacího manažeru.

Rozhodovací strategie a AccessDecisionManager

Voterů bývá v aplikaci víc a jejich hlasy někdo skládá dohromady. Dělá to AccessDecisionManager a strategie, kterou použije, mění výsledek zásadněji než cokoli uvnitř samotných Voterů:

Strategie Chování Kdy dává smysl
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: …)]

Výchozí affirmative je pro rámec této kapitoly š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. Pro vrstvenou autorizaci proto:

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.

yaml config/packages/security.yaml (kanonická konfigurace knihy)
1# config/packages/security.yaml2security:3    access_decision_manager:4        strategy: unanimous5        allow_if_all_abstain: false   # nikdo nehlasoval = zamítnuto

Volba allow_if_all_abstain: false je výchozí, ale patří do konfigurace explicitně. Je to poslední fail-closed pojistka: atribut, pro který se žádný Voter nepřihlásí, skončí zamítnutím místo tichého povolení.

#[IsGranted] na controlleru

Kontrola v handleru je autoritativní, na hranici HTTP se ale vyplatí odmítnout požadavek dřív, než se vůbec sestaví command. K tomu slouží atribut Symfony\Component\Security\Http\Attribute\IsGranted. Funguje na metodě i na celé třídě controlleru a druhým argumentem odkazuje na argument akce, který se má stát subjektem:

php src/Ordering/Infrastructure/Http/OrderController.php
1<?php2 3// src/Ordering/Infrastructure/Http/OrderController.php4declare(strict_types=1);5 6namespace App\Ordering\Infrastructure\Http;7 8use App\Ordering\Application\Command\CancelOrderCommand;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 CancelOrderCommand(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 potvrdí útočníkovi, že objednávka s daným ID existuje, a umožní enumerovat cizí identifikátory. Ke stejnému tématu se vrací callout o 403 vs. 409.

Dvě omezení, která je dobré znát předem. Atribut potřebuje subjekt už jako objekt, takže se neobejde bez převodu z parametru routy. Dotaz do databáze se tím přesouvá do controlleru. #[MapEntity] na to samo 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 hledá špatným směrem:

Could not convert PHP value '01a074c3-…' to type OrderIdType.
Expected one of the following types: null, OrderId.

Řešení je vlastní resolver, který řetězec převede na hodnotový objekt dřív, než se sáhne do repozitáře:

php src/Ordering/Infrastructure/Http/OrderValueResolver.php
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('Neplatné ID objednávky.');33        }34 35        yield $this->orders->get(OrderId::fromString($id));36    }37}

A 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í jako řešení per-objektových oprávnění komponentu ACL: tabulky acl_entries, acl_object_identities a MaskBuilder. Pro Symfony 8 už to není volba. ACL byla z jádra odstraněna ve verzi 6.0 a samostatný symfony/acl-bundle deklaruje ve svém posledním vydání podporu Symfony 4.4 až 7.0. Ani technicky by ale 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 rozpadne na řádky. Ty pak musí někdo držet v synchronizaci se stavem agregátu. Voter tutéž věc počí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), sáhne se dnes po vlastní tabulce vazeb nebo po ReBAC modelu z pozdější sekce.

Použití ve Command Handleru

Voter sám o sobě nestačí – někdo ho musí zavolat. Idiomatické místo 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á vrstva by tu závislost mít nesměla.

php src/Ordering/Application/Handler/CancelOrderHandler.php
1<?php2 3// src/Ordering/Application/Handler/CancelOrderHandler.php4declare(strict_types=1);5 6namespace App\Ordering\Application\Handler;7 8use App\Ordering\Application\Command\CancelOrderCommand;9use App\Ordering\Application\Exception\AccessDeniedDomainException;10use App\Ordering\Domain\Repository\OrderRepository;11use App\Ordering\Infrastructure\Security\OrderVoter;12use Symfony\Component\Messenger\Attribute\AsMessageHandler;13use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;14 15#[AsMessageHandler(bus: 'command.bus')]16final readonly class CancelOrderHandler17{18    public function __construct(19        private OrderRepository $orders,20        private AuthorizationCheckerInterface $auth,21    ) {}22 23    public function __invoke(CancelOrderCommand $command): void24    {25        $order = $this->orders->get($command->orderId);26 27        if (!$this->auth->isGranted(OrderVoter::CANCEL, $order)) {28            throw new AccessDeniedDomainException(29                sprintf('Cancel not allowed for order %s', $command->orderId->value)30            );31        }32 33        $order->cancel(reason: $command->reason, when: new \DateTimeImmutable());34        $this->orders->save($order);35 36        // Publikaci událostí ukázka vynechává, aby zůstala čitelná otázka37        // autorizace. V úplném tvaru ji doplňuje async varianta níže –38        // a bez ní se agregát a read model rozejdou.39    }40}

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.

php src/Ordering/Application/Exception/AccessDeniedDomainException.php + src/Ordering/Domain/Exception/CancellationWindowExpiredException.php
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            'Objednávku „%s“ potvrzenou %s už nelze stornovat (pokus %s).',30            $orderId->value,31            $placedAt->format('Y-m-d H:i'),32            $attemptedAt->format('Y-m-d H:i'),33        ));34    }35}

Po této kontrole zavolá handler doménovou operaci $order->cancel(...), která uvnitř agregátu ověří doménové invarianty (status, cancellation window). Tím vznikají dvě nezávislé bariéry: Voter řekne „smí Petr“, aggregate řekne „dá se to vůbec teď“. Detail aggregate vrstvy v sekci 11.06. Jeden háček tu ale je: handler nese atribut #[AsMessageHandler] a v asynchronním workeru žádný token neexistuje. Věnuje se tomu následující sekce.

Voter v Twig template

Stejný Voter pokrývá i view-level rozhodnutí (skrýt tlačítko „Zrušit objednávku“ pro ne-vlastníka). V Twigu funkce is_granted() volá tentýž AuthorizationCheckerInterface. Proměnnou now (\DateTimeImmutable) předává do šablony controller, protože doménová metoda isCancellable() si aktuální čas nezískává sama. Šablona přitom č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 getter navíc na agregátu:

twig templates/order/detail.html.twig
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 %} #}

Pozor: {% if is_granted(...) %} v Twigu jen schová tlačítko. Neověří, že request nebude poslán manuálně (curl, Postman, browser dev tools). View-level kontrola je UX, nikoli bezpečnostní bariéra. Bezpečnostní rozhodnutí padne v handleru.

11.05 Autorizace v asynchronním kontextu#

Jakmile command putuje přes asynchronní transport, kontrola přes AuthorizationCheckerInterface se rozpadne. Messenger worker běží mimo HTTP požadavek: TokenStorage je prázdná, $this->security->getUser() vrací null a Voter postavený na tokenu vyhodnotí každé volání jako zamítnuté. Kód, který v synchronním režimu fungoval, začne po přepnutí transportu tiše odmítat legitimní operace.

Řešení: command nese identitu aktéra. V místě vzniku, typicky v controlleru, token ještě existuje – tam se do commandu zapíše actorId jako doménový identifikátor uživatele, ne Symfony UserInterface. Handler pak autorizuje proti této identitě bez ohledu na to, kde a kdy běží.

php src/Ordering/Application/Command/CancelOrderCommand.php
1<?php2 3// src/Ordering/Application/Command/CancelOrderCommand.php4declare(strict_types=1);5 6namespace App\Ordering\Application\Command;7 8use App\Ordering\Domain\ValueObject\CustomerId;9use App\Ordering\Domain\ValueObject\OrderId;10 11final readonly class CancelOrderCommand12{13    public function __construct(14        public OrderId $orderId,15        public string $reason,16        public CustomerId $actorId, // identita aktéra z místa vzniku17    ) {}18}
php src/Ordering/Application/Handler/CancelOrderHandler.php (async varianta)
1<?php2 3// src/Ordering/Application/Handler/CancelOrderHandler.php (async varianta)4declare(strict_types=1);5 6namespace App\Ordering\Application\Handler;7 8use App\Ordering\Application\Command\CancelOrderCommand;9use App\Ordering\Application\Exception\AccessDeniedDomainException;10use App\Ordering\Domain\Repository\OrderRepository;11use App\SharedKernel\Domain\SystemActor;12use Doctrine\ORM\EntityManagerInterface;13use Symfony\Component\DependencyInjection\Attribute\Target;14use Symfony\Component\Messenger\Attribute\AsMessageHandler;15use Symfony\Component\Messenger\MessageBusInterface;16 17#[AsMessageHandler(bus: 'command.bus')]18final readonly class CancelOrderHandler19{20    public function __construct(21        private OrderRepository $orders,22        private EntityManagerInterface $em,23        #[Target('event.bus')]24        private MessageBusInterface $eventBus,25    ) {}26 27    public function __invoke(CancelOrderCommand $command): void28    {29        $order = $this->orders->get($command->orderId);30 31        // Autorizace proti identitě v commandu – token ve workeru neexistuje.32        // Systémová identita vlastníkem není a nikdy nebude, proto stojí ve33        // vlastní větvi. Bez ní handler odmítne vlastní kompenzaci ságy.34        $isSystem = $command->actorId->value === SystemActor::ID;35 36        if (!$isSystem && !$order->isOwnedBy($command->actorId)) {37            throw new AccessDeniedDomainException(38                sprintf('Cancel not allowed for order %s', $command->orderId->value)39            );40        }41 42        // Zámek patří procesu, takže si ho proces sám uvolní. Kdyby to43        // dělal až samostatný příkaz, záleželo by na pořadí ve frontě.44        if ($isSystem) {45            $order->releaseSagaLock();46        }47 48        $order->cancel(reason: $command->reason, when: new \DateTimeImmutable());49        $this->orders->save($order);50        $this->em->flush();51 52        // Bez tohohle kroku agregát skončí v cancelled, ale dashboard53        // zůstane na placed. Nic nespadne – stavy se jen rozejdou.54        foreach ($order->releaseEvents() as $event) {55            $this->eventBus->dispatch($event);56        }57    }58}

Tahle varianta je ta úplná. Obě ukázky nesou stejné FQCN, takže do projektu jde jedna z nich, a je to tahle. Synchronní verze výše zůstává kvůli tomu, aby na ní byla vidět samotná otázka autorizace; ve workeru by neobstála, protože AuthorizationCheckerInterface tam nemá token.

Owner-based pravidlo vystačí s porovnáním actorId proti vlastníkovi agregátu, jak ukazuje handler výše. Voter z HTTP vrstvy přitom nezaniká: controller před odesláním commandu volá is_granted jako rychlou zpětnou vazbu pro UI. Rozhodující kontrola ale sedí v handleru a v agregátu. Běží při každém zpracování, synchronním i asynchronním.

Když je potřeba ve workeru celý Voter

Ruční porovnání identit stačí na vlastnictví. Pravidla závislá na rolích (refund smí jen ROLE_REFUND_AGENT) by se tímto způsobem musela ve workeru napsat podruhé a jinak než ve Voteru. Tím vzniká přesně ta duplicita, kterou zakazuje anti-vzor 3. Symfony na to má UserAuthorizationCheckerInterface a metodu isGrantedForUser(), která spustí tytéž Votery proti předanému uživateli, aniž by potřebovala session nebo token v TokenStorage:

php src/Ordering/Application/Handler/RefundOrderHandler.php
1<?php2 3// src/Ordering/Application/Handler/RefundOrderHandler.php4declare(strict_types=1);5 6namespace App\Ordering\Application\Handler;7 8use App\Identity\Infrastructure\Security\SecurityUserProvider;9use App\Ordering\Application\Command\RefundOrderCommand;10use App\Ordering\Application\Exception\AccessDeniedDomainException;11use App\Ordering\Domain\Repository\OrderRepository;12use App\Ordering\Infrastructure\Security\OrderVoter;13use Symfony\Component\Messenger\Attribute\AsMessageHandler;14use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;15 16#[AsMessageHandler(bus: 'command.bus')]17final readonly class RefundOrderHandler18{19    public function __construct(20        private OrderRepository $orders,21        private SecurityUserProvider $users,22        private UserAuthorizationCheckerInterface $auth,23    ) {}24 25    public function __invoke(RefundOrderCommand $command): void26    {27        $order = $this->orders->get($command->orderId);28        // Aktér se načte podle identity v commandu, ne ze snapshotu rolí29        $actor = $this->users->byCustomerId($command->actorId);30 31        if (!$this->auth->isGrantedForUser($actor, OrderVoter::REFUND, $order)) {32            throw new AccessDeniedDomainException(33                sprintf('Refund not allowed for order %s', $command->orderId->value)34            );35        }36 37        $order->refund($command->amount);38        $this->orders->save($order);39    }40}

Ukázka stojí na třech věcech, které kniha dál nerozvádí: RefundOrderCommand se stejnou stavbou jako CancelOrderCommand, Order::refund() a SecurityUserProvider, tenký repozitář nad SecurityUser s jedinou metodou byCustomerId(). Refundace je tu jako druhý use case pro srovnání dvou přístupů k autorizaci, ne jako součást objednávkového procesu; ten vrací platbu přes kompenzaci v ságe.

Volba mezi oběma variantami se řídí povahou pravidla. Vlastnictví je vztah, který zná agregát sám, a porovnání actorId proti customerId nepotřebuje ani Security komponentu, ani dotaz navíc. Jakmile pravidlo závisí na rolích, hierarchii rolí nebo na atributech mimo agregát, vyplatí se sáhnout po isGrantedForUser() a mít pravidlo jen jednou – ve Voteru. Cenou je dotaz na aktéra a závislost aplikační vrstvy na Security komponentě. Tutéž závislost už nese synchronní handler.

Vzor má jeden trade-off. Mezi zařazením do fronty a zpracováním uplyne čas a oprávnění se mezitím mohla změnit: aktér přišel o roli, účet někdo zablokoval. Snapshot rolí přibalený do commandu proto slouží nanejvýš auditu; autoritativní je stav v okamžiku zpracování. Handler tedy nečte oprávnění ze zprávy, ale ověřuje je proti aktuálním datům: načte aktéra, nebo porovná vlastnictví, které se na rozdíl od rolí nemění.

Systémové procesy (cron, saga, batch) lidského aktéra nemají. Pro ně se zavádí explicitní systémová identita s vlastním actorId a vyhrazenými právy, ne obcházení kontroly podmínkou „když aktér chybí, povol vše“. Taková podmínka je přesně ten fail-open default, před kterým varuje sekce o multi-tenancy.

Identita bydlí na jednom místě. Kdyby ji sága a handler držely každý zvlášť, rozejdou se při první změně a chyba se projeví až v produkci:

php src/SharedKernel/Domain/SystemActor.php
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain;6 7/** Aktér pro procesy bez člověka: ságy, cron, batch. */8final class SystemActor9{10    public const ID = '01920000-0000-7000-8000-000000000001';11}

Právo se té identitě musí explicitně udělit, jinak vlastní kompenzace neprojde. Sága pošle storno, handler porovná actorId s vlastníkem, neshodne se a příkaz po vyčerpání pokusů skončí v DLQ. Objednávka pak zůstane zaplacená a nezrušená, aniž by cokoli spadlo. Ságu to nezastaví, protože o odmítnutí příkazu neví. CancelOrderHandler výše proto testuje systémovou identitu ve vlastní větvi před kontrolou vlastnictví.

11.06 Aggregate-level – doména sama rozhoduje#

Některá pravidla do Voteru nepatří. Vyžadují znalost doménového stavu, který Voter nemá natáhnout zvenku: typicky časové okno, předchozí status objednávky nebo invarianty napříč entitami uvnitř agregátu. Tato pravidla patří do aggregate root, který je vynutí doménovou výjimkou.

Praktická heuristika:

  • Pokud pravidlo vyžaduje stav agregátu („order musí být ve stavu PLACED a ne starší než 24 h“), patří do Aggregate.
  • Pokud pravidlo popisuje vztah mezi aktérem a agregátem (vlastnictví, členství, hierarchie), definuje ho také Aggregate. Zná ho a nepřestává ho znát, když se command zpracuje asynchronně. Voter se na něj ptá, neopisuje ho.
  • Pokud pravidlo mluví 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á.

Prostřední bod je ten, na kterém se týmy nejčastěji rozejdou. „Zrušit smí jen vlastník“ zní jako typické use-case pravidlo, ve skutečnosti je to 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.

php src/Ordering/Domain/Model/Order.php (výřez)
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 17class 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}

Aggregate nemá žádnou závislost na Symfony. Používá pouze PHP standardní typy a vlastní doménové třídy: žádný TokenInterface, žádný AuthorizationChecker, žádný UserInterface. Třídu lze proto testovat unit testem bez Symfony Kernel. Selhání hlásí doménovými výjimkami: InvalidOrderStateTransitionException a CancellationWindowExpiredException jsou doménové třídy v App\Ordering\Domain\Exception. Nesou doménový kontext (kdy byl order placed, kdy se zkouší cancel) a aplikační vrstva je překládá na HTTP status, typicky 409 Conflict, ne 403 Forbidden. Nejde o autorizační selhání, ale o doménový stav.

Pomocná metoda isCancellable() je dotaz bez vedlejších efektů. Používá ji UI vrstva pro skrytí tlačítka: Twig šablona ji volá s proměnnou now předanou z controlleru (kombinováno s is_granted). Tatáž logika je sdílená s cancel() přes konstantu CANCELLATION_WINDOW_SECONDS, takže nevzniká duplicita. Zbývají domain events: po úspěšné operaci agregát zaznamená OrderCancelled voláním record(), aplikační handler eventy po repository->save() vyzvedne přes releaseEvents() a publikuje (typicky přes Outbox). Aggregate sám nikdy nevolá EventDispatcher.

Zde tedy není otázka „smí Petr“; tu vyřešil Voter v sekci 11.04. Zde je otázka „dá se to vůbec teď udělat?“. A odpověď „ne“ se sem dostane i tehdy, když Voter řekl „ano“ (Petr je vlastník, ale order je už zaplacen a odeslán). Obě bariéry jsou nezávislé a nutné.

cancel() a isCancellable() výše nahrazují verze z Návrhu agregátu celé, ne po částech: nesou tytéž stavové podmínky i zámek a přidávají k nim lhůtu. Storno lhůta je jediné, co tahle kapitola k agregátu přidává; 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 a přitom by rozbilo kompenzaci: sága ruší objednávku zaplacenou, takže by jí handler storno odmítl a objednávka by zůstala viset.

End-to-end trace: cancellation request

Pro úplnost si projděme, co se konkrétně stane, když zákazník Petr klikne na tlačítko „Cancel order #42“ v rozhraní:

  1. Edge (firewall). Symfony ověří JWT/session token. Bez ověření → 401. Petr je přihlášený, pokračuje.
  2. Edge (access_control). URL /order/42/cancel spadá pod IS_AUTHENTICATED_FULLY. Petr je přihlášený, pokračuje.
  3. Controller validuje vstup (CSRF token, request body), vytvoří CancelOrderCommand(orderId: 42, reason: 'changed mind', actorId: <Petrovo CustomerId>) a předá ho na message bus.
  4. Application Handler (CancelOrderHandler) načte agregát z repository: $order = $repo->get($orderId).
  5. Use Case Voter. Handler volá $auth->isGranted('order.cancel', $order). OrderVoter se zeptá agregátu přes $order->isOwnedBy($user->customerId()). Petr je vlastník → ACCESS_GRANTED, pokračuje. Kdyby nebyl vlastník → AccessDeniedDomainException → HTTP 403.
  6. Aggregate. Handler volá $order->cancel('changed mind', $now). Aggregate ověří status === PLACED a age <= 24h. Order je placed před 30 min → ok, status se změní na CANCELLED, vznikne OrderCancelled event. Kdyby byl už shipped → InvalidOrderStateTransitionException → HTTP 409.
  7. Persistence + outbox. Handler zavolá $repo->save($order); v jedné transakci se uloží stav agregátu i OrderCancelled event do outbox tabulky.
  8. Field-level (response). Controller vrátí 200 OK. Pokud by Petr nebyl admin a v response figuroval audit_log, read model by ho vyfiltroval: na svém vlastním orderu vidí status, ale ne kdo a kdy ho editoval.

Každá z těchto vrstev selže po svém: jiný HTTP status, jiná chybová hláška, jiné logy. Generické „Access denied“ tu nestačí.

11.07 Field-level – read model filtrace#

Nejjemnější vrstva. Předchozí tři vrstvy řešily akce a existenci operace; field-level řeší viditelnost konkrétního pole během jinak povoleného čtení. Klasický příklad: detail orderu vidí customer i admin, ale sloupec audit_log (kdo a kdy editoval) má vidět jen admin.

Existují dva přístupy s odlišnými kompromisy:

Přístup 1: Twig if (view-level)

Nejjednodušší, ale s únikem dat: data se z databáze načtou všechna, jen se ve view zahodí. Pro většinu UI to stačí; na citlivá data nepatří: unikají přes HTML komentáře, JSON serializaci v JS aplikaci nebo ETag hashing.

twig templates/order/detail.html.twig (varianta nad read modelem)
1{# Jiná varianta téže šablony než v 11.04. Tam čte agregát, tady2   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. Read model vrací různé DTO podle role. Bez data leaku, ale za cenu duplicity (dvě query, dvě DTO struktury). Vhodné pro PII, finanční data, audit logy.

php src/Ordering/Application/ReadModel/OrderDetailReadModel.php
1<?php2 3// src/Ordering/Application/ReadModel/OrderDetailReadModel.php4declare(strict_types=1);5 6namespace App\Ordering\Application\ReadModel;7 8use App\Identity\Infrastructure\Security\SecurityUser;9use App\Ordering\Domain\Exception\OrderNotFoundException;10use App\Ordering\Domain\ValueObject\OrderId;11use Doctrine\DBAL\Connection;12use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;13 14final readonly class OrderDetailReadModel15{16    public function __construct(17        private Connection $db,18        private UserAuthorizationCheckerInterface $auth,19    ) {}20 21    public function forUser(string $orderId, SecurityUser $user): OrderDetailDto22    {23        // Read model čte projekci order_dashboard z kapitoly o CQRS, ne24        // tabulku agregátu. Celková částka je tam předpočítaná; nad `orders`25        // by se musela dopočítat joinem přes položky.26        $columns   = 'order_id, customer_id, total_amount, status, placed_at';27        $seesAudit = $this->auth->isGrantedForUser($user, 'ROLE_ADMIN');28 29        $sql = "SELECT {$columns} FROM order_dashboard WHERE order_id = :id";30 31        $row = $this->db->fetchAssociative($sql, ['id' => $orderId]);32        if ($row === false) {33            throw OrderNotFoundException::withId(OrderId::fromString($orderId));34        }35 36        // Audit log je vlastní tabulka, ne sloupec projekce – dotaz se37        // pro něj vůbec nepoloží, když ho aktér nesmí vidět.38        $row['audit_log'] = $seesAudit39            ? $this->db->fetchAllAssociative(40                'SELECT at, action, actor FROM order_audit_log41                  WHERE order_id = :id ORDER BY at',42                ['id' => $orderId],43            )44            : null;45 46        return OrderDetailDto::fromRow($row, includeAudit: $seesAudit);47    }48}

Tabulku zakládá migrace a do schema_filter z kapitoly o CQRS patří ze stejného důvodu jako ostatní projekce; jinak ji migrations:diff navrhne zahodit:

sql migrations/Version20260906130000.php (výřez)
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)~'

DTO drží jen to, co obrazovka opravdu dostala. auditLog je null, pokud ho dotaz nevybral. Proti prázdnému poli je to rozdíl s významem: log se nenačetl, ne že by žádné záznamy neexistovaly.

php src/Ordering/Application/ReadModel/OrderDetailDto.php
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
Data leak Riziko (data v paměti; u API/SPA unikají do response) Ne
Implementační složitost Triviální Vyžaduje různé DTO / read modely
Vhodné pro UI hidden, neostrá ochrana PII, finance, audit log, GDPR
Testování Twig integrační test Unit + integrační test read modelu
OWASP A01:2021 compliance Insufficient – viz [8] Splňuje (server-side enforcement)

Pro necitlivá data Twig if stačí a šetří čas. Pro citlivá data vždy query filter. OWASP Top 10 v kategorii „A01 Broken Access Control“ výslovně varuje před UI-only kontrolou jako jedinou bariérou.

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 je to den, kdy tým narazí na strop celého rámce postaveného na Voterech.

Naivní řešení načte stránku výsledků a přefiltruje ji v PHP:

twig templates/order/list.html.twig (anti-vzor)
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 %}

Dvě věci se rozbijí 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 jde přitom dolů lineárně: každý řádek spustí rozhodovací proces 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.

Odpověď je přesunout autorizaci do dotazu. Read model dostane identitu aktéra a promítne ji do WHERE:

php src/Ordering/Application/ReadModel/OrderListReadModel.php
1<?php2 3// src/Ordering/Application/ReadModel/OrderListReadModel.php4declare(strict_types=1);5 6namespace App\Ordering\Application\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 v WHERE je ale druhý zápis téhož pravidla, které už zná OrderVoter. Jednu definici tu udržet nelze, protože SQL a PHP jsou různé jazyky. Pojmenovat tu vazbu explicitně ale lze. 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 ale test: vyjmenuje objednávky vrácené read modelem a u každé ověří, že Voter řekne ano. Rozejdou-li se, test spadne.

Modely vzniklé kolem Zanzibaru toto rozdělení pojmenovávají přímo: Check je otázka na jeden objekt, ListObjects vrací množinu. Symfony 8 nativní podporu pro druhou z nich nemá. Voter je dobrý Policy Enforcement Point a nic 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. Přechod od prvního ke druhému nepohání 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 než jedna aplikace. Dokud neplatí ani jeden z nich, Votery stačí a přidaná abstrakce je jen práce navíc.

NIST SP 800-162 dává pro tuto vrstvu slovník, který se vyplatí znát, protože ho používají 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 Rule objektů, které se vyhodnotí proti trojici subject/user/context. Slouží k tomu, aby byl model vidět. Zda se takto psát vyplatí, řeší závěr sekce; ve většině Symfony projektů odpověď zní „ne“.

php src/SharedKernel/Authorization/Policy.php + Rule.php + PolicyContext.php
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}
php src/Ordering/Authorization/CancelOrderPolicy.php
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 chyba 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 subjektem politiky bývá snapshot s veřejnými poli, ne agregát s privátním stavem. 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í jako číslo z proměnných evaluatoru, ne jako objekt. Backed enum se neporovnává přímo: subject.status == "confirmed" selže a srovnává se až hodnota přes subject.status.value. A protože výrazy jsou stringy, statická analýza je nevidí; každé pravidlo musí krýt test, viz tabulkové testy policy.

Poznámka: pravidla subject.status.value == "placed" a časové okno 24 h jsou v politice pro ilustraci ABAC zápisu. Jak popisuje sekce 11.06, tyto doménové invarianty patří primárně do agregátu. Politika je ověřuje jako pre-check před dosažením domény (obrana do hloubky). Agregát ale musí být zdrojem pravdy a nepřijmout neplatný příkaz ani bez autorizační vrstvy.

Jednoduchý PolicyEvaluator používá Symfony ExpressionLanguage komponentu a vyhodnocuje pravidla v daném kontextu. Balíček v základní instalaci není, takže composer require symfony/expression-language je první krok:

php src/SharedKernel/Authorization/PolicyEvaluator.php
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í být snapshot s veřejnými poli libovolný objekt
Kompozice hlasů vlastní kód AccessDecisionManager a strategie

Poslední dva řádky jsou skrytá cena, kterou tabulky výhod obvykle zamlčují. ExpressionLanguage čte veřejné properties, takže agregát s privátním stavem subjektem politiky být nemůže. Vzniká další model, který musí zůstat v synchronizaci s doménou. A hlasy voličů skládá vlastní evaluátor místo rozhodovacího manažeru, takže se strategiemi z 11.04 nepočítá.

Vlastní evaluátor, nebo Voter s Vote?

Hlavní argument pro vlastní PolicyEvaluator býval jediný: chceme 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():

php src/Ordering/Infrastructure/Security/OrderVoter.php (výřez: voteOnAttribute s důvody)
1// src/Ordering/Infrastructure/Security/OrderVoter.php (s důvody)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:

php src/Ordering/Infrastructure/Http/ExplainedAccessDenied.php (výřez: tělo metody)
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í vrstvu Policy/Rule stavět nemá smysl. Dá tytéž odpovědi jako Votery, ale bez statické analýzy, bez rozhodovacích strategií a s modelem navíc. ABAC model z této sekce zůstává užitečný jako způsob uvažování o pravidlech, implementuje se ale z Voterů. Externí engine přichází na řadu až tehdy, 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 dává smysl 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

Za ABAC nekončí cesta u OPA. Průmyslový posun posledních let míří k ReBAC (Relationship-Based Access Control), kde se přístup odvozuje ze vztahů mezi uživateli a objekty a mezi objekty navzájem. Typická otázka zní: „uživatel vidí dokument, pokud má přístup k jeho nadřazené složce“. RBAC se na takové hierarchii, sdílení a multi-tenancy láme: vznikají role pro každou kombinaci, nebo se logika rozpadne 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, kdy stejná aplikace obsluhuje více oddělených zákazníků (organizací, mandantů, tenantů) a žádný tenant nesmí vidět data jiného. Existují tři architektonické strategie:

  • Row-based – sdílená databáze, sdílené tabulky, sloupec tenant_id vš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, lepší performance než row-based.
  • Database-based – samostatná databáze per tenant. Nejvyšší izolace, nejnákladnější (DB connection per tenant, migrations × N).

Volba mezi nimi jde ruku v ruce s velikostí instalace. Row-based u 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 v regulovaných doménách. Rozhodovací kritérium je pokaždé stejné: jak drahá je chyba, když se dva tenanty potkají v jedné odpovědi. Pro row-based v Symfony je idiomatický nástroj Doctrine SQLFilter.

Namístě je vrátit se k chybě 3, kde jsme filtrování v perzistentní vrstvě označili za anti-vzor. Rozpor je zdánlivý a rozdíl je v tom, na co filtr odpovídá. Tenant je kontext dotazu, ne autorizační rozhodnutí o akci: dimenze, kterou má nést každý dotaz v požadavku, stejně jako jazyk nebo časová zóna. Autorizační 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 náleží, protože odpověď je pro celý request jediná a neměnná.

php src/SharedKernel/Infrastructure/Doctrine/TenantFilter.php
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 nese jen informaci „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:

php src/SharedKernel/Domain/TenantAware.php
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}

Filter aplikuje WHERE klauzuli tenant_id = ? na každý dotaz nad entitou, která implementuje marker rozhraní 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 parametr nastavit, a přihlášení pak spadne na Parameter 'tenant_id' does not exist. Tenantní jsou doménové entity za firewallem, ne třída, kterou firewall sám používá k autentizaci. Aktivace filtru v config/packages/doctrine.yaml:

yaml config/packages/doctrine.yaml (výřez: mapování identity)
1# config/packages/doctrine.yaml2doctrine:3    orm:4        filters:5            tenant:6                class:   App\SharedKernel\Infrastructure\Doctrine\TenantFilter7                enabled: true  # fail-closed: filter běží vždy, parametr dodá listener

Pozor na sémantiku výchozího stavu. Vypnutý nebo nenakonfigurovaný filter nepřidá do SQL žádné WHERE a dotaz vrátí data všech tenantů. SQLFilter je tedy ze své podstaty fail-open a to je hlavní riziko celého přístupu. Proto konfigurace výše filter zapíná globálně (enabled: true): běží pro každý dotaz a chybějící tenant_id skončí výjimkou, ne únikem dat. Hodnotu parametru dodává kernel event listener po autentizaci:

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 týž záznam přes find() nebo DQL nedostanete. Tam, kde na oddělení tenantů závisí bezpečnost, patří kontrola tenanta i do doménové vrstvy, ne jen do filtru.

php src/SharedKernel/Infrastructure/Http/TenantContextListener.php
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; // public endpoint, anonymous request32        }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, které se vyplatí zachytit:

  • Priority 7 v AsEventListener. V Symfony platí vyšší priority = dřívější vykonání. Symfony Firewall registruje svůj onKernelRequest s prioritou 8, takže aby měl listener k dispozici už autentizovaného uživatele, musí běžet s prioritou nižší než 8 (typicky 7 nebo 0). Detail v Symfony EventDispatcher dokumentaci.
  • Main request guard. Bez $event->isMainRequest() by se filter nastavoval i pro dílčí požadavky (např. ESI, render fragments), kde typicky není token a listener by spadl.
  • Anonymní požadavek parametr nedostane. U veřejných endpointů (login, register, health) listener skončí na guardu a tenant_id zůstane nenastavené. První dotaz nad TenantAware entitou pak vyhodí výjimku; globálně zapnutý filter 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ří takový 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:

sql migrations/tenant_rls.sql
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. Rozdíl proti SQLFilteru je v defaultu a ten 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 Test pyramida pro autorizaci#

Každá ze 4 vrstev se testuje jiným druhem testu. Snaha pokrýt vše end-to-end vede k pomalé, křehké testovací sadě. Dělení odpovídá klasické test pyramidě: hodně rychlých unit testů, méně integration, pár end-to-end.

Aggregate-level: čistý unit test

Doménová pravidla v aggregate jsou plain PHP: žádný framework, žádná databáze. Test je rychlý a deterministický:

Testy sahají po OrderFactory, jednoduchém test-data builderu, který drží sestavení agregátu na jednom místě. Vyplatí se, jakmile ho potřebuje víc než jeden testovací soubor:

php tests/Ordering/Domain/OrderFactory.php
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 tady. 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     * Builder 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}
php tests/Ordering/Domain/OrderCancelTest.php
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 s mock TokenInterface

Voter dostává TokenInterface; v testu stačí jeho mock, reálný subject a mock rozhodovacího manažeru pro role. Žádný Symfony Kernel:

php tests/Ordering/Infrastructure/Security/OrderVoterTest.php
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}
php tests/Identity/SecurityUserFixture.php
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}

Mock AccessDecisionManagerInterface je tu záměrně nastavený na false. Test tak ověřuje vlastnictví bez zásahu rolí; pro admin scénář stačí druhý test s návratovou hodnotou true.

End-to-end: WebTestCase

Pro pokrytí celé pipeline (firewall → controller → handler → voter → aggregate) slouží Symfony WebTestCase. Zde už je to integrační test, který používá kernel a databázi. Doporučená míra: 1 e2e test na use case, pokrývající hlavní scénář + 1–2 nejdůležitější chybové stavy. Detailní pokrytí okrajových případů 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 neušetří: s entity providerem firewall při každém dalším requestu uživatele načítá znovu, takže fixture musí být v databázi. Jinak test skončí přesměrováním na /login a vypadá to jako chyba autorizace.

php tests/Ordering/Http/CancelOrderE2eTest.php
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ší. Vynucovat ho má proto test. S PHPArkitect stačí jedno pravidlo:

php tests/Architecture/DomainRules.php (výřez: 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 importu, ne až při refaktoringu za rok. Detail pyramidy, příklady fixture builderů i další architektonická pravidla v samostatné kapitole o testování.

Policy: tabulkový unit test

Pokud používáte policy-based přístup, každé pravidlo v policy je jeden test case. Vyhodnocení výrazů stojí na balíčku symfony/expression-language, který v základní instalaci není. Tabulkový (data provider) test je nejlepší forma: jeden řádek = jeden scénář, čitelně i pro netechnického reviewera:

php tests/Ordering/Authorization/CancelOrderPolicyTest.php
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}

Tabulkový test má dvě výhody navíc oproti klasickému test-per-method přístupu. Přidání pravidla = přidání jednoho řádku 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 schválit doménová pravidla.

11.11 Anti-vzory#

Čtyři anti-vzory následují strukturu „symptom – důsledek – náprava“. První dva shrnují to, co kapitola už rozebrala, aby se v code review dalo projít celý seznam na jednom místě.

Anti-vzor 1: Autorizace v controlleru

Probrali jsme v sekci 11.01. Symptom: stejná autorizační podmínka opakovaná v 3+ controllerech, neexistující ve verzích volaných z konzolového commandu nebo Messenger handleru. Náprava: přesun do Voteru + 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: cancellation window pravidlo („order ne starší než 24 h“) je zapsané jak ve Voteru, tak v Order::cancel(). Když se doménové pravidlo změní (např. window se prodlouží na 48 h), obě místa se musí upravit a typicky se na jedno zapomene.

Náprava: pravidlo patří do aggregate (je to doménový invariant). Voter nesmí ověřovat doménový stav agregátu. Odpovídá jen na identitu/role uživatele a vlastnictví subjektu. Pro view-level skrytí tlačítka se v Twigu kombinuje {% if is_granted(...) and order.isCancellable(now) %}: voter pro permission, doménová metoda pro stavovou kontrolu.

Anti-vzor 4: Symfony User natažený do doménového Aggregate

Symptom:

php src/Ordering/Domain/Model/Order.php (anti-vzor)
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 je zřejmý: 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 rozpouští.

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. Detail v kapitole 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. Anonymous vs. authenticated, role-based hrubá separace. Žádná doménová znalost.
  • Use Case – Symfony Voter. „Smí Petr cancelnout order #42?“ Aplikační handler volá AuthorizationCheckerInterface::isGranted(); doména to nesmí.
  • Aggregate – doménový invariant + doménová výjimka. „Order musí být PLACED a ne starší než 24 h.“ Aggregate vyhazuje InvalidOrderStateTransitionException; aplikační vrstva to mapuje na HTTP 409.
  • Field – Twig is_granted pro view-level (s rizikem data leaku) nebo query filter / read model pro citlivá data (PII, audit log). Seznamy potřebují filtr v dotazu, ne Voter nad každým řádkem.

Kde co řešit:

Hrubé permissions 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á.

Rozhodnutí, kdy z Voterů odejít, nestojí na počtu pravidel. Stojí na třech otázkách: musí být policy čitelná mimo vývojový tým, mění se v jiném rytmu než kód, a sdílí ji víc aplikací? Dokud zní odpověď třikrát ne, zůstávají Votery s Vote::addReason() tou levnější variantou. Když aspoň jednou 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 si těchto devět bodů:

  1. Existuje v access_control default-deny pravidlo na konci? Pokud ne: nový endpoint bez explicitní role je veřejný.
  2. Volá Application Handler $auth->isGranted() před doménovou operací? Pokud ne: autorizace se může obejít přes alternativní vstupní bod (CLI, Messenger).
  3. 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.
  4. Je rozhodovací strategie nastavená na unanimous? Pokud ne: při výchozí affirmative přebije jeden souhlasící Voter všechny nesouhlasící.
  5. Vrací aplikace 403, 404 nebo 409 podle typu selhání? Pokud ne: uživatel dostane matoucí hlášku, nebo lze enumerovat cizí identifikátory.
  6. Mají citlivá pole (PII, audit) query filter, ne jen Twig if? Pokud ne: data leakují přes JSON API, dev tools, ETag.
  7. 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ě.
  8. Pokud je aplikace multi-tenant: má Doctrine SQLFilter fail-closed default? Pokud ne: chybějící tenant context vrátí všechna data.
  9. 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ž permissions využívají úplně jiný subset závislostí (typicky owner-based vs. role-based) a chcete je nezávisle testovat. 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 permissions sama o sobě“: ROLE_ADMIN smí všechno, ROLE_REFUND_AGENT smí refundy bez ohledu na konkrétní entitu. Jakmile permissions závisí na vztazích (vlastnictví, tenant, časové okno, stav agregátu), RBAC se rozroste a vznikají hyper-specific 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, … zaveďte atribut user.tenantId + jednu generickou roli ROLE_TENANT_ADMIN a ve Voteru ověřte, že user.tenantId == subject.tenantId. Zjednoduší to správu uživatelů, audit i delegaci. Detail v sekci o multi-tenancy.

Smí doménový Aggregate záviset na Symfony Security komponentě?

Ne. Doména musí být framework-agnostic. Bez toho ji nelze unit-testovat bez Kernelu, sdílet mezi webem a CLI ani migrovat 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. Pokud potřebuje aggregate „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 regulaci (PCI-DSS, GDPR Article 30); (3) externí audit služba (AWS CloudTrail, Datadog) pro multi-tenant SaaS. Implementačně se osvědčil decorator nad AuthorizationCheckerInterface, který každé volání zaloguje. Pro detail viz Audit log autorizačních rozhodnutí.

11.13 Další četba#