Commit Graph

3 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 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 2db41fec5e feat(platform-api): multi-audience JWT + Partner CRUD + tenant lifecycle (O.2)
JwtAuthGuard now accepts a comma-separated AUTHENTIK_AUDIENCE
('dezky-portal,dezky-operator'). jose.jwtVerify takes an array and succeeds
on any match — both customer-portal and operator-portal tokens validate
against this service. Per-endpoint guards restrict further.

New OperatorGuard enforces operator-only mutations:
  1. JWT audience claim includes 'dezky-operator' (proof from the token
     alone that this is a privileged session)
  2. ActorService-resolved User has platformAdmin=true (DB check so
     revocation works without waiting for the token to expire)
Both required; either alone is insufficient.

Partner module:
  - Partner schema: slug, name, domain, status, marginPct, contactInfo,
    billingInfo. marginPct is one number per partner (decided in grilling)
  - CRUD endpoints under @UseGuards(JwtAuthGuard, OperatorGuard) — every
    partner mutation requires operator scope
  - GET /partners returns each row with a computed customers count from
    aggregating Tenant.partnerId. MRR aggregation deferred until
    Subscription gains a price column
  - GET /partners/:slug/tenants for the partner detail view
  - DELETE soft-terminates (status='terminated') — never hard-delete
    because tenants may still reference the partner

Tenant changes:
  - partnerId?: Types.ObjectId (ref Partner, indexed sparse) added to
    Tenant schema
  - UpdateTenantDto accepts partnerId so PATCH can attach/detach
  - POST /tenants/:slug/suspend and /resume — operator-only via
    OperatorGuard. PATCH already covers plan/domains/partnerId changes

Smoke test: customer-portal session sends POST /api/partners through the
portal proxy → 403 "This endpoint requires an operator-scoped token". The
positive test (operator-token → 200) waits for O.3 when there's an
operator app to mint the right token.

apps/portal/server/api/partners/index.post.ts is a temporary verification
proxy — delete once the operator portal exists.
2026-05-24 07:08:59 +02:00