Přeskočit na obsah

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.

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 / getCapturedEvents

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

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' },
});
function publishAuditEvent(
event: Partial<AuditEvent> & { action: AuditAction; resource_type: string }
): Promise<void>
PoleTypPopis
actionAuditActionPovinné. Literál akce (viz níže)
resource_typestringPovinné. Typ dotčeného resource (např. cloud-instance, user)
resource_idstring | numberID dotčeného resource
actor_user_idstring | numberStrapi user ID nebo KC subject jednajícího uživatele/služby
actor_org_idstringOrganizace, ke které actor patří
customer_scopestring | nullTenant, který byl subjektem akce; null = platform-level. customer_scope !== actor_org_id značí cross-tenant operaci
actor_org_scopestring | nullOrg, ze které actor operoval — užitečné pro agency/MSP cross-tenant audit trail
elevation_grant_idstring | nullID JIT elevation grantu, pokud akce běžela pod elevací
changesRecord<string, { before, after }>Snapshot před/po — nikdy nesmí obsahovat secrets
correlation_idstringOTEL trace ID
risk_level'low' | 'medium' | 'high' | 'critical'Výchozí 'low', pokud není nastaveno nikde v kontextu ani v eventu
metadataRecord<string, unknown>Volná extra data — bez secrets, bez neredigovaného PII
ip_addressstringIP adresa klienta
user_agentstringUser agent klienta
timestampstringISO 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 v packages/audit/src/types.ts je jeden 700+ řádkový string-literal union, ne enum. Záměrně mísí dvě konvence:

  • Legacy UPPER_SNAKE akce 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.

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.

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 z packages/audit/src/classification.ts na cross-tenant datových tocích
  • sencai/no-self-policy-mutation (warn) — označuje kód, který mutuje vlastní policy/elevation/approval záznamy mimo privilegovanou cestu
Terminál
# Kompletní lint (zahrnuje všechna tři pravidla)
npm run lint
# Izolovaný audit gate — selže POUZE na porušení sencai/require-audit-log
npm run lint:audit

lint:audit se nachází v <služba>/scripts/audit-gate.mjs a spouští ESLint omezený jen na toto pravidlo.

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.

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_hash

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

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.

Audit události jsou publikovány do jediné dvojice exchange/routing key:

ExchangeRouting keyConsumer
audit.events (topic)audit.writemodul 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í).

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

  • 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é.

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

  1. In-module patch — pro testy uvnitř balíčku @sencai/audit, které volají publishAuditEvent přímo z ../publisher:

    import { mockAuditPublisher, restoreAuditPublisher, expectAuditEvent } from '../testing';
    beforeEach(() => mockAuditPublisher());
    afterEach(() => restoreAuditPublisher());
  2. jest.mock factory — nutná pro testy konzumujících služeb, které volají audit.log(...), protože index.ts váže audit = { 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 },
    };
    });

@sencai/audit se distribuuje ručním vendoringem, ne publikací do npm registry. Po jakékoliv změně v packages/audit/src/:

Terminál
cd packages/audit
npm 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.

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.