Kapitola 11 · Architektura · Autorizace v DDD na Symfony

Autorizace v DDD na Symfony

Kam patří otázka „smí to tento uživatel udělat?“ – do controlleru, do voteru, nebo do agregátu? Kapitola ji rozkládá do čtyř vrstev: Edge, Use Case, Aggregate a Field. Každá odpovídá na jinou otázku a má v Symfony vlastní nástroj.

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

Kapitola ve videokurzu

Celý videokurz
  1. Kapitoly videa a přepis dílu 11a →
  2. Kapitoly videa a přepis dílu 11b →

Přehrávač YouTube se načte až po kliknutí.

Předchozí kapitola, Implementace v Symfony, postavila v Symfony 8 agregáty, repozitáře a Application Services. Autorizaci záměrně nechala stranou a otevřená tak zůstala otázka, kterou projekty obvykle řeší případ od případu: kdo smí který use case zavolat a za jakých podmínek. Odpovědí je čtyřvrstvý rámec, který každé autorizační rozhodnutí umístí na jednu vrstvu: od HTTP firewallu přes Symfony Voter, který aplikační vrstva volá z handleru, až po doménové invarianty v agregátu. Volání z Command Handleru ukazuje sekce 11.04; kapitola o CQRS na to navazuje middleware vrstvou, kterou lze autorizaci vytáhnout před handler.

Autentizaci (Symfony firewall, JWT, OAuth) tým většinou postaví bez větších potíží. Otázka „kdo smí udělat co s konkrétní entitou v konkrétním stavu“ je ale jiná disciplína. Bez rámce se odpověď rozptýlí mezi controllery, listenery, Twig šablony a Doctrine query buildery. Podle rámce poznáte, kam které pravidlo patří a jak ho v Symfony 8 zapsat tak, aby Security komponenta nepronikla do doménového jádra.

Na kapitolu navazují CQRS (kde sedí ověření v Command Handleru) a Testování (jak otestovat každou ze čtyř vrstev samostatně). DDD v praxi – kde to bolí autorizaci zmiňuje jen letmo.

11.01 Tři chyby s autorizací, které se v review opakovaně objevují#

Tři vzory níže spojuje jedna příčina: chybí rozhodovací rámec, kam které pravidlo patří. Pořadí odpovídá odhadované četnosti v code review; měřená data k tomu nejsou, jde o autorský odhad. Že téma unese vlastní kapitolu, ale doložit lze: OWASP posunul Broken Access Control v Top 10 pro rok 2021 z pátého místa na první.

Chyba 1: Vše v controlleru

Nejčastější vzor. Controller přijme HTTP požadavek, načte entitu z repository a inline porovná atributy uživatele s atributy entity:

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í fronta) a z administračního panelu. Každý vstupní bod musí tutéž podmínku zopakovat a stačí, aby na ni jeden zapomněl. Pravidlo „zrušit smí jen vlastník“ má mít jedno místo definice a vynucovat se v use case, ne v každém vstupním bodu zvlášť.

Chyba 2: Vše ve Voteru, doména nezná autorizaci

Druhý extrém. Tým objeví Symfony Voter a přesune do něj všechna pravidla, včetně doménových invariantů. Agregát má veřejné API setStatus(), setTotal(), setCustomerId() a Voter „natáhne“ autorizaci přes ně:

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 (storno lhůta) ve Voteru22        if ($attribute === 'CANCEL') {23            if ($user->getId() !== $subject->getCustomerId()) { return false; }24            if ($subject->getStatus() !== 'placed')           { return false; }25            $age = (new \DateTimeImmutable())->getTimestamp() - $subject->getPlacedAt()->getTimestamp();26            if ($age > 86400) { return false; }27            return true;28        }29        return false;30    }31}

Co je špatně: setter Order::setStatus('cancelled') dál existuje a je veřejný. Stačí, aby ho kdokoli (test, fixture, migrační skript, jiný vývojář) zavolal mimo Voter, a invariant „storno do 24 h“ přestane platit. Voter je jen volitelný filtr před vstupem, doména žádnou pojistku nemá. Storno lhůta je doménové pravidlo, ne pravidlo use case.

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

Tým objeví Doctrine SQLFilter a rozhodne, že autorizaci vyřeší v perzistentní vrstvě: entity, ke kterým uživatel nemá přístup, se z databáze nevrátí. Pro read dotazy to funguje, v doménové logice ne:

  • Když handler dostane $orderId a entitu nenajde, neví, jestli neexistuje, nebo jen není dostupná pro daného uživatele. Hláška „Order not found“ pak mate.
  • Doctrine filtry se nevztahují na entity už načtené v identity map, na nativní SQL ani na Redis cache.
  • Filtr se neuplatní ani při načtení neowning strany asociace one-to-one. Ověřeno na ORM 3.6: find() i DQL cizí záznam skryjí, ale průchod z entity na druhý konec vztahu ho vrátí. Kdo staví oddělení tenantů jen na filtru, má tudy díru.
  • Doménová pravidla typu „objednávka patří zákazníkovi“ ztrácejí jedno závazné místo: zapsaná jsou v SQL filtru, ve Voteru se na ně zapomíná a v agregátu chybí. Při volání mimo HTTP vrstvu se nevynutí.

11.02 Čtyři vrstvy autorizace#

Nejdřív strategický kontext. Identita a oprávnění tvoří vlastní Bounded Context, kterému Vernon v Implementing Domain-Driven Design říká Identity and Access Context. V referenční implementaci IDDD_Samples stojí jako samostatný modul vedle Collaboration a Agile PM a ostatní kontexty ho konzumují jako službu. Typologicky jde o generickou subdoménu: kupuje se (Keycloak, Auth0, OIDC provider), nemodeluje se vlastními silami. Autorizační rozhodnutí přitom zůstává v konzumujícím kontextu, protože závisí na jeho entitách a stavech. Zdroj identit a rolí leží mimo něj a vazbu mezi obojím popisuje Context Mapping jako Open Host Service.

Uvnitř konzumujícího kontextu padá rozhodnutí ve čtyřech postupných vrstvách. Každá má vlastní otázku, Symfony nástroj i granularitu. Vrstvy fungují jako filtry: další odpovídá na jemnější otázku a předpokládá, že předchozí už řekla „ano“.

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 zrušit objednávku #42?“
Aggregate Dá se to vůbec teď udělat? doménová kontrola + výjimka „Objednávku lze zrušit jen do 24 h od potvrzení“
Field Smí vidět konkrétní pole? Twig + Voter, query filter „Sloupec audit_log vidí jen admin“

Každé autorizační pravidlo má právě jedno místo definice. Vynutit ho lze na více vrstvách, pokud všechny čtou tutéž definici. OWASP to formuluje jako požadavek implementovat kontrolu jednou a znovu ji používat. Duplicitou je až přepis téhož pravidla druhými slovy na druhé vrstvě; typické případy ukazuje sekce o anti-vzorech.

Metafora filtrů má jednu podmínku. Uvnitř use-case vrstvy platí jen při rozhodovací strategii unanimous. Výchozí affirmative ji obrací: stačí jeden souhlasící Voter a nesouhlas ostatních se ignoruje. Podrobnosti v sekci o rozhodovací strategii.

Citace: Symfony Security komponenta dokumentuje vícevrstvý přístup v sekci „Authorization“ [1]; obecné principy ABAC vs. RBAC najdete v NIST SP 800-162 [2]. Vernon v Implementing Domain-Driven Design (kap. 14, „Application“) umisťuje autorizační kontrolu do Application Services: aplikační služba se podle něj stará o bezpečnost a překlad objektů. Čtyřvrstvý rámec této kapitoly u něj nenajdete – jde o autorské rozšíření, ne o Vernonův model.

11.03 Edge – Symfony firewall a access_control#

