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#
- Napiš
internalAction<Akce>(), tělo obal dooperation(__FUNCTION__, …)a hned na jeho 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 17. - 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 dotests/Unit/Security/ApiClientKeyExemptionTest.php. - 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', []).
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#
- Napiš metodu jako
protecteda nepřidávej k níaction*(); bez ní ji Nette zvenčí odmítne 404. - 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.
- 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#
- Založ klíč
…:insert(případně…:update) a přiřaď ho rolím — vzorSELECT `key` FROM cms_system_user_role_privileges WHERE `key` LIKE '%:insert';. - Klíč
…:saveponech: zůstává pojistkou pro druhou větev a pro dobu mezi nasazením kódu a spuštěním SQL. - Ověř externí
savebezidi sidv řádkuAPI_RBAC(polekey).
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;
});
}
- Guard na první řádek uvnitř
operation()ve stejném tvaru jako v traitu:refineSaveAction()zachová zpřesnění nainsert/update,ownedEntityLoader()vlastníka aownedDatapayload pro zakládání pod rodičem. - 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*(). - 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:
- Do Api presenteru přidej
use OwnedListTrait;(případněOwnedOneTrait). - Založ klíč
:Api:<Modul>:<Entita>:myLists grantemregistrated = 1. Je to nepodmíněný grant — vlastnictví drží filtr, ne kontrolaisOwner(). - Front volá přes bridge
<endpoint>/my-list(my-one) místoget-all(get-by). - Ověř, že externí
GET …/my-listvrací 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:
- Napiš metodu
internalAction<Akce>()bezaction*(), aby akce zvenčí vracela 404. - Tělo obal do
operation(__FUNCTION__, …)a na jeho první řádek dejrequireApiPermission('<akce>'). userIdber jen zgetJwtPayload(). Bez identity (userId <= 0) vraťPERMISSION_DENIEDbez dotazu do DB.- Serverový filtr vlastníka přidej k filtru volajícího, ať jde rozsah jen zúžit.
- Založ klíč
:Api:<Modul>:<Entita>:<akce>s grantemregistrated = 1(nepodmíněný, vlastnictví drží akce sama). Obecnésave,delete,deleteAllagetBynech beze změny. - 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:
- privilegovaná role (
PermissionResolver::isGrantedByPrivilegedRole(), mapa v paměti, bez dotazu), - vlastník:
insertUserIdinzerátu načteného z DB se rovnájwt.userId(anonymní inzerát sinsertUserId0 nepatří nikomu), - 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íž |
- 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. Maskovaný telefon nebo zkrácený údaj není skrytí pole, ale odvozená hodnota — patří do vyhrazené akce (vzor:Api:System:User:publicProfileashowPhone). - Publikum nenahrazuje klíč akce. Klíč rozhoduje, jestli se volající k akci dostane;
publikum až to, co uvidí v odpovědi. Pole s
Allna 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 = '',
Audience::Internaljen na oseread. Hodnotu dostane jen interní volání přesApiBridge(front, administrace) — rozhoduje transport, který se pozná ze stavu frameworku, nikdy z dat. Na osáchinsert/updatejiFieldAccessCoverageTestnepustí.- Zápisové osy rozhodni zvlášť — u hesla inzerátu smí hodnotu poslat formulář
(
All), u tokenu jen server (None). - Dnes ji nesou hesla a aktivační klíče inzerátů (Bazaar, Comcat), heslo příspěvku
diskuze, aktivační klíč odběru novinek,
readKeyreakcí, 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í |
- Politika běží až na hotovém payloadu — v
BasePresenter::sendJson()a vApiBridge, 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). - Prochází celý payload: řádky, každé
_referencesi vložené kolekce. - Vlastníka určí
#[PersonalDataOwner]— pole řádku, případně přes rodiče (#[PersonalDataOwner('advert', via: 'insertUserId')]u parametru inzerátu). - Netextové skryté pole se vypouští, protože
''by konzument nacpal dointsetteru. Chybějící klíč znamená „ponech výchozí“. - 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.
Postup: zobrazuji údaje cizího uživatele
- Ve výpisu posbírej
userIda zavolej jednoufindPublicProfiles(), nefind()v cyklu. - V šabloně počítej s chybějícím klíčem (
?? null): smazaný účet nebo zamítnutí v mapě není. - Údaj, který v
PublicProfilechybí, přidej doPUBLIC_PROFILE_FIELDS,buildPublicProfile(), mapperu i DTO. Raw osobní údaj tam nepatří, jen odvozená hodnota. - Plný telefon ber jen v AJAX signálu (
#[Requires(sameOrigin: true, ajax: true)]) přesrevealPhone()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#
- Do Api presenteru
use InactiveRecordsReadFilterTrait. - Dodej
inactiveFilterEntityClass()(Api entita presenteru) ainactiveFilterHiddenFields()(pole, která neprivilegovaný nedostane ani u živého záznamu). - V
filterReadPayload()vrať$this->filterInactiveRecords($payload, $action). - Ověř veřejné
get-allbez 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 |
- Volá se hned za guardem přes
BasePresenter::rejectForbiddenQueryPaths()v akcíchgetAll,getBy,search,deleteAlla ve vlastních výpisech (myList,myOne). - Externí odpověď je HTTP 400 s kódem
VALIDATION_FAILEDa zprávouField path "…" is not allowed in filters, orderBys or columns. - Hotové objekty
Filterse nekontrolují. Z drátu přijde vždy jen pole; objekt staví serverový kód, který pole volí sám (front hledá inzerát podleactivationKey). columnszvenčí smí být jen holé jméno pole kořenové entity — uget-allse 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,
- Označ pole atributem výš. Hodnotu pak nastavuje výhradně Api manager na entitě
(
$entity->setAccessToken(…)vbeforeSave()), případně se předává přes$meta(vzor hesla: formulář pošle$meta['password']aUserManager::beforeSave()ho zahashuje). - Nečti pole z payloadu.
SaveTraitApi 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 osainsert, při úpravě osaupdate(zakládání = v payloadu chybíid). - Nespoléhej na čtení.
read: Noneznamená, ž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. - Doplň test, že pole z payloadu zmizí — vzor
tests/Unit/Hydrators/OrmHydratorNobodyWritableFieldsTest.php(heslo, sůl, tokeny; i s jinou velikostí písmen a vsnake_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č#
- 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ý; 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 icaller=— první soubor modulu (repository, presenter, komponenta), který o data požádal; Api vrstva aApiBridgese přeskakují. - Zkontroluj tvar klíče proti tabulce výš — Api větev má jiné akce než admin.
- Podívej se na
path.internalznamená, že rozhodujeinternalMode, nemode. Na vnější cestě ukážeclient=id klienta aroles=jeho povolené role — chybí-li v nich role, která by akci povolila, zamítl to strop rolí. - 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.
Řá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
- Řádek v
cms_system_user_rolesmigrací.assignable = 1jen u role, kterou má člověk přidělovat ručně; implicitní a podmíněné role (implicit,conditional) mají0. set_after_registration = 1jen u role, kterou má dostat každý nový účet.protect_last_holder = 1jen u role, bez které se do administrace nikdo nedostane.- Ověř v administraci uživatelů: účet bez
setRolesroli 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