RBAC v API#
Rozhodnutí, jestli tenhle volající smí tuhle akci nad tímhle záznamem — a
která pole z něj vůbec uvidí. Jádro: app/Core/Security/Rbac/, guard
app/Core/Traits/Api/Presenters/.
Čtyři vrstvy nad sebou#
Nejde o jednu kontrolu, ale o čtyři nezávislé. Každá může požadavek zastavit sama.
| Vrstva | Ptá se | Kde |
|---|---|---|
| 1. Identita | kdo volá — JWT, API klíč, nebo anonym | JwtAuthTrait::checkJwtAuth() |
| 2. Akce | má volající privilegium na tenhle klíč | PermissionResolver::decide() |
| 3. Vlastnictví | je ten konkrétní záznam jeho | isOwner() v guardu |
| 4. Publikum polí | která pole se smí dostat do odpovědi | FieldAccess + FieldAudienceContext |
Vrstva 2 sama o sobě nestačí: rozhodnutí ALLOW_IF_OWNER je platné teprve po
vrstvě 3.
Rozhodnutí má tři hodnoty, ne dvě#
PermissionResolver je čistý lookup nad databází — žádný strom, žádné dědění,
žádný fallback:
| Výsledek | Kdy vznikne |
|---|---|
ALLOW |
aspoň jedna role volajícího má allowed = 1 a conditional = 0 |
ALLOW_IF_OWNER |
grant existuje, ale jen od role s conditional = 1 (dnes owner) |
DENY |
žádná role klíč nepovoluje — nebo klíč v databázi vůbec není |
Nepodmíněný grant vyhraje i tehdy, když vedle něj existuje podmíněný.
Chybějící klíč = DENY, ne „nechráněno“
Klíč, který v cms_system_user_role_privileges neexistuje, se chová přesně jako
zamítnutý. Je to fail-closed a je to schválně, ale znamená to, že nová Api akce
bez řádku v databázi prostě nefunguje — a nevrátí chybu o chybějícím
oprávnění, jen „Permission denied“.
Kdo je privilegovaný a kdo vlastník, je řádek v databázi, ne jméno v kódu
Rozhodují sloupce cms_system_user_roles.privileged a .conditional. Nikde v cestě
rozhodování se nesmí objevit natvrdo napsané admin — přidání role s
privileged = 1 (třeba moderátora) by pak vyžadovalo sweep přes celý kód.
Skupiny oprávnění do rozhodování nepatří
cms_system_privilege_groups je čistě prezentační věc pro administrátorské UI —
rodina akcí, ne klíč. V SQL resolveru se objevit nesmí; hlídá to test, který čte
zdroják té třídy.
Dva nezávislé přepínače#
Tohle je nejdůležitější věc celé kapitoly. Externí a interní cesta mají každá svůj režim, protože mají nesouměřitelný poloměr výbuchu.
apiRbac:
mode: enforce # EXTERNÍ HTTP cesta
internalMode: enforce # INTERNÍ cesta = ApiBridge (admin, front, CLI)
| Hodnota | Chování |
|---|---|
off |
gate se nespustí vůbec — nula dotazů navíc, nula řádků v logu |
log |
vyhodnotí a zapíše API_RBAC … do auth kanálu, nic nezamítá |
enforce |
DENY → HTTP 403 (externě) / Result::failure(PERMISSION_DENIED) (interně) |
| Přepínač | Řídí | Kde se vyhodnotí |
|---|---|---|
mode |
externí HTTP požadavky | RbacCheckTrait::checkApiPermission() ve startup() |
internalMode |
admin, front a CLI přes ApiBridge |
ApiPermissionGuardTrait::requireApiPermission() v get*Data() |
mode NEŘÍDÍ admin ani front
Na interní cestě se startup() vůbec nespustí — ApiBridge presenter jen sestaví
a zavolá get*Data(). Kdo přepne mode: off a čeká, že tím odemkne administraci,
nezmění nic. Kill-switch pro administraci a front je internalMode.
Kontrola napsaná jen v Api presenteru neplatí pro každou cestu stejně
Guard v get*Data() je jediné místo, kudy prochází obě cesty. Kontrola
v startup() chrání jen externí HTTP. Pravidlo, které má platit vždy, patří do
guardu nebo do manageru — ne do action*().
Po změně kteréhokoli přepínače smažte temp/cache
Je to compile-time parametr. Bez smazání drží starý zkompilovaný DI kontejner a vypadá to, jako by přepínač nefungoval.
Překlep v hodnotě spadne na neutrální režim, ne na enforce
Neplatná hodnota mode spadne na log, neplatný internalMode na off — a fakt,
že je konfigurace rozbitá, se hlučně zapíše do auth kanálu jako
API_RBAC_CONFIG invalid. Interní fallback je off schválně: log znamená jeden
RBAC lookup na každé bridge volání, a jedno anonymní zobrazení frontové stránky
jich vyhodnotí desítky.
Kontrakt guardu#
Volání musí být první příkaz metody, před jakýmkoli dotazem do databáze:
public function getGetAllData(array $filters = [], …): array
{
if (($denied = $this->requireApiPermission('getAll')) !== null) { return $denied; }
…
}
null znamená „smíš pokračovat“. Pole znamená, že ho musíš okamžitě vrátit jako
svůj výsledek — na interní cestě není kam poslat 403.
Na externí cestě se rozhodnutí vyhodnotí dvakrát (v startup() i v metodě) a je to
správně: druhé volání je pro resolver jen isset() nad mapou načtenou v prvním, a kdo
by obešel startup(), narazí na guard v metodě.
Akce se do guardu píše LITERÁLEM
Na interní cestě je getAction() prázdný řetězec a getName() je null — klíč
odvozený z nich by vyšel jako '::'. Presenter si guard odvodí ze static::class,
akci musí dostat jako parametr. Odvozovat ji z debug_backtrace() je zakázané.
Transport se pozná ze stavu frameworku, nikdy z dat
$meta['__external'] se dá poslat po drátě. Kdyby se podle něj rozhodovalo, mohl by
si volající vybrat, která pravidla se na něj vztahují.
Postup: přidávám novou Api akci#
- Napiš
get<Akce>Data()a hned na první řádek dejrequireApiPermission('<akce>'). - Založ klíč v databázi ve tvaru
:Api:<Modul>:<Entita>:<akce>a přiřaď ho rolím, které ho mají mít. Klíče opiš od nejbližšího sourozence, ne z hlavy:
- Veřejnou akci označ
#[PublicAction]— bez atributu se ve vynucujícím režimu zamítne. V systému jich je dnes 27. - Akci pro externího klienta (API klíč) označ
#[RequiresScope]— klient není uživatel, nemá role, a autorizuje se scopy, ne privilegii. Bez toho atributu je pro klienta DENY. - Když má akce fungovat jen pro vlastníka, předej guardu líný loader entity
a implementuj
isOwner(). - Ověř obě cesty — externí (HTTP s tokenem) i interní (přes administraci nebo front).
Klíče se jmenují jinak v Api a jinak v administraci
| Větev | Tvar |
|---|---|
| Api | :Api:<Modul>:<Entita>:{get, getAll, getBy, save, updateColumns, delete, deleteAll} |
| Admin | :Admin:<Modul>:<Entita>:{show, add, edit, del} |
Když se pro Api založí admin-style klíč (:Api:System:Unit:show), resolver
fail-closed odmítne getAll a admin přehled je prázdný bez jediné chyby —
„Permission denied“ zůstane uvnitř ApiBridge a do stránky se nedostane. Vypadá to
jako rozbitá hydratace nebo chybějící data. Ověřit se to dá voláním bridge napřímo:
$bridge->call('api/<modul>/<entita>/get-all', []).
Vlastnictví#
Vlastníka určuje SELECT podle id, nikdy hodnota z požadavku
OwnershipPayload smí z payloadu vytáhnout jen identifikátor — „o kterém
záznamu se bavíme". userId, insertUserId ani user.id z něj brát nesmí:
kontrola nad hodnotou, kterou poslal tentýž požadavek, neověřuje nic. Kdo je
vlastník, rozhoduje výhradně manager dotazem do databáze.
ALLOW_IF_OWNER bez implementované kontroly se musí chovat jako DENY
Podmíněný grant sám o sobě nepovoluje nic. Kdyby se isOwner() nezavolalo, byl by
z podmíněného grantu nepodmíněný — a to je přesně ta chyba, kterou má tenhle model
vyloučit.
Chybějící id v payloadu znamená ZAKLÁDÁNÍ
Nula, záporné číslo, prázdný řetězec i nečíselná hodnota se berou jako „není id“ ⇒ INSERT. Je to fail-closed: větev zakládání je přísnější (ověřuje rodiče, vlastníka dosazuje server), zatímco větev editace by na neplatném id stejně skončila nenalezením.
Postup: pole nemá vidět každý#
Chránit endpoint nestačí — jakmile se entita objeví jako vnořená vazba pod jiným,
legitimně otevřeným endpointem, je dedikovaná akce mimo hru. #[FieldAccess] chrání
pole samotné, ať k němu vede jakákoli cesta.
#[FieldAccess(read: Audience::Privileged, insert: Audience::Privileged, update: Audience::None)]
protected ?string $settings = null;
| Publikum | Odpovídá roli, která má… |
|---|---|
All |
cokoli — pole prošlo už tím, že volající dostal akci |
Authenticated |
implicit = 'authenticated' |
Owner |
conditional = 1 ⇒ rozhodne isOwner() nad konkrétním záznamem |
Privileged |
privileged = 1 |
None |
žádnou — hodnotu určuje výhradně server |
- Všechny tři osy jsou povinné.
read(kdo uvidí),insert(kdo smí vyplnit při zakládání),update(kdo smí změnit u existujícího, včetněupdateColumn). - Pole bez atributu se chová jako
all/all/all— atribut nic neuzavírá zpětně. - Publikum je monotónní:
Privilegedvidí i to, co vidíOwner, ten i to, coAuthenticated. Opačná podmínka („uvidí to kdokoli kromě admina“) neexistuje.
Defaulty by z atributu udělaly hádanku
Proto jsou All i None pojmenované hodnoty, ne absence hodnoty. Bez toho by
po půl roce nikdo nerozeznal #[FieldAccess(read: Audience::Privileged)] od „na
zbylé osy se zapomnělo".
Mimo požadavek se pole NEfiltrují
Cron, CLI, generátory a e-maily běží bez publikum — a filtr se tam vědomě
neuplatní. Není to díra: filtr chrání přenos k volajícímu, a když žádný volající
není, není co chránit — zato je co rozbít (aktivační e-mail potřebuje
activationKey, faktura podpis). Na Api cestě je publikum vždycky nastavené,
protože ho nastavuje guard.
Publikum se plní z rozhodnutí guardu, nikdy z požadavku
Jinak by si volající publikum „koupil“ v payloadu. Proto actionSave() z payloadu
__externalTier odstraňuje.
Postup: akce je zamítnutá a nevím proč#
- Podívej se do
authkanálu na řádekAPI_RBAC. Nesemode,effect,decision,key,userId,path(internal/external) i volajícího — tedy všechno, co potřebuješ. - Přečti klíč v tom řádku a ověř, jestli takový klíč v databázi vůbec existuje. Neexistující klíč vypadá stejně jako zamítnutý.
- Zkontroluj tvar klíče proti tabulce výš — Api větev má jiné akce než admin.
- Podívej se na
path.internalznamená, že rozhodujeinternalMode, nemode. - U CLI ověř identitu. Skript přes
ApiBridgenemá session, takže ve vynucujícím režimu běží jakoguest. Musí si identitu dodat:ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)). - Cron tímhle dotčený není — injektuje Api managery přímo, ne přes bridge.
CLI harness bez tokenu vrací TICHÉ PRÁZDNO
Nevrátí chybu o oprávnění — vrátí prázdný výsledek, protože „Permission denied“ zůstane uvnitř bridge. Test pak projde nad prázdnými daty a tváří se zeleně.
Chyba databáze v resolveru je DENY, ne průchod
Celá třída je fail-closed: každá výjimka z DBAL se zaloguje do auth kanálu
a vyhodnotí jako zamítnutí. Výpadek databáze proto vypadá jako plošné odepření
přístupu.
Zápis sloupců#
Zvláštní případ — kdo smí přepsat konkrétní sloupec zvenčí — řeší atributy pro zápis sloupců.
Testy#
tests/Unit/Security/ — dnes 24 souborů. Kromě běžných testů rozhodování jsou mezi
nimi i strukturální testy, které čtou zdrojáky:
| Test | Co hlídá |
|---|---|
PermissionResolverTest |
že v SQL resolveru nejsou skupiny oprávnění |
ApiPermissionCoverageTest |
že guard je zavolaný v každé get*Data() |
FieldAccessCoverageTest |
že pole, jehož jméno vypadá jako tajemství, má buď #[FieldAccess], nebo řádek v seznamu výjimek s důvodem |
ExternalReadonlyCoverageTest, ExternalAdminOnlyCoverageTest |
totéž pro atributy zápisu sloupců |
Strukturální test chytí to, co běhový nezachytí
Zapomenutý requireApiPermission() v jedné nové metodě neshodí nic — jen tam díra
zůstane. Test, který prochází zdrojáky, ji najde hned.
Pokrytí podle jména pole je síto, ne ochrana
FieldAccessCoverageTest pozná password nebo secret. Pole, které se jmenuje
nevinně a přesto nese tajemství (meta, config, note), musí anotovat člověk.
Kam sáhnout#
| Chci | Kde |
|---|---|
| jádro rozhodování | app/Core/Security/Rbac/PermissionResolver.php |
| režimy a jejich sémantiku | app/Core/Security/Rbac/ApiRbacConfig.php + config/Shared/api.neon |
guard v get*Data() |
app/Core/Traits/Api/Presenters/ApiPermissionGuardTrait.php |
| gate na externí cestě | app/Core/Traits/Api/Presenters/RbacCheckTrait.php |
| čtení id z payloadu | app/Core/Security/Rbac/OwnershipPayload.php |
| publikum polí | app/Core/Security/Rbac/FieldAudienceContext.php, app/Core/Attributes/Hydration/{FieldAccess,Audience}.php |
| atributy akcí | app/Core/Attributes/{PublicAction,RequiresScope}.php |
| testy | tests/Unit/Security/ |
Navazující kapitoly: Autentizace a autorizace · CORS, rate limit a audit · Atributy pro zápis sloupců · Volání API