Edge je nejhrubší vrstva a leží mimo doménový kód. Odpovídá jen na otázku „kdo je vůbec na druhém konci socketu?“: anonymní, přihlášený, případně s rolí pro hrubě dělené sekce (/admin/*, /api/v1/*). Pravidla typu „zákazník X smí na tuto objednávku“ patří o vrstvu výš (use case).

Uživatelský provider ukazuje na třídu SecurityUser z infrastruktury, ne na doménovou entitu. Na tom stojí celý zbytek kapitoly: provider vyžaduje implementaci Symfony\Component\Security\Core\User\UserInterface, a kdyby ji nesla doménová třída, doména by se svázala se Security komponentou. Přesně to zakazuje anti-vzor 4. SecurityUser je navíc read model pro autentizaci: nese identifikátor, hash hesla, role a doménové ID (CustomerId, TenantId). Mění se z jiných důvodů než doménový model uživatele [3].

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                # Šablona posílá `_csrf_token` s id `authenticate`, bez této37                # volby ho ale form_login nečte ani neověřuje.38                enable_csrf: true39                # Bez tohohle řádku míří Symfony po přihlášení na `/`,40                # kterou kniha nikde nedefinuje – první proklik po loginu41                # by skončil na 404.42                default_target_path: app_profile43            # Bez `target` platí totéž co u přihlášení: odhlášení44            # přesměruje na `/` a skončí na 404.45            logout: { target: login }46 47    access_control:48        # Veřejné endpointy49        - { path: ^/login,        roles: PUBLIC_ACCESS }50        # Pozor na tvar cesty: ^/register nepokrývá /api/register.51        # JSON endpoint by jinak skončil přesměrováním na login dřív,52        # než se kontroler vůbec spustí.53        - { path: ^/register,     roles: PUBLIC_ACCESS }54        - { path: ^/api/register, roles: PUBLIC_ACCESS }55        - { path: ^/health,       roles: PUBLIC_ACCESS }56        # Hrubá role-based separace57        - { path: ^/admin,        roles: ROLE_ADMIN }58        - { path: ^/api/internal, roles: ROLE_SERVICE_ACCOUNT }59        # Vše ostatní za autentizací60        - { path: ^/,             roles: IS_AUTHENTICATED_FULLY }

form_login odkazuje na routu login. Firewall ji sám nezaloží a první chráněný request spadne na chybějící routě:

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 je krátká, ale dvě jména v ní nejsou volná: _username a _password očekává form_login autentikátor a přejmenovat je znamená přenastavit firewall.

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 %}

Třída SecurityUser tvoří most mezi Symfony a doménou. Kromě UserInterface vystavuje doménové identifikátory pro aplikační vrstvu:

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 must not be empty.');14        }15    }16 17    public static function fromString(string $value): self18    {19        return new self($value);20    }21 22    public function __toString(): string23    {24        return $this->value;25    }26}

Principy edge vrstvy:

  • Žádná doménová znalost. Edge nezná pojmy „objednávka“, „zákazník“ ani „storno lhůta“. Pracuje jen se vzorem URL, rolemi a stavem autentizace.
  • Default deny. Poslední pravidlo v access_control je „všechno ostatní vyžaduje přihlášení“. Bez něj stačí přidat nový endpoint, zapomenout ho zařadit, a je veřejný.
  • Role-based, ne attribute-based. ROLE_ADMIN je hrubá kategorie; jemnější rozhodnutí jako „admin tenantu T1, ne T2“ patří do Voteru.
  • JWT firewall vs. session. API bývá stateless (jwt autentikátor), web pracuje se session. 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?“. Přesně na to je Symfony Voter navržený. Pravidlo: 1 use case = 1 atribut Voteru; jeden Voter může pokrývat N atributů, pokud se týkají stejné entity (typicky operace nad jedním agregátem).

Voter zná dvě věci: identitu uživatele (přes TokenInterface) a cílový subjekt (typicky kořen agregátu). Nesmí načítat subjekt, o kterém rozhoduje, a nesmí znát doménové invarianty – ty vynucuje agregát. Storno lhůtu tedy Voter nepřebírá zvenku; patří ke stavu agregátu.

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). Nekolidují s atributy jiných Voterů (invoice.cancel, shipment.cancel) a z audit logu je hned vidět, kterého subjektu se rozhodnutí týkalo.
  • Match expression místo stromu if-else. Bez default větve PHPStan ohlásí nepokrytý případ; default => false volí tiché zamítnutí (fail-closed) a tuto kontrolu obětuje.
  • Privátní metody canView, canCancel. Každý use case má vlastní metodu a test ověří výsledek každého z nich zvlášť. Bez extrakce by Voter přerostl v nečitelný switch-case.
  • Role se uvnitř Voteru kontrolují přes AccessDecisionManagerInterface::decide(), ne dotazem na uživatelskou třídu. Dotaz in_array('ROLE_ADMIN', $user->getRoles(), true) obejde hierarchii rolí ze security.yaml: uživatel s ROLE_SUPER_ADMIN by ROLE_ADMIN nedostal, přestože ho hierarchie zahrnuje. Před ručním getRoles() varuje dokumentace Security [1], decide() ve Voteru doporučuje dokumentace k Voterům [7].
  • supportsAttribute() a supportsType() pocházejí z CacheableVoterInterface, které abstraktní Voter implementuje. Obě ve výchozím stavu vracejí true, takže bez override nic neušetří. Seznam s 200 řádky a pěti Votery znamená tisíc zbytečných volání supports(); s override jich většina odpadne už v rozhodovacím manažeru.

Rozhodovací strategie a AccessDecisionManager

Voterů bývá v aplikaci víc a jejich hlasy skládá dohromady AccessDecisionManager. Strategie, kterou použije, mění výsledek víc než cokoli uvnitř samotných Voterů:

Strategie Chování Kdy se hodí
affirmative výchozí; stačí jeden souhlas jednoduché aplikace s jedním Voterem na subjekt
unanimous zamítne, jakmile nesouhlasí kdokoli vrstvená autorizace, multi-tenancy
consensus rozhoduje většina zřídka; výsledek se hůř zdůvodňuje
priority rozhodne první nezdržující se volič explicitní pořadí přes #[AsTaggedItem(priority: …)]

Pro rámec této kapitoly je výchozí affirmative špatná volba. Kdo si vedle OrderVoter postaví TenantVoter, dostane opak toho, co čekal: TenantVoter cizí tenant zamítne, OrderVoter řekne ano podle vlastnictví a přístup projde. Vrstvená autorizace proto potřebuje unanimous.

Blok patří do stejného security.yaml jako firewall výše, ne místo něj. YAML má jeden kořenový klíč security: a druhý dokument by ten první přepsal.

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

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

#[IsGranted] na controlleru

Autoritativní je kontrola v handleru. Na hranici HTTP se ale vyplatí odmítnout požadavek dřív, než vznikne command. K tomu slouží atribut Symfony\Component\Security\Http\Attribute\IsGranted. Funguje na metodě i na celé třídě controlleru a parametrem subject určuje argument akce, o kterém se rozhoduje:

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\CancelOrder;9use App\Ordering\Domain\Model\Order;10use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;11use Symfony\Component\HttpFoundation\Request;12use Symfony\Component\HttpFoundation\Response;13use Symfony\Component\Messenger\MessageBusInterface;14use Symfony\Component\Routing\Attribute\Route;15use Symfony\Component\Security\Http\Attribute\IsGranted;16 17#[IsGranted('IS_AUTHENTICATED_FULLY')]18final class OrderController extends AbstractController19{20    #[Route('/order/{id}/cancel', name: 'order_cancel', methods: ['POST'])]21    #[IsGranted('order.cancel', subject: 'order', statusCode: 404)]22    public function cancel(Order $order, Request $request, MessageBusInterface $bus): Response23    {24        // Voter už rozhodl; controller jen přeloží vstup na command25        $bus->dispatch(new CancelOrder(26            orderId: $order->id,27            reason:  (string) $request->request->get('reason', ''),28            actorId: $this->getUser()->customerId(),29        ));30 31        return $this->redirectToRoute('order_detail', ['id' => $order->id->value]);32    }33 34    #[Route('/order/{id}', name: 'order_detail', methods: ['GET'])]35    #[IsGranted('order.view', subject: 'order', statusCode: 404)]36    public function detail(Order $order): Response37    {38        // isCancellable() si aktuální čas nebere sama, takže ho šablona39        // musí dostat odsud – jinak Twig hlásí, že proměnná neexistuje.40        return $this->render('order/detail.html.twig', [41            'order' => $order,42            'now'   => new \DateTimeImmutable(),43        ]);44    }45}

Parametr statusCode: 404 mění odpověď z 403 na 404. Rozdíl není kosmetický: 403 útočníkovi potvrdí, že objednávka s daným ID existuje, a otevře cestu k enumeraci cizích identifikátorů. Téma rozvádí callout o 403 vs. 409.

Atribut má dvě omezení. První: potřebuje subjekt už jako objekt, takže se neobejde bez převodu z parametru routy a dotaz do databáze se přesouvá do controlleru. #[MapEntity] na to sám nestačí. EntityValueResolver předá find() řetězec z URL, jenže identita je namapovaná vlastním typem, který řetězec odmítne. Resolver výjimku spolkne a vrátí 404, takže se chyba hledá na špatném místě:

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

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

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('Invalid order ID.');33        }34 35        yield $this->orders->get(OrderId::fromString($id));36    }37}

Druhé omezení: jakmile controller command jen odešle na asynchronní bus, #[IsGranted] chrání pouze vstup do fronty. Zpracování ve workeru běží bez tokenu a řeší ho následující sekce. Atribut proto kontrolu v handleru nenahrazuje, jen ji doplňuje na hranici.

Proč ne Symfony ACL

Starší materiály nabízejí pro oprávnění na jednotlivé objekty komponentu ACL: tabulky acl_entries, acl_object_identities a MaskBuilder. Pro Symfony 8 to volba není. Podpora ACL zmizela ze SecurityBundle ve verzi 4.0 a samostatný symfony/acl-bundle deklaruje ve svém posledním vydání (2.4.0 z dubna 2024) podporu Symfony 4.4 až 7.x. Symfony 8 mezi nimi není. Ani technicky by nešlo o dobrou náhradu. ACL ukládá rozhodnutí jako data v databázi, takže pravidlo „vlastník smí zrušit do 24 hodin“ se promítne do řádků, které musí někdo držet v synchronizaci se stavem agregátu. Voter tutéž věc spočítá z aktuálního stavu a nic synchronizovat nemusí. Kde jsou potřeba explicitně přidělovaná oprávnění na jednotlivé objekty (sdílení dokumentu, delegace), sahá se po vlastní tabulce vazeb nebo po ReBAC modelu z pozdější sekce.

Použití ve Command Handleru

Voter někdo musí zavolat. Místo pro to je Application Service / Command Handler, kde se autorizace ověří před doménovou operací. Handler injektuje AuthorizationCheckerInterface, tedy rozhraní Security komponenty. V aplikační vrstvě je taková závislost v pořádku, doménová by ji mít nesměla.

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\CancelOrder;9use App\Ordering\Application\Exception\AccessDeniedDomainException;10use App\Ordering\Domain\Repository\OrderRepository;11use Symfony\Component\Messenger\Attribute\AsMessageHandler;12use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;13 14#[AsMessageHandler(bus: 'command.bus')]15final readonly class CancelOrderHandler16{17    public function __construct(18        private OrderRepository $orders,19        private AuthorizationCheckerInterface $auth,20    ) {}21 22    public function __invoke(CancelOrder $command): void23    {24        $order = $this->orders->get($command->orderId);25 26        // Atribut jako řetězec, stejně jako v controlleru: konstanta27        // OrderVoter::CANCEL by handler svázala s infrastrukturou.28        if (!$this->auth->isGranted('order.cancel', $order)) {29            throw new AccessDeniedDomainException(30                sprintf('Cancel not allowed for order %s', $command->orderId->value)31            );32        }33 34        $order->cancel(reason: $command->reason, when: new \DateTimeImmutable());35        $this->orders->save($order);36 37        // Publikaci událostí ukázka vynechává, aby zůstala čitelná otázka38        // autorizace. V úplném tvaru ji doplňuje async varianta níže –39        // a bez ní se agregát a read model rozejdou.40    }41}

Obě výjimky, které v kapitole padají, jsou obyčejné doménové třídy. Dělí je vrstva: autorizační patří aplikaci, časové okno doméně, a aplikační vrstva každou překládá na jiný HTTP status.

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            'Order "%s" confirmed at %s can no longer be cancelled (attempted at %s).',30            $orderId->value,31            $placedAt->format('Y-m-d H:i'),32            $attemptedAt->format('Y-m-d H:i'),33        ));34    }35}

Po kontrole zavolá handler doménovou operaci $order->cancel(...) a ta uvnitř agregátu ověří invarianty (stav, storno lhůtu). Vznikají tak dvě nezávislé bariéry: Voter řekne „smí Petr“, agregát řekne „dá se to teď vůbec udělat“. Agregátovou vrstvu rozebírá sekce 11.06. Háček: handler nese atribut #[AsMessageHandler] a v asynchronním workeru žádný token neexistuje. Tomu se věnuje následující sekce.

Voter v Twig šabloně

Tentýž Voter pokrývá i rozhodnutí ve view, třeba skrýt tlačítko „Zrušit objednávku“ tomu, kdo objednávku nevlastní. Funkce is_granted() v Twigu volá stejný AuthorizationCheckerInterface. Proměnnou now (\DateTimeImmutable) předává do šablony controller, protože isCancellable() si aktuální čas nebere sama. Šablona čte agregát přímo a u detailu jedné objednávky to stačí. Jakmile obrazovka potřebuje jméno zákazníka nebo data z jiného kontextu, patří jí read model, ne další getter na agregátu:

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 %} #}

{% if is_granted(...) %} v Twigu jen schová tlačítko. Request poslaný ručně (curl, Postman, dev tools prohlížeče) nezastaví. Kontrola ve view je UX, ne bezpečnostní bariéra; ta stojí v handleru.

11.05 Autorizace v asynchronním kontextu#

Jakmile command putuje přes asynchronní transport, kontrola přes AuthorizationCheckerInterface přestane fungovat. Messenger worker běží mimo HTTP požadavek: TokenStorage je prázdná, $this->security->getUser() vrací null a Voter postavený na tokenu každé volání zamítne. Kód, který synchronně fungoval, začne po přepnutí transportu tiše odmítat oprávněné 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/CancelOrder.php
1<?php2 3// src/Ordering/Application/Command/CancelOrder.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 CancelOrder12{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\CancelOrder;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(CancelOrder $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}

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

Pravidlu vlastnictví stačí porovnat actorId s vlastníkem agregátu. Voter z HTTP vrstvy přitom nemizí: 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 a 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 tak musela ve workeru napsat podruhé a jinak než ve Voteru, což je přesně 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\Ordering\Application\Command\RefundOrder;9use App\Ordering\Application\Exception\AccessDeniedDomainException;10use App\Ordering\Application\Security\ActorProvider;11use App\Ordering\Domain\Repository\OrderRepository;12use Symfony\Component\Messenger\Attribute\AsMessageHandler;13use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;14 15#[AsMessageHandler(bus: 'command.bus')]16final readonly class RefundOrderHandler17{18    public function __construct(19        private OrderRepository $orders,20        private ActorProvider $actors,21        private UserAuthorizationCheckerInterface $auth,22    ) {}23 24    public function __invoke(RefundOrder $command): void25    {26        $order = $this->orders->get($command->orderId);27        // Aktér se načte podle identity v commandu, ne ze snapshotu rolí28        $actor = $this->actors->byCustomerId($command->actorId);29 30        if (!$this->auth->isGrantedForUser($actor, 'order.refund', $order)) {31            throw new AccessDeniedDomainException(32                sprintf('Refund not allowed for order %s', $command->orderId->value)33            );34        }35 36        $order->refund($command->amount);37        $this->orders->save($order);38    }39}

Ukázka stojí na třech věcech, které kniha dál nerozvádí: RefundOrder se stejnou stavbou jako CancelOrder, Order::refund() a ActorProvider. Poslední z nich je rozhraní aplikační vrstvy s jedinou metodou byCustomerId(), která vrací Symfony UserInterface. Implementuje ho tenký repozitář nad SecurityUser v infrastruktuře. Handler tak na infrastrukturu nesahá; ze stejného důvodu předává atribut Voteru řetězcem. Refundace tu slouží jako druhý use case pro srovnání dvou přístupů k autorizaci, ne jako součást objednávkového procesu; ten vrací platbu kompenzací v ságe.

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

Vzor má svou cenu. 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 oprávnění ze zprávy nečte, ale ověřuje 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, sága, batch) lidského aktéra nemají. Dostávají explicitní systémovou identitu s vlastním actorId a vyhrazenými právy, ne výjimku z kontroly typu „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 je definovaná 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 zůstane zaplacená a nezrušená, aniž by cokoli spadlo, a sága 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, kterou Voter nemá přebírat zvenku: časové okno, předchozí stav objednávky, invarianty napříč entitami uvnitř agregátu. Patří do kořene agregátu, který je vynutí doménovou výjimkou.

Praktická heuristika:

  • Pravidlo, které vyžaduje stav agregátu („objednávka nesmí být odeslaná a nesmí být starší než 24 h“), patří do agregátu.
  • Pravidlo o vztahu mezi aktérem a agregátem (vlastnictví, členství, hierarchie) definuje také agregát. Zná ho a nepřestává ho znát, ani když se command zpracuje asynchronně. Voter se na něj ptá, neopisuje ho.
  • Pravidlo o atributech aktéra („refund smí jen ROLE_REFUND_AGENT“, „mimo pracovní dobu ne“) patří do Voteru. Agregát o rolích nic neví a vědět nemá.

Na prostředním bodě se týmy rozcházejí nejčastěji. „Zrušit smí jen vlastník“ zní jako typické use-case pravidlo, ve skutečnosti jde o invariant vztahu mezi Order a CustomerId. Agregát na něj proto odpovídá metodou isOwnedBy() a Voter i asynchronní handler ji volají místo vlastního porovnání. Definice zůstane jedna, vynucení může být na obou místech.

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 17final class Order extends AggregateRoot18{19    private const CANCELLATION_WINDOW_SECONDS = 86_400; // 24 h20 21    public function cancel(string $reason, \DateTimeImmutable $when): void22    {23        // Zámek drží proces – viz kapitola o ságách, sekce Izolace ság.24        // Tahle podmínka je v kanonické verzi z Návrhu agregátu taky;25        // tenhle výpis metodu nahrazuje celou, ne po částech.26        if ($this->sagaInProgress) {27            throw new OrderLockedBySagaException($this->id);28        }29 30        // Odeslanou ani doručenou zásilku storno nevrátí – tam nastupuje31        // kompenzace v ságe.32        if (in_array($this->status, [OrderStatus::Shipped, OrderStatus::Delivered], true)) {33            throw InvalidOrderStateTransitionException::cannotTransition(34                $this->status->value,35                OrderStatus::Cancelled->value,36            );37        }38 39        // Opakované storno není chyba volajícího, jen už není co dělat.40        if ($this->status === OrderStatus::Cancelled) {41            return;42        }43 44        // Lhůta běží od potvrzení. Draft ji ještě nemá a rozpracovaný45        // košík taky nikdo neruší na čas.46        if ($this->placedAt !== null) {47            $age = $when->getTimestamp() - $this->placedAt->getTimestamp();48 49            if ($age > self::CANCELLATION_WINDOW_SECONDS) {50                throw new CancellationWindowExpiredException(51                    $this->id,52                    $this->placedAt,53                    $when,54                );55            }56        }57 58        $this->status = OrderStatus::Cancelled;59        $this->record(new OrderCancelled(60            orderId:    $this->id,61            customerId: $this->customerId,62            reason:     $reason,63            occurredAt: $when,64        ));65    }66 67    public function isCancellable(\DateTimeImmutable $now): bool68    {69        // Šablona se ptá právě téhle metody, takže musí znát i zámek.70        // Jinak nabídne tlačítko, jehož příkaz skončí v dead-letter frontě.71        if ($this->sagaInProgress) {72            return false;73        }74 75        if (in_array($this->status, [76            OrderStatus::Shipped,77            OrderStatus::Delivered,78            OrderStatus::Cancelled,79        ], true)) {80            return false;81        }82 83        return $this->placedAt === null84            || $now->getTimestamp() - $this->placedAt->getTimestamp()85               <= self::CANCELLATION_WINDOW_SECONDS;86    }87 88    // Vztahový invariant: vlastnictví zná agregát, ne Voter89    public function isOwnedBy(CustomerId $customerId): bool90    {91        return $this->customerId->equals($customerId);92    }93}

Agregát nemá žádnou závislost na Symfony. Používá jen standardní typy PHP a vlastní doménové třídy: žádný TokenInterface, AuthorizationChecker ani UserInterface. Proto ho lze testovat unit testem bez Symfony Kernelu. Selhání hlásí výjimkami InvalidOrderStateTransitionException a CancellationWindowExpiredException z App\Ordering\Domain\Exception. Nesou doménový kontext (kdy byla objednávka potvrzena, kdy přišel pokus o storno) a aplikační vrstva je překládá na 409 Conflict, ne na 403 Forbidden. Nejde o autorizační selhání, ale o doménový stav.

Pomocná metoda isCancellable() je dotaz bez vedlejších efektů. UI podle ní skrývá tlačítko: Twig šablona ji volá s proměnnou now z controlleru a kombinuje ji s is_granted. Lhůtu sdílí s cancel() přes konstantu CANCELLATION_WINDOW_SECONDS, takže nevzniká duplicita. Zbývají doménové události: po úspěšné operaci agregát zaznamená OrderCancelled voláním record(), handler je po repository->save() vyzvedne přes releaseEvents() a publikuje (typicky přes Outbox). Agregát sám EventDispatcher nikdy nevolá.

Otázku „smí Petr“ zde agregát neřeší; tu zodpověděl Voter v sekci 11.04. Agregát odpovídá na „dá se to teď vůbec udělat?“, a jeho „ne“ platí i tehdy, když Voter řekl „ano“: Petr je vlastník, ale objednávka už odešla. Obě bariéry jsou nezávislé a obě nutné.

cancel() výše nahrazuje verzi z Návrhu agregátu celou, ne po částech: nese tytéž stavové podmínky i zámek a přidává k nim lhůtu. Nová je jen metoda isCancellable(); isOwnedBy() je ve výpisu pro úplnost a shoduje se s verzí z Návrhu agregátu. Konstruktor, továrny i markPaid() zůstávají tak, jak je zavádí Návrh agregátu. Stavová podmínka je proto stejná jako tam: blokuje odeslanou a doručenou objednávku, ne všechno kromě Confirmed. Zúžení na Confirmed by vypadalo přísněji, ale rozbilo by kompenzaci: sága ruší objednávku zaplacenou, handler by jí storno odmítl a objednávka by zůstala viset.

End-to-end průchod: žádost o storno

Co se stane, když zákazník Petr v rozhraní klikne na „Zrušit objednávku #42“:

  1. Edge (firewall). Symfony ověří JWT nebo session. Bez ověření → 401. Petr je přihlášený, pokračuje se.
  2. Edge (access_control). URL /order/42/cancel spadá pod IS_AUTHENTICATED_FULLY. Petr je přihlášený, pokračuje se.
  3. Controller validuje vstup (CSRF token, tělo požadavku), vytvoří CancelOrder(orderId: 42, reason: 'changed mind', actorId: <Petrovo CustomerId>) a předá ho na message bus.
  4. Application Handler (CancelOrderHandler) načte agregát z repozitáře: $order = $repo->get($orderId).
  5. Use Case. Synchronní handler volá $auth->isGranted('order.cancel', $order) a OrderVoter se zeptá agregátu přes $order->isOwnedBy($user->customerId()); asynchronní varianta volá isOwnedBy() přímo s actorId z commandu. Petr je vlastník → pokračuje se. Kdyby nebyl → AccessDeniedDomainException → HTTP 403.
  6. Aggregate. Handler volá $order->cancel('changed mind', $now). Agregát ověří, že objednávka není odeslaná ani doručená a že od potvrzení neuplynulo víc než 24 h. Petr ji potvrdil před 30 minutami → stav se změní na Cancelled a vznikne událost OrderCancelled. Kdyby už byla odeslaná → InvalidOrderStateTransitionException → HTTP 409.
  7. Persistence + outbox. Handler zavolá $repo->save($order); v jedné transakci se uloží stav agregátu a událost OrderCancelled do outbox tabulky.
  8. Field-level (odpověď). Controller přesměruje na detail objednávky. Kdyby detail obsahoval audit_log a Petr nebyl admin, read model by ho vynechal: u vlastní objednávky vidí stav, ale ne kdo a kdy ji upravoval.

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

11.07 Field-level – read model filtrace#

Předchozí tři vrstvy řešily akce a přípustnost operace. Field-level řeší viditelnost konkrétního pole při jinak povoleném čtení. Klasický příklad: detail objednávky vidí zákazník i admin, ale sloupec audit_log (kdo a kdy objednávku upravoval) jen admin.

Nabízejí se dva přístupy s odlišnými kompromisy.

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

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

twig templates/order/detail.html.twig (varianta nad read modelem)
1{# Jiná varianta téže šablony než v 11.04. Tam čte agregát, zde2   OrderDetailDto z read modelu níže – obrazovka potřebuje audit log,3   který agregát nenese. Do projektu jde jedna z nich, ne obě. #}4<dl>5    <dt>Zákazník</dt> <dd>{{ order.customerId }}</dd>6    <dt>Celkem</dt>   <dd>{{ (order.totalAmount / 100)|number_format(2, ',', ' ') }} Kč</dd>7    <dt>Stav</dt>     <dd>{{ order.status }}</dd>8 9    {% if is_granted('order.audit_log', order) %}10        <dt>Audit log</dt>11        <dd>12            <ul class="audit">13                {% for entry in order.auditLog %}14                    <li>{{ entry.at|date }}: {{ entry.action }} ({{ entry.actor }})</li>15                {% endfor %}16            </ul>17        </dd>18    {% endif %}19</dl>

Přístup 2: Query filter (read model)

Citlivá pole se z databáze vůbec nenačtou a read model vrací různá DTO podle role. Data neunikají, cenou je duplicita (dva dotazy, dvě struktury DTO). Hodí se pro PII, finanční data a audit logy.

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

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

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)~'

Read model sahá přímo na DBAL, proto leží v infrastruktuře, stejně jako read modely v kapitole o CQRS. DTO, které vrací, patří aplikační vrstvě. Drží jen to, co obrazovka opravdu dostala. auditLog je null, pokud ho dotaz nevybral. Oproti prázdnému poli to nese jiný význam: log se nenačetl, ne že by neobsahoval žádné záznamy.

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
Únik dat Riziko (data v paměti; u API/SPA unikají do odpovědi) Ne
Implementační složitost Triviální Vyžaduje různé DTO / read modely
Vhodné pro skrytí v UI, neostrá ochrana PII, finance, audit log, GDPR
Testování Twig integrační test Unit + integrační test read modelu
Soulad s OWASP A01:2021 Nedostatečný – viz [8] Ano (vynucení na serveru)

Pro necitlivá data Twig if stačí a šetří čas, pro citlivá data vždy query filter. Hodnocení v posledním řádku je výklad, ne citace. OWASP v kategorii „A01 Broken Access Control“ uznává jen kontrolu v důvěryhodném serverovém kódu a mezi slabinami uvádí obejití kontroly úpravou HTML stránky nebo požadavku na API. Twig if sice běží na serveru, data ale už načetl. Kdo k nim přijde jinou cestou (API, export), podmínku v šabloně obejde.

Seznamy: Voter na otázku „které objekty smí?“ neodpoví

Voter odpovídá na uzavřenou otázku: smí tento uživatel tento konkrétní objekt? Endpoint se seznamem ale potřebuje otázku opačnou: které objekty z deseti tisíc smí vidět? Rozdíl vypadá formálně, v praxi jde o strop celého rámce postaveného na Voterech.

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

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 %}

Rozbijí se dvě věci naráz. Stránkování přestane sedět: dotaz vrátí 20 řádků, filtr jich zahodí 7 a uživatel uvidí stránku o třinácti položkách. Celkový počet nikdo nespočítá, dokud nenačte všechno. Výkon navíc klesá lineárně: každý řádek projde rozhodováním přes všechny registrované Votery, takže dvacet řádků a pět Voterů znamená sto rozhodnutí na jedno vykreslení. Bez override supportsAttribute() a supportsType() (viz implementační detaily Voteru) se z toho počtu neubere nic.

Autorizace proto patří do dotazu. Read model dostane identitu aktéra a promítne ji do WHERE:

php src/Ordering/Infrastructure/ReadModel/OrderListReadModel.php
1<?php2 3// src/Ordering/Infrastructure/ReadModel/OrderListReadModel.php4declare(strict_types=1);5 6namespace App\Ordering\Infrastructure\ReadModel;7 8use App\Ordering\Domain\ValueObject\CustomerId;9use Doctrine\DBAL\Connection;10 11final readonly class OrderListReadModel12{13    public function __construct(private Connection $db) {}14 15    /** @return list<array<string, mixed>> */16    public function visibleTo(CustomerId $actor, bool $isAdmin, int $limit, int $offset): array17    {18        $sql = 'SELECT order_id, status, total_amount, placed_at FROM order_dashboard';19        $params = ['limit' => $limit, 'offset' => $offset];20 21        // Autorizace je součástí dotazu, ne postprocessingu22        if (!$isAdmin) {23            $sql .= ' WHERE customer_id = :actor';24            $params['actor'] = $actor->value;25        }26 27        $sql .= ' ORDER BY placed_at DESC LIMIT :limit OFFSET :offset';28 29        return $this->db->fetchAllAssociative($sql, $params);30    }31}

Podmínka ve WHERE je ale druhý zápis pravidla, které už zná OrderVoter. Jednu definici tu udržet nelze, protože SQL a PHP jsou různé jazyky; vazbu ale lze pojmenovat. Osvědčuje se držet obojí v jedné třídě nebo aspoň v jednom adresáři a doplnit komentář s odkazem na Voter. Hlavní pojistkou je test: projde objednávky vrácené read modelem a u každé ověří, že Voter řekne ano. Když se rozejdou, test spadne.

Modely vzniklé kolem Zanzibaru toto rozdělení pojmenovávají přímo: Check je otázka na jeden objekt, ListObjects vrací množinu. Pro druhou z nich Symfony 8 nativní podporu nemá. Voter je dobrý Policy Enforcement Point a víc si nenárokuje. Detail v sekci o ReBAC.

11.08 Policy-based přístup (ABAC)#

RBAC (Role-Based Access Control) se ptá na roli. ABAC (Attribute-Based Access Control) vyhodnocuje kombinaci atributů subjektu, akce, prostředku a kontextu proti policy a vrátí povoleno / zakázáno. O přechodu od prvního ke druhému nerozhoduje počet pravidel, ale tři kvalitativní signály: policy musí být čitelná pro někoho mimo vývojový tým, mění se v jiném rytmu než kód, nebo ji sdílí víc aplikací. Dokud neplatí ani jeden, Votery stačí a další abstrakce je jen práce navíc.

NIST SP 800-162 dává pro tuto vrstvu slovník, který používají i externí enginy [2]. PEP (Policy Enforcement Point) je místo, kde se rozhodnutí vynutí: v Symfony access_control, #[IsGranted] a volání isGranted() v handleru. Rozhodnutí samo padne v PDP (Policy Decision Point), u nás v rozhodovacím manažeru s Votery, případně ve vzdáleném enginu. PIP (Policy Information Point) dodává atributy, PAP (Policy Administration Point) policy spravuje. Rámec čtyř vrstev z 11.02 je tedy rozmístění PEP; PDP zůstává jeden.

Následující ukázka staví ABAC model explicitně: Policy jako kolekce objektů Rule, které se vyhodnotí proti trojici subject/user/context. Slouží k tomu, aby byl model vidět. Zda se takový kód vyplatí psát, řeší závěr sekce; ve většině Symfony projektů ne.

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 chyby se projeví až za běhu. ExpressionLanguage čte veřejné properties a volá veřejné metody. Gettery k privátním polím nedohledá, takže subjekt politiky musí mít stav čitelný zvenku: buď snapshot s veřejnými poli, nebo agregát s public private(set) jako kanonický Order. Odečíst DateTimeImmutable od čísla komponenta neumí: datum se převádí na unixový timestamp metodou objektu (subject.placedAt.getTimestamp()) a now přichází z proměnných evaluatoru jako číslo, ne jako objekt. Backed enum se neporovnává přímo: subject.status == "confirmed" selže, porovnává se až hodnota subject.status.value. A protože výrazy jsou řetězce, statická analýza je nevidí; každé pravidlo musí krýt test, viz tabulkové testy policy.

Pravidla subject.status.value == "confirmed" a lhůta 24 h stojí v politice jen pro ilustraci ABAC zápisu. Jak popisuje sekce 11.06, doménové invarianty patří do agregátu a politika je ověřuje nanejvýš jako předběžnou kontrolu (obrana do hloubky). Zdrojem pravdy zůstává agregát, který neplatný příkaz odmítne i bez autorizační vrstvy.

Jednoduchý PolicyEvaluator vyhodnocuje pravidla v daném kontextu pomocí Symfony ExpressionLanguage. Balíček v základní instalaci není, prvním krokem je tedy composer require symfony/expression-language:

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í mít veřejně čitelný stav libovolný objekt
Kompozice hlasů vlastní kód AccessDecisionManager a strategie

Poslední dva řádky jsou skrytá cena, kterou tabulky výhod obvykle zamlčují. ExpressionLanguage vidí jen veřejné properties a metody. Kanonický Order s public private(set) to splní, agregát se stavem čitelným jen přes privátní pole ne. Pak vzniká snapshot navíc, který se musí držet v synchronizaci s doménou. Hlasy navíc skládá vlastní evaluátor místo rozhodovacího manažeru, takže strategie z 11.04 se neuplatní.

Vlastní evaluátor, nebo Voter s Vote?

Pro vlastní PolicyEvaluator býval jediný silný argument: vědět, které pravidlo selhalo, ne jen že přístup nebyl povolen. Od Symfony 7.3 to umí Security komponenta sama. Voter přijímá volitelný parametr ?Vote $vote a může do něj zapsat důvod. Aplikační vrstva pak čte celé rozhodnutí přes Security::getAccessDecision(), které přibylo ve verzi 7.4:

php src/Ordering/Infrastructure/Security/OrderVoter.php (výřez: voteOnAttribute s důvody)
1// src/Ordering/Infrastructure/Security/OrderVoter.php (s důvody, jen větev CANCEL)2protected function voteOnAttribute(3    string $attribute,4    mixed $subject,5    TokenInterface $token,6    ?Vote $vote = null,7): bool {8    $user = $token->getUser();9    if (!$user instanceof SecurityUser) {10        $vote?->addReason('Aktér není přihlášený uživatel aplikace.');11        return false;12    }13 14    if (!$subject->isOwnedBy($user->customerId())) {15        $vote?->addReason('Objednávku smí zrušit pouze její vlastník.');16        return false;17    }18 19    if (!$subject->isCancellable(new \DateTimeImmutable())) {20        $vote?->addReason('Lhůta 24 h pro zrušení objednávky uplynula.');21        return false;22    }23 24    return true;25}

Důvody se čtou z veřejné vlastnosti $vote->reasons (pole stringů); getter třída Vote nemá. Stejně je na tom AccessDecision, kde je výsledek vlastnost $decision->isGranted. Aplikační vrstva je vytáhne z AccessDecision a předá do chybové odpovědi:

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í vrstva Policy/Rule se nevyplatí. Dá tytéž odpovědi jako Votery, ale bez statické analýzy, bez rozhodovacích strategií a s modelem navíc. ABAC zůstává užitečný jako způsob uvažování o pravidlech, implementace ale vzniká z Voterů. Externí engine přichází na řadu, až když policy musí žít mimo aplikaci: sdílí ji víc služeb, spravuje ji jiný tým nebo ji auditor kontroluje nezávisle na deploy cyklu. Tehdy se nabízí OPA s jazykem Rego nebo Cerbos a Voter se stane tenkým PEP, který se ptá vzdáleného PDP. Rozhraní mezi nimi standardizuje AuthZEN Authorization API 1.0, schválené v lednu 2026 [9].

ReBAC: když je oprávnění vztah, ne atribut

OPA není poslední stanice. Posun posledních let míří k ReBAC (Relationship-Based Access Control), kde se přístup odvozuje ze vztahů mezi uživateli a objekty i mezi objekty navzájem. Typické pravidlo: „uživatel vidí dokument, pokud má přístup k jeho nadřazené složce“. Na hierarchiích, sdílení a multi-tenancy RBAC selhává: buď vznikají role pro každou kombinaci, nebo se logika roztříští do Voterů.

Referenčním modelem je Zanzibar, autorizační systém Googlu popsaný na USENIX ATC '19. Ukládá vztahy jako trojice objekt#relace@uživatel, konfiguraci vztahů popisuje vlastním jazykem namespace a konzistenci řeší tokeny zvanými zookies. Provozní čísla z paperu dávají měřítko: biliony ACL záznamů, miliony autorizačních dotazů za sekundu, p95 latence pod 10 ms. Otevřené implementace téhož modelu jsou dnes dvě: OpenFGA (projekt CNCF) a SpiceDB od Authzed se schema jazykem, který rozlišuje zapsané vztahy a počítaná oprávnění.

11.09 Multi-tenancy – tenant kontext#

Multi-tenancy (vícenájemnost) je speciální případ ABAC: jedna aplikace obsluhuje více oddělených zákazníků (organizací, mandantů, tenantů) a žádný nesmí vidět data jiného. Architektonické strategie jsou tři:

  • Row-based – sdílená databáze, sdílené tabulky, sloupec tenant_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. Výkon závisí na počtu tenantů: menší tabulky pomohou velkým tenantům, tisíce schémat ale zatíží katalog, migrace i connection pooling.
  • Database-based – samostatná databáze per tenant. Nejvyšší izolace, nejnákladnější (DB connection per tenant, migrations × N).

Volba mezi nimi souvisí s velikostí instalace. Row-based se hodí pro SaaS s velkým počtem malých tenantů, schema-based tam, kde se mají oddělit zálohy a migrace per tenant, database-based pro regulované domény. Kritérium je pokaždé stejné: jak drahá je chyba, když se dva tenanty potkají v jedné odpovědi. Pro row-based je v Symfony zavedeným nástrojem Doctrine SQLFilter.

To vypadá jako rozpor s chybou 3, která filtrování v perzistentní vrstvě označila za anti-vzor. Rozpor je jen zdánlivý a rozdíl spočívá v tom, na co filtr odpovídá. Tenant je kontext dotazu, ne autorizační rozhodnutí o akci: dimenze, kterou nese každý dotaz v požadavku, stejně jako jazyk nebo časová zóna. Rozhodnutí „Petr smí zrušit objednávku #42“ do SQL nepatří, protože handler pak nerozezná neexistující záznam od cizího. Otázka „ke kterému tenantovi tento request patří“ do SQL patří, protože odpověď je pro celý request jediná a neměnná.

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 říká jen „tahle entita patří tenantovi“. Bez něj filtr spadne u prvního dotazu na Interface … does not exist, a protože je zapnutý globálně, shodí každý dotaz v aplikaci:

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}

Filtr přidá klauzuli tenant_id = ? do každého dotazu nad entitou, která implementuje TenantAware. Dokud ho neimplementuje žádná, je filtr no-op: zapnutý, ale bez účinku. Značka nepatří na SecurityUser. Toho načítá provider uvnitř firewallu, tedy dřív, než listener stihne nastavit parametr, a přihlášení by spadlo na Parameter 'tenant_id' does not exist. Tenantní jsou doménové entity za firewallem, ne třída, kterou firewall používá k autentizaci. Aktivace filtru v config/packages/doctrine.yaml:

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

Pozor na výchozí stav. Vypnutý nebo nenakonfigurovaný filtr nepřidá do SQL žádné WHERE a dotaz vrátí data všech tenantů. SQLFilter je ze své podstaty fail-open a v tom je hlavní riziko celého přístupu. Konfigurace výše proto filtr zapíná globálně (enabled: true): běží pro každý dotaz a chybějící tenant_id skončí výjimkou, ne únikem dat.

Jedna mezera zůstane i pak. Filtr nepokrývá načtení neowning strany asociace one-to-one. Měřeno na ORM 3.6 vrátí druhý konec vztahu i záznam cizího tenanta, přestože přes find() nebo DQL týž záznam nedostanete. Kde na oddělení tenantů závisí bezpečnost, patří kontrola tenanta i do doménové vrstvy, ne jen do filtru.

Hodnotu parametru dodává kernel event listener po autentizaci:

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; // veřejný endpoint, anonymní požadavek32        }33 34        $tenantId = $user->tenantId()->value;35        $filter   = $this->em->getFilters()->enable('tenant');36        $filter->setParameter('tenant_id', $tenantId);37    }38}

Volání enable('tenant') v listeneru je u globálně zapnutého filtru neškodné: vrátí existující instanci, na kterou stačí nastavit parametr.

Tři detaily:

  • Priorita 7 v AsEventListener. V Symfony platí vyšší priorita = dřívější vykonání. Firewall registruje svůj onKernelRequest s prioritou 8, takže listener, který potřebuje už autentizovaného uživatele, musí mít prioritu nižší než 8 (typicky 7 nebo 0). Detail v dokumentaci EventDispatcheru.
  • Main request guard. Bez $event->isMainRequest() by se filtr nastavoval i pro dílčí požadavky (ESI, render fragments), kde token obvykle není a listener by spadl.
  • Anonymní požadavek parametr nedostane. U veřejných endpointů (login, register, health) listener skončí na guardu a tenant_id zůstane nenastavené. První dotaz nad TenantAware entitou pak vyhodí výjimku, protože globálně zapnutý filtr bez parametru dotaz nepustí. Hlučné selhání je tu záměr: veřejný endpoint nemá tenantní data co číst. Pokud je přesto čte, patří požadavek odmítnout už na firewallu.

PostgreSQL Row-Level Security

Díra popsaná výše má u PostgreSQL řešení o patro níž. Row-Level Security posouvá filtrování do databáze, takže platí i pro nativní SQL, pro konzolové skripty i pro připojení mimo aplikaci:

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. Proti SQLFilteru se RLS liší výchozím chováním, a to rozhoduje. Po ENABLE ROW LEVEL SECURITY platí na tabulce default-deny: bez politiky se nevrátí nic. SQLFilter je naopak fail-open a fail-closed chování se musí vyrobit ručně, jak popisuje předchozí callout. Cenou za RLS je vázanost na PostgreSQL, obtížnější ladění (dotaz vrátí prázdno a nikde není proč) a role s atributem BYPASSRLS, kterou potřebují migrace a zálohy. Obě vrstvy se nevylučují: filtr drží čitelné chování v ORM, RLS je poslední záchytná síť.

11.10 Testovací pyramida pro autorizaci#

Každá ze čtyř vrstev se testuje jiným druhem testu. Kdo se snaží pokrýt všechno end-to-end, skončí u pomalé a křehké sady. Dělení odpovídá klasické testovací pyramidě: hodně rychlých unit testů, méně integračních, pár end-to-end.

Aggregate-level: čistý unit test

Doménová pravidla v agregátu jsou čisté PHP bez frameworku a databáze, takže test je rychlý a deterministický.

Testy sahají po OrderFactory, která drží sestavení agregátu na jednom místě. Podle taxonomie z Testování DDD jde o Object Mother: pojmenované statické metody vracejí hotové scénáře (placed(), shipped()). S Foundry továrnou stejného jména z téže kapitoly nemá nic společného. Vyplatí se, jakmile ji potřebuje víc testovacích souborů:

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 zde. Bez něj by testy policy hlásily31    // porušení vlastnictví místo pravidla, které chtěly ověřit.32    public static function shipped(?CustomerId $customerId = null): Order33    {34        $order = self::build($customerId ?? CustomerId::generate(), self::AT);35        $order->markPaid();36        $order->ship(ShipmentId::generate());37 38        return $order;39    }40 41    /**42     * Továrna jde přes veřejné API agregátu, ne přes reflexi. Konstruktor43     * je privátní a stav se mění jen přechody – kdyby si test sahal dovnitř,44     * přestal by hlídat právě ta pravidla, kvůli kterým existuje.45     */46    private static function build(CustomerId $customerId, string $at): Order47    {48        // Poslední parametr je čas potvrzení. Bez něj by se scénář49        // „potvrzeno v 10:00, stornováno ve 12:00“ nedal postavit jinak50        // než reflexí – a test by přestal hlídat pravidla agregátu.51        $order = Order::placeWithFirstItem(52            $customerId,53            ProductId::generate(),54            1,55            new Money(10_000, Currency::CZK),56            new \DateTimeImmutable($at),57        );58 59        $order->releaseEvents(); // fronta událostí patří testu, ne továrně60 61        return $order;62    }63}
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 se stubem TokenInterface

Voter dostává TokenInterface; v testu stačí jeho stub, reálný subjekt a stub rozhodovacího manažeru pro role. Symfony Kernel test nepotřebuje:

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}

