Autorizace v DDD na Symfony
V DDD aplikacích se opakovaně objevuje stejná otázka: „smí to ten uživatel udělat?“ – patří do controlleru, do voteru, do aggregate, nebo někam jinam? Kapitola dává konkrétní čtyřvrstvý rámec: Edge, Use Case, Aggregate, Field. Každá vrstva odpovídá na jinou otázku a používá jiný Symfony nástroj.
Obsah kapitoly
V předchozí kapitole jsme implementovali agregáty, repozitáře a Application Services v Symfony 8. Otevřená zůstala otázka, kterou projekty obvykle řeší případ od případu: kdo smí který use case zavolat a za jakých podmínek. V této kapitole zavedeme čtyřvrstvý rámec, který autorizační rozhodnutí umístí na správnou vrstvu – od HTTP firewallu přes Symfony Voter v aplikační vrstvě až po doménové invarianty v agregátu. V navazující kapitole o CQRS pak ukážeme, jak se autorizace integruje do Command Handleru.
Autentizaci (Symfony firewall, JWT, OAuth) tým většinou postaví bez větších potíží. Otázka „kdo smí udělat co s konkrétní entitou v konkrétním stavu“ je ale jiná disciplína. Bez rámce se odpověď rozpadne mezi controllery, listenery, twig šablony a Doctrine query buildery. Kapitola dává čtyřvrstvý rámec: podle něj poznáte, kam které pravidlo patří a jak ho v Symfony 8 implementovat idiomaticky. Security komponenta přitom nepronikne do doménového jádra.
Kapitola navazuje na Implementaci v Symfony, která autorizaci záměrně ponechala stranou a odkazuje sem. Doplňuje praktický pohled k tématům CQRS (kde sedí ověření Command Handleru), Testování (jak otestovat každou ze 4 vrstev samostatně) a DDD v praxi – kde to bolí (která autorizaci zmiňuje jen letmo).
11.01 Tři chyby s autorizací, které se v review opakovaně objevují#
Tři vzory níže se v code review objevují pravidelně, zejména v projektech, kde DDD běží na Symfony bez ujasněných hranic. Diagnóza je pokaždé stejná: chybí rozhodovací rámec, kam které pravidlo patří.
Chyba 1: Vše v controlleru
Nejčastější vzor. Controller přijme HTTP požadavek, načte entitu z repository a inline porovná atributy uživatele s atributy entity:
1// src/Controller/OrderController.php (anti-vzor)2namespace App\Controller;3 4final class OrderController extends AbstractController5{6 #[Route('/order/{id}/cancel', methods: ['POST'])]7 public function cancel(string $id, OrderRepository $orders): Response8 {9 $order = $orders->find($id);10 $user = $this->getUser();11 12 // Anti-vzor: autorizační logika rozsypaná v controlleru13 if ($user->getId() !== $order->getCustomerId()) {14 throw $this->createAccessDeniedException('Not your order');15 }16 if ($order->getStatus() !== 'placed') {17 throw new \LogicException('Cannot cancel a non-placed order');18 }19 20 $order->setStatus('cancelled');21 $orders->save($order);22 23 return $this->redirectToRoute('order_detail', ['id' => $id]);24 }25}
Co je špatně: stejný use case se volá i z konzolového commandu (cron, batch), ze Symfony Messenger handleru (asynchronní queue) a z administračního panelu. Při každém volání musí někdo tutéž podmínku zopakovat – a stačí, aby jeden vstupní bod selhal, a celá ochrana padá. Pravidlo „zrušit smí jen vlastník“ patří do use-case vrstvy – zde je ale rozeseté po infrastruktuře, ne na jednom místě.
Chyba 2: Vše ve Voteru, doména nezná autorizaci
Druhý extrém. Tým objeví Symfony Voter a přesune do něj všechna pravidla – včetně doménových invariantů. Aggregate má veřejné API setStatus(), setTotal(), setCustomerId() a Voter „natáhne“ autorizaci přes ně:
1// src/Security/OrderVoter.php (anti-vzor)2namespace App\Security;3 4final class OrderVoter extends Voter5{6 protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool7 {8 $user = $token->getUser();9 10 // Anti-vzor: doménové pravidlo (cancellation window) ve Voteru11 if ($attribute === 'CANCEL') {12 if ($user->getId() !== $subject->getCustomerId()) { return false; }13 if ($subject->getStatus() !== 'placed') { return false; }14 $age = (new \DateTimeImmutable())->getTimestamp() - $subject->getPlacedAt()->getTimestamp();15 if ($age > 86400) { return false; }16 return true;17 }18 return false;19 }20}
Co je špatně: Aggregate Order::setStatus(OrderStatus::CANCELLED) stále existuje a je veřejné. Stačí, aby kdokoli (test, fixture, migration script, jiný vývojář) zavolal setter mimo Voter – a invariant „24h cancellation window“ je porušen. Voter je jen volitelný filtr před vstupem; doména nemá žádnou pojistku. Pravidlo „cancellation window“ je doménové, ne use-case-level.
Chyba 3: Autorizace na úrovni databázových řádků
Tým objeví Doctrine SQLFilter a rozhodne, že autorizaci vyřeší v perzistentní vrstvě – entity se z databáze nevrátí, pokud k nim uživatel nemá přístup. Funguje to pro read dotazy, ale rozpadá se v doménové logice:
- Když handler dostane
$orderIda entita se nenajde, neví, jestli neexistuje, nebo jen není dostupná pro daného uživatele. Chybová hláška „Order not found“ je matoucí. - Doctrine filtry se nevztahují na entity už načtené v identity map, na nativní SQL ani na Redis cache.
- Doménová pravidla typu „order patří customerovi“ ztrácejí jedno závazné místo: zapsaná jsou v SQL filtru, ve Voteru se na ně zapomíná a v aggregate chybí – při volání mimo HTTP vrstvu se nevynutí.
11.02 Čtyři vrstvy autorizace#
Autorizační rozhodnutí padá ve čtyřech postupných vrstvách. Každá vrstva má vlastní otázku, Symfony nástroj i granularitu. Vrstvy fungují jako filtry: každá další odpovídá na jemnější otázku a předpokládá, že předchozí vrstva už řekla „ano“.
| Vrstva | Otázka | Symfony nástroj | Příklad |
|---|---|---|---|
| Edge | Je přihlášený? Smí na tuhle URL? | access_control, JWT firewall |
/admin/* jen pro ROLE_ADMIN |
| Use Case | Smí vykonat use case na tomto objektu? | Voter |
„Smí Petr cancelnout order #42?“ |
| Aggregate | Dá se to vůbec teď udělat? | doménový check + výjimka | „Order lze cancelnout jen 24 h od vytvoření“ |
| Field | Smí vidět konkrétní pole? | Twig + Voter, query filter | „Sloupec audit_log vidí jen admin“ |
Pravidlo: každé autorizační rozhodnutí patří do právě jedné vrstvy. Pokud zjistíte, že stejné pravidlo musíte zapsat na dvou vrstvách, jedna z nich je špatně zvolená. V sekci o anti-vzorech ukážeme typické duplicity, kterým se vyhnout.
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]; praktický pohled na vrstvení autorizace v doménové aplikaci dává Vernon v Implementing Domain-Driven Design (kap. 14, „Application“).
11.03 Edge – Symfony firewall a access_control#
Edge je nejhrubší vrstva a leží mimo doménový kód. Odpovídá pouze na otázku „kdo je vůbec na druhém konci socketu?“ – anonymous, authenticated, případně role-based pro hrubě dělené sekce (/admin/*, /api/v1/*). Doménová pravidla typu „zákazník X smí na tuto objednávku“ patří o vrstvu výš (use case).
1# config/packages/security.yaml2security:3 providers:4 app_user_provider:5 entity:6 class: App\Identity\Domain\AppUser7 property: email8 9 firewalls:10 # Stateless API – JWT11 api:12 pattern: ^/api/13 stateless: true14 jwt: ~15 provider: app_user_provider16 17 # Web – session18 main:19 pattern: ^/20 lazy: true21 provider: app_user_provider22 form_login:23 login_path: login24 check_path: login25 logout: ~26 27 access_control:28 # Veřejné endpointy29 - { path: ^/login, roles: PUBLIC_ACCESS }30 - { path: ^/register, roles: PUBLIC_ACCESS }31 - { path: ^/health, roles: PUBLIC_ACCESS }32 # Hrubá role-based separace33 - { path: ^/admin, roles: ROLE_ADMIN }34 - { path: ^/api/internal, roles: ROLE_SERVICE_ACCOUNT }35 # Vše ostatní za autentizací36 - { path: ^/, roles: IS_AUTHENTICATED_FULLY }
Principy edge vrstvy:
- Žádná doménová znalost. Edge nezná pojem „order“, „customer“, „cancellation window“. Pracuje jen s URL pattern + roles + autentizační stav.
- Default deny. Poslední pravidlo v
access_controlje „všechno ostatní vyžaduje přihlášení“. Bez tohoto fallbacku stačí přidat nový endpoint a zapomenout ho zařadit – automaticky bude veřejný. - Role-based, ne attribute-based. ROLE_ADMIN je hrubá kategorizace; jemnější rozhodnutí jako „admin tenantu T1, ne T2“ patří do Voteru, ne do
access_control. - JWT firewall vs. session. API typicky stateless (
jwtautentikátor), web typicky session-based. Pro JWT v Symfony existuje balíčeklexik/jwt-authentication-bundlenebo nativníaccess_tokenautentikátor sOidcTokenHandlerpro OpenID Connect provider [3].
11.04 Use Case – Symfony Voter#
Use case vrstva odpovídá na otázku „smí tento uživatel vykonat tento use case na tomto objektu?“. Symfony Voter je přesně k tomu navržený nástroj. Pravidlo: 1 use case = 1 atribut Voteru; jeden Voter může pokrývat N atributů, pokud se týkají stejné entity (typicky CRUD operace nad agregátem).
Voter zná dvě věci: identitu uživatele (přes TokenInterface) a cílový subjekt (typicky aggregate root). Co Voter nesmí dělat: fetchovat entity z databáze (to je práce handleru) a znát doménové invarianty (to je práce aggregate). Pravidla typu „cancellation window“ Voter nesmí natáhnout zvenku – patří ke stavu agregátu.
1// src/Ordering/Infrastructure/Security/OrderVoter.php2declare(strict_types=1);3 4namespace App\Ordering\Infrastructure\Security;5 6use App\Identity\Domain\AppUser;7use App\Ordering\Domain\Order;8use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;9use Symfony\Component\Security\Core\Authorization\Voter\Voter;10 11final class OrderVoter extends Voter12{13 public const VIEW = 'order.view';14 public const CANCEL = 'order.cancel';15 public const REFUND = 'order.refund';16 17 protected function supports(string $attribute, mixed $subject): bool18 {19 return in_array($attribute, [self::VIEW, self::CANCEL, self::REFUND], true)20 && $subject instanceof Order;21 }22 23 protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool24 {25 $user = $token->getUser();26 if (!$user instanceof AppUser) {27 return false;28 }29 30 \assert($subject instanceof Order);31 32 return match ($attribute) {33 self::VIEW => $this->canView($subject, $user),34 self::CANCEL => $this->canCancel($subject, $user),35 self::REFUND => $user->hasRole('ROLE_REFUND_AGENT'),36 default => false,37 };38 }39 40 private function canView(Order $order, AppUser $user): bool41 {42 return $user->customerId()->equals($order->customerId())43 || $user->hasRole('ROLE_ADMIN');44 }45 46 private function canCancel(Order $order, AppUser $user): bool47 {48 return $user->customerId()->equals($order->customerId());49 }50}
Tři implementační detaily:
- Konstanty atributů s prefixem entity (
order.cancel, ne jenCANCEL). Vyhne se kolizi s atributy jiných Voterů (invoice.cancel,shipment.cancel) a v audit logu je hned jasné, kterého subjektu se rozhodnutí týkalo. - Match expression (PHP 8.0+) místo if-else stromu. Bez default větve PHPStan ohlásí nepokrytý case;
default => falsenaopak volí tiché zamítnutí (fail-closed) výměnou za ztrátu této kontroly. - Privátní metody
canView,canCancel. Každý use case má vlastní privátní metodu – testy umí mockovat token a subjekt, asserce na výsledek metody je explicitní. Bez extrakce by se voter rozrostl do nečitelného switch-case.
Použití ve Command Handleru
Voter sám o sobě nestačí – někdo ho musí zavolat. Idiomatické místo je Application Service / Command Handler, kde se autorizace ověří před doménovou operací. Handler injektuje AuthorizationCheckerInterface (rozhraní Security komponenty), což je v aplikační vrstvě v pořádku – doménová vrstva by tu závislost mít nesměla.
1// src/Ordering/Application/Handler/CancelOrderHandler.php2declare(strict_types=1);3 4namespace App\Ordering\Application\Handler;5 6use App\Ordering\Application\Command\CancelOrderCommand;7use App\Ordering\Application\Exception\AccessDeniedDomainException;8use App\Ordering\Domain\OrderRepository;9use App\Ordering\Infrastructure\Security\OrderVoter;10use Symfony\Component\Messenger\Attribute\AsMessageHandler;11use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;12 13#[AsMessageHandler]14final readonly class CancelOrderHandler15{16 public function __construct(17 private OrderRepository $orders,18 private AuthorizationCheckerInterface $auth,19 ) {}20 21 public function __invoke(CancelOrderCommand $command): void22 {23 $order = $this->orders->getOrFail($command->orderId);24 25 if (!$this->auth->isGranted(OrderVoter::CANCEL, $order)) {26 throw new AccessDeniedDomainException(27 sprintf('Cancel not allowed for order %s', $command->orderId->toString())28 );29 }30 31 $order->cancel(reason: $command->reason, when: new \DateTimeImmutable());32 $this->orders->save($order);33 }34}
Po této kontrole zavolá handler doménovou operaci $order->cancel(...), která uvnitř agregátu ověří doménové invarianty (status, cancellation window). Tím vznikají dvě nezávislé bariéry: Voter řekne „smí Petr“, aggregate řekne „dá se to vůbec teď“. Detail aggregate vrstvy v sekci 11.06. Jeden háček tu ale je: handler nese atribut #[AsMessageHandler] a v asynchronním workeru žádný token neexistuje – tomu se věnuje následující sekce.
Voter v Twig template
Stejný Voter pokrývá i view-level rozhodnutí (skrýt tlačítko „Cancel order“ pro ne-vlastníka). V Twigu funkce is_granted() volá tentýž AuthorizationCheckerInterface. Proměnnou now (\DateTimeImmutable) předává do šablony controller – doménová metoda isCancellable() si aktuální čas nezískává sama:
1{# templates/order/detail.html.twig #}2<h1>Order #{{ order.id }}</h1>3 4{% if is_granted('order.view', order) %}5 <dl>6 <dt>Customer</dt><dd>{{ order.customer.name }}</dd>7 <dt>Total</dt> <dd>{{ order.total|format_currency('CZK') }}</dd>8 <dt>Status</dt> <dd>{{ order.status.label }}</dd>9 </dl>10{% endif %}11 12{% if is_granted('order.cancel', order) and order.isCancellable(now) %}13 <form method="post" action="{{ path('order_cancel', {id: order.id}) }}">14 <button type="submit">Cancel order</button>15 </form>16{% endif %}17 18{% if is_granted('order.refund', order) %}19 <a href="{{ path('order_refund', {id: order.id}) }}" class="btn-danger">Refund</a>20{% endif %}
Pozor: {% if is_granted(...) %} v Twigu jen schová tlačítko – neověří, že request nebude poslán manuálně (curl, Postman, browser dev tools). View-level kontrola je UX, nikoli bezpečnostní bariéra. Bezpečnostní rozhodnutí padne v handleru.
11.06 Aggregate-level – doména sama rozhoduje#
Některá pravidla do Voteru nepatří. Vyžadují znalost doménového stavu, který Voter nemá natáhnout zvenku – typicky časové okno, předchozí status objednávky nebo invarianty napříč entitami uvnitř agregátu. Tato pravidla patří do aggregate root a vynucují se vyhozením doménové výjimky.
Praktická heuristika:
- Pokud lze pravidlo zformulovat v jazyce uživatel + use case + entita („smí Petr zrušit objednávku #42“), patří do Voteru.
- Pokud pravidlo vyžaduje stav agregátu + doménové pravidlo („order musí být ve stavu PLACED a ne starší než 24 h“), patří do Aggregate.
- Pokud pravidlo kombinuje obojí, rozdělí se: část do Voteru, část do Aggregate, a každá vrstva ověří svou polovinu.
1// src/Ordering/Domain/Order.php2declare(strict_types=1);3 4namespace App\Ordering\Domain;5 6use App\Ordering\Domain\Event\OrderCancelled;7use App\Ordering\Domain\Exception\CancellationWindowExpiredException;8use App\Ordering\Domain\Exception\InvalidOrderStateException;9use App\SharedKernel\Domain\AggregateRoot;10 11final class Order extends AggregateRoot12{13 private const CANCELLATION_WINDOW_SECONDS = 86_400; // 24 h14 15 public function __construct(16 private readonly OrderId $id,17 private readonly CustomerId $customerId,18 private OrderStatus $status,19 private readonly \DateTimeImmutable $placedAt,20 ) {}21 22 public function cancel(string $reason, \DateTimeImmutable $when): void23 {24 if ($this->status !== OrderStatus::PLACED) {25 throw new InvalidOrderStateException(26 sprintf(27 'Cancel allowed only for PLACED orders, got %s',28 $this->status->value,29 )30 );31 }32 33 $age = $when->getTimestamp() - $this->placedAt->getTimestamp();34 if ($age > self::CANCELLATION_WINDOW_SECONDS) {35 throw new CancellationWindowExpiredException(36 $this->id,37 $this->placedAt,38 $when,39 );40 }41 42 $this->status = OrderStatus::CANCELLED;43 $this->record(new OrderCancelled(44 orderId: $this->id,45 customerId: $this->customerId,46 reason: $reason,47 cancelledAt: $when,48 ));49 }50 51 public function isCancellable(\DateTimeImmutable $now): bool52 {53 if ($this->status !== OrderStatus::PLACED) {54 return false;55 }56 return $now->getTimestamp() - $this->placedAt->getTimestamp()57 <= self::CANCELLATION_WINDOW_SECONDS;58 }59}
Aggregate nemá žádnou závislost na Symfony. Používá pouze PHP standardní typy a vlastní doménové třídy – žádný TokenInterface, žádný AuthorizationChecker, žádný UserInterface. Třídu lze proto testovat unit testem bez Symfony Kernel. Selhání hlásí doménovými výjimkami: InvalidOrderStateException a CancellationWindowExpiredException jsou doménové třídy v App\Ordering\Domain\Exception. Nesou doménový kontext (kdy byl order placed, kdy se zkouší cancel) a aplikační vrstva je překládá na HTTP status – typicky 409 Conflict, ne 403 Forbidden, protože není to autorizační selhání, je to doménový stav.
Pomocná metoda isCancellable() je dotaz bez vedlejších efektů. Používá ji UI vrstva pro skrytí tlačítka: Twig šablona ji volá s proměnnou now předanou z controlleru (kombinováno s is_granted). Tatáž logika je sdílená s cancel() přes konstantu CANCELLATION_WINDOW_SECONDS – žádná duplicita. Zbývají domain events: po úspěšné operaci agregát zaznamená OrderCancelled voláním record(), aplikační handler eventy po repository->save() vyzvedne přes releaseEvents() a publikuje (typicky přes Outbox). Aggregate sám nikdy nevolá EventDispatcher.
Zde tedy není otázka „smí Petr“ – tu vyřešil Voter v sekci 11.04. Zde je otázka „dá se to vůbec teď udělat?“. A odpověď „ne“ se sem dostane i v případě, že Voter řekl „ano“ (Petr je vlastník, ale order je už zaplacen a odeslán). Obě bariéry jsou nezávislé a nutné.
End-to-end trace: cancellation request
Pro úplnost si projděme, co se konkrétně stane, když zákazník Petr klikne na tlačítko „Cancel order #42“ v rozhraní:
- Edge (firewall). Symfony ověří JWT/session token. Bez ověření → 401. Petr je přihlášený, pokračuje.
- Edge (access_control). URL
/order/42/cancelspadá podIS_AUTHENTICATED_FULLY. Petr je přihlášený, pokračuje. - Controller validuje vstup (CSRF token, request body), vytvoří
CancelOrderCommand(orderId: 42, reason: 'changed mind', actorId: <Petrovo CustomerId>)a předá ho na message bus. - Application Handler (CancelOrderHandler) načte agregát z repository:
$order = $repo->getOrFail(42). - Use Case Voter. Handler volá
$auth->isGranted('order.cancel', $order). OrderVoter porovná$order->customerId()s$user->customerId(). Petr je vlastník → ACCESS_GRANTED, pokračuje. Kdyby nebyl vlastník → AccessDeniedDomainException → HTTP 403. - Aggregate. Handler volá
$order->cancel('changed mind', $now). Aggregate ověřístatus === PLACEDaage <= 24h. Order je placed před 30 min → ok, status se změní na CANCELLED, vznikne OrderCancelled event. Kdyby byl už shipped → InvalidOrderStateException → HTTP 409. - Persistence + outbox. Handler zavolá
$repo->save($order); v jedné transakci se uloží stav agregátu i OrderCancelled event do outbox tabulky. - Field-level (response). Controller vrátí 200 OK. Pokud by Petr nebyl admin a v response figuroval
audit_log, read model by ho vyfiltroval – na svém vlastním orderu vidí status, ale ne kdo a kdy ho editoval.
Každá z těchto vrstev selže po svém: jiný HTTP status, jiná chybová hláška, jiné logy. Generické „Access denied“ tu nestačí.
11.07 Field-level – read model filtrace#
Nejjemnější vrstva. Předchozí tři vrstvy řešily akce a existenci operace; field-level řeší viditelnost konkrétního pole během jinak povoleného čtení. Klasický příklad: detail orderu vidí customer i admin, ale sloupec audit_log (kdo a kdy editoval) má vidět jen admin.
Existují dva přístupy s odlišnými kompromisy:
Přístup 1: Twig if (view-level)
Nejjednodušší, ale s únikem dat: data se z databáze načtou všechna, jen se ve view zahodí. Pro většinu UI to stačí; na citlivá data nepatří – unikají přes HTML komentáře, JSON serializaci v JS aplikaci nebo ETag hashing.
1{# templates/order/detail.html.twig #}2<dl>3 <dt>Customer</dt> <dd>{{ order.customer.name }}</dd>4 <dt>Total</dt> <dd>{{ order.total|format_currency('CZK') }}</dd>5 <dt>Status</dt> <dd>{{ order.status.label }}</dd>6 7 {% if is_granted('order.audit_log', order) %}8 <dt>Audit log</dt>9 <dd>10 <ul class="audit">11 {% for entry in order.auditLog %}12 <li>{{ entry.at|date }}: {{ entry.action }} ({{ entry.actor }})</li>13 {% endfor %}14 </ul>15 </dd>16 {% endif %}17</dl>
Přístup 2: Query filter (read model)
Citlivá pole se z databáze vůbec nenačtou. Read model vrací různé DTO podle role. Bez data leaku, ale za cenu duplicity (dvě query, dvě DTO struktury). Vhodné pro PII, finanční data, audit logy.
1// src/Ordering/Application/ReadModel/OrderDetailReadModel.php2declare(strict_types=1);3 4namespace App\Ordering\Application\ReadModel;5 6use App\Identity\Domain\AppUser;7use Doctrine\DBAL\Connection;8 9final readonly class OrderDetailReadModel10{11 public function __construct(private Connection $db) {}12 13 public function forUser(string $orderId, AppUser $user): OrderDetailDto14 {15 $columns = 'id, customer_id, total_cents, status, placed_at';16 17 if ($user->hasRole('ROLE_ADMIN')) {18 $columns .= ', audit_log';19 }20 21 $sql = "SELECT {$columns} FROM orders WHERE id = :id";22 23 $row = $this->db->fetchAssociative($sql, ['id' => $orderId]);24 if ($row === false) {25 throw new OrderNotFoundException($orderId);26 }27 28 return OrderDetailDto::fromRow($row, includeAudit: $user->hasRole('ROLE_ADMIN'));29 }30}
Volba mezi přístupy:
| Kritérium | Twig if | Query filter |
|---|---|---|
| Data leak | Riziko (data v paměti; u API/SPA unikají do response) | Ne |
| Implementační složitost | Triviální | Vyžaduje různé DTO / read modely |
| Vhodné pro | UI hidden, neostrá ochrana | PII, finance, audit log, GDPR |
| Testování | Twig integrační test | Unit + integrační test read modelu |
| OWASP A01:2021 compliance | Insufficient – viz [5] | Splňuje (server-side enforcement) |
Pro necitlivá data Twig if stačí a šetří čas. Pro citlivá data vždy query filter – OWASP Top 10 v kategorii „A01 Broken Access Control“ výslovně varuje před UI-only kontrolou jako jedinou bariérou.
11.08 Policy-based přístup (ABAC)#
Když počet pravidel naroste a vrstvení do Voterů přestane být udržitelné (zhruba od stovky pravidel, např. 5+ rolí × 10+ entit × 3+ atributy), je čas přejít z RBAC (Role-Based Access Control) na ABAC (Attribute-Based Access Control). RBAC se ptá na roli; ABAC vyhodnocuje kombinaci atributů subjektu, akce, prostředku a kontextu proti policy a vrátí povoleno / zakázáno.
V čisté Symfony aplikaci si stačí napsat tenkou vrstvu nad Voter API: Policy jako kolekce Rule objektů, které se vyhodnotí proti subject/user/context trojici. Pro velké organizace se vyplatí externí policy engine (OPA – Open Policy Agent), engine v Go s vlastním policy jazykem Rego, který umí policy verzovat, distribuovat a auditovat nezávisle na aplikaci.
1// src/SharedKernel/Authorization/Policy.php2declare(strict_types=1);3 4namespace App\SharedKernel\Authorization;5 6interface Policy7{8 public function name(): string;9 10 /** @return list<Rule> */11 public function rules(): array;12}13 14final readonly class Rule15{16 public function __construct(17 public string $expression,18 public string $description,19 ) {}20}21 22final readonly class PolicyContext23{24 public function __construct(25 public object $subject,26 public object $user,27 public \DateTimeImmutable $now,28 ) {}29}
1// src/Ordering/Authorization/CancelOrderPolicy.php2declare(strict_types=1);3 4namespace App\Ordering\Authorization;5 6use App\SharedKernel\Authorization\Policy;7use App\SharedKernel\Authorization\Rule;8 9final class CancelOrderPolicy implements Policy10{11 public function name(): string12 {13 return 'order.cancel';14 }15 16 /** @return list<Rule> */17 public function rules(): array18 {19 return [20 new Rule(21 expression: 'subject.customerId == user.customerId',22 description: 'Pouze vlastník objednávky',23 ),24 new Rule(25 expression: 'subject.status.value == "placed"',26 description: 'Order musí být ve stavu PLACED',27 ),28 new Rule(29 expression: 'subject.placedAt.getTimestamp() >= now - 86400',30 description: 'Cancellation window 24 h ještě neuplynulo',31 ),32 new Rule(33 expression: 'user.tenantId == subject.tenantId',34 description: 'Stejný tenant',35 ),36 ];37 }38}
Zápis výrazů má svá úskalí a chyba se projeví až za běhu. ExpressionLanguage čte veřejné properties a volá veřejné metody – gettery k privátním polím nedohledá, subjektem politiky proto bývá snapshot s veřejnými poli, ne agregát s privátním stavem. Odečíst DateTimeImmutable od čísla komponenta neumí: datum se převádí na unixový timestamp metodou objektu (subject.placedAt.getTimestamp()) a now přichází jako číslo z proměnných evaluatoru, ne jako objekt. Backed enum se neporovnává přímo – subject.status == "placed" selže, srovnává se hodnota přes subject.status.value. A protože výrazy jsou stringy, statická analýza je nevidí; každé pravidlo musí krýt test, viz tabulkové testy policy.
Poznámka: pravidla subject.status.value == "placed" a časové okno 24 h jsou v politice pro ilustraci ABAC zápisu. Jak popisuje sekce 11.06, tyto doménové invarianty patří primárně do agregátu. Politika je ověřuje jako pre-check před dosažením domény (obrana do hloubky). Agregát ale musí být zdrojem pravdy a nepřijmout neplatný příkaz ani bez autorizační vrstvy.
Jednoduchý PolicyEvaluator používá Symfony ExpressionLanguage komponentu a vyhodnocuje pravidla v daném kontextu:
1// src/SharedKernel/Authorization/PolicyEvaluator.php2declare(strict_types=1);3 4namespace App\SharedKernel\Authorization;5 6use Symfony\Component\ExpressionLanguage\ExpressionLanguage;7 8final class PolicyEvaluator9{10 public function __construct(private readonly ExpressionLanguage $expr = new ExpressionLanguage()) {}11 12 /**13 * Vrací první porušené pravidlo, nebo null pokud všechna prošla.14 */15 public function evaluate(Policy $policy, PolicyContext $ctx): ?Rule16 {17 $vars = [18 'subject' => $ctx->subject,19 'user' => $ctx->user,20 'now' => $ctx->now->getTimestamp(),21 ];22 foreach ($policy->rules() as $rule) {23 if (!$this->expr->evaluate($rule->expression, $vars)) {24 return $rule;25 }26 }27 return null;28 }29}
Výhody policy-based přístupu:
- Auditovatelnost. Pravidla jsou data, ne kód.
PolicyEvaluatorvrací, které pravidlo selhalo – uživatel dostane přesnou chybovou hlášku („Cancellation window 24 h ještě neuplynulo“) místo generického „Access denied“. - Verzování. Policy je třída v repu – změny přes git, code review, deploy. ABAC standardně vyžaduje verzování policy [2].
- Testovatelnost. Test policy je čistý unit test bez frameworku – pro každé pravidlo jeden case.
- Externí policy engine. Když policy přerostou aplikaci, lze je portovat do OPA zmíněného v úvodu sekce – Symfony aplikace potom dělá HTTP volání místo lokálního
evaluate().
11.09 Multi-tenancy – tenant kontext#
Multi-tenancy (vícenájemnost) je speciální případ ABAC, kdy stejná aplikace obsluhuje více oddělených zákazníků (organizací, mandantů, tenantů) a žádný tenant nesmí vidět data jiného. Existují tři architektonické strategie:
- Row-based – sdílená databáze, sdílené tabulky, sloupec
tenant_idvšude. Nejlevnější, nejméně izolace, vyžaduje pečlivé filtry. - Schema-based – sdílená databáze, samostatné schema per tenant (PostgreSQL
SET search_path). Střední izolace, lepší performance než row-based. - Database-based – samostatná databáze per tenant. Nejvyšší izolace, nejnákladnější (DB connection per tenant, migrations × N).
V praxi se nejčastěji volí row-based pro startupy a SaaS s malým počtem tenantů, schema-based pro mid-size B2B, database-based pro enterprise / compliance-heavy domény (zdravotnictví, finance). Pro row-based v Symfony je idiomatický nástroj Doctrine SQLFilter.
1// src/SharedKernel/Infrastructure/Doctrine/TenantFilter.php2declare(strict_types=1);3 4namespace App\SharedKernel\Infrastructure\Doctrine;5 6use App\SharedKernel\Domain\TenantAware;7use Doctrine\ORM\Mapping\ClassMetadata;8use Doctrine\ORM\Query\Filter\SQLFilter;9 10final class TenantFilter extends SQLFilter11{12 public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string13 {14 if (!$targetEntity->reflClass->implementsInterface(TenantAware::class)) {15 return '';16 }17 18 return sprintf(19 '%s.tenant_id = %s',20 $targetTableAlias,21 $this->getParameter('tenant_id'),22 );23 }24}
Filter aplikuje WHERE klauzuli tenant_id = ? na každý dotaz nad entitou, která implementuje marker rozhraní TenantAware. Aktivace filtru v config/packages/doctrine.yaml:
1# config/packages/doctrine.yaml2doctrine:3 orm:4 filters:5 tenant:6 class: App\SharedKernel\Infrastructure\Doctrine\TenantFilter7 enabled: true # fail-closed: filter běží vždy, parametr dodá listener
Pozor na sémantiku výchozího stavu. Vypnutý nebo nenakonfigurovaný filter nepřidá do SQL žádné WHERE – dotaz vrátí data všech tenantů. SQLFilter je tedy ze své podstaty fail-open a to je hlavní riziko celého přístupu. Proto konfigurace výše filter zapíná globálně (enabled: true): běží pro každý dotaz a chybějící tenant_id skončí výjimkou, ne únikem dat. Hodnotu parametru dodává kernel event listener po autentizaci:
1// src/SharedKernel/Infrastructure/Http/TenantContextListener.php2declare(strict_types=1);3 4namespace App\SharedKernel\Infrastructure\Http;5 6use Doctrine\ORM\EntityManagerInterface;7use Symfony\Component\EventDispatcher\Attribute\AsEventListener;8use Symfony\Component\HttpKernel\Event\RequestEvent;9use Symfony\Component\HttpKernel\KernelEvents;10use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;11 12#[AsEventListener(event: KernelEvents::REQUEST, priority: 7)]13final readonly class TenantContextListener14{15 public function __construct(16 private EntityManagerInterface $em,17 private TokenStorageInterface $tokens,18 ) {}19 20 public function __invoke(RequestEvent $event): void21 {22 if (!$event->isMainRequest()) {23 return;24 }25 26 $token = $this->tokens->getToken();27 $user = $token?->getUser();28 if ($user === null || !method_exists($user, 'tenantId')) {29 return; // public endpoint, anonymous request30 }31 32 $tenantId = $user->tenantId()->toString();33 $filter = $this->em->getFilters()->enable('tenant');34 $filter->setParameter('tenant_id', $tenantId);35 }36}
Volání enable('tenant') v listeneru je u globálně zapnutého filtru neškodné – vrátí existující instanci, na kterou stačí nastavit parametr.
Tři detaily, které se vyplatí zachytit:
- Priority 7 v
AsEventListener– v Symfony platí vyšší priority = dřívější vykonání. Symfony Firewall registruje svůjonKernelRequests prioritou 8, takže aby měl listener k dispozici už autentizovaného uživatele, musí běžet s prioritou nižší než 8 (typicky 7 nebo 0). Detail v Symfony EventDispatcher dokumentaci. - Main request guard. Bez
$event->isMainRequest()by se filter nastavoval i pro dílčí požadavky (např. ESI, render fragments) – tam typicky není token a listener by spadl. - Anonymní požadavek parametr nedostane. U veřejných endpointů (login, register, health) listener skončí na guardu a
tenant_idzůstane nenastavené. První dotaz nadTenantAwareentitou pak vyhodí výjimku – globálně zapnutý filter bez parametru dotaz nepustí. Hlučné selhání je tu záměr: veřejný endpoint nemá tenantní data co číst. Pokud je přesto čte, patří takový požadavek odmítnout už na firewallu.
11.10 Test pyramida pro autorizaci#
Každá ze 4 vrstev se testuje jiným druhem testu – a snaha pokrýt vše end-to-end vede k pomalé, křehké testovací sadě. Dělení odpovídá klasické test pyramidě: hodně rychlých unit testů, méně integration, pár end-to-end.
Aggregate-level: čistý unit test
Doménová pravidla v aggregate jsou plain PHP – žádný framework, žádná databáze. Test je rychlý a deterministický:
1// tests/Ordering/Domain/OrderCancelTest.php2declare(strict_types=1);3 4namespace Tests\Ordering\Domain;5 6use App\Ordering\Domain\Event\OrderCancelled;7use App\Ordering\Domain\Exception\CancellationWindowExpiredException;8use App\Ordering\Domain\Exception\InvalidOrderStateException;9use App\Ordering\Domain\Order;10use PHPUnit\Framework\TestCase;11 12final class OrderCancelTest extends TestCase13{14 public function testCancelWithinWindowSucceeds(): void15 {16 $order = OrderFactory::placed(at: '2026-04-29 10:00:00');17 $order->releaseEvents(); // vyprázdní eventy z fáze vytvoření18 19 $order->cancel('changed mind', new \DateTimeImmutable('2026-04-29 12:00:00'));20 21 // Stav se ověří přes chování: úspěšný cancel zaznamená OrderCancelled22 $events = $order->releaseEvents();23 self::assertCount(1, $events);24 self::assertInstanceOf(OrderCancelled::class, $events[0]);25 }26 27 public function testCancelOfShippedOrderThrows(): void28 {29 $order = OrderFactory::shipped();30 31 $this->expectException(InvalidOrderStateException::class);32 $order->cancel('changed mind', new \DateTimeImmutable());33 }34 35 public function testCancelAfter24hThrows(): void36 {37 $order = OrderFactory::placed(at: '2026-04-29 10:00:00');38 39 $this->expectException(CancellationWindowExpiredException::class);40 $order->cancel('too late', new \DateTimeImmutable('2026-04-30 11:00:00'));41 }42}
Voter: unit test s mock TokenInterface
Voter dostává TokenInterface; v testu stačí jeho mock + reálný subject. Žádný Symfony Kernel:
1// tests/Ordering/Infrastructure/Security/OrderVoterTest.php2declare(strict_types=1);3 4namespace Tests\Ordering\Infrastructure\Security;5 6use App\Identity\Domain\AppUser;7use App\Identity\Domain\CustomerId;8use App\Ordering\Domain\Order;9use App\Ordering\Infrastructure\Security\OrderVoter;10use PHPUnit\Framework\TestCase;11use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;12use Symfony\Component\Security\Core\Authorization\Voter\Voter;13 14final class OrderVoterTest extends TestCase15{16 public function testOwnerCanCancelOwnOrder(): void17 {18 $voter = new OrderVoter();19 $owner = new AppUser(CustomerId::fromString('cus_1'), ['ROLE_USER']);20 $order = OrderFactory::placedFor(CustomerId::fromString('cus_1'));21 $token = $this->createMock(TokenInterface::class);22 $token->method('getUser')->willReturn($owner);23 24 self::assertSame(25 Voter::ACCESS_GRANTED,26 $voter->vote($token, $order, [OrderVoter::CANCEL])27 );28 }29 30 public function testStrangerCannotCancelOrder(): void31 {32 $voter = new OrderVoter();33 $stranger = new AppUser(CustomerId::fromString('cus_2'), ['ROLE_USER']);34 $order = OrderFactory::placedFor(CustomerId::fromString('cus_1'));35 $token = $this->createMock(TokenInterface::class);36 $token->method('getUser')->willReturn($stranger);37 38 self::assertSame(39 Voter::ACCESS_DENIED,40 $voter->vote($token, $order, [OrderVoter::CANCEL])41 );42 }43}
End-to-end: WebTestCase
Pro pokrytí celé pipeline (firewall → controller → handler → voter → aggregate) slouží Symfony WebTestCase. Zde už je to integrační test, který používá kernel a databázi. Doporučená míra: 1 e2e test na use case, pokrývající hlavní scénář + 1–2 nejdůležitější chybové stavy. Detailní pokrytí okrajových případů patří do unit testů na nižších vrstvách.
Detail pyramidy + příklady fixture builderů v samostatné kapitole o testování.
Policy: tabulkový unit test
Pokud používáte policy-based přístup, každé pravidlo v policy je jeden test case. Tabulkový (data provider) test je nejlepší forma – jeden řádek = jeden scénář, čitelně i pro netechnického reviewera:
1// tests/Ordering/Authorization/CancelOrderPolicyTest.php2declare(strict_types=1);3 4namespace Tests\Ordering\Authorization;5 6use App\Ordering\Authorization\CancelOrderPolicy;7use App\SharedKernel\Authorization\PolicyContext;8use App\SharedKernel\Authorization\PolicyEvaluator;9use PHPUnit\Framework\Attributes\DataProvider;10use PHPUnit\Framework\TestCase;11 12final class CancelOrderPolicyTest extends TestCase13{14 public static function scenarios(): iterable15 {16 yield 'happy path' => [17 'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 30),18 'user' => UserFixture::for('cus_1', 'tenant_a'),19 'expected' => null,20 ];21 yield 'wrong customer' => [22 'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 30),23 'user' => UserFixture::for('cus_2', 'tenant_a'),24 'expected' => 'Pouze vlastník objednávky',25 ];26 yield 'shipped order' => [27 'subject' => OrderFixture::shippedFor('cus_1', 'tenant_a'),28 'user' => UserFixture::for('cus_1', 'tenant_a'),29 'expected' => 'Order musí být ve stavu PLACED',30 ];31 yield 'window expired' => [32 'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 1500),33 'user' => UserFixture::for('cus_1', 'tenant_a'),34 'expected' => 'Cancellation window 24 h ještě neuplynulo',35 ];36 yield 'cross-tenant' => [37 'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 30),38 'user' => UserFixture::for('cus_1', 'tenant_b'),39 'expected' => 'Stejný tenant',40 ];41 }42 43 #[DataProvider('scenarios')]44 public function testEvaluate(object $subject, object $user, ?string $expected): void45 {46 $evaluator = new PolicyEvaluator();47 $context = new PolicyContext($subject, $user, new \DateTimeImmutable());48 49 $violation = $evaluator->evaluate(new CancelOrderPolicy(), $context);50 51 self::assertSame($expected, $violation?->description);52 }53}
Tabulkový test má dvě výhody navíc oproti klasickému test-per-method přístupu. Přidání pravidla = přidání jednoho řádku v scenarios(). A celý test slouží jako spustitelná dokumentace policy – reviewer mimo vývojový tým vidí všechny případy v jedné tabulce a může schválit doménová pravidla.
11.11 Anti-vzory#
Čtyři anti-vzory následují strukturu „symptom – důsledek – náprava“. Pořadí odpovídá četnosti, s jakou se objevují v projektech, kde rámec ze sekce 11.02 chybí.
Anti-vzor 1: Autorizace v controlleru
Probrali jsme v sekci 11.01. Symptom: stejná autorizační podmínka opakovaná v 3+ controllerech, neexistující ve verzích volaných z konzolového commandu nebo Messenger handleru. Náprava: přesun do Voteru + volání AuthorizationCheckerInterface v Application Service. Souvisí: obecné anti-vzory v DDD.
Anti-vzor 2: Voter, který načte aggregate z databáze
Symptom:
1// src/Security/OrderVoter.php (anti-vzor)2final class OrderVoter extends Voter3{4 public function __construct(private OrderRepository $orders) {}5 6 protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool7 {8 // Anti-vzor: voter dostane jen ID a sám načte entitu9 $order = $this->orders->find($subject);10 // ... rozhodování ...11 }12}
Důsledek: handler načte order, pak voter načte order podruhé, mezi tím se může stát race condition (jiný proces order změní). Náprava: handler načte entitu jednou, předá ji do $auth->isGranted($attr, $order), voter pracuje s touto instancí.
Anti-vzor 3: Voter == Aggregate logic
Symptom: cancellation window pravidlo („order ne starší než 24 h“) je zapsané jak ve Voteru, tak v Order::cancel(). Když se doménové pravidlo změní (např. window se prodlouží na 48 h), obě místa se musí upravit – a typicky se zapomene jedno.
Náprava: pravidlo patří do aggregate (je to doménový invariant). Voter nesmí ověřovat doménový stav agregátu – odpovídá jen na identitu/role uživatele a vlastnictví subjektu. Pro view-level skrytí tlačítka se v Twigu kombinuje {% if is_granted(...) and order.isCancellable(now) %} – voter pro permission, doménová metoda pro stavovou kontrolu.
Anti-vzor 4: Symfony User natažený do doménového Aggregate
Symptom:
1// src/Ordering/Domain/Order.php (anti-vzor)2namespace App\Ordering\Domain;3 4use Symfony\Component\Security\Core\User\UserInterface;5 6final class Order7{8 // Anti-vzor: doména závisí na Symfony Security komponentě9 public function cancel(UserInterface $user, string $reason): void10 {11 if ($user->getUserIdentifier() !== $this->customerEmail) {12 throw new \DomainException('Not your order');13 }14 // ...15 }16}
Doména teď závisí na Symfony\Component\Security. Pokud byste chtěli stejný kód spustit z konzolového commandu, asynchronně přes Messenger nebo v unit testu bez Kernel, narazíte na chybějící UserInterface. Náprava: doména pracuje s vlastním typem (CustomerId, doménový AppUser bez Symfony rozhraní). Aplikační handler překládá ze Symfony UserInterface na doménový typ. Detail v kapitole o anti-vzorech.
11.12 Shrnutí#
Autorizace v DDD aplikaci na Symfony 8 sedí na čtyřech vrstvách, každá s vlastním Symfony nástrojem a vlastní granularitou:
- Edge – Symfony firewall +
access_control. Anonymous vs. authenticated, role-based hrubá separace. Žádná doménová znalost. - Use Case – Symfony Voter. „Smí Petr cancelnout order #42?“ Aplikační handler volá
AuthorizationCheckerInterface::isGranted(); doména to nesmí. - Aggregate – doménový invariant + doménová výjimka. „Order musí být PLACED a ne starší než 24 h.“ Aggregate vyhazuje
InvalidOrderStateException; aplikační vrstva to mapuje na HTTP 409. - Field – Twig
is_grantedpro view-level (s rizikem data leaku) nebo query filter / read model pro citlivá data (PII, audit log).
Kde co řešit:
Hrubé permissions pokryje RBAC. Jakmile pravidla závisí na vztazích mezi entitami, nastupuje ABAC či policy-based přístup. Vícenájemnost řeší Doctrine SQLFilter s kernel listenerem, nastavený fail-closed – globálně zapnutý filter a povinný parametr. Doménové stavové pravidlo patří do agregátu, ne do Voteru.
Škála zůstává stejná v celé kapitole: do desítek pravidel stačí Voter, od zhruba stovky se vyplatí interní policy vrstva nad Voter API (11.08). Externí policy engine přichází na řadu až u výrazně větších systémů: stovky pravidel či policy sdílené více aplikacemi, multi-tenant SaaS s individuálními policy per tenant, regulovaná doména s nutností auditovat policy nezávisle na aplikačním kódu.
Praktický checklist před deploy
Než commitnete autorizační změnu, projděte si těchto sedm bodů:
- Existuje v
access_controldefault-deny pravidlo na konci? Pokud ne – nový endpoint bez explicitní role je veřejný. - Volá Application Handler
$auth->isGranted()před doménovou operací? Pokud ne – autorizace se může obejít přes alternativní vstupní bod (CLI, Messenger). - 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.
- Vrací aplikace 403 vs. 409 podle typu selhání? Pokud ne – uživatel dostane matoucí hlášku.
- Mají citlivá pole (PII, audit) query filter, ne jen Twig if? Pokud ne – data leakují přes JSON API, dev tools, ETag.
- Pokud je aplikace multi-tenant: má Doctrine SQLFilter fail-closed default? Pokud ne – chybějící tenant context vrátí všechna data.
- Existuje na každé vrstvě alespoň jeden test? Aggregate test, Voter test, e2e test minimum.
Časté otázky
Mám psát jeden Voter na entitu, nebo víc?
Jeden Voter na entitu, který pokrývá N atributů (VIEW, CANCEL, REFUND, …). V supports() se filtruje podle $subject instanceof Order a podle whitelistu atributů; v voteOnAttribute() se atributy mapují přes match expression na privátní metody. Více Voterů na jednu entitu se vyplatí jen tehdy, když permissions využívají úplně jiný subset závislostí (typicky owner-based vs. role-based) a chcete je nezávisle testovat. Detail v sekci o Voteru.
Smí Voter načítat aggregate z databáze?
Ne. Voter dostává $subject jako parametr; handler ho už načetl a předává v paměti. Voterové fetchování je anti-vzor (11.11) – vede k duplicate query, race condition a pomalé testovací sadě. Pokud Voter potřebuje další data, předajte je přes konstruktor (např. config) nebo přes obohacený DTO subject, ne přes repository.
Kdy stačí ROLE_USER a kdy je třeba attribute-based přístup?
RBAC (role) stačí, dokud platí „role popisuje permissions sama o sobě“ – ROLE_ADMIN smí všechno, ROLE_REFUND_AGENT smí refundy bez ohledu na konkrétní entitu. Jakmile permissions závisí na vztazích (vlastnictví, tenant, časové okno, stav agregátu), RBAC se rozroste – vznikají hyper-specific role typu ROLE_TENANT_42_ORDER_AGENT. Tehdy přejít na ABAC (11.08): permissions vyhodnocují atributy subjektu, uživatele a kontextu proti policy.
Co když máme 100 různých rolí?
To je obvykle příznak, že role replikují data, která patří do entit. Místo ROLE_TENANT_42_ADMIN, ROLE_TENANT_43_ADMIN, … zaveďte atribut user.tenantId + jednu generickou roli ROLE_TENANT_ADMIN a ve Voteru ověřte, že user.tenantId == subject.tenantId. Zjednoduší to správu uživatelů, audit i delegaci. Detail v sekci o multi-tenancy.
Smí doménový Aggregate záviset na Symfony Security komponentě?
Ne. Doména musí být framework-agnostic – bez ní nelze unit-testovat bez Kernel, nelze sdílet kód mezi web a CLI, nelze migrovat na jiný framework. Pokud potřebuje aggregate „znát“ uživatele, dostane vlastní doménový typ (CustomerId, doménový AppUser). Aplikační handler překládá Symfony UserInterface na doménový typ. 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 – stačí pro většinu aplikací, log do souboru / ELK / Loki; (2) doménová tabulka authorization_decisions s parametry (user_id, attribute, subject_id, decision, policy_version) – vhodné pro regulaci (PCI-DSS, GDPR Article 30); (3) externí audit služba (AWS CloudTrail, Datadog) pro multi-tenant SaaS. Implementačně se osvědčil decorator nad AuthorizationCheckerInterface, který každé volání zaloguje. Pro detail viz Audit log autorizačních rozhodnutí.
11.13 Další četba#
- Symfony Security komponenta – oficiální dokumentace
- Symfony Voters – Custom Authorization
- OWASP Top 10 (2021): A01 – Broken Access Control
- NIST SP 800-162 – Guide to Attribute-Based Access Control
- OpenID Connect Core 1.0 – autentizační vrstva nad OAuth 2.0
- Stripe API keys – restricted keys s explicitním scope
- Open Policy Agent (OPA) – externí policy engine
- Vernon, V.: Implementing Domain-Driven Design (kap. 14, „Application“)