Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Bezpečnost / RBAC v API

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#

  1. Napiš get<Akce>Data() a hned na první řádek dej requireApiPermission('<akce>').
  2. 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:
SELECT `key` FROM cms_system_user_role_privileges WHERE `key` LIKE '%NotificationType%';
  1. Veřejnou akci označ #[PublicAction] — bez atributu se ve vynucujícím režimu zamítne. V systému jich je dnes 27.
  2. 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.
  3. Když má akce fungovat jen pro vlastníka, předej guardu líný loader entity a implementuj isOwner().
  4. 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
  1. 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).
  2. Pole bez atributu se chová jako all/all/all — atribut nic neuzavírá zpětně.
  3. Publikum je monotónní: Privileged vidí i to, co vidí Owner, ten i to, co Authenticated. 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č#

  1. Podívej se do auth kanálu na řádek API_RBAC. Nese mode, effect, decision, key, userId, path (internal/external) i volajícího — tedy všechno, co potřebuješ.
  2. 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ý.
  3. Zkontroluj tvar klíče proti tabulce výš — Api větev má jiné akce než admin.
  4. Podívej se na path. internal znamená, že rozhoduje internalMode, ne mode.
  5. U CLI ověř identitu. Skript přes ApiBridge nemá session, takže ve vynucujícím režimu běží jako guest. Musí si identitu dodat: ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)).
  6. 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