Stub AccessDecisionManagerInterface vrací záměrně false. Test tak ověřuje vlastnictví bez vlivu rolí; pro scénář s adminem stačí druhý test s návratovou hodnotou true.

End-to-end: WebTestCase

Celou pipeline (firewall → controller → handler → Voter → agregát) pokrývá Symfony WebTestCase. To už je integrační test s kernelem a databází. Rozumná míra: jeden e2e test na use case, který pokryje hlavní scénář a jeden až dva nejdůležitější chybové stavy. Okrajové případy patří do unit testů na nižších vrstvách.

Přihlášení v takovém testu neprobíhá přes formulář. KernelBrowser::loginUser() vloží uživatele rovnou do session a ušetří jeden request i závislost na podobě login stránky. Jednu vazbu ale neodstraní: s entity providerem firewall uživatele při každém dalším requestu načítá znovu, takže fixture musí být v databázi. Jinak test skončí přesměrováním na /login a tváří se jako chyba autorizace.

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ší, proto ho vynucuje 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 nepovoleném importu, ne až při refaktoringu za rok. Pyramidu, fixture buildery i další architektonická pravidla rozebírá kapitola o testování.

Policy: tabulkový unit test

U policy-based přístupu je každé pravidlo jeden test case. Vyhodnocení výrazů stojí na balíčku symfony/expression-language, který v základní instalaci není. Nejlépe poslouží tabulkový test s data providerem: jeden řádek = jeden scénář, čitelný i pro netechnického reviewera:

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}

