adt / exporter
Unified data export pipeline: audit log (export_log) + synchronous download or background processing with e-mail delivery.
Requires
- php: >=8.4
- adt/background-queue: ^3.0|^4.0|^5.0
- adt/doctrine-components: ^3.0
- doctrine/orm: ^2.9|^3.0
- nette/application: ^3.1
- nette/mail: ^3.1|^4.0
- symfony/console: ^6.0|^7.0|^8.0
Requires (Dev)
- nette/tester: ^2.5
README
Jednotne hrdlo vsech exportu dat: auditni zaznam (export_log) + synchronni
download nebo background zpracovani s dorucenim e-mailem.
Proc
Auditni pozadavek "kdo, kdy a co presne exportoval" musi platit pro KAZDY export - grid, formular i konzolovy prikaz. Misto per-misto logovani vola vsechno jednu funkci.
Provozni a auditni data jsou ODDELENA (stejny vzor jako session vs. auth log):
- export (provozni): ridi background regeneraci, soubor, doruceni,
download; soubor spravuje
ExportFileStorage(aplikace typicky vlastni File ekosystem); po doruceni a retenci souboru muze zaznam casem zaniknout - export_log (audit): append-only stream udalosti BEZ vazby na soubor -
odvazi ho mover do dlouhodobeho auditniho uloziste. Sloupec
actionrozlisuje typ udalosti:export- zadani exportu, "kdo/kdy/co presne"download- kazdy VYDEJ souboru zvlast; data opousteji system az stazenim, opakovane a klidne nekym jinym nez zadavatelem
Oba zaznamy i pripadny background job vznikaji v JEDNE transakci (outbox garance adt/background-queue).
Pouziti
Format (CSV/Excel/...) urcuje predany generator; pocet sekci urcuje obsah (Excel: sheet per sekce, CSV: prave jedna sekce).
// jednoducha tabulka (grid): $export = $this->exporter->export(new ExportRequest( identifier: 'smart-cards', sections: new ExportSection('items', $queryObject, ['number' => 'Cislo', ...]), generator: $excelGenerator, // vs. $csvGenerator = volba formatu email: $user->getEmail(), )); // vicesheetovy report (ruzne zdroje, vcetne agregatu bez entit): $export = $this->exporter->export(new ExportRequest( identifier: 'order-payments', sections: [ new ExportSection('Items', $orderItemsQb, $itemColumns), new ExportSection('Payments', $paymentsQb, $paymentColumns), new ExportSection('Summary', $summaryRows, $summaryColumns), // pole poli = snapshot primo v auditu ], generator: $excelGenerator, email: $user->getEmail(), )); if (!$export->isInBackground()) { $this->sendResponse(new FileResponse($this->exporter->getFilePath($export), $export->getFileName())); } // jinak flash "export prijde e-mailem"
Instalace v aplikaci
- Entita
Export(ExportTrait + vlastni id/createdBy atributy) v aplikaci;ExportLogje FINALNI entita knihovny - staci pridat mapping (vendor/adt/exporter/src/Model/Entities) a migraci. Volitelne vlastniExportFileStorage(soubory pres aplikacni File ekosystem) aExportActorProvider(akter auditu ze SecurityUser) - obe sluzby si extension najde podle typu. - Neon:
extensions: exporter: ADT\Exporter\DI\ExporterExtension exporter: syncRowLimit: 500 fileDir: %appDir%/../data/exports downloadLink: ':Portal:Export:download' backgroundQueue: callbacks: processExport: [@exporter.exporter, processExport]
- Generatory souboru registrovat jako sluzby (implementuji
ExportFileGenerator, extension je poskytne background handleru automaticky).
Akter auditu
Kazdy projekt identifikuje uzivatele jinak - nekde jmeno a e-mail, jinde jen prihlasovaci jmeno, servisni ucet nebo API klic. Knihovna proto nepredepisuje zadna konkretni pole a uklada:
| sloupec | k cemu |
|---|---|
created_by_id |
klic aktera ve zdrojovem systemu - spojovaci klic auditu (podle nej se v auditnim ulozisti joinuje napric logy) |
created_by_label |
jedno lidsky citelne oznaceni - aby sel log cist bez znalosti tvaru created_by |
created_by |
JSON: cimkoliv dalsim projekt aktera identifikuje |
Ploche jsou tedy jen ty dve veci, ktere maji smysl v kazdem systemu; promenna
cast jde do JSONu. Aplikace dodava ExportActorProvider:
public function getActor(): ?ExportActor { if (!$this->securityUser->isLoggedIn()) { return null; // cron, konzument fronty, CLI } $identity = $this->securityUser->getIdentity(); return new ExportActor( id: $identity->getId(), label: $identity->getName() ?: $identity->getEmail(), data: ['name' => $identity->getName(), 'email' => $identity->getEmail()], ); }
Obsah e-mailu vlastni projekt: implementuj ExportMailFactory jako sluzbu
(preklady, Latte sablona, branding) - extension ji pouzije automaticky misto
vestaveneho defaultu. Background job ji dostane z DI, nic se nepredava.
Bezpecnost stahovani
Odkaz v e-mailu NEVEDE na soubor, ale na aplikacni routu (downloadLink).
Soubor lezi ve fileDir MIMO docroot - jedina cesta k nemu je pres presenter,
ktery MUSI overit prihlaseni a vlastnictvi:
public function actionExport(int $id): void { $export = $this->exportQueryFactory->create()->byId($id)->fetchOneOrNull(); if (!$export || !$export->getFile()) { $this->error(); } // stahnout smi jen autor exportu (pripadne rozsirit o admin ACL) if ($export->getCreatedBy()?->getId() !== $this->securityUser->getId()) { $this->error('', \Nette\Http\IResponse::S403_Forbidden); } $this->exporter->logDownload($export); // az po overeni, pred vydanim $this->sendResponse(new FileResponse($export->getFile()->getPath(), $export->getFileName())); }
Overeni vlastnictvi je PROVOZNI vec, proto FK created_by na aplikacniho
uzivatele zustava na Export - narozdil od auditu, ktery zadnou relaci nema.
Presenter MUSI pred vydanim souboru zavolat $this->exporter->logDownload($export)
(viz priklad vyse). Zadani exportu a vydej souboru jsou dve ruzne udalosti:
odkaz plati po celou retenci souboru, da se pouzit opakovane a klidne nekym
jinym nez zadavatelem - bez toho vypada deset stazeni v auditu jako zadne.
logDownload() zamerne neodchytava vyjimky: kdyz selze audit, soubor se nevyda.
Neprihlaseneho uzivatele posle bezny auth mechanismus aplikace na login a po prihlaseni zpet - odkaz z e-mailu tak funguje kdykoli behem retence souboru, ale vzdy jen pro opravneneho.
Retence souboru
Vygenerovane soubory nesmi na disku lezet dele, nez je nutne pro doruceni (obsahuji exportovana, casto osobni data). Denni cron:
0 3 * * * php bin/console exporter:purge-files
maze soubory starsi nez fileRetentionDays (default 7). Auditni zaznam
zustava po celou svou retenci - jen prijde o soubor; download expirovaneho
exportu vrati chybu.
Auditni vlastnosti zaznamu
- vznika VZDY, i pro maly synchronni download
- zaznam je NEMENNY (final entita bez setteru) a aktera nese DENORMALIZOVANE (snapshot v okamziku akce, zadna FK relace) - nezavisi na zbytku databaze a prezije odvoz do externiho auditniho uloziste
- provozni beh na auditni zaznam NIKDY nesaha (po odvozu moverem tu neni);
vse, co potrebuje background regenerace, je na
Export - selekce KAZDE sekce se materializuje V OKAMZIKU volani: entity sekce nesou presny vycet ID + DQL s parametry, agregatove sekce primo snapshot radku (query nejde serializovat do jobu a pozdejsi prehrani by neodpovidalo dorucenemu souboru)
- CIM se vybiralo, nese vyhradne
sections(dql + parameters ze SKUTECNE spustene query). Knihovna zadny popis filtru od volajiciho neprijima: neoverila by ho a mohl by se s realnym dotazem rozejit - v auditu je pole, ktere muze lhat, horsi nez zadne identifier+rowCount= rychly kontext,recipientEmailodpovida na "kam data odesla" (jina otazka nez kdo export spustil)- zaznam se po vytvoreni needituje; dlouhodobou retenci resi mover do auditniho uloziste (viz projektova infrastruktura)
Pripravenost pro SIEM
- identita udalosti je dvojice (zdroj,
id). Zdroj NENI sloupec: mover vi, ze ktere databaze cte, a stampuje ho pri odvozu; dedupikacni klic si odvodi jakozdroj:tabulka:id. Diky tomu je identita v kazde tabulce rodiny logu zdarma -iduz tam vsude je. Vlastniuuidby muselo pribyt do vsech (tedy i dodoctrine-authenticatorproauth_logadoctrine-loggableprochange_log), a jednotne jednodussi schema je cennejsi nez robustnejsi schema nasazene jen v jednom logu. Zbytkove riziko: po obnove databaze ze zalohy se autoinkrement preposadi aidse muze vydat znovu - proti tomu jeuuidimunni, dvojice ne - korelace mezi udalostmi jde pres
export_id(id provozniho zaznamu). Tutez hodnotu nesou vsechny akce, takzeWHERE export_id = ? ORDER BY idvrati zadani exportu i vsechna jeho stazeni. Zamerne ne id auditniho radku: ten uz mohl byt odvezen moverem actionmapuje na ECSevent.action; dalsi typ udalosti (napr. smazani souboru retenci) pribude jako nova hodnota enumu, bez migracecreated_atje VZDY v UTC - jinak by cas sedel o offset vedle logu ostatnich systemu a pri prechodu na zimni cas by byla jedna hodina v roce nejednoznacna (2:30 nastane dvakrat). Sloupec je beznyDATETIME; UTC zajistuje zapis (format()bere zonu z objektu), zadny vlastni Doctrine typ neni potreba.ExportLogproto ZAMERNE nemagetCreatedAt(): pri hydrataci by Doctrine dosadila lokalni zonu a vratila okamzik posunuty o offset. Zaznam je write-only, cte ho mover pres SQL - kdyby cteni z PHP nekdy bylo potreba, musi se soucasne zavest UTC Doctrine typsource_ipauser_agentjsou ploche, protoze na nich stoji detekcni pravidla ("stejny ucet, jina zeme")- mapovani na ECS:
created_at->@timestamp,identifier->event.action,created_by_id/_label->user.id/user.name,recipient_email->email.to.address,source_ip->source.ip - POZOR na velikost:
sectionsnese vycet ID, takze u velkeho exportu jde o stovky KB az jednotky MB. Do SIEM patri souhrn (identifier, akter, row_count, fields, prijemce) a odkaz presid; enumerace ID zustava v archivnim ulozisti - jinak se udalost usekne na limitu ingesce (Splunk ma default TRUNCATE 10 000 B)