feat(audit): Authentik events ingest worker (Phase 2 chunk 1)
Background worker that pulls Authentik's /api/v3/events/events/ on a 60s cadence and writes each event into our audit log via AuditService. External system events now share the same /audit timeline as internally-recorded platform mutations — operator queries don't have to cross-reference Authentik's own UI to see logins, password changes, group membership, impersonation, etc. Pieces: - src/schemas/ingest-cursor.schema.ts: one row per source, tracks lastEventAt + lastEventId so restarts resume without re-pulling. - src/schemas/audit-event.schema.ts: new `externalId` field; new compound unique index on (source, externalId) with a partial filter on externalId being a string. Partial (not sparse) so internally- recorded events with externalId=null don't collide. - src/audit/audit.service.ts: AuditRecordInput grows `externalId` + `at` fields. record() now silently swallows MongoError code 11000 (duplicate key) so re-pulling the cursor overlap doesn't log noise. - src/integrations/authentik.client.ts: listEvents(since, page, pageSize) on the existing client — reuses the admin token and base URL the provisioning code already configured. - src/ingest/action-map.ts: 16 known Authentik actions → dotted authentik.* verbs (login, login_failed, password_changed, impersonation_started, …). Unknown actions fall through to authentik.<raw> rather than getting silently dropped. - src/ingest/authentik.ingest.ts: OnApplicationBootstrap worker. Reads cursor → pulls events with created__gt=cursor, ordering=created ASC → paginates forward (10 pages × 100/page safety cap per tick) → writes each event with source='authentik' + externalId=pk + at= evt.created → advances cursor to the newest seen. inFlight guard prevents overlapping ticks. AUDIT_INGEST_ENABLED=false disables for test environments. - Tenant inference: from the user's groups (same convention the portal flag-eval proxy uses). Admin groups stripped; first match against a real Tenant.slug wins. Unmatched → tenantSlug undefined, event still lands in the global timeline. Smoke-tested: fresh Mongo + restart → 78 Authentik events ingested, 0 duplicates. Performed a login at app.dezky.local → next 60s tick captured the new login row with actor email + IP. Compound unique index on (source, externalId) verified to reject re-pulled events silently (no error logs). Out of scope here (covered by chunks 2 + 3): - Stalwart webhook ingest - OCIS file-tail ingest
This commit is contained in:
@@ -69,4 +69,37 @@ export class AuthentikClient {
|
||||
}
|
||||
this.logger.log(`Deleted Authentik group ${groupId}`)
|
||||
}
|
||||
|
||||
// Pull a window of Authentik events. Used by the audit ingest worker.
|
||||
// `since` filters by created timestamp (strict greater-than); pagination is
|
||||
// forward-only via `page`. Authentik's default page size is 100.
|
||||
async listEvents(
|
||||
since?: Date,
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
): Promise<AuthentikEventPage> {
|
||||
const params = new URLSearchParams({
|
||||
ordering: 'created',
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
if (since) params.set('created__gt', since.toISOString())
|
||||
return this.request<AuthentikEventPage>(`/events/events/?${params}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Shape returned by /events/events/. Only the fields we read; Authentik
|
||||
// includes a number of others (tenant, brand) we don't need.
|
||||
export interface AuthentikEvent {
|
||||
pk: string
|
||||
action: string
|
||||
app?: string
|
||||
user?: { pk?: number; username?: string; name?: string; email?: string }
|
||||
context?: Record<string, unknown>
|
||||
client_ip?: string
|
||||
created: string
|
||||
}
|
||||
export interface AuthentikEventPage {
|
||||
pagination: { next: number; previous: number; count: number; current: number; total_pages: number; start_index: number; end_index: number }
|
||||
results: AuthentikEvent[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user