Oproti testu s metodou na každý případ má tabulka dvě výhody. Nové pravidlo znamená jeden řádek navíc v scenarios(). A celý test slouží jako spustitelná dokumentace policy: reviewer mimo vývojový tým vidí všechny případy v jedné tabulce a může doménová pravidla schválit.

11.11 Anti-vzory#

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

Anti-vzor 1: Autorizace v controlleru

Rozebírá ho sekce 11.01. Symptom: stejná autorizační podmínka opakovaná ve třech a více controllerech, která chybí ve verzích volaných z konzolového commandu nebo Messenger handleru. Náprava: přesun do Voteru a volání AuthorizationCheckerInterface v Application Service. Souvisí: obecné anti-vzory v DDD.

Anti-vzor 2: Voter, který si načte vlastní subjekt

Rozebráno v calloutu u Voteru. Symptom: konstruktor Voteru přijímá repository a voteOnAttribute() volá find($subject) nad ID, které dostal místo objektu. Důsledek: druhý dotaz na tutéž entitu a rozhodování nad stavem, který se mezitím mohl změnit. Náprava: handler načte entitu jednou a předá ji do isGranted(). Doplňková data mimo subjekt (členství, delegace) si Voter načíst smí; zákaz míří na subjekt, ne na všechny dotazy.

