Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Bezpečnost / Podmíněné role

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#

  1. PermissionResolver::decide() vrátí ALLOW, ALLOW_IF_OWNER, nebo DENY. Nepodmíněný grant vyhrává i tehdy, když vedle něj existuje podmíněný.
  2. Při ALLOW_IF_OWNER zavolá brána PermissionResolver::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.
  3. Role bez kontroly v $checks neprojde nikdy — neznámá podmíněná role nepovolí nic.
  4. Kontroly jsou líné closure: spustí se jen ta, jejíž role klíč povoluje, takže owner nezaplatí 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í trait UserAssociationOwnerTrait, 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ázvem receiver_id nemá žádná tabulka; jednotný je název funkce, ne sloupce.
  • null znamená „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
  1. Trait dej na konkrétní presenter, nikdy na Api\BasePresenter. Tam už verifyOwnership() deklaruje RbacCheckTrait a dva traity se stejnou metodou na jedné třídě jsou fatální chyba PHP; na potomkovi metodu předka legitimně přebije.
  2. Rozhodni dotazem v Api manageru (isOwnedBy($id, $userId), canUserWrite(…)), ne SQL v presenteru.
  3. isOwner() nesmí volat helpery. Všechny jdou přes getRequestParameters() a na interní cestě getRequest() vyhodí Nette\InvalidStateException, protože Presenter::run() neproběhl. Výjimka skončí v guardu jako zamítnutí.
  4. Zapiš presenter do OwnershipFallbackCoverageTest::FALLBACK_REVIEWED s 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#

  1. 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 presenteru isReceiver() i verifyReceiver(), rozhodni dotazem přes manager a respektuj pravidla níž.
  2. Napiš grant do databáze. Klíč :Api:<Modul>:<Entita>:<akce> má pro roli receiver allowed = 1; pro guest a registrated ho vypni, jinak podmínka nikdy nezabere (nepodmíněný grant vyhrává). Roli receiver musí mít řádek ve vazbě na každý klíč (allowed = 0 je výchozí). Vzor: sql/2026-10-02_12_response_receiver_role.sql.
  3. Akci pro externího klienta dej do action*(); interní akci (jen pro web) nech bez action*() — pak ji zvenčí nikdo nezavolá. Obojí hlídá FrontApiSurfacePolicyTest::testInternalOnlyKeysHaveNoActionCounterpart().
  4. Ověř podporu v matici. ConditionalRoleSupport::supports($role, $klíč) musí vrátit true, jinak administrace roli u řádku nenabídne (viz níž).
  5. Napiš test jako kontrakt, ne z implementace: příjemce ✓, odesílatel ✗, cizí ✗, anonym ✗, záznam bez příjemce ✗; vzor ResponseReceiverTest a ConditionalRoleGateTest.
  6. 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

Nastavení cookies