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á — uživatel z JWT, nebo host 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.

Klíč aplikace není vrstva RBAC

Na vnější cestě před vrstvou 1 běží checkClientKey() — „smí tahle aplikace Api volat?“. Práva nepřidává; klient je smí jen omezit stropem rolí. Podrobně Autentizace → Klíč aplikace.

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 a receiver, viz Podmíněné role)
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ý.

Podmíněných rolí je víc než jedna

conditionalGrantHolds() zavolá kontrolu každé podmíněné role, která klíč povoluje: owner → verifyOwnership() / isOwner(), receiver → verifyReceiver() / isReceiver(). Neznámá podmíněná role neprojde. Podrobně Podmíněné role: vlastník a příjemce.

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.

Prázdná identita je host#

JwtPayload::getUserId() vrací int s výchozí 0, ne null. Resolver proto v rowsForKey() převede 0 i záporné id na null (normalizeIdentity()) dřív, než vybere větev dotazu:

userId Větev Role, které vidí
kladné id SELECT_FOR_AUTHENTICATED implicit = 'authenticated' + role přidělené uživateli
null, 0, záporné SELECT_FOR_ANONYMOUS implicit = 'anonymous' + všechny conditional = 1

Anonymní větev vidí i podmíněné role kvůli capability: anonymní editor s heslem k inzerátu (userId = 0) se musí dostat ke stejné otázce isOwner() jako přihlášený vlastník. Sama nic nepovolí — podmíněný řádek dá nejvýš ALLOW_IF_OWNER a guard bez tokenu (userId je null) kontrolu vlastníka vůbec nezavolá.

Normalizace patří do resolveru, ne k volajícímu

Bez ní by token s userId = 0 šel větví přihlášených: ztratil by granty role guest a zároveň by se dostal do větve vlastníka. Že by z toho nevznikla eskalace, by pak drželo jen to, že každá implementace isOwner() sama hlídá > 0. Volající (RbacCheckTrait, ApiPermissionGuardTrait) předávají getUserId() beze změny — nový volající nesmí normalizaci obcházet ani opakovat jinak.

Strop rolí aplikace#

