Commit Graph

9 Commits

Author SHA1 Message Date
Ronni Baslund 47eb9502f8 feat(platform): real email domains, mailboxes & member lifecycle
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.

Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
  email domains via x:Domain at the internal http://stalwart:8080 listener;
  DKIM auto-generated; the records to publish are read from the domain's
  dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
  tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
  resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
  domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
  attention.

Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
  tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
  account permissions, original password preserved), force-logout (terminate
  sessions, filtered client-side so it can never end other users' sessions),
  reset password (new one-time password on SSO + mailbox), and remove (tear down
  mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
  users). Self-suspend / self-force-logout are blocked.

Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
2026-06-01 21:19:42 +02:00
Ronni Baslund 559348f6bc feat(portal): real Security & audit page (+ bundled Storage / per-tenant-roles WIP)
Security & audit (admin)
- Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with
  q/action/outcome/actorEmail/since/before; UI gains search, outcome + time
  filters, action chips, cursor pagination, and client-side CSV export.
- Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute,
  allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy
  (membership-gated, audited). Editable, labelled by enforcement status.
- MFA: live enrollment overview via GET /tenants/:slug/mfa-status
  (Authentik countAuthenticators per member).
- SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD,
  scoped to the tenant group. New AuthentikClient methods (provider/app/binding
  + flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback
  on partial failure; client secret never stored), GET/POST/DELETE
  /tenants/:slug/sso-apps. Validated end-to-end against live Authentik.
- Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast
  radius) — to be done as its own reviewed change.

Bundled in-progress work that shares the same files (kept together so the tree
stays green):
- Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed),
  storage.get proxy, storage.vue.
- Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
2026-05-31 17:20:36 +02:00
Ronni Baslund 6370e392cc feat(reports): partner and platform analytics
Partner reports — health cohorts, revenue-by-plan, top customers, signup/churn cohorts, plus saved custom reports (create/list/delete). Operator platform-wide reports (MRR, revenue by plan, top tenants, growth). Replaces the reports fixtures in both apps.
2026-05-30 08:03:14 +02:00
Ronni Baslund 89691626f4 feat: partner enrichment, mutations, settings & branding + operator quick-wins
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.

Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.

Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
2026-05-30 08:03:07 +02:00
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00
Ronni Baslund 0299328175 feat(authentik): auto-wire recovery flow on bootstrap + expire fallback temp passwords
Two related fixes that together close the "no recovery flow" gap behind
the invite-operator feature.

1. SeedService now provisions an Authentik recovery flow on every boot.
   Without this, /core/users/{pk}/recovery/ returns 400 "No recovery flow
   set." and our invite endpoint silently falls back to setting a plaintext
   temp password — operationally fine in dev but not appropriate for prod.

   ensureRecoveryFlow() (in seed.service.ts):
     - Check if a flow with designation='recovery' already exists → no-op
     - Otherwise create one with slug='default-dezky-recovery'
       (designation='recovery', authentication='none' so the link token
       is the only auth needed)
     - Bind three default Authentik stages to it in order:
         10: default-authentication-identification (auto-skipped when the
             recovery token already pins a user; lets the flow also work
             for self-service "forgot password" entry)
         20: default-password-change-prompt
         30: default-password-change-write
     - PATCH the default brand's flow_recovery to point at the new flow
     - Wrapped in .catch(warn) so an Authentik blip during boot doesn't
       crash platform-api — next restart retries.

   AuthentikClient additions:
     - findRecoveryFlow(), getDefaultBrand(), findStageByName(),
       createFlow(), bindStageToFlow(), setBrandRecoveryFlow().

   IntegrationsModule pulled into SeedModule so SeedService can use
   AuthentikClient.

2. Temp-password fallback path now marks the password expired so
   Authentik forces a change on next login. Closes the window where an
   operator's plaintext share could outlive the new user's first session.

   AuthentikClient.markPasswordExpired(userPk):
     - GET user → merge attributes.passwordExpired=true +
       passwordExpiredAt=now → PATCH back
     - Read-modify-write because Authentik PATCH replaces nested objects
       and we don't want to clobber other attributes

   UsersService.inviteOperator() calls it on the fallback branch only —
   the recovery-link path doesn't need it (clicking the link sets a
   fresh password through the flow anyway).

Verified end-to-end:
  - Boot → recovery flow auto-provisioned with three correctly-ordered
    stage bindings, default brand patched to flow_recovery=<new pk>.
  - Re-invite test user → modal now shows a single recovery link
    starting with https://auth.dezky.local/if/flow/default-dezky-
    recovery/?flow_token=... (no temp password fallback).
  - Operator-team list still updates to include the new user
    immediately via the pre-created local User doc.