Anti-vzor 3: Voter == Aggregate logic

Symptom: storno lhůta („objednávka ne starší než 24 h“) je zapsaná jak ve Voteru, tak v Order::cancel(). Když se pravidlo změní (lhůta se prodlouží na 48 h), musí se upravit obě místa a na jedno se typicky zapomene.

Náprava: pravidlo patří do agregátu, protože jde o doménový invariant. Voter doménový stav agregátu neověřuje; odpovídá jen na identitu a role uživatele a na vlastnictví subjektu. Pro skrytí tlačítka ve view se v Twigu kombinuje {% if is_granted(...) and order.isCancellable(now) %}: Voter pro oprávnění, doménová metoda pro stav.

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

Symptom:

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: doména závisí na Symfony\Component\Security, takže stejný kód nespustíte z konzolového commandu, z Messenger workeru ani z unit testu bez Kernelu. Modelový důsledek váží víc. Role a oprávnění jsou slovník jiné subdomény, Identity & Access kontextu z 11.02. Jakmile se objeví v Order::cancel(), mluví Ordering kontext cizím Ubiquitous Language a hranice mezi kontexty se stírá.

Náprava: doména pracuje s vlastním typem (CustomerId, TenantId), aplikační handler překládá SecurityUser na doménový identifikátor a vynucuje to architektonický test. Opačný směr téhož porušení vrstev, tedy doménovou logiku v infrastruktuře, rozebírá kapitola o anti-vzorech.

