Commit Graph

5 Commits

Author SHA1 Message Date
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 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
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