Audit Trail — Developer Reference
Audit Trail — Developer Reference
Section titled “Audit Trail — Developer Reference”Každá mutující operace v Sencai by měla produkovat audit událost. Balíček @sencai/audit poskytuje sdílené rozhraní pro publikování napříč službami. Audit události jsou po zápisu hash-chainované a neměnné, scope na actora/organizaci je ale řízen explicitními poli — publisher sám o sobě žádný scope nevynucuje.
Balíček
Section titled “Balíček”packages/audit/ # @sencai/audit — vendorovaný balíček, ne z npm registry├── src/│ ├── index.ts # veřejné exporty, `audit = { log: publishAuditEvent, init: initAuditPublisher }`│ ├── types.ts # AuditAction (string-literal union), AuditEvent interface│ ├── context.ts # AsyncLocalStorage kontext (AuditContext)│ ├── classification.ts # data-classification kontrakt (podklad pro cross-tenant-flow ESLint pravidlo)│ ├── publisher.ts # publishAuditEvent() — fire-and-forget publish do RabbitMQ│ └── testing.ts # testovací helpery mockAuditPublisher / expectAuditEvent / getCapturedEventsaudit.log je pohodlný alias pro publishAuditEvent, navázaný jednou při importu v index.ts. To je důležité pro testování — viz níže.
Použití
Section titled “Použití”publishAuditEvent() (a jeho alias audit.log()) přijímá jeden objektový argument, ne poziční argumenty:
import { audit } from '@sencai/audit';
await audit.log({ action: 'cloud.asset.adopt', resource_type: 'cloud-instance', resource_id: instanceId, actor_user_id: kcSubjectUuid, actor_org_id: orgDocumentId, customer_scope: orgDocumentId, correlation_id: traceId, risk_level: 'medium', changes: { status: { before: 'discovered', after: 'adopted' } }, metadata: { provider: 'hetzner', region: 'nbg1' },});Signatura publishAuditEvent
Section titled “Signatura publishAuditEvent”function publishAuditEvent( event: Partial<AuditEvent> & { action: AuditAction; resource_type: string }): Promise<void>Pole AuditEvent
Section titled “Pole AuditEvent”| Pole | Typ | Popis |
|---|---|---|
action | AuditAction | Povinné. Literál akce (viz níže) |
resource_type | string | Povinné. Typ dotčeného resource (např. cloud-instance, user) |
resource_id | string | number | ID dotčeného resource |
actor_user_id | string | number | Strapi user ID nebo KC subject jednajícího uživatele/služby |
actor_org_id | string | Organizace, ke které actor patří |
customer_scope | string | null | Tenant, který byl subjektem akce; null = platform-level. customer_scope !== actor_org_id značí cross-tenant operaci |
actor_org_scope | string | null | Org, ze které actor operoval — užitečné pro agency/MSP cross-tenant audit trail |
elevation_grant_id | string | null | ID JIT elevation grantu, pokud akce běžela pod elevací |
changes | Record<string, { before, after }> | Snapshot před/po — nikdy nesmí obsahovat secrets |
correlation_id | string | OTEL trace ID |
risk_level | 'low' | 'medium' | 'high' | 'critical' | Výchozí 'low', pokud není nastaveno nikde v kontextu ani v eventu |
metadata | Record<string, unknown> | Volná extra data — bez secrets, bez neredigovaného PII |
ip_address | string | IP adresa klienta |
user_agent | string | User agent klienta |
timestamp | string | ISO timestamp, nastaví publisher, pokud chybí |
Neexistuje zkratka userId/orgId/requestId/ipAddress — vždy použijte skutečné názvy polí výše (actor_user_id, actor_org_id, correlation_id, ip_address).
publishAuditEvent() nikdy nevyhodí výjimku a nikdy neblokuje volajícího. Je to fire-and-forget: pokud je RabbitMQ v okamžiku publikování nedostupné, event se zahodí s console.warn (při NODE_ENV=test je warning potlačen) a volající business logika pokračuje bez přerušení. Kompletnost na straně publikování je tedy best-effort — durabilita a retry logika začínají až ve chvíli, kdy zpráva reálně dorazí ke konzumentovi (viz níže).
AuditAction
Section titled “AuditAction”AuditAction v packages/audit/src/types.ts je jeden 700+ řádkový string-literal union, ne enum. Záměrně mísí dvě konvence:
- Legacy
UPPER_SNAKEakce z dřívějších vln, např.ORG_CREATED,INSTANCE_PROVISIONED,CREDENTIAL_CREATED,USER_LOGGED_IN - Novější dot-notation akce z pozdějších vln, např.
cloud.inventory.scan,runbook.triggered,billing.usage.synced,fleet.agent.quarantined,lumen.query.sent
Oba styly jsou aktivně používány vedle sebe a žádný z nich se plošně neruší — některé starší literálové stringy jsou load-bearing pro evidence queries (např. NIS2 evidence, cost-allocation reporting), které filtrují na přesný string, takže přejmenování by tiše rozbilo tuto evidenci. Při přidávání nové akce následujte konvenci již použitou nejbližší podobnou skupinou, nezavádějte třetí styl.
AsyncLocalStorage kontext
Section titled “AsyncLocalStorage kontext”packages/audit/src/context.ts definuje skutečný tvar AuditContext:
interface AuditContext { actor_user_id?: string | number; actor_org_id?: string; customer_scope?: string | null; actor_org_scope?: string | null; correlation_id?: string;}Kontext neobsahuje pole ipAddress ani requestId — ty se předávají explicitně na samotném eventu (ip_address, a korelaci požadavku/trace pokrývá correlation_id).
import { runWithAuditContext } from '@sencai/audit';
// V HTTP middleware, jednou za request:app.addHook('preHandler', async (request) => { return runWithAuditContext( { actor_user_id: request.user?.id, actor_org_id: request.user?.orgId, correlation_id: request.id, }, async () => { // jakékoliv volání audit.log() hlouběji v zásobníku tento kontext automaticky převezme } );});publisher.ts sloučí finální payload přesně v tomto pořadí:
{ risk_level: 'low', timestamp: new Date().toISOString(), ...auditLocalStorageContext, ...event }Pole předaná explicitně do audit.log(event) mají vždy přednost před tím, co je v ambientním AsyncLocalStorage kontextu, a risk_level padá zpět na 'low' jen pokud ho nenastaví ani kontext, ani explicitní event.
ESLint audit gate
Section titled “ESLint audit gate”Vlastní ESLint pravidlo sencai/require-audit-log (z tooling/eslint-plugin-sencai, verze pluginu 0.3.0) shodí CI, pokud mutující funkci chybí audit volání. Reálně aplikuje dva nezávislé vzory na jméno funkce:
- HTTP-layer vzor (case-insensitive prefix match): funkce se jmény
create*,update*,delete*,attach*,detach*,invite*,revoke*,provision*,terminate*,impersonate*,assume* - Non-HTTP vzor (case-insensitive, jen přesná shoda):
cronJob,runCron,processJob,consumeMessage,processMessage,handleMessage,processEvent,runWorker,executeJob,afterCreate,afterUpdate,afterDelete,beforeCreate,beforeUpdate,beforeDelete
Pozor: remove* není součástí ani jednoho vzoru, i když se to někdy mylně předpokládá.
Funkce odpovídající některému vzoru musí buď volat audit.log(...) / publishAuditEvent(...) někde ve svém těle, nebo mít nad sebou (případně nad svou obalující deklarací/exportem) opt-out komentář s povinným důvodem:
/** @no-audit { reason: "tenký wrapper — podkladové service volání už audituje na hranici domény" } */Holé /** @no-audit */ bez důvodu nesplňuje zamýšlenou konvenci zdokumentovanou u pravidla.
Plugin obsahuje ve stejném balíčku ještě dvě další pravidla:
sencai/cross-tenant-flow-requires-classification(error) — vynucuje data-classification kontrakt zpackages/audit/src/classification.tsna cross-tenant datových tocíchsencai/no-self-policy-mutation(warn) — označuje kód, který mutuje vlastní policy/elevation/approval záznamy mimo privilegovanou cestu
CI příkazy
Section titled “CI příkazy”# Kompletní lint (zahrnuje všechna tři pravidla)npm run lint
# Izolovaný audit gate — selže POUZE na porušení sencai/require-audit-lognpm run lint:auditlint:audit se nachází v <služba>/scripts/audit-gate.mjs a spouští ESLint omezený jen na toto pravidlo.
Pokrytí ESLint gate
Section titled “Pokrytí ESLint gate”Podle packages/audit/README.md následující služby vendorují @sencai/audit jako skutečnou dependency a jsou zamýšlenými konzumenty ESLint audit gate pravidla: api-manager, auth-service-consumer, cloud-connector, git-connector, graphql-gateway, opsloop-consumer, sencai-watchdog, ssh-proxy-service, workspace-connector.
scripts/sync-eslint-plugin.sh ale automatizuje sync vendorovaného pluginu jen pro tři z nich — api-manager, git-connector, cloud-connector. auth-service-consumer a zbylé služby musí mít svou vendorovanou kopii eslint-plugin-sencai po každé změně pravidla aktualizovánu ručně. Toto je reálné provozní riziko: nic v CI nedetekuje zastaralou vendorovanou kopii, takže služba může tiše dál lintovat proti staré verzi pravidla.
Neměnný hash chain
Section titled “Neměnný hash chain”Chain zapisuje modul uvnitř auth-service-consumer (audit-consumer.js), ne samostatná služba “audit-consumer”.
entry_hash je SHA-256 hash počítaný nad pevnou, explicitně seřazenou sadou sloupců — zdokumentovanou v audit-consumer.js jako “AUDIT-HASH-CONTRACT v1” a vyžadující byte-identickou shodu se serializací použitou v verify-chain/last-hash controlleru na straně sencai.space:
action, actor_user_id, actor_org_id, customer_scope, actor_org_scope,elevation_grant_id, resource_type, resource_id, changes, correlation_id,risk_level, metadata, ip_address, user_agent, prev_hashNejde o hash založený na eventId/UUID. Před hashováním projdou changes a metadata (oba MySQL JSON sloupce) rekurzivní kanonizací klíčů — MySQL při uložení přeskládá klíče JSON objektu, takže bez kanonizace by pozdější přepočet hashe z uloženého řádku neodpovídal hashi spočítanému při zápisu.
Hlava chainu (prev_hash pro další záznam) se čte přes fail-closed, jen-servisní endpoint: GET /api/audit-logs/last-hash (vyžaduje X-Service-Secret). Pokud toto čtení z jakéhokoliv důvodu selže (síťová chyba, 401, nevalidní odpověď), consumer vyhodí výjimku místo fallbacku na prev_hash = null — zápis s null-prev_hash “poison linkem” by tiše rozbil chain, takže se zpráva místo toho nackne a zkusí znovu později.
Kotvení (anchoring)
Section titled “Kotvení (anchoring)”Ed25519 kotvení chainu je jen na vyžádání: POST /api/audit-logs/anchor (jen admin, vyžaduje X-Service-Secret) podepíše a uloží kotvu nad posledním záznamem v chainu. Neexistuje žádné automatické plánované kotvení — žádný “každých 1 000 událostí” dávkový job ani hodinový cron. Ke kotvení dochází jen při explicitním zavolání tohoto endpointu.
RabbitMQ
Section titled “RabbitMQ”Audit události jsou publikovány do jediné dvojice exchange/routing key:
| Exchange | Routing key | Consumer |
|---|---|---|
audit.events (topic) | audit.write | modul audit-consumer.js uvnitř auth-service-consumer |
Existuje jen tento jeden routing key — neexistuje samostatné dělení audit.event/audit.anchor. Kotvení se spouští přes výše uvedený HTTP endpoint, ne přes samostatnou frontovou zprávu.
Na straně konzumenta se neúspěšné zprávy zkoušejí znovu až MAX_RETRIES = 5× s exponenciálním backoffem; po vyčerpání pokusů zpráva putuje do fronty audit.fallback (durable: true, TTL 14 dní).
Izolace mezi tenanty
Section titled “Izolace mezi tenanty”Audit události nesou actor_org_id/customer_scope/actor_org_scope explicitně, nejsou scopované automaticky publisherem. Audit pipeline sama byla v minulosti reálným cílem nálezů, ne konstrukčně vyřešeným problémem — viz SECURITY-AUDIT.md pro historický záznam, včetně padělání audit záznamů přes nedostatečně chráněný zápisový endpoint, úniku PII do metadata eventu a cross-tenant čtení audit logů přes cestu generování post-mortem reportu. Všechny tyto nálezy jsou tam označeny jako vyřešené v rámci remediace F3.SECREM, ale při posuzování aktuálního stavu berte jako zdroj pravdy SECURITY-AUDIT.md, ne trvalý předpoklad, že je pipeline jednou provždy bezpečná.
Audit Viewer SPA
Section titled “Audit Viewer SPA”- Služba:
sencai-audit/ - Port: 3300
- URL (lokálně):
audit.sencai.localhost - Auth: Keycloak OIDC (KC klient
sencai-audit)
Viewer je read-only — veškerá business logika žije ve Strapi a v audit-consumer.js. Dnes implementuje stránkovaný seznam logů a detail jednotlivého záznamu zobrazující pole hash chainu. Filtrace podle organizace/akce/actora/rozsahu data, export do CSV/PDF a real-time streaming jsou zatím work-in-progress/plánované, ne implementované. Jen role sencai-admin v Keycloaku je dnes zapojena end-to-end; dedikované role sencai-auditor/support jsou plánované, ale zatím se mapují na stejný admin přístup. Zda detail view vizuálně signalizuje přerušený chain (např. červeným indikátorem) není v aktuálním kódu potvrzeno — ověřte proti sencai-audit/pages/logs/[id].vue, než na toto chování budete spoléhat, místo předpokladu, že je implementované.
Testování
Section titled “Testování”@sencai/audit/testing nabízí mockAuditPublisher, restoreAuditPublisher, expectAuditEvent a getCapturedEvents. Existují dva odlišné mockovací postupy kvůli tomu, jak je navázán alias audit.log:
-
In-module patch — pro testy uvnitř balíčku
@sencai/audit, které volajípublishAuditEventpřímo z../publisher:import { mockAuditPublisher, restoreAuditPublisher, expectAuditEvent } from '../testing';beforeEach(() => mockAuditPublisher());afterEach(() => restoreAuditPublisher()); -
jest.mockfactory — nutná pro testy konzumujících služeb, které volajíaudit.log(...), protožeindex.tsvážeaudit = { log: publishAuditEvent }při importu a in-module patch výše tento alias neovlivní:jest.mock('@sencai/audit', () => {const testing = jest.requireActual('@sencai/audit/testing');return {...jest.requireActual('@sencai/audit'),publishAuditEvent: (e: unknown) => testing._recordEvent(e),audit: { log: (e: unknown) => testing._recordEvent(e), init: () => undefined },};});
Distribuce — ruční vendoring
Section titled “Distribuce — ruční vendoring”@sencai/audit se distribuuje ručním vendoringem, ne publikací do npm registry. Po jakékoliv změně v packages/audit/src/:
cd packages/auditnpm run build…a poté ručně zkopírovat dist/ a package.json do vendor/@sencai/audit/ každé konzumující služby. Žádný skript tento re-sync neautomatizuje (na rozdíl od ESLint pluginu, který má alespoň částečnou automatizaci přes scripts/sync-eslint-plugin.sh). Zastaralá vendorovaná kopie je opakující se reálné riziko — vždy při review změny v packages/audit/src/ ověřit, které služby byly skutečně re-synced.
sencai.space (Strapi backend) tento balíček vůbec nevendoruje. Má vlastní, samostatně udržovaný, volně typovaný audit publisher (action: string), který sdílí jen koncept, ne skutečný kód.
Pokrytí: non-HTTP kódové cesty
Section titled “Pokrytí: non-HTTP kódové cesty”HTTP routy jsou pokryté globálním audit-logger middlewarem ve Strapi. Background workery, cron joby a lifecycle hooky (afterCreate, beforeUpdate, message handlery konzumentů atd.) musí volat audit.log()/publishAuditEvent() explicitně — non-HTTP vzor výše je to, co tohle vynucuje na úrovni lintu ve službách, které mají pravidlo zapojené a udržované v syncu.