Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Atributy sloupců

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í:

  1. stripNobodyWritableFields() — pole s Audience::None (každá cesta),
  2. stripExternalReadonlyFields() — #[ExternalReadonly] (jen externí),
  3. stripExternalAdminOnlyFields() — #[ExternalAdminOnly], přeskočí ho privilegovaný volající,
  4. stripFieldsOutsideWriteAudience() — osy insert/update atributu #[FieldAccess],
  5. applyExternalOwner() a applyExternalRemoteIp() — 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#

  1. 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) je insert: All, update: Privileged — při zakládání ji klient poslat musí, přesunout záznam nesmí.
  2. 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,
  1. Uprav allowlist v tests/Unit/Security/OwnerWritableFieldsTest.php (EXPECTED_OWNER_WRITABLE). Test pro každou entitu s owner grantem na save spočí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.
  2. Ověř živě externím save s 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#

  1. Přidej klíč do $graphDataKeys manageru (vzor options, image, imageDelete u Parameter).
  2. Sync hook (beforeSave/afterSave) ho přečte z $meta.
  3. 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 (vzor CategoryRelsPaidSanitizationTrait u inzerátu).
  4. Pošli externí save s klíčem v data a 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#

  1. Oprávnění updateColumns — projde-li, atributy se vůbec nečtou.
  2. Teprve při zamítnutí se prochází sloupec po sloupci a hledá se atribut.
  3. Stačí jeden sloupec bez atributu a padá celé volání. Žádný částečný zápis.
Permission denied: column "banned" is not updatable without the updateColumns privilege

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.“

#[ConditionallyUpdatable(check: 'checkPermissionForRead')]
protected bool $read = false;

A na Api presenteru:

protected function checkPermissionForRead(int $id, mixed $value, ?int $userId): bool
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čí#

  1. 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í.
  2. Rozhodni podle tabulky níž, který atribut to je.
  3. Napiš ho na property ve sdílené base entitě — aby platil pro všechny sekce.
  4. U podmíněné varianty napiš metodu na Api presenteru a rozhodni v ní dotazem do databáze.
  5. Ověř obojí — že povolený zápis projde a že zamítnutý skutečně padá.
  6. 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

  1. Přidej sloupec s #[ExternalReadonly] — počítadlo nesmí přijít v save.
  2. 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že parent:: míří na BasePresenter, kde není.
  3. 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

Nastavení cookies