V LydiaCMS, mém dlouhodobém vlastním CMS postaveném na Nette a Doctrine, na kterém běží i tento web, může editor blogové články zobrazovat a upravovat, ale nesmí je mazat. Nestačí mu proto skrýt tlačítko „Smazat“ - oprávnění musí ověřit také server při zpracování požadavku. Tomuto ověření, zda uživatel smí danou akci provést, se říká autorizace. Samotné přihlášení uživatele ji nenahrazuje.
V Nette může uživatelská akce vyvolat signál, například deletePost. Ten zpracuje odpovídající metoda s předponou handle, tedy handleDeletePost(). Této metodě se říká handler signálu. Při její kontrole řeším dvě otázky: co daná akce provede a kdo ji smí spustit.
S rostoucím využitím AI při psaní kódu rostou také nároky na code review, tedy ruční kontrolu změn v kódu. Vygenerovaný kód musím ověřit stejně jako vlastní - včetně toho, zda správně kontroluje oprávnění uživatele. U opakovaných pravidel proto hledám způsob, jak ruční kontrolu doplnit automatickou pojistkou.
V tomto případě potřebuji automaticky ověřit, že u každého handleru signálu výslovně určím požadované oprávnění, případně zdůvodním výjimku. K tomu se hodí statická analýza.
Co je statická analýza a proč jsem zvolil PHPStan
Statická analýza kontroluje zdrojový kód bez spuštění aplikace. V PHP k tomu používám PHPStan, který odhaluje například nesprávné typy nebo volání neexistujících metod. Vlastními pravidly ho mohu rozšířit o kontroly specifické pro mou aplikaci.
S PHPStanem mám zkušenosti a v projektu už běží jako součást CI, tedy automatických kontrol spouštěných při začleňování změn do projektu. Nemusel jsem proto zavádět další nástroj ani samostatný kontrolní krok.
Než ukážu vlastní pravidlo, potřebuji stručně vysvětlit, jak v LydiaCMS oprávnění vyhodnocuji.
Jak fungují oprávnění v LydiaCMS
Pro kontrolu oprávnění používám kontexty, role a jednotlivá oprávnění. Kontext určuje, ke které části systému nebo konkrétnímu záznamu se oprávnění vztahuje. Role, například editor, sdružuje oprávnění přidělená uživateli. Vlastní kontext může mít sekce administrace i konkrétní článek.
Zjednodušený příklad pro správu článků:
Systém
└── Administrace
└── Rozšíření Blog
└── Sekce článků ← role editor článků: view, edit; bez delete
└── Konkrétní článek #42Role editora má v sekci článků oprávnění view a edit, ale nemá delete. Konkrétní článek může mít vlastní nastavení oprávnění.
Ověření provádí objekt Guard. Využívá Nette ACL, tedy pravidla určující, co jednotlivé role smějí, a zjišťuje, zda má uživatel požadované oprávnění v příslušném kontextu.
Problém: Chybějící autorizace v handle metodě
Přístup do administrace ještě neznamená právo mazat články. Následující handler ale sám žádnou kontrolu oprávnění neobsahuje:
use LydiaCMS\Core\Modules\AdminModule\Presenters\AdminPresenter;
class BlogPresenter extends AdminPresenter
{
public function handleDeletePost(int $idPost): void
{
// Odstranění článku z databáze
}
}Z pohledu syntaxe je kód správně a PHPStan v základním nastavení nic nenamítne. Bez další ochrany by ale mazání mohl vyvolat i editor, který k němu nemá oprávnění.
Jde o chybějící kontrolu oprávnění k funkci (Broken Function Level Authorization, BFLA). Přihlášený uživatel s rolí editora legálně vidí administraci. Stačí mu odeslat požadavek se signálem deletePost sám, například z konzole prohlížeče na stránce administrace nebo nástrojem jako curl s vlastní session cookie.
Nette ve výchozím nastavení chrání signály před CSRF, tedy podvržením požadavku z jiného webu pod identitou přihlášeného uživatele. Tady ale požadavek posílá sám přihlášený uživatel, ne cizí web. CSRF ochrana proto autorizaci nenahrazuje: i požadavek ze stejného původu musí projít kontrolou oprávnění.
Jaké možnosti jsem zvažoval
Kontrola v každém handleru (
$this->guard->checkCapability(...)) - funguje, ale spoléhá na to, že ji nikdo nezapomene. To je přesně riziko, které chci odstranit.Centrální kontrola bez deklarace u metody - například v
checkRequirements()zachytí každý signál, ale nemá odkud zjistit, jaké oprávnění konkrétní akce potřebuje. Musela by hádat, nebo povolit vše s přístupem do sekce.Atribut, PHPStan pravidlo a runtime kontrola - deklarace je viditelná přímo u metody, chybějící deklaraci ohlásí analýza a runtime kontrola funguje jako pojistka i ve chvíli, kdy analýza neproběhla.
Zvolil jsem poslední variantu, protože PHPStan už v projektu běží v CI a deklarace u metody je při code review nejlépe vidět.
Řešení: SignalHandlerRequiresCapabilityRule
Abych chybějící deklaraci zachytil při kontrole kódu, zavedl jsem pravidlo SignalHandlerRequiresCapabilityRule. Pokud zapomenu oprávnění definovat, PHPStan okamžitě vyhodí chybové hlášení:
Signal handler handleDeletePost() must carry either
#[RequiresCapability('...')] or #[PublicSignal('...')].PHP atribut je značka u metody, kterou zde deklaruji požadované oprávnění. Systém ji před spuštěním handleru přečte a ověří. Inspiroval jsem se konceptem atributu #[IsGranted] ze Symfony a požadavek na oprávnění deklaruji pomocí PHP 8 atributu přímo u handle metody:
use LydiaCMS\Core\Modules\AdminModule\Presenters\AdminPresenter;
use LydiaCMS\Core\Security\Attributes\RequiresCapability;
class BlogPresenter extends AdminPresenter
{
#[RequiresCapability('delete', recordIdParameter: 'idPost')]
public function handleDeletePost(int $idPost): void
{
// Odstranění článku.
}
}Atribut vyžaduje oprávnění delete. Volitelný argument recordIdParameter určuje parametr, ze kterého se načte ID záznamu; zde idPost.
Dvě kontroly: při analýze kódu a za běhu aplikace
PHPStan kontroluje deklaraci, runtime kontrola vyhodnocuje skutečné oprávnění uživatele. Každá z těchto kontrol řeší jinou část problému:
Při analýze kódu: PHPStan ověřuje, že handler nese
#[RequiresCapability]nebo#[PublicSignal]. U uvedeného názvu ID parametru také kontroluje jeho existenci v podpisu handleru, pokud ho dokáže vyhodnotit jako konstantní řetězec. Nález způsobí neúspěšný výsledek kontroly v CI; skutečná práva uživatelů se v této fázi neověřují.Za běhu aplikace: před spuštěním handleru systém načte atribut metody. Případné ID převezme z parametrů presenteru nebo komponenty, ověří jeho formát a také to, že takový záznam existuje. Následně Guard vyhodnotí požadované oprávnění v příslušném kontextu. Pokud ho uživatel nemá, požadavek se odmítne kvůli chybějícímu oprávnění (HTTP 403). Stejně se odmítne handler bez obou povolených atributů.
Pro handlery, které záměrně nepotřebují dodatečnou kontrolu oprávnění, používám #[PublicSignal] s povinným textovým zdůvodněním výjimky. Rozhodnutí je tak viditelné při code review.
Zjednodušený průchod kontrolou za běhu aplikace vypadá takto:
Akce uživatele
↓
Kontrola atributu handleru
├── bez atributu → HTTP 403
├── PublicSignal → bez dodatečné kontroly
└── RequiresCapability
↓
Uvedené ID patří existujícímu záznamu?
├── ne → HTTP 403
└── ano, nebo bez ID
↓
Výběr kontextu oprávnění
sekce / konkrétní záznam podle ID
↓
Má uživatel požadované oprávnění?
├── ne → HTTP 403
└── ano → spuštění handleruBez recordIdParameter se oprávnění ověřuje v kontextu sekce. Pokud ho uvedu, Guard vyhledá záznam a jeho bezpečnostní kontext.
Platné číslo neznamená existující záznam. Pokud handler uvádí recordIdParameter a záznam s daným ID neexistuje, požadavek se odmítne. Atribut ale sám nezaručuje ochranu před neoprávněným přístupem ke konkrétnímu záznamu (Broken Object Level Authorization, BOLA): nemá-li existující záznam vlastní kontext, platí pro něj oprávnění celé sekce. Také PublicSignal pouze vynechává tuto dodatečnou kontrolu; sám o sobě nezpřístupňuje administraci anonymním uživatelům.
Kompromisy a realita vývoje
PHPStan pohlídá deklaraci požadovaného oprávnění nebo výjimky. Nedokáže ale posoudit, zda je zvolené oprávnění věcně správné (zda místo #[RequiresCapability('delete')] omylem nenapíšu pouze view). Správnost zvoleného kontextu tak dál zůstává předmětem mého code review.
Vlastní pravidlo musím udržovat, ale při každém běhu PHPStanu získávám automatickou kontrolu jedné konkrétní podmínky. Správnost samotné autorizace - zvolené oprávnění, výběr ID parametru a nastavení kontextů - dál vyžaduje ruční posouzení a testy.
Pokud při code review opakovaně kontroluji stejné mechanické pravidlo, hledám způsob, jak ho nechat ověřovat nástrojem. Review pak může věnovat víc pozornosti tomu, zda zvolené oprávnění a kontext dávají smysl.
V balíčcích LydiaCMS dnes pravidlo hlídá přes sedmdesát handlerů signálů. U 54 je deklarované konkrétní oprávnění, 19 je záměrně veřejných a každý z nich nese zdůvodnění výjimky.
Celý zdrojový kód PHPStan pravidla včetně testů jsem dal k dispozici jako open-source. Můžete se jím inspirovat v mém GitLab repozitáři.
Jak oprávnění, validace a audit fungují při správě obsahu pomocí AI, popisuji v navazující části AI a bezpečnost v LydiaCMS.
Zavádíte do existující aplikace podobné automatické pojistky, nebo řešíte oprávnění v Nette? Rád si poslechnu, na čem pracujete.