Klient vnějšího Api (klíč aplikace) má seznam povolených rolí (cms_system_api_client_roles). PermissionResolver::restrictToClientRoles() z řádků každého klíče zahodí role, které klient nepovoluje — dřív, než se rozhoduje. Klienta požadavku drží ApiClientContext; bez klienta (web, administrace, cron, #[NoClientKey]) strop neplatí. Nastavuje ho jen checkClientKey() ve startup(), ale platí pro každé rozhodnutí požadavku — i pro vnořené interní volání, které Api presenter během vnějšího požadavku udělá přes bridge; volající je pořád tentýž.

Co strop zasáhne Jak
explicitní role (admin…) admin přes klienta bez admin se rozhoduje jako běžný uživatel
implicitní role (guest, registrated) klient bez guest neprojde žádnou akcí bez tokenu ani #[PublicAction] (přihlášení, obnova)
podmíněné role (owner, receiver) klient bez owner = uživatel přes něj neupraví ani vlastní záznam
tier polí a osobní údaje FieldAccess Privileged, #[ExternalAdminOnly] i PersonalDataPolicy jedou z isGrantedByPrivilegedRole(), takže ořez platí i tam

Filtr sedí v rowsForKey(), jediném trychtýři všech veřejných metod (decide(), grantingRoleCodes(), isGrantedByPrivilegedRole()). Cache větví drží neořezané řádky, strop je levný filtr při čtení. hasKey() strop ignoruje — ptá se na politiku, ne na volajícího.

Výchozí role nového klienta: neprivilegované role, které jsou implicitní nebo podmíněné (ApiClientManager::isDefaultRole()), tedy dnes guest, registrated, owner, receiver.

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 internalAction*()

mode NEŘÍDÍ admin ani front

Na interní cestě se startup() vůbec nespustí — ApiBridge presenter jen sestaví a zavolá internalAction*(). 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 internalAction*() 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#

Tělo každé internalAction<Akce>() je celé obalené v operation() (hranice operace, viz Identity mapa a hranice operace) a volání guardu musí být první příkaz uvnitř něj, před jakýmkoli dotazem do databáze:

public function internalActionGetAll(array $filters = [], …): array
{
    return $this->operation(__FUNCTION__, function () use ($filters, …) {
        if (($denied = $this->requireApiPermission('getAll')) !== null) { return $denied; }
        …
    });
}

Jedna internalAction*() nesmí volat jinou internalAction*() (přes $this, self, static ani parent); obal i tento zákaz hlídá ApiOperationBoundaryTest.

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š internalAction<Akce>(), tělo obal do operation(__FUNCTION__, …) a hned na jeho 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 17.
  2. Kvůli klíči aplikace nic neznač — klíč se ověřuje pro všechny vnější akce stejně a práva dál určují role. Jen akci, kterou volá třetí strana neschopná poslat náš klíč (zpětné volání brány, agregátoru, poskytovatele), označ #[NoClientKey] a zapiš ji s důvodem do tests/Unit/Security/ApiClientKeyExemptionTest.php.
  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', []).

Presenter mimo tvar App\UI\Api\<Modul>\Presenters\<Jméno>Presenter klíč nemá

Guard skládá klíč ze jména třídy (resolveApiPresenterName()) a modul smí být jen jeden segment. Presenter v podsložce modulu nebo mimo Presenters\ vyhodí LogicException; guard ji zapíše jako API_RBAC_ERROR … key=(unresolved):<akce> a ve vynucujícím režimu akci zamítne. Víc segmentů guard schválně nedoplňuje: Nette mapování Api\*\ je u víceúrovňových jmen převádí na jinou třídu, takže by vznikl klíč, který v DB není.

Které akce presenter dostane

CrudTrait skládá get, getBy, getAll, save, updateColumns, delete a deleteAll. Stromové akce (TreeTrait) a počítadla (RegisterEventTrait) se přidávají zvlášť jen tam, kde dávají smysl — každá z nich potřebuje vlastní klíč. Výjimkou je přeskládání dětí (reorder-children), které sdílí klíč moveSubtreeTo, viz níž.

Literál akce a sdílený klíč#

Literál v guardu je jméno metody bez předpony internalAction s malým prvním písmenem: internalActionUpdateColumns() → updateColumns. Je to obrácené pravidlo, kterým ApiBridge::internalCall() skládá jméno metody ('internalAction' . ucfirst($action)). Jiný tvar ('update-columns', 'UpdateColumns') dá klíč, který v databázi není, a akce skončí tichým zamítnutím, které vypadá jako „nemá oprávnění“.

Metoda Co smí být v guardu Příklad
veřejná internalAction*() jen jméno metody; sdílený klíč jen se zápisem do ALIASED_GUARD_ACTIONS v testu ProductCategoryPresenter::internalActionProductCounts() → getAll
protected/private internalAction*() (jen interní bridge) libovolný čitelný literál, typicky klíč příbuzné akce TreeTrait::internalActionReorderChildren() → moveSubtreeTo
kterákoli nikdy dynamický argument —

Neveřejná metoda nemá vnější routu, takže jí žádný klíč podle jména nepatří. Bridge ji volá reflexí, proto ji ApiPermissionCoverageTest skenuje taky — guard mít musí.

Postup: akce jen pro bridge, která převezme existující politiku#

  1. Napiš metodu jako protected a nepřidávej k ní action*(); bez ní ji Nette zvenčí odmítne 404.
  2. Do guardu dej literál klíče, jehož politika na akci sedí — ne nový klíč, který by potřeboval seed u každého presenteru.
  3. Spusť ApiPermissionCoverageTest.

Sdílený klíč sdílí i role

Kdo má moveSubtreeTo, smí i přeskládat děti; odebrat jedno bez druhého nejde. Když akce potřebuje jinou politiku než sourozenec, sdílený klíč je chyba a patří jí vlastní.

Zakládání a úprava: insert a update místo save#

Jeden klíč nese jednu politiku, ale zakládání a úprava jsou dvě různé věty o téže roli: inzerát smí založit kdokoli, i anonym, upravit jen vlastník nebo správce. SaveTrait::refineSaveAction() proto akci save zpřesní:

Payload Klíč, je-li v politice Jinak
bez data.id (OwnershipPayload::entityId() === null) :Api:<Modul>:<Entita>:insert :Api:<Modul>:<Entita>:save
s data.id :Api:<Modul>:<Entita>:update :Api:<Modul>:<Entita>:save

Zakládání pozná stejná implementace jako $owned closure a externí verifyOwnership(), takže se větve nerozejdou. Existenci klíče zjistí PermissionResolver::hasKey() z per-request cache, kterou naplnilo první decide() — dotaz navíc nestojí.

Postup: zpřesnit akci entity#

  1. Založ klíč …:insert (případně …:update) a přiřaď ho rolím — vzor SELECT `key` FROM cms_system_user_role_privileges WHERE `key` LIKE '%:insert';.
  2. Klíč …:save ponech: zůstává pojistkou pro druhou větev a pro dobu mezi nasazením kódu a spuštěním SQL.
  3. Ověř externí save bez id i s id v řádku API_RBAC (pole key).

Překlep v klíči se tiše vrátí na save

Neexistující zpřesňující klíč znamená dnešní chování klíče save — nic se nerozbije, ale ani neotevře. Kdo čeká přísnější update a klíč špatně napíše, zůstane u politiky save.

Postup: vlastní internalActionSave() s krokem po uložení#

Když presenter potřebuje po uložení něco udělat (založit notifikaci do zvonku), přepíše internalActionSave(), ale guard i uložení vezme z SaveTrait — vzor ContactMessagePresenter, PaymentPresenter, UserPresenter.

public function internalActionSave(array $data, array $meta = [], array $skipFields = []): array
{
    return $this->operation(__FUNCTION__, function () use ($data, $meta, $skipFields) {
        if (($denied = $this->requireApiPermission($this->refineSaveAction($data), owned: $this->ownedEntityLoader($data), ownedData: $data)) !== null) { return $denied; }

        $response = $this->saveEntityData($data, $meta, $skipFields);
        // … krok po uložení
        return $response;
    });
}
  1. Guard na první řádek uvnitř operation() ve stejném tvaru jako v traitu: refineSaveAction() zachová zpřesnění na insert/update, ownedEntityLoader() vlastníka a ownedData payload pro zakládání pod rodičem.
  2. Ulož přes saveEntityData() — obsahuje všechny ořezy polí, hydrataci i úklid entity manageru po chybě. Guard v sobě nemá; bridge ho nevolá, protože to není internalAction*().
  3. Krok po uložení dělej jen při úspěchu ($response['success']).

saveEntityData() nevolej odjinud

Bez guardu před sebou je to zápis bez kontroly oprávnění. ApiPermissionCoverageTest hlídá jen metody internalAction*(), takže jiné místo volání nezachytí.

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.

Id rodiče čte OwnershipPayload::parentId($data, 'order') ze všech tvarů, kterými ToOne vazba přijde: data[order][id]=5, data[order]=5, data[orderId]=5 i data[order_id]=5. Hodnota je jen přání klienta, ke kterému rodiči se chce připojit; že rodič patří jemu, ověřuje manager dotazem do DB.

Klíč se hledá bez ohledu na velikost písmen a podtržítka

findValue() normalizuje stejně jako hydrátor (normalizePropertyName()). Kdyby se brána dívala jen na přesný klíč id, klient pošle ID nebo _id: brána uvidí zakládání (jinou větev), ale hydratace to zpracuje jako úpravu, protože PHP setID() i set_id() rozešle na setId(). Nové čtení id z payloadu jde proto přes OwnershipPayload, ne přes $data['id'].

Na kterých akcích podmíněný grant zabere#

Kontrola vlastníka na interní cestě dostane entitu z líného loaderu $owned, a ten předávají jen akce nad jedním záznamem:

Akce Co dostane isOwner()
get záznam načtený z DB podle parametru id
save s data.id záznam načtený podle data.id (ownedEntityLoader())
save bez data.id (zakládání) null a payload v $data; presenter z něj smí vzít jen id rodiče
delete s jedním id záznam načtený podle id
delete s polem id null ⇒ DENY
updateColumns jen když presenter přebije ownedForUpdateColumns(); výchozí null ⇒ DENY
getAll, getBy, search nic, owner = 1 tu znamená vždy DENY

Hromadné mazání vlastníka nemá schválně: množina záznamů nemá jednoho vlastníka a smazat „jen ty moje“ by bylo horší než odmítnout. Kolekci vlastních záznamů proto nevyjádříš grantem, ale akcí, která filtr vlastníka dosadí sama.

Výpis jen mých záznamů: OwnedListTrait a OwnedOneTrait#

Trait Akce Vrací Bez identity
OwnedListTrait myList výpis ve tvaru getAll prázdný výpis, bez dotazu do DB
OwnedOneTrait myOne jeden záznam ve tvaru getBy [], tedy totéž co „nenalezeno“

Server přidá k filtrům volajícího EqualFilter(<pole vlastníka>, jwt.userId). Filtry se skládají přes andWhere(), takže volající výsledek smí jen zúžit: userId = 999 v požadavku dá průnik s jeho vlastním id, tedy prázdno. Dnes je používají hlídací psi (Inzerce, Katalog firem), platby členstva, sledování inzerátu (myOne) a účastníci soukromých zpráv.

Hook Výchozí K čemu
ownedListUserField() / ownedOneUserField() userId vlastník je ve sloupci s jiným jménem
ownedListExtraFilters() žádné další serverová podmínka, kterou volající nevynechá (soukromé zprávy skrývají odebrané účastníky)

Postup:

  1. Do Api presenteru přidej use OwnedListTrait; (případně OwnedOneTrait).
  2. Založ klíč :Api:<Modul>:<Entita>:myList s grantem registrated = 1. Je to nepodmíněný grant — vlastnictví drží filtr, ne kontrola isOwner().
  3. Front volá přes bridge <endpoint>/my-list (my-one) místo get-all (get-by).
  4. Ověř, že externí GET …/my-list vrací 404 i s tokenem administrátora.

Ani myList, ani myOne nesmí dostat action*()

Bez ní Nette externí požadavek odmítne 404 dřív, než se cokoli načte. S ní by grant registrated otevřel akci přes HTTP komukoli, kdo se zaregistruje.

Zápis vlastního záznamu: vyhrazená akce#

Podmíněný grant owner neobslouží dva běžné zápisy přihlášeného uživatele. Zakládání (save bez data.id) dá kontrole null a obecné isOwner() traitu vrátí false. Hromadné mazání (deleteAll) vlastníka nemá vůbec (tabulka výš). Kde uživatel zakládá a maže jen své řádky, dostane presenter vlastní interní akce, kterým vlastníka dosadí server. Vzor je sledování inzerátu (AdvertFollowerPresenter):

Akce Vstup od volajícího Co server udělá
myFollow advertId založí záznam jen z advert = advertId a user = jwt.userId, payload volajícího se nepoužije
myUnfollow advertId deleteAll() s filtry advert = advertId a userId = jwt.userId
myOne filtry stav tlačítka, viz OwnedOneTrait výš

Postup:

  1. Napiš metodu internalAction<Akce>() bez action*(), aby akce zvenčí vracela 404.
  2. Tělo obal do operation(__FUNCTION__, …) a na jeho první řádek dej requireApiPermission('<akce>').
  3. userId ber jen z getJwtPayload(). Bez identity (userId <= 0) vrať PERMISSION_DENIED bez dotazu do DB.
  4. Serverový filtr vlastníka přidej k filtru volajícího, ať jde rozsah jen zúžit.
  5. Založ klíč :Api:<Modul>:<Entita>:<akce> s grantem registrated = 1 (nepodmíněný, vlastnictví drží akce sama). Obecné save, delete, deleteAll a getBy nech beze změny.
  6. Ověř, že externí volání vrací 404 a že akce zapíše nebo smaže jen řádek volajícího, i když v datech pošle cizí userId.

Mazání vlastních řádků nech množinové

myFollow záznam neduplikuje a tabulka cms_mod_bazaar_advert_followers unikátní index nemá (index se jmenuje unique_index, ale unikátní není). Kdyby myUnfollow mazal přes delete(id), ze dvou řádků téže dvojice by zmizel jeden a tlačítko by dál ukazovalo „sleduji“.

Výpis vázaný na jeden inzerát: AdvertScopedListTrait#

Záložky Reakce a Statistiky na editaci inzerátu otevře i anonym, který zná heslo k inzerátu. Výpis podle userId by ho neobsloužil, proto mají Response a AdvertStatistics (Inzerce i Katalog firem) vlastní akci myList, kde „moje“ znamená „k inzerátu, ke kterému se volající prokázal“. Rozhoduje jediná metoda AdvertScopeGuardTrait::advertScopeAllows(), od nejlevnější kontroly:

  1. privilegovaná role (PermissionResolver::isGrantedByPrivilegedRole(), mapa v paměti, bez dotazu),
  2. vlastník: insertUserId inzerátu načteného z DB se rovná jwt.userId (anonymní inzerát s insertUserId 0 nepatří nikomu),
  3. capability v tokenu pro tenhle inzerát, s otiskem hesla porovnaným s hashem v DB (viz Heslo k inzerátu).
Vstup Co server přidá do filtrů
advertId prošel kontrolou advertId = <ověřené id>
advertId chybí, presenter má advertScopeOwnerField() <pole> = jwt.userId (reakce na všechny moje inzeráty)
cokoli jiného nic, vrátí prázdnou kolekci bez dotazu

advertId z požadavku je jen tvrzení, o který inzerát jde; cizí id dá prázdnou kolekci. Filtry volajícího výsledek smějí jen zúžit. AdvertStatistics druhý režim nemá: statistika nese jen skalární advertId bez vazby na inzerát, takže se „moje inzeráty“ filtrem vyjádřit nedají.

myList nesmí dostat actionMyList()

Akce potřebuje grant guest = 1 kvůli anonymnímu editorovi. Bez action*() ji Nette zvenčí odmítne 404; s ní by ten grant otevřel výpis komukoli přes HTTP. Hlídá FrontApiSurfacePolicyTest::testInternalOnlyKeysHaveNoActionCounterpart().

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; na ose read zatím nepodporované a vyhodnotí se přísněji, jako Privileged (test FieldAudienceContextTest hlídá, že read: Owner nikde není)
Privileged privileged = 1
None žádnou — hodnotu určuje výhradně server
Internal jen pro osu read: hodnotu dostane jen serverový kód přes ApiBridge, nikdy externí HTTP — viz níž
  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. Maskovaný telefon nebo zkrácený údaj není skrytí pole, ale odvozená hodnota — patří do vyhrazené akce (vzor :Api:System:User:publicProfile a showPhone).
  4. Publikum nenahrazuje klíč akce. Klíč rozhoduje, jestli se volající k akci dostane; publikum až to, co uvidí v odpovědi. Pole s All na akci jen pro administrátora je pro ostatní pořád nedostupné.

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.

Osy insert a update se na externím save vyhodnocují podle publika volajícího — kdo co smí zapsat a jak pole zamknout proti vlastníkovi, popisují Atributy pro zápis sloupců.

Postup: hodnotu potřebuje jen serverový kód#

Některé hodnoty musí znát web uvnitř serveru, ale nesmí odejít po drátě — ani k administrátorovi s tokenem. Typicky editační heslo inzerátu (front ho ověřuje sám a drží hash v session), aktivační klíč inzerátu nebo readKey reakce (potvrzovací odkazy). None na ose read tu nejde použít: front by hodnotu nedostal a zápis celé entity by ji v DB přepsal prázdnou.

#[FieldAccess(read: Audience::Internal, insert: Audience::All, update: Audience::All)]
protected string $password = '',
  1. Audience::Internal jen na ose read. Hodnotu dostane jen interní volání přes ApiBridge (front, administrace) — rozhoduje transport, který se pozná ze stavu frameworku, nikdy z dat. Na osách insert/update ji FieldAccessCoverageTest nepustí.
  2. Zápisové osy rozhodni zvlášť — u hesla inzerátu smí hodnotu poslat formulář (All), u tokenu jen server (None).
  3. Dnes ji nesou hesla a aktivační klíče inzerátů (Bazaar, Comcat), heslo příspěvku diskuze, aktivační klíč odběru novinek, readKey reakcí, klíč košíku, heslo SMTP a otisky refresh tokenů a API klíčů.

Publikum je i v klíči query cache

Front naplní cache payloadem s Internal poli; kdyby klíč publikum nenesl, externí volající by dostal tentýž payload z cache. Proto nese klíč query cache i složku publika. Totéž platí pro vnořené volání: ApiBridge publikum před voláním uloží a po něm obnoví (snapshot()/restore()), jinak by vnější odpověď odešla bez filtru.

Osobní údaje: #[PersonalData]#

FieldAccess rozhoduje podle role, osobní údaje navíc podle toho, čí záznam to je. Vlastník a privilegovaný volající vidí vše; ostatním je upraví App\Core\Security\PersonalData\PersonalDataPolicy podle nastavení 1/security.personalDataExposure (sanitized = výchozí, full, empty).

#[PersonalDataOwner('insertUserId')]          // na třídě: kdo je vlastník řádku
class Advert
{
    #[PersonalData(PersonalDataKind::Email)]   // maska pet***@p***.cz
    protected ?string $email = null;

    #[PersonalData(PersonalDataKind::Hidden)]  // cizímu prázdné / vypuštěné
    protected ?string $insertIp = null;
}
Druh V režimu sanitized V režimu empty
Email zamaskovaný prázdný řetězec
Hidden, textové pole prázdný řetězec prázdný řetězec
Hidden, netextové pole (credits, discount) klíč se z řádku vypustí klíč se vypustí
  1. Politika běží až na hotovém payloadu — v BasePresenter::sendJson() a v ApiBridge, ne v hydrátoru. Kdyby maskoval hydrátor, query cache by zafixovala výsledek prvního volajícího (vlastník by nacachoval své celé údaje pro všechny).
  2. Prochází celý payload: řádky, každé _references i vložené kolekce.
  3. Vlastníka určí #[PersonalDataOwner] — pole řádku, případně přes rodiče (#[PersonalDataOwner('advert', via: 'insertUserId')] u parametru inzerátu).
  4. Netextové skryté pole se vypouští, protože '' by konzument nacpal do int setteru. Chybějící klíč znamená „ponech výchozí“.
  5. Zápis: neprivilegovaný UPDATE cizího záznamu osobní údaje z payloadu vyřadí (stripForeignUpdate()) — jinak by front, který dostal masku, uložil masku zpět do DB.

Přihlašovací toky a držitel hesla inzerátu jsou výjimky

Odpověď přihlášení nese účet volajícího, ale publikum v tu chvíli ještě nezná jeho id (isPersonalDataPolicyExempt()). Kdo prokázal heslo k inzerátu, dostane údaje toho jednoho inzerátu nezamaskované (exemptPersonalDataForCapabilityHolder()).

Označené pole nejde použít ve filtru ani v řazení

QueryPathPolicy (níž) zamítá cesty na #[PersonalData] neprivilegovanému volajícímu. Proto třeba město uživatele označené není — hledá se podle něj.

Cizí uživatel na frontu: veřejný profil#

Front, který ukazuje někoho jiného než přihlášeného (profil, autor komentáře, recenze, diskuze), nesmí sahat na :Api:System:User:get, getAll ani getBy. Ty jsou jen pro administrátora a vrací celou entitu včetně password, salt, email a ip. Pro hosta v režimu enforce vrátí prázdno a šablona pak dereferencuje null (500 uvnitř stránky).

Potřebuji Front UserManager Api klíč Vrací
jeden profil findPublicProfile($id) :Api:System:User:publicProfile PublicProfile nebo null
profily autorů výpisu findPublicProfiles($ids) tentýž klíč, parametr ids array<userId, PublicProfile>; kdo nepřišel, v mapě chybí
telefon na kliknutí revealPhone($id) :Api:System:User:showPhone (guard + limit na IP) číslo, nebo '' při zamítnutí

Odpověď publicProfile je pozitivní allowlist (UserPresenter::PUBLIC_PROFILE_FIELDS) a mapper ji ručně převádí na PublicProfile, ne na User. Ta třída nemá gettery na e-mail, telefon, adresu ani heslo, takže {$profile->getEmail()} spadne hned při vývoji, místo aby tiše vypsalo prázdno. getEmailMasked() a getPhoneMasked() nesou hotové řetězce z Api (e-mail podle 1/security.personalDataExposure); v šabloně je znovu nemaskuj.

{var $author = $users[$userId] ?? null}
{if $author}{$author->getUsername()}{/if}

Postup: zobrazuji údaje cizího uživatele

  1. Ve výpisu posbírej userId a zavolej jednou findPublicProfiles(), ne find() v cyklu.
  2. V šabloně počítej s chybějícím klíčem (?? null): smazaný účet nebo zamítnutí v mapě není.
  3. Údaj, který v PublicProfile chybí, přidej do PUBLIC_PROFILE_FIELDS, buildPublicProfile(), mapperu i DTO. Raw osobní údaj tam nepatří, jen odvozená hodnota.
  4. Plný telefon ber jen v AJAX signálu (#[Requires(sameOrigin: true, ajax: true)]) přes revealPhone() a prázdný výsledek nech u maskované hodnoty.

Nezjednodušuj zpátky na find($id) nebo findAll(InFilter)

Na veřejné stránce to vrátí prázdno a pád šablony, a kdyby někdo kvůli tomu dal hostovi grant na get, vydá osobní údaje všech účtů. Nehydratuj profil do User: hydrátor by chybějícím polím dal výchozí hodnoty a rozdíl „nemá telefon“ × „telefon jsme nepřenesli“ by nebyl vidět.

Plný telefon nepatří do render*()

Kdyby se revealPhone() volalo při vykreslení, číslo by bylo v HTML každého profilu a šlo by posbírat bez guardu i limitu. Výsledek signálu v presenteru nastavuj přes ??= v render*(), jinak ho běžné přiřazení v témže požadavku přepíše prázdnem.

Čtecí filtr presenteru: filterReadPayload()#

Když tvar odpovědi závisí na volajícím a atribut na poli to nevyjádří (celé řádky, ne sloupce), přepiš v Api presenteru hook filterReadPayload(array $payload, string $action). Výchozí je identita; volá se z get, getBy, getAll a search s názvem akce.

Presenter Co filtr dělá
UserPresenter vlastník přes owner grant nedostane password, salt, activationKey
CustomAssetPresenter, PageSectionPresenter neprivilegovaný volající dostane jen aktivní a nesmazané záznamy bez interních polí (InactiveRecordsReadFilterTrait)

Postup: veřejné čtení nemá vracet koncepty#

  1. Do Api presenteru use InactiveRecordsReadFilterTrait.
  2. Dodej inactiveFilterEntityClass() (Api entita presenteru) a inactiveFilterHiddenFields() (pole, která neprivilegovaný nedostane ani u živého záznamu).
  3. V filterReadPayload() vrať $this->filterInactiveRecords($payload, $action).
  4. Ověř veřejné get-all bez přihlášení a totéž z administrace.

Filtr běží až za query cache, ne v ní

Výsledek závisí na volajícím. Kdyby se filtrovalo uvnitř cache, první volající by tvar odpovědi zafixoval pro všechny ostatní.

Filtry, řazení a sloupce: QueryPathPolicy#

Filtr je stejně dobrý kanál jako odpověď: like po znacích nad user.password zrekonstruuje hash, řazení podle skrytého pole prozradí pořadí. App\Core\Security\Rbac\QueryPathPolicy proto pouští v filters (i vnořených skupinách), orderBys a columns jen segmenty, které jsou mapované pole nebo asociace a které by volající dostal i ve výstupu.

Volající Chování
neprivilegovaný segment s FieldAccess(read: …), který mu publikum nepovolí, nebo s #[PersonalData] → zamítnuto
neznámé pole, externí cesta zamítnuto (jinak by skončilo výjimkou Doctrine = 500)
neznámé pole, interní cesta projde jako dřív
privilegovaný, cron, CLI bez kontroly
  1. Volá se hned za guardem přes BasePresenter::rejectForbiddenQueryPaths() v akcích getAll, getBy, search, deleteAll a ve vlastních výpisech (myList, myOne).
  2. Externí odpověď je HTTP 400 s kódem VALIDATION_FAILED a zprávou Field path "…" is not allowed in filters, orderBys or columns.
  3. Hotové objekty Filter se nekontrolují. Z drátu přijde vždy jen pole; objekt staví serverový kód, který pole volí sám (front hledá inzerát podle activationKey).
  4. columns zvenčí smí být jen holé jméno pole kořenové entity — u get-all se navíc zvenčí nepodporují vůbec.

Nová čtecí akce s filtry od volajícího

Zavolej rejectForbiddenQueryPaths($filters, $orderBys, $columns) hned za requireApiPermission() a vrať jeho výsledek, není-li null. Vzor je GetAllTrait.

Postup: přidat nové tajné pole#

Pole, které smí nastavit jen server (heslo a sůl uživatele, token konverzace, token zrušení rezervace), se označí Audience::None na všech třech osách:

#[FieldAccess(read: Audience::None, insert: Audience::None, update: Audience::None)]
protected ?string $accessToken = null,
  1. Označ pole atributem výš. Hodnotu pak nastavuje výhradně Api manager na entitě ($entity->setAccessToken(…) v beforeSave()), případně se předává přes $meta (vzor hesla: formulář pošle $meta['password'] a UserManager::beforeSave() ho zahashuje).
  2. Nečti pole z payloadu. SaveTrait Api presenteru volá OrmEntityHydrator::stripNobodyWritableFields() hned na začátku — klíč s tímhle jménem se z payloadu odstraní na každé cestě, externí i interní (admin, web, bridge). Při zakládání rozhoduje osa insert, při úpravě osa update (zakládání = v payloadu chybí id).
  3. Nespoléhej na čtení. read: None znamená, že entita načtená přes Api nese výchozí hodnotu ('', null), ne uloženou. Každý kód, který entitu načte, upraví a pošle zpět celou, by jinak uložený tajný údaj přepsal prázdným.
  4. Doplň test, že pole z payloadu zmizí — vzor tests/Unit/Hydrators/OrmHydratorNobodyWritableFieldsTest.php (heslo, sůl, tokeny; i s jinou velikostí písmen a v snake_case).

Proč to vzniklo: uložení uživatele v administraci mazalo heslo

Čtení heslo nevydává (read: None), takže entita uživatele v administraci nesla výchozí password = ''. Formulář ji při uložení poslal zpět celou a Api tím uložené heslo přepsalo prázdným řetězcem — účet se pak nedal přihlásit. Pole s Audience::None se proto z payloadu neberou nikdy. Změna hesla přes formulář funguje dál, protože jde mimo sloupec přes $meta['password'].

Filtr platí jen pro kořen payloadu a jen přes Api presenter

Vnořené grafy tato pole nenesou a odstraňují se jen klíče na nejvyšší úrovni (porovnání nerozlišuje velikost písmen a podtržítka, takže access_token i accessToken padnou stejně). Zápis přímo z kódu manageru ($entity->setPassword(…)) filtr obchází — a tak to má být: tudy server tajné pole nastavuje.

Osy se volí pro každé pole zvlášť

Stav zrušení účtu (deletionRequestedAt, deletedAt) je read: Privileged, insert: None, update: None: administrace stav vidí, ale z payloadu ho nezapíše nikdo — mění ho jen manager. Hodnota, kterou má administrátor i měnit, None na ose update nedostane.

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ý; rozliší je pole key_exists=no (překlep v literálu, zapomenutý seed): grep 'API_RBAC.*key_exists=no' auth.log. Na interní cestě nese řádek i caller= — první soubor modulu (repository, presenter, komponenta), který o data požádal; Api vrstva a ApiBridge se přeskakují.
  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. Na vnější cestě ukáže client= id klienta a roles= jeho povolené role — chybí-li v nich role, která by akci povolila, zamítl to strop rolí.
  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.

Řádek API_COLTIER není zamítnutí

Je to měření, nic nefiltruje. Při internalMode log nebo enforce zapíše interní save a updateColumns neprivilegovaného volajícího, která pole by mu ořez podle role vzal, kdyby platil i na interní cestě (would_strip=… u save, would_drop=… u updateColumns; - = nic). Privilegovaný volající řádek nemá; stejný řádek se v jednom požadavku zapíše jen jednou.

Zápis sloupců#

Zvláštní případ — kdo smí přepsat konkrétní sloupec zvenčí — řeší atributy pro zápis sloupců.

Role uživatele: kdo je smí měnit#

Množinu rolí účtu posílá formulář v meta.roleIds akce :Api:System:User:save. Než se uloží, rozhodne App\UI\Api\System\Models\Helpers\UserRolePolicy (čistá funkce, data dodá UserManager). Právo upravit ostatní pole účtu ověřila už brána akce save; role mají vlastní klíč :Api:System:User:setRoles.

Pravidlo Výsledek
volající bez setRoles posílá jinou množinu, než účet má (přidání i odebrání) USER_ROLE_ASSIGNMENT_DENIED s roleIds
stejná množina nebo meta.roleIds chybí projde i bez setRoles
role s assignable = 0 (guest, owner, receiver) nebo neznámé id nepřidělí ji nikdo, ani admin, ani interní kód; odebrat chybnou vazbu jde (se setRoles)
zakládání účtu výchozí bod jsou role se set_after_registration = 1; bez setRoles smí přijít jen ty, nebo nic
manager volaný bez volajícího (cron, CLI; FieldAudienceContext nevyhodnocený) setRoles se nekontroluje, assignable ano

Role s protect_last_holder = 1 (dnes admin) nesmí přijít o posledního aktivního držitele. Aktivní = aktivovaný, nezabanovaný, nezrušený účet bez čekající žádosti o zrušení (počítá UserRelationManager). Platí pro každého volajícího včetně interního kódu:

Pokus Výsledek
odebrat roli poslednímu aktivnímu držiteli USER_ROLE_LAST_HOLDER_BLOCKED
deaktivovat, zabanovat nebo zrušit jeho účet (save i hromadně updateColumns) USER_DEACTIVATE_LAST_HOLDER_BLOCKED
smazat posledního aktivního držitele (i hromadně, kdy by zmizeli všichni) USER_DELETE_LAST_HOLDER_BLOCKED
smazat vlastní účet volajícího USER_DELETE_SELF_BLOCKED

Postup: nová role

  1. Řádek v cms_system_user_roles migrací. assignable = 1 jen u role, kterou má člověk přidělovat ručně; implicitní a podmíněné role (implicit, conditional) mají 0.
  2. set_after_registration = 1 jen u role, kterou má dostat každý nový účet.
  3. protect_last_holder = 1 jen u role, bez které se do administrace nikdo nedostane.
  4. Ověř v administraci uživatelů: účet bez setRoles roli nezmění, s ním ano.

setRoles dej jen roli, která smí rozdávat admina

Kdo má setRoles, přidělí libovolnou roli s assignable = 1 včetně admin, i sám sobě. Migrace ho dává jen roli admin; jiná role s tímto klíčem je cesta k plným právům.

Prázdné roleIds při zakládání není „bez rolí“

Bez setRoles se [] odmítne, protože by zahodilo výchozí role nového účtu. Formulář, který pole rolí nemá, nemá posílat ani meta.roleIds.

Testy#

tests/Unit/Security/ — dnes 76 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é internalAction*()
FieldAccessCoverageTest že pole, jehož jméno vypadá jako tajemství, má buď #[FieldAccess], nebo řádek v seznamu výjimek s důvodem; širší síto (…Key, ip, …Ip, token, hash) ve všech sdílených entitách vyžaduje omezené čtecí publikum nebo #[PersonalData]; Internal jen na ose read
OwnerWritableFieldsTest co vlastník zapíše externím save — proti allowlistu, v obou směrech
QueryPathPolicyTest že filtr, řazení ani sloupec nesáhne na skryté pole nebo osobní údaj
PersonalDataPolicyTest maskování a vypouštění osobních údajů podle vlastníka a režimu
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 internalAction*() 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
výpis jen mých záznamů app/Core/Traits/Api/Presenters/{OwnedListTrait,OwnedOneTrait}.php
podmíněné role a matice app/Core/Security/Rbac/ConditionalRoleSupport.php + Podmíněné role
publikum polí app/Core/Security/Rbac/FieldAudienceContext.php, app/Core/Attributes/Hydration/{FieldAccess,Audience}.php
osobní údaje app/Core/Security/PersonalData/PersonalDataPolicy.php, app/Core/Attributes/Hydration/{PersonalData,PersonalDataOwner,PersonalDataKind}.php
cesty ve filtrech app/Core/Security/Rbac/QueryPathPolicy.php
atributy akcí app/Core/Attributes/{PublicAction,NoClientKey}.php
klíč aplikace a strop rolí app/Core/Traits/Api/Presenters/ApiClientKeyTrait.php, app/Core/Security/ApiClient/
testy tests/Unit/Security/

Navazující kapitoly: Autentizace a autorizace · Podmíněné role · Odkaz s klíčem · CORS, rate limit a audit · Atributy pro zápis sloupců · Volání API

Nastavení cookies