Atributy pro zápis sloupců#
Kdo smí zvenčí přepsat konkrétní sloupec. Generické updateColumns je záměrně jen pro
administrátora — front přes něj ale legitimně mění pár počítadel a příznaků. Výjimku
proto nese sloupec sám, ve formě PHP atributu na property entity.
Atributy jsou v app/Core/Attributes/Hydration/, vyhodnocuje je
app/Core/Traits/Api/Presenters/SaveTrait.php.
Dvě generace atributů vedle sebe#
| Atribut | Výskytů (řádově) | Role |
|---|---|---|
#[ExternalReadonly] |
~630 | sloupec nesmí přijít zvenčí (hodnotu určuje server) |
#[ExternalAdminOnly] |
13 | smí ho zapsat jen privilegovaný volající |
#[PubliclyUpdatable] |
5 | přes updateColumns ho smí zapsat kdokoli |
#[ConditionallyUpdatable] |
6 | rozhodne pojmenovaná metoda Api presenteru |
#[FieldAccess] |
~80 | nová, sjednocující — tři osy read / insert / update |
Stará čtveřice se neruší, jen se do ní nepřidává
#[FieldAccess] je cílový tvar a čte se oboje; plošná migrace starých výskytů
přijde až s testem parity. Do nového kódu piš FieldAccess, hotová místa nepřepisuj
kvůli kosmetice. Podrobně o publiku polí v RBAC v API.
Při převodu starého atributu na nový platí tyto ekvivalenty:
| Starý atribut | #[FieldAccess] |
|---|---|
#[ExternalReadonly] |
insert: None, update: None |
#[ExternalAdminOnly] |
insert: Privileged, update: Privileged |
#[PubliclyUpdatable] |
update: All |
#[ConditionallyUpdatable(check: 'x')] |
update: Owner, updateCheck: 'x' |
Ekvivalent platí jen na externí cestě a updateCheck se zatím nečte
Staré atributy běží jen na externím save, nový je založený na rolích a jeho osa
read platí na obou cestách. Poslední řádek tabulky je cílový tvar: dokud zápis
bere Owner přísně jako Privileged, parametr updateCheck nic nevyhodnocuje.
Podmíněný zápis jednoho sloupce proto dál piš přes #[ConditionallyUpdatable].
Kde se která osa vynucuje#
| Osa | Vynucuje se | Kde v kódu |
|---|---|---|
read |
při odeslání odpovědi na obou cestách (externí HTTP i ApiBridge), podle publika, které nastavil guard |
emit hydrátoru + FieldAudienceContext |
insert / update s Audience::None |
v save na každé cestě, i v administraci |
OrmEntityHydrator::stripNobodyWritableFields() |
insert / update s jiným publikem |
jen v externím save (HTTP), podle publika volajícího |
SaveTrait::stripFieldsOutsideWriteAudience() |
update u updateColumn(s) |
nevyhodnocuje se — externí updateColumns je zavřený allowlistem |
— |
Interní cesta zápisové osy (kromě None) zatím nevynucuje
Front formuláře a administrace jdou přes ApiBridge a ořez podle publika se jich
netýká; drží je vlastní formulářová pravidla (front profil má e-mail a přezdívku jen
pro čtení). Rozšíření na interní cestu je plánované až po měření, protože fail-closed
ořez u insert závisí na tom, jestli manager server-počítaná pole opravdu dosadí.
Staré atributy (ExternalReadonly, ExternalAdminOnly) běží také jen při
$meta['__external'], což nastavuje výhradně actionSave().
Zápisové publikum na externím save#
Externí save s podmíněným grantem owner pustí vlastníka k jeho záznamu — a bez
dalšího ořezu by si mohl zapsat cokoli, co má setter: kredity, slevu, členství, e-mail. Ořez
podle os insert/update to zavírá. Běží v SaveTrait::saveEntityData() v tomto pořadí:
stripNobodyWritableFields()— pole sAudience::None(každá cesta),stripExternalReadonlyFields()—#[ExternalReadonly](jen externí),stripExternalAdminOnlyFields()—#[ExternalAdminOnly], přeskočí ho privilegovaný volající,stripFieldsOutsideWriteAudience()— osyinsert/updateatributu#[FieldAccess],applyExternalOwner()aapplyExternalRemoteIp()— vlastník a IP ze serveru.
Ořez je „nech, co je v DB“: u úpravy zůstane uložená hodnota, u vložení výchozí
hodnota entity. Odpověď je úspěšná a ořez se zapíše do auth kanálu jako
EXTERNAL_FIELD_AUDIENCE_STRIPPED presenter=… op=insert|update fields=….
| Publikum na ose zápisu | Kdo smí pole poslat |
|---|---|
All |
kdokoli, kdo prošel guardem akce |
Authenticated |
volající s identitou |
Owner, Privileged |
jen privilegovaný (privileged = 1) — Owner se na zápisu zatím bere přísně jako Privileged |
None |
nikdo, ani administrátor |
Internal |
na zápisových osách se nepoužívá (hlídá FieldAccessCoverageTest) |
Bez nastaveného publika je ořez fail-closed
Když FieldAudienceContext není vyhodnocený, projde jen Audience::All (privilegovanému
tieru z actionSave() i ostatní kromě None). Neznámý stav tedy pole zavře, neotevře.
Privilegovaný je řádek v databázi, ne jméno role
Zamčená pole se otevřou každé roli s privileged = 1 — ale jen té, která má zároveň
právo na akci. Kdo má mít přístup k User:save, řídí matice oprávnění, ne atribut.
Postup: zamknout pole proti vlastníkovi#
- Rozhodni osy. Smí se pole poslat při zakládání (
insert)? Smí ho vlastník změnit (update)? Typicky vazba na rodiče (threadId,advertId) jeinsert: All, update: Privileged— při zakládání ji klient poslat musí, přesunout záznam nesmí. - Napiš atribut na property ve sdílené base entitě. Leaf entita, která property redeklaruje, ho musí nést sama — platí první deklarace odspodu hierarchie.
#[FieldAccess(read: Audience::All, insert: Audience::Privileged, update: Audience::Privileged)]
protected int $credits = 0,
- Uprav allowlist v
tests/Unit/Security/OwnerWritableFieldsTest.php(EXPECTED_OWNER_WRITABLE). Test pro každou entitu s owner grantem nasavespočítá, co vlastník po všech ořezech zapíše, a padá v obou směrech: při novém zapisovatelném poli i při zastaralém allowlistu. - Ověř živě externím
saves tokenem vlastníka a pak řádek v DB: zamčené pole se nezmění, odpověď je úspěšná.
Nový sloupec se setterem je pro vlastníka zapisovatelný
Pole bez atributu se chová jako all/all/all. Strážní test na to upozorní, ale
rozhodnutí, jestli má být zamčené, je tvoje.
Klíče mimo sloupce na externím save#
Atributy hlídají sloupce entity. Co jde mimo objektový graf (meta, vnořené kolekce
synchronizované hookem, vlastník a IP), řídí Api manager přes nepovinné protected
property a dvě metody. Kromě $defaultSkipFields (platí na obou cestách) se uplatní jen na
externím HTTP; interní cesta (ApiBridge, admin a front) posílá meta sama:
| Na manageru | Výchozí | Co dělá na externí cestě |
|---|---|---|
$externalMetaAllowlist |
[] |
z meta projdou jen vyjmenované klíče, zbytek se zahodí (log EXTERNAL_META_STRIPPED) |
$externalUpdateColumnsAllowlist |
[] |
updateColumns smí jen vyjmenované sloupce; prázdné = zavřeno (log EXTERNAL_UPDATE_COLUMNS_STRIPPED) |
$graphDataKeys |
[] |
klíče, které klient smí poslat vnořené v data; před hydratací se přesunou do meta, kde je přečte sync hook manageru. Pošle-li klient týž klíč i v meta, vyhrává meta |
$defaultSkipFields |
[] |
na obou cestách: klíče, které generický save() nekopíruje na entitu (spravuje je hook přes meta); ostříhají se z data před hydratací, jinak by je graf zapsal podruhé |
getExternalOwnerData(int $userId) |
[] |
při vložení dosadí vlastníka z JWT |
getExternalRemoteIpData(string $ip) |
[] |
při vložení dosadí IP z IRequest::getRemoteAddress() |
Žádný manager dnes oba allowlisty nepřepisuje, takže externí meta se zahazuje celé
a externí updateColumns je zavřený.
Klíč vrácený z getExternalOwnerData() musí odpovídat mapování sloupce, ne tomu, co
vypadá hezčí:
| Kdo sloupec vlastní | Návrat | Příklad |
|---|---|---|
| skalár | ['userId' => $userId] |
Watchdog, Comment, Rating, Message, Comcat Response |
| mapovaný sloupec s jiným jménem | ['insertUserId' => $userId] |
Advert (inzerce, katalog) |
asociace (skalár má insertable: false) |
['user' => ['id' => $userId]] |
Bazaar Response, AdvertFollower, Complaint |
Postup: externí klient má poslat vnořenou kolekci#
- Přidej klíč do
$graphDataKeysmanageru (vzoroptions,image,imageDeleteuParameter). - Sync hook (
beforeSave/afterSave) ho přečte z$meta. - Nese-li klíč pole, které jinak určuje server (zaplaceno, schváleno), přepiš ho na externí
cestě v
beforeSave()při$meta['__external'] === true(vzorCategoryRelsPaidSanitizationTraitu inzerátu). - Pošli externí
saves klíčem vdataa ověř v DB, co se zapsalo.
Promoce klíče neznamená, že je jeho obsah bezpečný
$graphDataKeys jen řekne, kudy klíč teče. Bez serverového přepisu citlivého pole si
klient nastaví třeba zaplacený inzerát bez platby.
Špatný klíč vlastníka = vlastník se tiše nezapíše
U asociace se skalár s insertable: false do INSERTu nedostane. Sjednocovat Bazaar
a Comcat Response „kvůli konzistenci“ jeden z modulů rozbije.
Vlastník se dosazuje jen při vložení
Na úpravě by dosazení znamenalo, že kdo uloží cizí záznam, ten ho vlastní.
Pořadí rozhodování u updateColumns#
- Oprávnění
updateColumns— projde-li, atributy se vůbec nečtou. - Teprve při zamítnutí se prochází sloupec po sloupci a hledá se atribut.
- Stačí jeden sloupec bez atributu a padá celé volání. Žádný částečný zápis.
Povolení přes atribut se loguje
Do auth kanálu přibude řádek API_COLATTR. Bez něj by v logu bylo zamítnutí
následované úspěšným zápisem — a log by lhal.
#[PubliclyUpdatable]#
„Tenhle sloupec smí zapsat kdokoli.“
#[ORM\Column(name: 'reported', type: 'boolean')]
#[ExternalReadonly]
#[PubliclyUpdatable]
protected bool $reported = false;
Typický případ je příznak „nahlášeno“ u inzerátu nebo příspěvku.
Povoluje LIBOVOLNOU hodnotu, ne jen rozumnou
Proto se nehodí na počítadla — votesUp by šlo nastavit na milion jedním
požadavkem. Počítadla se zvyšují akcí registerEvent, která dělá serverový
+ 1.
Nekontroluje vlastnictví záznamu
Nezkoumá, komu záznam patří. Když na tom záleží, je to případ pro podmíněnou variantu.
#[ConditionallyUpdatable]#
„Pravidlo pro tenhle sloupec je tamhle.“
A na Api presenteru:
| Parametr | Význam |
|---|---|
$id |
id jednoho záznamu; u hromadného updateColumns se metoda volá pro každé id zvlášť |
$value |
hodnota, která se má zapsat (posoudit ji smíte, obvykle netřeba) |
$userId |
identita z getJwtPayload(), nikdy z požadavku; null = anonym |
Metoda smí být protected — SaveTrait ji volá na témže presenteru.
Dnes to používá příznak „přečteno“ u odpovědi na inzerát a u odpovědi v katalogu firem — tedy případ, kdy vlastník není na tomhle záznamu, ale na jeho rodiči.
Rozhodujte proti databázi, ne proti požadavku
$id říká jen, o kterém záznamu se bavíme. Cokoli dalšího z požadavku je tvrzení
klienta — kontrola nad hodnotou z téhož požadavku neověřuje nic.
Jméno metody se PÍŠE, neodvozuje
Konvence typu read → checkReadPermission() je past: překlep by se neprojevil
chybou, ale tichým zamítnutím s hláškou „nemáš práva“. Existenci metod hlídá
tests/Unit/Security/ConditionallyUpdatableContractTest.php.
Fail-closed: cokoli nejasného je zamítnutí
Jedno false u kteréhokoli id shodí celé volání. Chybějící záznam i výjimka
v kontrole se počítají jako zamítnutí.
Postup: potřebuju pustit zápis jednoho sloupce zvenčí#
- Zeptej se nejdřív, jestli je to opravdu sloupec. Zvýšení počítadla je
registerEvent; zápis s obchodní logikou je vlastní Api akce s vlastním klíčem oprávnění. - Rozhodni podle tabulky níž, který atribut to je.
- Napiš ho na property ve sdílené base entitě — aby platil pro všechny sekce.
- U podmíněné varianty napiš metodu na Api presenteru a rozhodni v ní dotazem do databáze.
- Ověř obojí — že povolený zápis projde a že zamítnutý skutečně padá.
- Zkontroluj log: povolení přes atribut musí být vidět jako
API_COLATTR.
| Situace | Řešení |
|---|---|
| zápis smí kdokoli a hodnota nemá stupně | #[PubliclyUpdatable] |
| zápis smí jen někdo konkrétní | #[ConditionallyUpdatable] |
| počítadlo, které se má zvýšit o 1 | ani jeden — akce registerEvent |
| zápis s vlastní obchodní logikou | ani jeden — vlastní Api akce |
| hodnotu určuje výhradně server | #[ExternalReadonly] |
Atribut na property znamená: kdokoli s přihlášením, kolikrát chce
Sedí na entitě a čte ho hranice, kterou procházejí obě cesty. Označit sloupec není „povolit jednomu formuláři“ — je to plošné povolení.
Property nesmí nést oba atributy naráz
Nebylo by jasné, které pravidlo platí. Hlídá to týž test.
Asociace na tentýž sloupec potřebuje atribut taky
Ořez se řídí jménem property, ne sloupcem. Advert mapuje insert_user_id dvakrát:
skalárem insertUserId ve sdílené entitě a asociací user v Api entitě. Kdyby
#[ExternalReadonly] nesl jen skalár, klient pošle data.user = {"id": X} a přepíše
vlastníka inzerátu na cizí účet. Když přidáváš asociaci ke sloupci, který už má skalár
s atributem, dej jí stejný atribut.
Počítadla: registerEvent#
Akce registerEvent (RegisterEventTrait) dostane ids a jméno pole a udělá serverový
SET <pole> = <pole> + 1. Jméno pole posílá volající, a mapper pod akcí vezme jakékoli pole
entity — allowlist $externalUpdateColumnsAllowlist na tuhle cestu nedosáhne. Proto
internalActionRegisterEvent() pustí jen pole ze seznamu registerEventProperties(); cokoli jiného
vrátí INVALID_VALUE a nic nezapíše.
| Presenter | Povolená počítadla |
|---|---|
| výchozí (trait) | views, viewsDetail, viewsList |
Diskuze MessagePresenter |
navíc votesUp, votesDown |
Postup: nové počítadlo
- Přidej sloupec s
#[ExternalReadonly]— počítadlo nesmí přijít vsave. - V Api presenteru přebij
registerEventProperties()a vrať celý seznam včetně tří výchozích.parent::registerEventProperties()nefunguje: metoda pochází z traitu, takžeparent::míří naBasePresenter, kde není. - Ověř, že povolené pole přičte 1 a nepovolené (
price) vrátíINVALID_VALUE.
Bez allowlistu by šel přičíst jakýkoli sloupec cizího záznamu
Část klíčů registerEvent má grant i pro hosta a registrovaného, protože počítadla zvyšuje
web. Jméno pole z požadavku by jinak dovolilo měnit price, paid, deleted nebo
topedUntil cizího inzerátu.
Allowlist stojí za guardem a platí na obou cestách
Kontrola je v internalActionRegisterEvent(), kudy jde externí HTTP i bridge, ne v
actionRegisterEvent(). Zamítnutý volající se o tvaru seznamu nic nedozví.
Pokrytí hlídají strukturální testy#
| Test | Co kontroluje |
|---|---|
ExternalReadonlyCoverageTest |
že server-počítaná pole mají #[ExternalReadonly] |
ExternalAdminOnlyCoverageTest |
totéž pro admin-only sloupce |
ConditionallyUpdatableContractTest |
že pojmenovaná kontrolní metoda existuje |
FieldAccessCoverageTest |
že pole, jehož jméno vypadá jako tajemství, je anotované nebo má výjimku s důvodem; že Audience::Internal je jen na ose read |
OwnerWritableFieldsTest |
co smí vlastník zapsat externím save u každé entity s owner grantem — proti allowlistu, v obou směrech |
Pokrytí podle jména pole je síto, ne ochrana
Test pozná password nebo secret. Pole s nevinným jménem, které přesto nese
tajemství (meta, config, note), musí anotovat člověk.
Kam sáhnout#
| Chci | Kde |
|---|---|
| definice atributů | app/Core/Attributes/Hydration/ |
| vyhodnocení při zápisu | app/Core/Traits/Api/Presenters/SaveTrait.php |
| ořez polí podle oprávnění | tamtéž → stripExternalReadonlyFields(), stripExternalAdminOnlyFields(), stripFieldsOutsideWriteAudience() |
| publikum volajícího | app/Core/Security/Rbac/FieldAudienceContext.php |
| allowlist počítadel | app/Core/Traits/Api/Presenters/RegisterEventTrait.php → registerEventProperties() |
| testy pokrytí | tests/Unit/Security/ |
Navazující kapitoly: RBAC v API · Hydrátory · Životní cyklus manageru