11.12 Shrnutí#

Autorizace v DDD aplikaci na Symfony 8 sedí na čtyřech vrstvách, každá s vlastním Symfony nástrojem a vlastní granularitou:

  • Edge – Symfony firewall + access_control. Anonymní vs. přihlášený, hrubé dělení podle rolí. Žádná doménová znalost.
  • Use Case – Symfony Voter. „Smí Petr zrušit objednávku #42?“ Aplikační handler ověří oprávnění před doménovou operací: synchronně přes isGranted(), ve workeru proti actorId z commandu. Doména Security komponentu nezná.
  • Aggregate – doménový invariant + doménová výjimka. „Objednávku nelze stornovat po odeslání ani po 24 h od potvrzení.“ Agregát vyhazuje InvalidOrderStateTransitionException nebo CancellationWindowExpiredException; aplikační vrstva je mapuje na HTTP 409.
  • Field – Twig is_granted ve view (s rizikem úniku dat) nebo query filter / read model pro citlivá data (PII, audit log). Seznamy potřebují filtr v dotazu, ne Voter nad každým řádkem.

Hrubá oprávnění pokryje RBAC. Jakmile pravidla závisí na vztazích mezi entitami, nastupuje uvažování v ABAC pojmech, implementované ale z Voterů, ne z vlastní policy vrstvy. Vícenájemnost řeší Doctrine SQLFilter s kernel listenerem, nastavený fail-closed, a v PostgreSQL k tomu RLS jako záchytná síť pod aplikací. Doménové stavové pravidlo patří do agregátu; vztah aktéra k agregátu (vlastnictví) definuje rovněž agregát a Voter se ho ptá.

