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á jinou otázku a používá jiný Symfony nástroj.
Obsah kapitoly
Autorizace je v DDD aplikacích dlouhodobě podceněné téma. Většina týmů zvládne autentizaci (Symfony firewall, JWT, OAuth) bez větších potíží – ale když přijde na otázku „kdo smí udělat co s konkrétní entitou v konkrétním stavu“, kód se rozsype napříč controllery, listenery, twig templaty a Doctrine query buildery. Tato kapitola dává čtyřvrstvý rámec, podle kterého poznáte, kam které pravidlo patří, a jak ho v Symfony 8 implementovat idiomaticky – bez toho, aby Symfony Security komponenta pronikla do doménového jádra.
Kapitola navazuje na DDD Pain Points, kde jsme autorizaci jen letmo zmínili, a doplňuje praktický pohled k tématům CQRS (kde sedí ověření Command Handleru) a Testování (jak otestovat každou ze 4 vrstev samostatně).
12.01 Tři chyby s autorizací, které se v review opakovaně objevují
Než přejdeme ke správnému přístupu, projděme si tři opakující se chyby. V code review za poslední tři roky se objevily téměř ve všech projektech, které pracovaly s neformální DDD strukturou nad Symfony. Diagnóza: chybějící framework, kde co umístit.
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
namespace App\Controller;
final class OrderController extends AbstractController
{
#[Route('/order/{id}/cancel', methods: ['POST'])]
public function cancel(string $id, OrderRepository $orders): Response
{
$order = $orders->find($id);
$user = $this->getUser();
// Anti-vzor: autorizační logika rozsypaná v controlleru
if ($user->getId() !== $order->getCustomerId()) {
throw $this->createAccessDeniedException('Not your order');
}
if ($order->getStatus() !== 'PLACED') {
throw new \LogicException('Cannot cancel a non-placed order');
}
$order->setStatus('CANCELLED');
$orders->save($order);
return $this->redirectToRoute('order_detail', ['id' => $id]);
}
}
Co je špatně: stejný use case se volá i z konzolového commandu (cron, batch), z 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á. Doménové pravidlo „zrušit smí jen vlastník“ je rozeseté po infrastruktuře, ne na jednom místě.
Chyba 2: Vše ve Voteru, doména nezná autorizaci
Druhý extrém. Tým objeví Symfony Voter a přesune do něj všechna
pravidla – včetně doménových invariantů. Aggregate má veřejné API
setStatus(), setTotal(), setCustomerId()
a Voter „natáhne“ autorizaci přes ně:
<?php
namespace App\Security;
final class OrderVoter extends Voter
{
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// Anti-vzor: doménové pravidlo (cancellation window) ve Voteru
if ($attribute === 'CANCEL') {
if ($user->getId() !== $subject->getCustomerId()) { return false; }
if ($subject->getStatus() !== 'PLACED') { return false; }
$age = (new \DateTimeImmutable())->getTimestamp() - $subject->getPlacedAt()->getTimestamp();
if ($age > 86400) { return false; }
return true;
}
return false;
}
}
Co je špatně: Aggregate Order::setStatus(OrderStatus::CANCELLED)
stále existuje a je veřejné. Stačí, aby kdokoli (test, fixture, migration script,
jiný developer) 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 prostě nevrátí, pokud k nim uživatel nemá přístup. Funguje to pro read dotazy, ale úplně se rozpadá 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
EntityManager::find()z jiného Bounded Contextu, na nativní SQL, na Redis cache. - Doménová pravidla typu „order patří customerovi“ jsou duplikovaná: jednou v SQL filtru, jednou (zapomenutě) ve Voteru, jednou (chybějícím způsobem) v aggregate.
12.02 Čtyři vrstvy autorizace
Autorizační rozhodnutí v DDD aplikaci nikdy nepadá na jednom místě – padá ve čtyřech postupných vrstvách, každá s vlastní otázkou, vlastním Symfony nástrojem a vlastní granularitou. Vrstvy fungují jako filtry: každá další odpovídá 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 + exception | „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é duplicate, 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 ve Implementing Domain-Driven Design (kap. 14, „Application“).
12.03 Edge – Symfony firewall a access_control
Edge je nejhrubější 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“
sem nepatří – to je use-case-level.
# config/packages/security.yaml
security:
providers:
app_user_provider:
entity:
class: App\Identity\Domain\AppUser
property: email
firewalls:
# Stateless API – JWT
api:
pattern: ^/api/
stateless: true
jwt: ~
provider: app_user_provider
# Web – session
main:
pattern: ^/
lazy: true
provider: app_user_provider
form_login:
login_path: login
check_path: login
logout: ~
access_control:
# Veřejné endpointy
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/health, roles: PUBLIC_ACCESS }
# Hrubá role-based separace
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/api/internal, roles: ROLE_SERVICE_ACCOUNT }
# Vše ostatní za autentizací
- { 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íOidcAuthenticatorpro OpenID Connect provider [3].
12.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), znát
doménové invarianty (to je práce aggregate) a obsahovat doménová pravidla typu
„cancellation window“ (to je doménový stav, který Voter nesmí natáhnout zvenku).
<?php
declare(strict_types=1);
namespace App\Ordering\Infrastructure\Security;
use App\Identity\Domain\AppUser;
use App\Ordering\Domain\Order;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class OrderVoter extends Voter
{
public const VIEW = 'order.view';
public const CANCEL = 'order.cancel';
public const REFUND = 'order.refund';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::CANCEL, self::REFUND], true)
&& $subject instanceof Order;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof AppUser) {
return false;
}
\assert($subject instanceof Order);
return match ($attribute) {
self::VIEW => $this->canView($subject, $user),
self::CANCEL => $this->canCancel($subject, $user),
self::REFUND => $user->hasRole('ROLE_REFUND_AGENT'),
default => false,
};
}
private function canView(Order $order, AppUser $user): bool
{
return $user->customerId()->equals($order->customerId())
|| $user->hasRole('ROLE_ADMIN');
}
private function canCancel(Order $order, AppUser $user): bool
{
return $user->customerId()->equals($order->customerId());
}
}
Tři detaily, které stojí za pozornost:
- 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. Při přidání nového atributu PHPStan na úrovni 8 odhalí chybějící case (díky
default => false+ případnémuthrowve striktnější verzi). - 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.
<?php
declare(strict_types=1);
namespace App\Ordering\Application\Handler;
use App\Ordering\Application\Command\CancelOrderCommand;
use App\Ordering\Application\Exception\AccessDeniedDomainException;
use App\Ordering\Domain\OrderRepository;
use App\Ordering\Infrastructure\Security\OrderVoter;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
#[AsMessageHandler]
final readonly class CancelOrderHandler
{
public function __construct(
private OrderRepository $orders,
private AuthorizationCheckerInterface $auth,
) {}
public function __invoke(CancelOrderCommand $command): void
{
$order = $this->orders->getOrFail($command->orderId);
if (!$this->auth->isGranted(OrderVoter::CANCEL, $order)) {
throw new AccessDeniedDomainException(
sprintf('Cancel not allowed for order %s', $command->orderId->toString())
);
}
$order->cancel(reason: $command->reason, when: new \DateTimeImmutable());
$this->orders->save($order);
}
}
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
další sekci.
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:
{# templates/order/detail.html.twig #}
<h1>Order #{{ order.id }}</h1>
{% if is_granted('order.view', order) %}
<dl>
<dt>Customer</dt><dd>{{ order.customer.name }}</dd>
<dt>Total</dt> <dd>{{ order.total|format_currency('CZK') }}</dd>
<dt>Status</dt> <dd>{{ order.status.label }}</dd>
</dl>
{% endif %}
{% if is_granted('order.cancel', order) and order.isCancellable %}
<form method="post" action="{{ path('order_cancel', {id: order.id}) }}">
<button type="submit">Cancel order</button>
</form>
{% endif %}
{% if is_granted('order.refund', order) %}
<a href="{{ path('order_refund', {id: order.id}) }}" class="btn-danger">Refund</a>
{% 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.
12.05 Aggregate-level – doména sama rozhoduje
Některá pravidla nelze rozumně dát do Voteru, protože vyžadují znalost doménového stavu, který Voter nemá natáhnout zvenku – typicky časové okno, předchozí stav agregátu, doménové invarianty napříč vlastními entitami uvnitř agregátu. Tato pravidla patří do aggregate root a vynucují se vyhozením doménové exception.
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ělte ho: část do Voteru, část do Aggregate, a každá vrstva ověří svou polovinu.
<?php
declare(strict_types=1);
namespace App\Ordering\Domain;
use App\Ordering\Domain\Event\OrderCancelled;
use App\Ordering\Domain\Exception\CancellationWindowExpiredException;
use App\Ordering\Domain\Exception\InvalidOrderStateException;
final class Order
{
private const CANCELLATION_WINDOW_SECONDS = 86_400; // 24 h
/** @var list<object> */
private array $releasedEvents = [];
public function __construct(
private readonly OrderId $id,
private readonly CustomerId $customerId,
private OrderStatus $status,
private readonly \DateTimeImmutable $placedAt,
) {}
public function cancel(string $reason, \DateTimeImmutable $when): void
{
if ($this->status !== OrderStatus::PLACED) {
throw new InvalidOrderStateException(
sprintf(
'Cancel allowed only for PLACED orders, got %s',
$this->status->value,
)
);
}
$age = $when->getTimestamp() - $this->placedAt->getTimestamp();
if ($age > self::CANCELLATION_WINDOW_SECONDS) {
throw new CancellationWindowExpiredException(
$this->id,
$this->placedAt,
$when,
);
}
$this->status = OrderStatus::CANCELLED;
$this->releasedEvents[] = new OrderCancelled(
orderId: $this->id,
customerId: $this->customerId,
reason: $reason,
cancelledAt: $when,
);
}
public function isCancellable(\DateTimeImmutable $now): bool
{
if ($this->status !== OrderStatus::PLACED) {
return false;
}
return $now->getTimestamp() - $this->placedAt->getTimestamp()
<= self::CANCELLATION_WINDOW_SECONDS;
}
}
Vlastnosti tohoto kódu:
- Žádná závislost na Symfony. Aggregate používá pouze PHP standardní typy a vlastní doménové třídy. Žádný
TokenInterface, žádnýAuthorizationChecker, žádnýUserInterface. Třídu lze testovat unit testem bez Symfony Kernel. - Doménové exceptions.
InvalidOrderStateExceptionaCancellationWindowExpiredExceptionjsou doménové třídy vApp\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 – není to autorizační selhání, je to doménový stav). - Idempotentní pomocná metoda
isCancellable(). Voter ani Twig ji nevolají; používá ji UI pro skrytí tlačítka (kombinováno sis_granted). Tatáž logika je sdílená scancel()přes konstantuCANCELLATION_WINDOW_SECONDS– žádná duplicita. - Domain Events. Po úspěšné operaci se do
$releasedEventspřidáOrderCancelled. Aplikační handler je porepository->save()publikuje (typicky přes Outbox). Aggregate sám nikdy nevoláEventDispatcher.
Zde tedy není otázka „smí Petr“ – tu vyřešil Voter v předchozí sekci. 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 obě jsou potřeba.
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')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.
Několik vrstev kontroly v jediné cestě požadavku – a každá vrstva selže jiným způsobem, s jiným HTTP statusem, s jinou chybovou hláškou. To je rozdíl mezi doménově navrženou autorizací a generickým „Access denied“.
12.06 Field-level – read model filtrace
Nejjemnější vrstva. Až dosud jsme řešili akce (smí udělat) a existenci
operace (dá se vůbec); 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čí; nikdy to nepoužívejte pro citlivá data, která mohou unikat skrz HTML komentáře, JSON serializaci v JS aplikaci nebo Etag hashing.
{# templates/order/detail.html.twig #}
<dl>
<dt>Customer</dt> <dd>{{ order.customer.name }}</dd>
<dt>Total</dt> <dd>{{ order.total|format_currency('CZK') }}</dd>
<dt>Status</dt> <dd>{{ order.status.label }}</dd>
{% if is_granted('order.audit_log', order) %}
<dt>Audit log</dt>
<dd>
<ul class="audit">
{% for entry in order.auditLog %}
<li>{{ entry.at|date }}: {{ entry.action }} ({{ entry.actor }})</li>
{% endfor %}
</ul>
</dd>
{% endif %}
</dl>
Přístup 2: Query filter (read model)
Citlivá pole se z databáze vůbec nenačtou. Read model vrací různé DTO podle role. Bez data leaku, ale za cenu duplicity (dvě query, dvě DTO struktury). Vhodné pro PII, finanční data, audit logy.
<?php
declare(strict_types=1);
namespace App\Ordering\Application\ReadModel;
use App\Identity\Domain\AppUser;
use Doctrine\DBAL\Connection;
final readonly class OrderDetailReadModel
{
public function __construct(private Connection $db) {}
public function forUser(string $orderId, AppUser $user): OrderDetailDto
{
$base = 'SELECT id, customer_id, total_cents, status, placed_at FROM orders WHERE id = :id';
if ($user->hasRole('ROLE_ADMIN')) {
$sql = $base . ', audit_log';
} else {
$sql = $base;
}
$row = $this->db->fetchAssociative($sql, ['id' => $orderId]);
if ($row === false) {
throw new OrderNotFoundException($orderId);
}
return OrderDetailDto::fromRow($row, includeAudit: $user->hasRole('ROLE_ADMIN'));
}
}
Volba mezi přístupy:
| Kritérium | Twig if | Query filter |
|---|---|---|
| Data leak | Ano (data v paměti, response, dev tools) | 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 integration test | Unit + integration test read modelu |
| OWASP A01:2021 compliance | Insufficient – viz [5] | Splňuje (server-side enforcement) |
Pro necitlivá data je Twig if zcela dostatečný 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.
12.07 Policy-based přístup (ABAC)
Když počet pravidel naroste a vrstvení do Voterů začne být neudržovatelné (typicky 5+ rolí × 10+ entit × 3+ atributy = 150+ pravidel), je čas přejít z RBAC (Role-Based Access Control) na ABAC (Attribute-Based Access Control). RBAC říká „role X smí Y“; ABAC říká „kombinace atributů subjektu, akce, prostředku a kontextu vyhodnocená proti policy 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), který umí policy verzovat, distribuovat a auditovat nezávisle na aplikaci.
<?php
declare(strict_types=1);
namespace App\SharedKernel\Authorization;
interface Policy
{
public function name(): string;
/** @return list<Rule> */
public function rules(): array;
}
final readonly class Rule
{
public function __construct(
public string $expression,
public string $description,
) {}
}
final readonly class PolicyContext
{
public function __construct(
public object $subject,
public object $user,
public \DateTimeImmutable $now,
) {}
}
<?php
declare(strict_types=1);
namespace App\Ordering\Authorization;
use App\SharedKernel\Authorization\Policy;
use App\SharedKernel\Authorization\Rule;
final class CancelOrderPolicy implements Policy
{
public function name(): string
{
return 'order.cancel';
}
/** @return list<Rule> */
public function rules(): array
{
return [
new Rule(
expression: 'subject.customerId == user.customerId',
description: 'Pouze vlastník objednávky',
),
new Rule(
expression: 'subject.status == "PLACED"',
description: 'Order musí být ve stavu PLACED',
),
new Rule(
expression: '(now - subject.placedAt) <= 86400',
description: 'Cancellation window 24 h ještě neuplynulo',
),
new Rule(
expression: 'user.tenantId == subject.tenantId',
description: 'Stejný tenant',
),
];
}
}
Poznámka: pravidla subject.status == "PLACED" a časové okno 24 h jsou v politice pro ilustraci ABAC zápisu. Jak popisuje sekce 12.05, 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), ale agregát 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:
<?php
declare(strict_types=1);
namespace App\SharedKernel\Authorization;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
final class PolicyEvaluator
{
public function __construct(private readonly ExpressionLanguage $expr = new ExpressionLanguage()) {}
/**
* Vrací první porušené pravidlo, nebo null pokud všechna prošla.
*/
public function evaluate(Policy $policy, PolicyContext $ctx): ?Rule
{
$vars = [
'subject' => $ctx->subject,
'user' => $ctx->user,
'now' => $ctx->now->getTimestamp(),
];
foreach ($policy->rules() as $rule) {
if (!$this->expr->evaluate($rule->expression, $vars)) {
return $rule;
}
}
return null;
}
}
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“. - Versioning. Policy je třída v repu – změny přes git, code review, deploy. ABAC standardně vyžaduje policy versioning [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 Open Policy Agent (OPA) – engine v Go s vlastním policy language (Rego). Symfony aplikace potom dělá HTTP volání místo lokálního
evaluate().
12.08 Multi-tenancy – owner 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.
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Doctrine;
use App\SharedKernel\Domain\TenantAware;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
if (!$targetEntity->reflClass->implementsInterface(TenantAware::class)) {
return '';
}
return sprintf(
'%s.tenant_id = %s',
$targetTableAlias,
$this->getParameter('tenant_id'),
);
}
}
Filter aplikuje WHERE klauzuli tenant_id = ? na každý dotaz nad entitou,
která implementuje marker rozhraní TenantAware. Aktivace filtru
v config/packages/doctrine.yaml:
# config/packages/doctrine.yaml
doctrine:
orm:
filters:
tenant:
class: App\SharedKernel\Infrastructure\Doctrine\TenantFilter
enabled: false # zapne kernel listener až po identifikaci tenanta
Hlavní detail: filter se musí aktivovat v každém požadavku a předat
mu správné tenant_id. Bez toho je výchozí stav „filter vypnutý“ –
tedy žádná izolace. Aktivaci řeší kernel event listener:
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Http;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
#[AsEventListener(event: KernelEvents::REQUEST, priority: 7)]
final readonly class TenantContextListener
{
public function __construct(
private EntityManagerInterface $em,
private TokenStorageInterface $tokens,
) {}
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$token = $this->tokens->getToken();
$user = $token?->getUser();
if ($user === null || !method_exists($user, 'tenantId')) {
return; // public endpoint, anonymous request
}
$tenantId = $user->tenantId()->toString();
$filter = $this->em->getFilters()->enable('tenant');
$filter->setParameter('tenant_id', $tenantId);
}
}
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. - Anonymous fallback. Pokud je požadavek anonymní (login, register, health), listener prostě filter neaktivuje – Doctrine queries nevrátí žádné
TenantAwareentity bez explicitního filteru. Tím vzniká fail-closed default.
12.09 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é test suitě. 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ý:
<?php
declare(strict_types=1);
namespace Tests\Ordering\Domain;
use App\Ordering\Domain\Exception\CancellationWindowExpiredException;
use App\Ordering\Domain\Exception\InvalidOrderStateException;
use App\Ordering\Domain\Order;
use App\Ordering\Domain\OrderStatus;
use PHPUnit\Framework\TestCase;
final class OrderCancelTest extends TestCase
{
public function testCancelWithinWindowSucceeds(): void
{
$order = OrderFactory::placed(at: '2026-04-29 10:00:00');
$order->cancel('changed mind', new \DateTimeImmutable('2026-04-29 12:00:00'));
self::assertSame(OrderStatus::CANCELLED, $order->status());
}
public function testCancelOfShippedOrderThrows(): void
{
$order = OrderFactory::shipped();
$this->expectException(InvalidOrderStateException::class);
$order->cancel('changed mind', new \DateTimeImmutable());
}
public function testCancelAfter24hThrows(): void
{
$order = OrderFactory::placed(at: '2026-04-29 10:00:00');
$this->expectException(CancellationWindowExpiredException::class);
$order->cancel('too late', new \DateTimeImmutable('2026-04-30 11:00:00'));
}
}
Voter: unit test s mock TokenInterface
Voter dostává TokenInterface; v testu stačí jeho mock + reálný subject.
Žádný Symfony Kernel:
<?php
declare(strict_types=1);
namespace Tests\Ordering\Infrastructure\Security;
use App\Identity\Domain\AppUser;
use App\Identity\Domain\CustomerId;
use App\Ordering\Domain\Order;
use App\Ordering\Infrastructure\Security\OrderVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class OrderVoterTest extends TestCase
{
public function testOwnerCanCancelOwnOrder(): void
{
$voter = new OrderVoter();
$owner = new AppUser(CustomerId::fromString('cus_1'), ['ROLE_USER']);
$order = OrderFactory::placedFor(CustomerId::fromString('cus_1'));
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($owner);
self::assertTrue(
$voter->vote($token, $order, [OrderVoter::CANCEL]) === Voter::ACCESS_GRANTED
);
}
public function testStrangerCannotCancelOrder(): void
{
$voter = new OrderVoter();
$stranger = new AppUser(CustomerId::fromString('cus_2'), ['ROLE_USER']);
$order = OrderFactory::placedFor(CustomerId::fromString('cus_1'));
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($stranger);
self::assertSame(
Voter::ACCESS_DENIED,
$voter->vote($token, $order, [OrderVoter::CANCEL])
);
}
}
End-to-end: WebTestCase
Pro pokrytí celé pipeline (firewall → controller → handler → voter → aggregate)
slouží Symfony WebTestCase. Zde už je to integration test, který
používá kernel a databázi. Doporučená míra: 1 e2e test na use case,
pokrývající happy path + 1-2 nejdůležitější chybové stavy. Detailní edge-case
pokrytí 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:
<?php
declare(strict_types=1);
namespace Tests\Ordering\Authorization;
use App\Ordering\Authorization\CancelOrderPolicy;
use App\SharedKernel\Authorization\PolicyContext;
use App\SharedKernel\Authorization\PolicyEvaluator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class CancelOrderPolicyTest extends TestCase
{
public static function scenarios(): iterable
{
yield 'happy path' => [
'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 30),
'user' => UserFixture::for('cus_1', 'tenant_a'),
'expected' => null,
];
yield 'wrong customer' => [
'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 30),
'user' => UserFixture::for('cus_2', 'tenant_a'),
'expected' => 'Pouze vlastník objednávky',
];
yield 'shipped order' => [
'subject' => OrderFixture::shippedFor('cus_1', 'tenant_a'),
'user' => UserFixture::for('cus_1', 'tenant_a'),
'expected' => 'Order musí být ve stavu PLACED',
];
yield 'window expired' => [
'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 1500),
'user' => UserFixture::for('cus_1', 'tenant_a'),
'expected' => 'Cancellation window 24 h ještě neuplynulo',
];
yield 'cross-tenant' => [
'subject' => OrderFixture::placedFor('cus_1', 'tenant_a', minutesAgo: 30),
'user' => UserFixture::for('cus_1', 'tenant_b'),
'expected' => 'Stejný tenant',
];
}
#[DataProvider('scenarios')]
public function testEvaluate(object $subject, object $user, ?string $expected): void
{
$evaluator = new PolicyEvaluator();
$context = new PolicyContext($subject, $user, new \DateTimeImmutable());
$violation = $evaluator->evaluate(new CancelOrderPolicy(), $context);
self::assertSame($expected, $violation?->description);
}
}
Tabulkový test má dvě hodnoty 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žit jako spustitelná dokumentace policy – netechnický reviewer
vidí všechny případy v jedné tabulce a může schválit doménová pravidla.
12.10 Anti-vzory
Čtyři opakující se anti-vzory, které se v projektech objevují nejčastěji. Každý je pojmenován, doložen konkrétním příkladem a doplněn náhradou.
Anti-vzor 1: Autorizace v controlleru
Probrali jsme v sekci 12.01. Vyplatí se to zdůraznit znovu,
protože jde o nejčastější chybu. 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. Cross-link na
obecné anti-vzory v DDD.
Anti-vzor 2: Voter, který fetchne aggregate z databáze
Symptom:
<?php
final class OrderVoter extends Voter
{
public function __construct(private OrderRepository $orders) {}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
// Anti-vzor: voter dostane jen ID a fetchne entitu
$order = $this->orders->find($subject);
// ... rozhodování ...
}
}
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), je třeba upravit
obě místa – 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 %} –
voter pro permission, doménová metoda pro stavovou kontrolu.
Anti-vzor 4: Symfony User natažený do doménového Aggregate
Symptom:
<?php
namespace App\Ordering\Domain;
use Symfony\Component\Security\Core\User\UserInterface;
final class Order
{
// Anti-vzor: doména závisí na Symfony Security komponentě
public function cancel(UserInterface $user, string $reason): void
{
if ($user->getUserIdentifier() !== $this->customerEmail) {
throw new \DomainException('Not your order');
}
// ...
}
}
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á
z Symfony UserInterface na doménový typ. Detail v
kapitole o anti-vzorech.
12.11 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á exception. „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 → RBAC (role).
- Jemné, vztahy mezi entitami → ABAC / policy-based.
- Vícenájemnost → Doctrine SQLFilter + kernel listener (fail-closed default).
- Doménové stavové pravidlo → Aggregate, ne Voter.
Kdy zvážit externí policy engine: 100+ pravidel, 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. Pro většinu Symfony aplikací stačí Voter + (volitelně) tenké policy-evaluator vrstvení nad Voter API.
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 aplikace je 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 (12.10) – vede k duplicate query, race condition a pomalé test suitě. 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 explodne – vznikají hyper-specific role typu ROLE_TENANT_42_ORDER_AGENT. Tehdy přejít na ABAC (12.07): 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 v Voteru ověřte, že user.tenantId == subject.tenantId. Drasticky to zjednoduší správu uživatelů, audit a 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 12.10.
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ě doporučuji decorator nad AuthorizationCheckerInterface, který každé volání zaloguje. Pro detail viz hub o Základech, případně sekci o testování v 12.09.
12.12 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“)