Symfony Voter a DDD: kam patří autorizace
Kam v DDD aplikaci na Symfony 8 patří které autorizační pravidlo: tři časté chyby, čtyři vrstvy (Edge, Use Case, Aggregate, Field), access_control a past roles + allow_if, Voter a rozhodovací strategie unanimous, #[IsGranted] se statusCode 404, autorizace v Messenger workeru přes actorId a proč agregát vrací 409, ne 403.
Přehrávač YouTube se načte až po kliknutí · otevřít na YouTube (nové okno) ↗
Přepis dílu komentář tak, jak zazní ve videu
Kontrola, na kterou jeden vstup zapomene
Modelová situace. Kontrola, že objednávku smí zrušit jen její vlastník, sedí v controlleru. Pro webový formulář funguje.
Jenže stejný use case volá i konzolový command z cronu, handler v Messengeru a administrace. Každý vstupní bod musí podmínku zopakovat a stačí, aby na ni jeden zapomněl.
Pravidlo potřebuje jedno místo, na které žádný vstupní bod nezapomene. Autorizace má čtyři vrstvy a za chvíli budete vědět, na kterou z nich patří.
Tři chyby s autorizací
Chyba z úvodu není jediná. Kapitola popisuje tři, které se v code review opakují, a mají stejnou příčinu. Chybí rámec, kam které pravidlo patří.
Že téma unese celou kapitolu, dokládá OWASP. V Top 10 pro rok 2021 se Broken Access Control posunul z pátého místa na první.
První chybu znáte. Kontrola v controlleru chrání jen jeden vstupní bod, pravidlo ale patří do use casu.
Druhá je opačný extrém. Do Voteru se přestěhuje všechno včetně lhůty na storno, jenže agregát má dál veřejný setStatus. Zavolá ho test nebo migrační skript a lhůta přestane platit.
Třetí cesta vede do databáze. Doctrine SQL filtr cizí objednávky z dotazů vynechá. Handler pak nerozezná neexistující objednávku od cizí. A filtr se nevztahuje na identity map, nativní SQL ani Redis.
Vývojář má přitom v každé chvíli jednu konkrétní otázku. Smí to vidět? Smí to udělat? Je to vůbec možné? Má vidět tento sloupec? Každá má svou vrstvu i nástroj.
Čtyři vrstvy a Edge
Identita a oprávnění tvoří vlastní kontext, Vernon mu říká Identity and Access. Je to generická subdoména, takže se kupuje, třeba Keycloak.
Rozhodnutí ale zůstává v kontextu, který identitu používá. Závisí totiž na jeho entitách a stavech.
Uvnitř něj padá rozhodnutí ve čtyřech vrstvách. Edge se ptá, jestli je uživatel přihlášený a smí na danou adresu. Use case, jestli smí akci na tomhle objektu. Agregát, jestli se to dá teď udělat. A field, jestli smí vidět konkrétní pole.
Vrstvy fungují jako filtry a každá počítá s tím, že předchozí řekla ano. Pravidlo má jedno místo definice. Vynutit ho jde klidně na víc vrstvách.
Čtyřvrstvý rámec je přitom rozšíření knihy. Vernon sám dává autorizaci do aplikačních služeb.
Edge doménu nezná. Pracuje jen s adresou, rolemi a stavem přihlášení.
Provider přitom míří na SecurityUser v infrastruktuře, ne na doménového uživatele. Registrace proto zapisuje dvakrát, jinak vznikne uživatel, který se nepřihlásí. Posluchač pro druhý zápis byl v minulém dílu.
V access control vyhrává první shodné pravidlo. Poslední řádek vyžaduje přihlášení pro všechno ostatní, to je default deny. Bez něj je každý zapomenutý endpoint veřejný.
Teď past. Pravidlo pro reporty chce roli analytika a výraz allow if s rolí admina. Krátký odhad. Pustí analytika, který admin není?
Pustí. Při výchozí strategii affirmative hlasuje každá podmínka zvlášť, takže to funguje jako nebo. Obě podmínky zároveň patří do jednoho výrazu allow if.
Voter a rozhodovací strategie
Druhou vrstvu drží Voter. Platí jeden use case, jeden atribut, třeba order cancel. Jeden Voter pokryje atributy téže entity a prefix brání kolizím s invoice cancel.
Nejdřív varianta, kterou kapitola odmítá. Voter porovná vlastníka, zkontroluje stav a spočítá stáří objednávky. Osmdesát šest tisíc čtyři sta sekund.
Jenže stav a lhůta jsou doménový invariant. Ve Voteru jdou obejít přímým voláním agregátu a při změně lhůty musíte upravit dvě místa.
Voter proto řeší identitu, role a vlastnictví. Na vlastnictví se ptá agregátu metodou isOwnedBy.
Roli pro refund kontroluje přes decide na AccessDecisionManageru. Ruční čtení rolí by obešlo hierarchii rolí.
Hlasy Voterů skládá AccessDecisionManager. Výchozí strategie affirmative potřebuje jediný souhlas.
Krátký odhad. TenantVoter řekne ne, protože objednávka patří jinému tenantovi. OrderVoter podle vlastnictví řekne ano. Co vyjde?
Povoleno. Affirmative nesouhlas ignoruje. Vrstvená autorizace proto potřebuje strategii unanimous, která zamítne, jakmile nesouhlasí kdokoli.
K ní patří allow if all abstain na false. Když se k atributu nepřihlásí žádný Voter, výsledkem je zamítnutí.
Kdo Voter volá? Na hranici HTTP atribut IsGranted se subjektem order. Požadavek odmítne dřív, než vznikne command.
Čtyři sta čtyřka skryje, že objednávka s tím ID existuje. Útočník pak nemůže procházet cizí identifikátory.
Rozhodující kontrola ale sedí v handleru. Ten objednávku načte, zavolá isGranted a teprve potom cancel. Aplikační vrstva na Security záviset smí, doména ne.
Twigové is granted jen schová tlačítko a ruční request nezastaví. Je to jen pohodlí pro uživatele.
A Voter si subjekt nenačítá sám, dostane ho od handleru. Doplňková data, třeba členství v týmu, načíst smí.
Autorizace ve workeru
Teď háček. Command může jít přes asynchronní transport a worker Messengeru běží mimo HTTP požadavek. TokenStorage je prázdná.
Voter nad tokenem pak každé volání zamítne a oprávněné operace začnou tiše padat.
Proto command nese identitu aktéra. V controlleru token ještě existuje a do commandu se zapíše actorId, tedy doménový CustomerId.
Handler pak porovná actorId s vlastníkem přes isOwnedBy. To funguje i ve workeru.
Pravidla podle rolí se tak ale psát nemají, byla by to druhá kopie Voteru. Pro ně je isGrantedForUser, které spustí tytéž Votery bez tokenu.
Rozhoduje přitom stav v okamžiku zpracování. Snapshot rolí v commandu slouží nanejvýš auditu.
Krátký odhad. Sága pošle storno zaplacené objednávky jako kompenzaci a žádný člověk za ní nestojí. Co udělá handler, který jen porovná actorId s vlastníkem?
Odmítne ho. Po vyčerpání pokusů skončí příkaz ve frontě nedoručitelných zpráv a objednávka zůstane zaplacená. Nic přitom nespadne.
Systémové procesy proto mají vlastní identitu SystemActor a handler ji testuje ve zvláštní větvi. Nikdy ne pravidlem, že bez aktéra je povoleno všechno.
Agregát, průchod a 403 vs. 409
Zbývá agregát a k němu heuristika o třech bodech.
Pravidlo nad stavem agregátu patří do agregátu, třeba lhůta na storno.
Vlastnictví definuje taky agregát. Zní to jako pravidlo use casu, ale je to invariant vztahu mezi Order a CustomerId.
A atributy aktéra, jako role nebo pracovní doba, patří do Voteru.
Metoda cancel odmítne odeslanou objednávku i storno po čtyřiadvaceti hodinách od potvrzení. TokenInterface v ní nenajdete.
Bariéry jsou tedy dvě a nezávislé. Voter řekne, jestli smí Petr. Agregát řekne, jestli se to dá teď udělat.
Celý průchod na modelovém e-shopu. Petr klikne na zrušení objednávky čtyřicet dva.
Firewall ověří session a access control pustí přihlášeného. Controller pošle CancelOrder s Petrovým actorId a handler načte agregát.
V pátém kroku se Voter ptá na vlastníka. Kdyby Petr nebyl, přišlo by čtyři sta tři.
V šestém rozhoduje agregát. Odeslaná objednávka by znamenala čtyři sta devět. Petr ji ale potvrdil před půl hodinou, takže storno projde.
Uložení s outboxem a filtr polí přijdou v dalších dílech.
Krátký odhad. Petr je vlastník, ale objednávka už odešla. Čtyři sta tři, nebo čtyři sta devět?
Čtyři sta devět. Právo Petr má, operaci nedovolí stav.
Překlad se neudělá sám. Bez posluchače vybublá doménová výjimka jako pět set, a tak listener mapuje výjimky na stavové kódy.
U asynchronního storna výsledek v odpovědi ještě není. Tlačítko proto skryje šablona přes isCancellable.
Otázky a shrnutí
Otázka na rozmyšlenou. Modelově: storno smí i operátor podpory s vlastní rolí. Handler ve workeru porovnává jen actorId s vlastníkem. Kam nové pravidlo patří?
Do Voteru, protože jde o atribut aktéra. Handler ve workeru spustí Votery přes isGrantedForUser. Druhé porovnání rolí v handleru by byla duplicita.
A jedna z dílu devět bé. Je Vertical Slice alternativou k hexagonální architektuře?
Není, každý řeší jinou osu. Vertical Slice řeže kód po funkcích, Hexagonal strukturuje závislosti uvnitř nich.
Takže v kostce. Každé pravidlo má jedno místo definice. Edge řeší přihlášení, Voter oprávnění na objekt a agregát, jestli se to dá teď udělat.
Vrstvená autorizace potřebuje strategii unanimous. Ve workeru se autorizuje proti actorId z commandu, ne proti tokenu.
A každá vrstva selže po svém. Čtyři sta jedna, čtyři sta tři, čtyři sta čtyři, nebo čtyři sta devět.
Příští díl patří datům. Co smí uživatel vidět, jak filtrovat seznamy a jak oddělit tenanty. Celou kapitolu o autorizaci najdete v knize na ddd-v-symfony.katuscak.cz.