Ronni Baslund b1d717e466 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
2026-05-24 20:12:21 +02:00

Dezky

Sovereign workspace platform for European businesses. Mail, files, calendar, video meetings — all EU-hosted, all open source.

Quick start (local development)

# 1. Clone and enter
git clone <repo-url> dezky
cd dezky

# 2. Run bootstrap (handles everything)
./scripts/bootstrap.sh

# 3. Open the portal
open https://app.dezky.local

The bootstrap script:

  • Checks prerequisites (Docker, mkcert, openssl)
  • Generates wildcard TLS certificate via mkcert
  • Adds /etc/hosts entries (with your permission)
  • Generates secure random secrets in .env
  • Pulls Docker images
  • Starts all services in correct order
  • Prints next-step instructions

Service URLs (local development)

Service URL Purpose
Portal https://app.dezky.local Customer-facing landing & launcher
Authentik https://auth.dezky.local Identity provider (OIDC/SAML)
Files (OCIS) https://files.dezky.local File storage & sharing
Mail (Stalwart) https://mail.dezky.local Mail server admin UI
Office https://office.dezky.local Collabora Online editor
Traefik https://traefik.dezky.local Reverse proxy dashboard

What's in this repo

dezky/
├── apps/portal/                Nuxt 3 customer portal
├── services/platform-api/      NestJS service · tenants, partners, users, provisioning orchestration
├── packages/                   Shared TypeScript libraries
├── infrastructure/
│   └── docker-compose/         Local development stack
├── scripts/                    Setup, reset, helpers
└── docs/                       Service references & guides

Prerequisites

  • macOS or Linux (Windows users: use WSL2)
  • Docker Desktop 24+ or OrbStack
  • mkcert (brew install mkcert)
  • pnpm 9+ (brew install pnpm)
  • Node.js 20+
  • 16 GB RAM recommended

Common commands

# Start everything
docker compose -f infrastructure/docker-compose/docker-compose.yml up -d

# View logs
docker compose -f infrastructure/docker-compose/docker-compose.yml logs -f [service]

# Stop everything (keeps data)
docker compose -f infrastructure/docker-compose/docker-compose.yml down

# Nuke and restart (DESTROYS DATA)
./scripts/reset.sh

Architecture

This is a multi-tenant SaaS platform. Each tenant gets:

  • Isolated Authentik OIDC tenant
  • Custom subdomain (e.g. customer-name.dezky.local)
  • Mail domain in Stalwart with auto-generated DKIM
  • Dedicated OCIS space hierarchy
  • Branded launcher in the portal

All components are Apache 2.0 / MIT licensed — no per-seat fees, full whitelabel rights.

Production

The production target is a single Hetzner AX41-NVMe server (€39/mo) with:

  • Stalwart on bare-metal
  • k3s for all other services
  • Hetzner Object Storage (€5/mo) as OCIS S3 backend
  • Storage Box BX11 (€3.20/mo) for Restic backups
  • Storage Box BX11 in Helsinki (€3.20/mo) for DR

See docs/PRODUCTION-DEPLOYMENT.md (TBD) for migration plan.

Stack rationale

These choices are deliberate after extensive license/architecture research. See CLAUDE.md for the full reasoning.

Component License Why this one
Stalwart Mail Apache 2.0 Modern Rust, ActiveSync built-in, JMAP support
OCIS Apache 2.0 Cleaner license than Nextcloud (AGPL+trademark)
Zulip Apache 2.0 Only truly open-core-free chat option
Authentik MIT Better multi-tenancy than Keycloak
Hetzner N/A 100% EU sovereignty — core to business

License

Application code: MIT (own code) Third-party services: see individual service licenses in stack.

S
Description
No description provided
Readme 1.1 MiB
Languages
Vue 60.4%
TypeScript 37.8%
Shell 0.9%
CSS 0.5%
JavaScript 0.4%