Known follow-ups:
  - Enforce MFA enrollment in the recovery flow (add an authenticator
    stage). Deferred — locks users out if they lose the second factor
    on day one. Better to fire MFA from a separate "MFA required" stage
    on subsequent logins for platform admins.
  - Outbound SMTP (Phase 5/6) so Authentik emails the recovery link
    directly and the modal hides it.
2026-05-24 21:46:35 +02:00
Ronni Baslund 9a97945565 feat(operator): invite operator → creates user in Authentik
New "Invite operator" button + modal on /operator-team. Replaces the
bounce-to-Authentik flow with an inline invite that creates the user via
the Authentik API and pre-populates our local User doc so they appear
immediately.

services/platform-api/src/integrations/authentik.client.ts:
  - findUserByEmail(): early-conflict check before we attempt the create
  - createUser(): POST /core/users/ with username = email, internal type,
    is_active, attached to the supplied group PKs
  - addUserToGroup(): kept for tenant-member invites later
  - recoveryLink(): tries POST /core/users/{pk}/recovery/, returns
    undefined when no recovery flow is configured on the Authentik brand
    (we soft-fail and the service falls back to setInitialPassword)
  - setInitialPassword(): POST /core/users/{pk}/set_password/. Returns 204
    No Content so we bypass request<T>'s JSON parser and call fetch
    directly with explicit ok check.

services/platform-api/src/users/users.service.ts:
  - inviteOperator(dto, actor) orchestrates: dedup by email →
    findOrCreate Authentik group → create user in group → pre-create
    local User doc with platformAdmin=true so the list reflects them
    immediately → try recovery link → fall back to temp password →
    record platform.user_invited audit event with handoff method.
  - Return type is { subject, userId, link? | tempPassword? } —
    exactly one credential mode set depending on Authentik config.
  - generateTempPassword(): 16-char with at least one upper/lower/digit/
    symbol, shuffled. Confusable chars (I/O/0/1/l) omitted.
  - Cached platform-admin group ID after first lookup.

services/platform-api/src/users/users.controller.ts:
  - POST /users/invite behind OperatorGuard. Calls the service with
    actor + IP from the JWT/request.

apps/operator:
  - server/api/users/invite.post.ts: standard platformApi proxy.
  - components/InviteOperatorModal.vue: 2-step form. Step 1: name +
    email with client-side validation. Step 2: shows whichever
    credential the backend returned — recovery link OR username+
    temp-password — with copy-to-clipboard buttons and a note about
    SMTP/recovery-flow follow-up paths.
  - pages/operator-team.vue: "Invite operator" replaces "Manage in
    Authentik" as the primary action; Authentik link demoted to
    secondary. Refreshes the list on @invited so the new user shows
    up without a manual reload.

Verified end-to-end against real Authentik:
  - Invite created user pk=7, uid=f22f2bb…, group=dezky-platform-admins,
    is_active=true, temp password set. Modal showed both fields with
    copy buttons; operator-team count went 1 → 2 immediately. Audit
    event recorded (platform.user_invited with handoff='temp-password').
  - Recovery link path is preferred but Authentik has no recovery flow
    configured on the default brand. AuthentikClient.recoveryLink()
    soft-fails on the "No recovery flow set." 400, returns undefined,
    and inviteOperator transparently falls back to set_password. Once
    a recovery flow is configured (Authentik admin → Flows), the link
    path becomes active and the temp-password path stops firing
    without any code changes.

Known follow-ups:
  - Configure Authentik recovery flow so the link path activates
    (one-time admin task, not in code)
  - Outbound SMTP wiring (Phase 5/6) → Authentik can email link/temp
    directly; modal stops showing the credential
  - Deactivate / remove operator from inside the app (currently still
    Authentik UI; defensible until proven needed)
  - Tenant-member invite — similar flow but adds to tenant group
    instead, exposed from /users (global users) or tenant detail
2026-05-24 21:27:46 +02:00
Ronni Baslund 02341d8ba5 feat(audit): platform-api audit log + operator UI wired to real events
Phase 1 of the audit work — capture everything we control today, ingest from
external systems (Authentik / OCIS / Stalwart) in a later phase. The mock
OP_AUDIT fixture is gone; both the /audit page and Overview's activity card
now show real events recorded by AuditService.record() in platform-api.

