Multi-tenancy a ABAC v Symfony: autorizace dat
Co smí uživatel v DDD aplikaci na Symfony 8 vidět: citlivá pole v read modelu místo Twig if, seznamy filtrované v dotazu místo Voteru nad každým řádkem, ABAC a kdy z Voterů odejít (Vote::addReason, OPA, Cerbos), multi-tenancy přes Doctrine SQLFilter nastavený fail-closed, PostgreSQL Row-Level Security a testy autorizace po vrstvách.
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
Stránka o třinácti řádcích
Modelová situace. Seznam objednávek načte stránku o dvaceti řádcích a šablona se u každého zeptá Voteru. Sedm řádků zmizí.
Zákazník uvidí stránku o třinácti položkách a celkový počet nikdo nespočítá, dokud nenačte všechno. Při pěti Voterech je to navíc sto rozhodnutí na jedno vykreslení.
Voter tu odpovídá na jinou otázku, než jakou seznam klade. Tenhle díl patří datům. Co smí uživatel vidět, jak filtrovat seznamy a jak se nepotkají dva tenanti.
Pole a seznamy v read modelu
Minulý díl řešil akce. Autorizace na úrovni polí řeší, co uživatel uvidí, když číst smí. Detail objednávky vidí zákazník i admin, audit log ale jen admin.
Nejkratší cesta vede přes Twig. Šablona se zeptá na oprávnění a audit log nevykreslí.
Z databáze se ale načetlo všechno a šablona část jen zahodila. U API nebo SPA data skončí v odpovědi. Pro běžné údaje to stačí, pro audit log nebo osobní údaje ne.
Druhý přístup citlivé pole vůbec nenačte. OrderDetailReadModel zjistí, jestli je aktér admin, a jen tehdy položí dotaz na audit log.
Cenou jsou dva dotazy a DTO, které nese jen to, co obrazovka opravdu dostala. Zákazník v něm má audit log null.
To znamená nenačteno. Prázdné pole by říkalo, že záznamy neexistují, a to je jiná informace.
Seznamy mají vlastní potíž. Voter odpovídá na uzavřenou otázku, jestli smí tento objekt. Seznam se ptá, které objekty z deseti tisíc smí uživatel vidět.
Autorizace proto patří do dotazu. OrderListReadModel dostane identitu aktéra a zákazníkovi přidá podmínku na customer id. Admin ji nedostane.
Limit se pak počítá z řádků, které už prošly podmínkou, a stránkování sedí.
Háček je jinde. Podmínka ve WHERE je druhý zápis pravidla, které už zná OrderVoter. SQL a PHP jednu definici nesdílí.
Vazbu proto drží test. Projde objednávky vrácené read modelem a u každé se zeptá Voteru. Když se zápisy rozejdou, test spadne.
ABAC a kdy z Voterů odejít
RBAC se ptá na roli. ABAC skládá rozhodnutí z atributů subjektu, akce, prostředku a kontextu.
O přechodu ale nerozhoduje počet pravidel. Kapitola jmenuje tři signály. Pravidla musí číst někdo mimo tým, mění se v jiném rytmu než kód, nebo je sdílí víc aplikací.
Dokud neplatí ani jeden, stačí Votery a další abstrakce je práce navíc.
Slovník k tomu dává americký NIST. Policy Enforcement Point rozhodnutí vynucuje. V Symfony je to access control, atribut IsGranted a isGranted v handleru.
Rozhoduje Policy Decision Point, u nás AccessDecisionManager s Votery. Čtyři vrstvy z minulého dílu jsou tedy rozmístění vynucovacích bodů. Rozhodovací bod zůstává jeden.
Kapitola ukazuje i ABAC jako samostatnou vrstvu. CancelOrderPolicy drží tři pravidla jako výrazy pro ExpressionLanguage.
Výrazy jsou ale řetězce. PHPStan do nich nevidí a překlep se ukáže až za běhu.
Subjekt musí mít stav čitelný zvenku, protože na privátní pole ExpressionLanguage nesáhne. A hlasy skládá vlastní evaluátor, takže strategie unanimous se neuplatní.
Stav a lhůta tu stojí jen kvůli ukázce zápisu. Zdrojem pravdy zůstává agregát.
Pro vlastní evaluátor býval jediný silný argument. Vědět, které pravidlo selhalo. Od Symfony sedm tři to umí i Voter.
Dostane volitelný objekt Vote a metodou addReason do něj zapíše důvod.
Kontrola isCancellable je tu výjimka kvůli ukázce. Pravidlo dál definuje agregát a Voter ho jen volá.
Závěr kapitoly je proto střízlivý. Vlastní vrstva pravidel dává tytéž odpovědi jako Votery, jen bez statické analýzy a bez rozhodovacích strategií. ABAC zůstává způsobem uvažování a implementace vzniká z Voterů.
Externí engine, třeba OPA nebo Cerbos, přichází na řadu, až pravidla musí žít mimo aplikaci. Voter se pak stane tenkým vynucovacím bodem a ptá se vzdáleného rozhodovacího.
Kde oprávnění vyplývá ze vztahů, třeba ze sdílené složky, mluví se o ReBAC. Pro PHP kolem něj zatím moc nástrojů není.
Krátký odhad. Máte čtyřicet pravidel, všechna v jednom repozitáři, a mění je jen vývojáři. Externí engine?
Ne. Žádný ze tří signálů neplatí a samotný počet pravidel o přechodu nerozhoduje.
Multi-tenancy
Multi-tenancy je speciální případ ABAC. Jedna aplikace obsluhuje víc oddělených zákazníků a žádný nesmí vidět data jiného.
Strategie jsou tři. Sdílené tabulky se sloupcem tenant id, vlastní schéma pro každého tenanta, nebo vlastní databáze. Kritérium je pořád stejné. Jak drahá je chyba, když se dva tenanti potkají v jedné odpovědi?
Pro sdílené tabulky je v Symfony zavedeným nástrojem Doctrine SQL filtr. Minulý díl ho přitom označil za chybu.
Rozpor je jen zdánlivý. Tenant je kontext dotazu, podobně jako jazyk nebo časová zóna. Pro celý request je jediný a neměnný. U storna konkrétní objednávky to neplatí.
Filtr je krátký. Když entita nese značku TenantAware, přidá do dotazu podmínku na tenant id. Hodnotu dostane jako parametr.
Krátký odhad. Filtr je vypnutý, nebo nenakonfigurovaný. Co vrátí dotaz na objednávky?
Data všech tenantů. SQL filtr je ze své podstaty fail-open. Když podmínku nepřidá, dotaz nic neomezí.
Fail-closed se proto musí vyrobit. Filtr se zapne globálně v konfiguraci Doctrine. Parametr tenant id je povinný, a když chybí, Doctrine dotaz nesestaví a vyhodí výjimku.
Parametr nastaví kernel listener po autentizaci. Má prioritu sedm. Firewall běží s prioritou osm a vyšší číslo se vykoná dřív, takže listener už uživatele zná.
Filtr ovšem mění jen SQL, které generuje ORM. Na nativní dotaz přes connection ani na Redis se neuplatní. Entity, které už leží v identity map, se vrátí tak, jak se načetly.
Kniha na ORM tři šest naměřila i mezeru u inverzní strany vztahu jedna k jedné. Kde na oddělení tenantů závisí bezpečnost, hlídá tenanta i doména.
Report za všechny tenanty řeší dvojice suspend a restore. Disable zahodí filtr i s nastavenými parametry.
PostgreSQL nabízí pojistku o patro níž, Row-Level Security. Filtrování přejde do databáze, takže platí i pro nativní SQL a konzolové skripty.
Výchozí chování je opačné než u filtru. Po zapnutí RLS bez politiky nevrátí tabulka nic. Vlastník tabulky ji ale obchází, dokud nepřidáte FORCE.
Cenou je vazba na PostgreSQL a horší ladění, protože dotaz prostě vrátí prázdno. Migrace a zálohy potřebují roli s BYPASSRLS. Obě vrstvy se nevylučují a RLS je poslední záchytná síť.
Testy po vrstvách a anti-vzory
Každá vrstva se testuje jinak. Kdo chce všechno pokrýt end to end, skončí u pomalé a křehké sady.
Agregát dostane čistý unit test bez kernelu a bez databáze. Čas se do cancel předává zvenku. Potvrzeno v deset, storno druhý den v jedenáct, a test pokaždé vyjde stejně.
Voteru stačí stub tokenu a stub rozhodovacího manažeru. Kernel ani tady potřeba není.
Krátký odhad. Kolik end to end testů potřebuje storno?
Jeden na use case. Pokryje hlavní scénář a jeden až dva nejdůležitější chybové stavy. Okrajové případy patří do unit testů níž.
Jeden detail ušetří ladění. LoginUser vloží uživatele do session, firewall ho ale při dalším requestu načte znovu z databáze. Bez fixture test skončí na přihlášení a vypadá jako chyba autorizace.
Zbývá hranice domény. Pravidlo v PHPArkitect zakáže třídám v doménovém namespace závislost na Symfony Security.
Tím padá i čtvrtý anti-vzor kapitoly, UserInterface v metodě cancel. Ordering by pak mluvil slovníkem cizí subdomény, Identity and Access.
Všechny čtyři anti-vzory kapitoly mají jednu příčinu. Rozhodnutí skončilo na nesprávné vrstvě.
Otázky a shrnutí
Otázka na rozmyšlenou. Messenger worker zpracuje zprávu tenanta A a sáhne na objednávky. Filtr je zapnutý globálně. Co udělá první dotaz?
Spadne na chybějícím parametru. Kernel listener ve workeru neběží, takže tenant id nikdo nenastavil. Tenanta tam musí dodat Messenger middleware nebo samotný command.
A jedna z dílu osm bé. PHPArkitect přichází do hotového projektu s dluhem. Jak zařídit, aby hned první build nespadl?
Přes baseline. Starý dluh se zapíše a build neshodí, každé nové porušení ano.
Takže v kostce. Citlivá pole a seznamy se autorizují v dotazu read modelu. Šablona ani Voter nad každým řádkem na to nestačí.
ABAC je způsob uvažování a implementace zůstává ve Voterech. Externí engine přichází, až pravidla musí žít mimo aplikaci.
Tenantní filtr je fail-open, dokud ho nenastavíte jako fail-closed. Pod aplikací ho jistí PostgreSQL RLS.
Příští díl rozdělí model na dva, jeden pro zápis a druhý pro čtení. Read model z dnešního dílu v něm dostane pevné místo. Celou kapitolu o autorizaci najdete v knize na ddd-v-symfony.katuscak.cz.