Kdy z Voterů odejít, neurčuje počet pravidel, ale tři otázky: musí být policy čitelná mimo vývojový tým, mění se v jiném rytmu než kód, sdílí ji víc aplikací? Dokud zní odpověď třikrát ne, jsou Votery s Vote::addReason() levnější varianta. Při prvním ano přichází externí engine (OPA, Cerbos) a Voter se stane tenkým vynucovacím bodem.

Praktický checklist před deploy

Než commitnete autorizační změnu, projděte těchto devět bodů:

  1. Existuje v access_control default-deny pravidlo na konci? Pokud ne: nový endpoint bez explicitní role je veřejný.
  2. Ověřuje Application Handler oprávnění (isGranted(), ve workeru actorId z commandu) 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 unikají 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ž oprávnění potřebují úplně jiné závislosti (typicky vlastnictví vs. role) a chcete je testovat nezávisle. Detail v sekci o Voteru.

Smí Voter načítat aggregate z databáze?

Subjekt, o kterém rozhoduje, ne. Voter ho dostává jako $subject; handler ho už načetl a předává v paměti. Druhé načtení je anti-vzor (11.11): vede k duplicate query a k rozhodování nad stavem, který se mezitím mohl změnit. Doplňková data, která na subjektu nejsou (členství v týmu, delegace, hierarchie tenantů), si Voter načíst musí a Symfony s injektovanými službami ve Voteru počítá. Takové dotazy patří za cache platnou po dobu requestu.