Schema (services/platform-api/src/schemas/audit-event.schema.ts):
  AuditEvent { at, actorType, actorId, actorEmail, actorIp, action, outcome,
    resourceType, resourceId, resourceName, tenantSlug, partnerSlug, source,
    metadata, prevHash, hash }
  Indexes: {at:-1}, {tenantSlug,at:-1}, {actorId,at:-1}, {action,at:-1}.
  prevHash/hash are nullable now; hash-chain tamper evidence is a later phase.

AuditService:
  - record() — best-effort write, swallows errors so the underlying mutation
    that succeeded isn't failed by a downstream log issue. Surfaces failures
    via Logger.
  - list() — filters: since/until/before, action (exact OR prefix match
    via leading-anchor regex), tenantSlug, partnerSlug, actorEmail, outcome,
    free-text q across action/resourceName/actorEmail/tenantSlug, limit
    (default 100, max 500). Cursor pagination via `before`.
  - No UPDATE/DELETE surface — entries are append-only by construction.

AuditController: GET /audit, behind JwtAuthGuard + OperatorGuard. No mutations
exposed; entries written internally by other modules.

X-Forwarded-For threading:
  - apps/operator/server/utils/platform-api.ts forwards the originating
    client IP to platform-api so audit entries carry a real address.
  - services/platform-api/src/auth/client-ip.ts extracts leftmost
    X-Forwarded-For, falls back to socket.remoteAddress.

Instrumented mutations (every one threads actor + IP through):
  Tenants: create, update, softDelete, setStatus(suspend/resume)
  Partners: create, update, terminate
  Flags:   create, update (incl. flag.killed verb when state=off+note=kill-switch),
           remove
  Users:   deactivate

Each controller resolves the User doc via ActorService, extracts IP via
clientIp(req), and passes { userId, email, ip } as AuditActor to the service.
FlagsService's local ActorRef collapses to AuditActor so flag history and the
audit log share one shape.

Operator UI:
  - /api/audit proxy that forwards query params verbatim
  - types/audit.ts
  - pages/audit.vue: real list with quick-pick action chips (All/Tenants/
    Partners/Flags/Users), outcome filter, free-text search, "Load older
    events" cursor pagination
  - pages/index.vue: Overview activity card swaps mock OP_AUDIT for the
    same /api/audit endpoint, rows link into /audit
  - data/fixtures.ts: OP_AUDIT / AuditEntry / AuditTone exports removed

Verified end-to-end: suspended + resumed acme, flipped oci_versioning through
rollout → kill → on, then /audit returned all 5 events with the right action
verbs (tenant.suspended, tenant.resumed, flag.updated, flag.killed,
flag.updated), actor admin@dezky.local, IP 192.168.65.1. Filters (action
prefix + free-text q) narrow correctly.

Out of scope for this commit (each gets its own conversation):
  - Authentik / OCIS / Stalwart ingest adapters (Phase 2)
  - Hash-chain tamper evidence (Phase 3)
  - TTL + cold-storage archival to Hetzner Object Storage (Phase 4)
  - GDPR right-to-erasure tooling
2026-05-24 19:50:24 +02:00
Ronni Baslund 22b2583f0b chore(services): rename services/provisioning -> services/platform-api
O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner
management and operator-specific endpoints. The service now owns more than
just provisioning orchestration (it'll soon own partners, tenant lifecycle
actions, multi-audience JWT validation), so the name 'platform-api' reflects
its scope better.

What changed:
- Directory: services/provisioning/ -> services/platform-api/
- Package: @dezky/provisioning -> @dezky/platform-api
- Docker: container_name dezky-provisioning -> dezky-platform-api;
  compose service key 'provisioning' -> 'platform-api'; volume
  provisioning_node_modules -> platform_api_node_modules
- Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL,
  default URL http://provisioning:3001 -> http://platform-api:3001 in all
  three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/
  reconcile.post.ts), plus NUXT_API_BASE updated
- Health endpoint service identifier and main.ts log lines updated to
  'dezky-platform-api'
- Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md,
  NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml

What deliberately stays:
- Internal module names ProvisioningService / ProvisioningModule (those
  describe an orchestration sub-concern, not the service's purpose)
- Tenant.provisioningStatus / provisioningErrors field names (state
  per integration, not service name)
- File services/platform-api/src/tenants/provisioning.service.ts
- 'Hetzner provisioning' references in production-prep docs (infrastructure
  provisioning, unrelated)

Verified end-to-end after rename: /api/me returns 200 with profile + 2
tenants + subscription, /api/tenants/dezky/reconcile returns 200 with
Authentik integration still ok.

OPERATOR-PLAN.md O.0 checkboxes ticked.
2026-05-24 00:35:01 +02:00