Podmíněné role: vlastník a příjemce#
Role, které nejde přidělit uživateli, protože platí jen nad jedním záznamem:
owner („záznam je můj") a receiver („záznam přišel mně"). Navazuje na
RBAC v API; tahle kapitola popisuje, jak se druhá role rozhoduje a jak ji
zapojit do nové oblasti. Pro provozovatele je totéž v
Role a oprávnění.
Co to je#
Obě role mají v cms_system_user_roles příznak conditional = 1. Grant podmíněné role
sám nic nepovolí — jen vrátí ALLOW_IF_OWNER a o výsledku rozhodne kontrola presenteru
té role:
| Role | Otázka | Externí cesta | Interní cesta |
|---|---|---|---|
owner |
je volající tvůrce / odesílatel záznamu? | verifyOwnership(int $userId) |
isOwner(?object $entity, int $userId, array $data) |
receiver |
je volající tím, komu záznam přišel? | verifyReceiver(?int $userId) |
isReceiver(?object $entity, ?int $userId, array $data) |
Příjemce smí být null
U receiver je $userId nepovinné: příjemcem může být i anonymní editor s heslem k
inzerátu (capability), nebo držitel klíče z odkazu v e-mailu. U owner bez identity
není co vlastnit.
Jak se rozhoduje#
PermissionResolver::decide()vrátíALLOW,ALLOW_IF_OWNER, neboDENY. Nepodmíněný grant vyhrává i tehdy, když vedle něj existuje podmíněný.- Při
ALLOW_IF_OWNERzavolá bránaPermissionResolver::conditionalGrantHolds($userId, $key, $checks). Pro každou podmíněnou roli, která klíč povoluje (conditional = 1,allowed = 1) spustí její kontrolu z mapy$checks(klíč = kód role). Stačí, aby prošla jedna. - Role bez kontroly v
$checksneprojde nikdy — neznámá podmíněná role nepovolí nic. - Kontroly jsou líné closure: spustí se jen ta, jejíž role klíč povoluje, takže
ownernezaplatí za dotaz příjemce a naopak. Výjimka z kontroly se z resolveru vrací volajícímu a obě brány ji odchytí jako zamítnutí.
Kódy rolí jsou konstanty PermissionResolver::ROLE_OWNER a ROLE_RECEIVER. Brána pro
interní cestu je ApiPermissionGuardTrait::requireApiPermission(), pro externí
RbacCheckTrait::checkApiPermission().
isOwner() a isReceiver() se nikdy nesmí změnit na return true
Podmíněný grant bez fungující kontroly by se z podmíněného stal nepodmíněným. Výchozí implementace jsou fail-closed a drží to testy.
Rozhraní entit#
Obecný základ stojí na dvou rozhraních v
app/Core/Base/Shared/Shared/Models/Entities/Interfaces/. Entita, která je implementuje,
si role vyhodnotí bez kódu v presenteru.
| Rozhraní | Metoda | Dnes mají |
|---|---|---|
OwnerAwareInterface |
getOwnerUserId(): ?int |
23 tříd v sdílené základní entitě (Comment, Rating, RatingVote, Response, Advert, Watchdog, Reservation, Cart, Order, Complaint, …) |
ReceiverAwareInterface |
getReceiverUserId(): ?int |
Notification |
getOwnerUserId()jen volá existující getter (getUserId(),getInsertUserId()); sloupce se nepřejmenovávají. Entity s asociací$user(Cart, Order, Complaint) používají traitUserAssociationOwnerTrait, který bere ID z reference bez inicializace líné proxy.getReceiverUserId()nesmí spouštět líné načtení ani dotaz. Příjemce je dnes všude vazba (inzerát → majitel, vlákno → účastník), sloupec s názvemreceiver_idnemá žádná tabulka; jednotný je název funkce, ne sloupce.nullznamená „neznámý nebo anonymní" a kontrola pak vracífalse.
Výchozí kontroly:
| Metoda | Co udělá |
|---|---|
RbacCheckTrait::verifyOwnership() |
ID záznamu z requestu (id, u save data.id; nesouhlasná dvojice = false), manager->find(), projde jen OwnerAwareInterface s kladným shodným getOwnerUserId(); INSERT bez ID, anonym a entita bez rozhraní = false |
RbacCheckTrait::verifyReceiver() |
totéž nad ReceiverAwareInterface, bez klíče z URL |
ApiPermissionGuardTrait::isOwner() / isReceiver() |
fail-closed; entita s rozhraním a shodným kladným ID projde |
OwnershipCheckTrait::isOwner() |
getOwnerUserId(), jinak původní getUserId() |
Všech 24 živých owner klíčů patří 11 presenterům a každý má verifyOwnership() definovanou
ve svém souboru — výchozí kontrola se jich nedotkne (hlídá to test).
Postup: vlastní kontrola vlastníka v presenteru#
Když výchozí kontrola nestačí (vlastník je v jiném sloupci, záznam patří i rodiči), presenter
dostane OwnershipCheckTrait a obě metody přebije zvlášť — každá patří jedné cestě:
| Metoda | Cesta | Odkud ví, o který záznam jde |
|---|---|---|
verifyOwnership(int $userId) |
externí HTTP | z požadavku přes helpery traitu |
isOwner(?object $entity, int $userId, array $data) |
interní (bridge) | entitu dostane hotovou z DB, při zakládání null a payload v $data |
Helper (jen pro verifyOwnership()) |
Vrací |
|---|---|
getOwnedEntityId() |
data.id upravovaného záznamu, null = zakládání |
isOwnershipInsert() |
true, když data.id chybí |
getOwnedRequestId() |
parametr id (mazání, čtení); pole id = null |
getOwnedParentId('order') |
id rodiče z data ve všech tvarech ToOne vazby |
- Trait dej na konkrétní presenter, nikdy na
Api\BasePresenter. Tam užverifyOwnership()deklarujeRbacCheckTraita dva traity se stejnou metodou na jedné třídě jsou fatální chyba PHP; na potomkovi metodu předka legitimně přebije. - Rozhodni dotazem v Api manageru (
isOwnedBy($id, $userId),canUserWrite(…)), ne SQL v presenteru. isOwner()nesmí volat helpery. Všechny jdou přesgetRequestParameters()a na interní cestěgetRequest()vyhodíNette\InvalidStateException, protožePresenter::run()neproběhl. Výjimka skončí v guardu jako zamítnutí.- Zapiš presenter do
OwnershipFallbackCoverageTest::FALLBACK_REVIEWEDs důvodem:fallback= obecnéisOwner()traitu odpovídá správně,custom= presenter má vlastní.
Obecné isOwner() traitu bere vlastníka z getUserId() s hranou > 0
Entita s OwnerAwareInterface dá getOwnerUserId(), jinak se volá getUserId(); entita bez
něj (Advert má insertUserId) projde vždy jako false a presenter musí přebít. Hrana
> 0 je nutná: hlídací pes i sledování inzerátu mají userId jako int s výchozí 0 a
anonymní záznam je povolený, takže bez ní by anonymní záznam „vlastnil“ každý nepřihlášený.
Postup: zapojit příjemce do nové oblasti#
- Rozhodni, kdo je příjemce a kde je ten údaj. Je-li to sloupec nebo asociace entity,
implementuj na entitě
ReceiverAwareInterface(ve sdílené base entitě, ať platí ve všech vrstvách). Není-li, přebij v Api presenteruisReceiver()iverifyReceiver(), rozhodni dotazem přes manager a respektuj pravidla níž. - Napiš grant do databáze. Klíč
:Api:<Modul>:<Entita>:<akce>má pro rolireceiverallowed = 1; proguestaregistratedho vypni, jinak podmínka nikdy nezabere (nepodmíněný grant vyhrává). Rolireceivermusí mít řádek ve vazbě na každý klíč (allowed = 0je výchozí). Vzor:sql/2026-10-02_12_response_receiver_role.sql. - Akci pro externího klienta dej do
action*(); interní akci (jen pro web) nech bezaction*()— pak ji zvenčí nikdo nezavolá. Obojí hlídáFrontApiSurfacePolicyTest::testInternalOnlyKeysHaveNoActionCounterpart(). - Ověř podporu v matici.
ConditionalRoleSupport::supports($role, $klíč)musí vrátittrue, jinak administrace roli u řádku nenabídne (viz níž). - Napiš test jako kontrakt, ne z implementace: příjemce ✓, odesílatel ✗, cizí ✗,
anonym ✗, záznam bez příjemce ✗; vzor
ResponseReceiverTestaConditionalRoleGateTest. - Ověř obě cesty: externí (HTTP s tokenem) i interní (z administrace nebo z webu).
Pravidla#
| Pravidlo | Proč |
|---|---|
| Záznam se čte z DB podle id, nikdy z payloadu | kontrola nad hodnotou, kterou poslal tentýž požadavek, nic neověřuje |
| Identita je z JWT, klíč nebo capability z výslovného parametru volání | userId z požadavku by si volající vybral sám |
receiver nikdy na updateColumns |
grant by pustil zápis libovolného sloupce dřív, než dojde na kontrolu sloupce |
Zápis jednoho sloupce příjemci dej přes #[ConditionallyUpdatable(check: …)] |
volá tutéž kontrolu příjemce a pustí jen ten sloupec |
| Klíč z odkazu jen na interní cestě | read_key se zvenčí nepředává; viz Odkaz s klíčem |
| Odpověď nečlena = odpověď neexistujícího záznamu | akce nesmí být oracle na existenci záznamu |
Co v systému používá receiver#
| Oblast | Jak se příjemce určuje | Grant |
|---|---|---|
Reakce na inzerát (Bazaar, Comcat) — ResponseReceiverTrait |
privilegovaná role ∨ majitel inzerátu (advert.insertUserId z DB) ∨ heslo k inzerátu (capability) ∨ platný read_key (jen interně); odesílatel ne |
Response:get, Response:markReadByKey |
Odpověď na hodnocení (Rating:saveReply) |
majitel hodnoceného profilu (canRespondTo proti DB); Rating:save je owner (autor) |
Rating:saveReply |
Soukromé zprávy — MessageThreadRolesTrait |
role se odvozují z autorů jednotlivých zpráv, viz Soukromé zprávy | myThreadMessages, myThreadReply, myThreadRead, myThreadHide, myThreadParticipants |
| Notifikace | Notification implementuje ReceiverAwareInterface; matice nabízí roli, grant není zapnutý |
— |
Reakce rozhraní neimplementuje schválně: příjemce je advert.insertUserId, který z entity
bez líného načtení spolehlivě dostupný není, takže o něm rozhoduje manager SELECT.
isOwner() a verifyOwnership() u reakce (odesílatel nebo majitel inzerátu) zůstaly beze
změny.
Administrace matice oprávnění#
Matice (skupina privilegií) bere podmíněné role z DB a nabízí je jen tam, kde mají účinek.
| Část | Co dělá |
|---|---|
ConditionalRoleSupport (app/Core/Security/Rbac/) |
reflexí zjistí, zda presenter klíče roli umí vyhodnotit: owner = presenter přebíjí verifyOwnership() ∨ entita je OwnerAwareInterface; receiver = entita je ReceiverAwareInterface ∨ presenter přebíjí isReceiver(). Entita presenteru = konstanta ENTITY_CLASS, jinak App\UI\Api\<Modul>\Models\Entities\<Presenter>. Neznámá podmíněná role se bere jako podporovaná |
OwnershipOverrideChecker |
tenká fasáda nad ConditionalRoleSupport pro administraci |
PrivilegeGroup/edit.latte |
zaškrtávátko podmíněné role je disabled mimo :Api řádky a u :Api řádků bez podpory; už uložený mrtvý grant zůstane zaškrtnutý s varovnou ikonou a jde odškrtnout |
UserRolePrivilegeMatrixManager::saveGroupPrivileges() |
před zápisem odmítne nový grant bez podpory (CONDITIONAL_ROLE_NOT_SUPPORTED:<role>:<klíč>); grant, který v DB už je, tiše projde. Mimo větev :Api se podmíněná role vynutí na 0 u všech podmíněných rolí |
RoleLabelTrait |
jediné místo pro lidský název a popis role: admin.system.rbacRoles.<kód>.{name,description}; role bez překladu (vlastní role provozovatele) zůstanou s názvem z DB |
Zaškrtávátko se kreslí přes getControlPart()
getControl() u Multiplieru vrací bezejmenný obal <label> a atribut disabled se na
něj nikdy nevykreslil. Dřívější „vypnuté" buňky mimo :Api byly proto ve skutečnosti
aktivní a jistil je jen server. Teď se kreslí getControlPart() (samotný <input>).
Podpora se posuzuje po presenterech, ne po akcích
Grant receiver jde zapnout i na akci, kde ho presenter fakticky nepoužije (třeba
Response:getAll). Nic neotevře — kontrola bez záznamu zamítne — jen zbytečně mate.
Nová podmíněná role potřebuje: řádek v cms_system_user_roles (conditional = 1,
assignable = 0), řádky allowed = 0 pro všechny existující klíče, překlady
admin.system.rbacRoles.<kód>, konstantu v PermissionResolver, kontrolu v mapě
conditionalGrantHolds() v obou branách a větev v ConditionalRoleSupport.
Testy#
| Test | Co hlídá |
|---|---|
ConditionalRoleGateTest |
bránu obou rolí; plochu grantů receiver |
ConditionalRoleSupportTest |
reflexi podpory |
ConditionalGrantGuardTest |
server odmítne nový grant bez podpory, existující projde |
DefaultOwnerVerificationTest, OwnerAwareEntityTest |
výchozí verifyOwnership(), shodu getOwnerUserId() s getterem u každé třídy |
ResponseReceiverTest, RatingSaveReplyReceiverGateTest, MessageThreadRolesTest |
domény |
RbacRoleTranslationsTest |
názvy rolí ve třech jazycích |
OwnershipFallbackCoverageTest |
každý presenter s grantem owner má kontrolu |
Kam sáhnout#
| Chci | Kde |
|---|---|
| jádro rozhodnutí | app/Core/Security/Rbac/PermissionResolver.php (conditionalGrantHolds) |
| brány | app/Core/Traits/Api/Presenters/{RbacCheckTrait,ApiPermissionGuardTrait}.php |
| rozhraní entit | app/Core/Base/Shared/Shared/Models/Entities/Interfaces/ |
| reakce | app/Core/Traits/Api/Presenters/ResponseReceiverTrait.php |
| vlákna zpráv | app/Core/Traits/Api/Presenters/MessageThreadRolesTrait.php |
| matice v administraci | app/Core/Security/Rbac/ConditionalRoleSupport.php, UserRolePrivilegeMatrixManager |