Kdy stačí ROLE_USER a kdy je třeba attribute-based přístup?

RBAC (role) stačí, dokud platí „role popisuje oprávnění sama o sobě“: ROLE_ADMIN smí všechno, ROLE_REFUND_AGENT smí refundy bez ohledu na konkrétní entitu. Jakmile oprávnění závisí na vztazích (vlastnictví, tenant, časové okno, stav agregátu), RBAC se rozroste a vznikají úzce specifické role typu ROLE_TENANT_42_ORDER_AGENT. Tehdy nastupuje uvažování v ABAC pojmech (11.08): rozhodnutí vyhodnocuje atributy subjektu, uživatele a kontextu. Neznamená to psát vlastní policy engine; v Symfony 8 se totéž postaví z Voterů, které umí i vrátit důvod zamítnutí.

Co když máme 100 různých rolí?

To je obvykle příznak, že role replikují data, která patří do entit. Místo ROLE_TENANT_42_ADMIN, ROLE_TENANT_43_ADMIN, … stačí atribut user.tenantId, jedna generická role ROLE_TENANT_ADMIN a Voter, který ověří user.tenantId == subject.tenantId. Zjednoduší se tím správa uživatelů, audit i delegace. Detail v sekci o multi-tenancy.

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

Ne. Doména musí být nezávislá na frameworku. Jinak ji nelze unit-testovat bez Kernelu, sdílet mezi webem a CLI ani převést na jiný framework. Modelový důvod je ale silnější než technický: role a oprávnění jsou slovník Identity & Access kontextu, ne toho, ve kterém agregát žije. Potřebuje-li agregát „znát“ uživatele, dostane vlastní doménový typ (CustomerId). Překlad ze SecurityUser obstará aplikační handler. Detail v anti-vzoru 4 v 11.11.

Kam ukládat audit log autorizačních rozhodnutí?

Tři možnosti, podle compliance požadavků: (1) Symfony Monolog s vlastním channelem authorization, což stačí pro většinu aplikací, log do souboru / ELK / Loki; (2) doménová tabulka authorization_decisions s parametry (user_id, attribute, subject_id, decision, policy_version), vhodné pro regulované domény (PCI DSS, zdravotnictví); (3) externí audit služba (AWS CloudTrail, Datadog) pro multi-tenant SaaS. Nejjednodušší je dekorátor nad AuthorizationCheckerInterface, který každé volání zaloguje; hlasy jednotlivých voličů ale vidí až vlastní AccessDecisionStrategyInterface nebo Security::getAccessDecision(). Detail v sekci Audit log autorizačních rozhodnutí.

11.13 Další četba#