Commit Graph

6 Commits

Author SHA1 Message Date
Ronni Baslund 77a09aaf77 feat(operator): live Infrastructure probes + honest split between deployed and planned
The Infrastructure page used to read from a mock fixture that lied two ways:
it listed services that aren't deployed (Jitsi, Zulip, Cloudflare, Object
Storage, Postmark) and showed hardcoded uptime/latency for the ones that
are. Now it shows truth from real probes plus a clearly-labelled "planned"
section for the rest.

Backend (services/platform-api):
- New src/health/ module — HealthService runs 9 probes in parallel with a
  1.5s timeout each:
    Stalwart  → TCP stalwart:8080
    OCIS      → HTTP GET ocis:9200/health
    Collabora → HTTP GET collabora:9980/hosting/discovery
    Authentik → HTTP GET authentik-server:9000/-/health/ready/
    Postgres  → TCP postgres:5432
    Mongo     → existing Mongoose connection.db.admin().ping()
    Redis     → TCP redis:6379
    Traefik   → TCP traefik:80
    Platform API → trivially ok (this code is running)
  Status thresholds: ok ≤500ms, warn 500–1500ms, bad on timeout/refuse.
- HealthController exposes GET /health/platform behind JwtAuthGuard, plus
  keeps the existing public GET /health for infra liveness checks.
- Moved the old src/health.controller.ts into the new module.

Frontend (apps/operator):
- /api/health/platform proxy forwards the operator's access token.
- Infrastructure page swaps SERVICES fixture for useFetch with 30s auto-
  refresh + a manual Refresh button. Cards show real status badge + real
  latency; uptime/error stay as em-dash with a "no probe history yet"
  tooltip until a Prometheus/event-log backend lands.
- Below the live grid, a "Planned · not deployed" section renders 5 dimmed
  cards (Jitsi, Zulip, simpledns.plus, Hetzner Object Storage, Postmark).
  simpledns.plus replaces the misnamed Cloudflare entry — we use
  simpledns.plus, not Cloudflare.
- Subtitle is now truthful: "8 / 9 services live · checked 2s ago".

Verified: stopped redis → card flipped to "down · getaddrinfo ENOTFOUND
redis", subtitle reflected 8/9, incident banner appeared. Restarted →
back to 9/9, banner gone.

SERVICES fixture stays in place for Overview's incident banner — replacing
that is a separate follow-up tied to the incident-management backend.
2026-05-24 18:47:38 +02:00
Ronni Baslund 19e1a4fca3 chore(operator): O.9 verification + roll follow-ups into NEXT-STEPS
- Add _verify-token.get.ts to both operator and portal — decodes the
  access token stored in the nuxt-oidc-auth session and echoes iss/aud/
  sub/groups. Used to confirm operator tokens carry aud=dezky-operator
  and portal tokens carry aud=dezky-portal. Listed in NEXT-STEPS.md as
  throwaway, to be removed when proper verification surfaces exist.
- OPERATOR-PLAN.md O.9 marked done with the actual claims captured + the
  Mongo-side verification of attach + suspend flows.
- NEXT-STEPS.md: replaced the "Operator portal — out-of-band track"
  section with a "shipped + follow-ups" version. The 9-item follow-up
  list (impersonation, audit, flags, incidents, support, partner
  portal, env switcher, on-call, workspace impersonation) is now the
  authoritative roadmap, not buried inside OPERATOR-PLAN.md.
2026-05-24 08:47:56 +02:00
Ronni Baslund e0ac643e80 feat(operator): visual-only screens with real-data overview (O.7)
- Overview (pages/index.vue): KPIs from real /tenants /partners /users,
  status meter, recent + needs-follow-up tables. Mock activity stream and
  incident banner overlay come from data/fixtures.ts.
- Operator team: real GET /users filtered to platformAdmin === true,
  with last-seen + tenant counts.
- Users (global): real read with All/Admins/Inactive views and search.
- Infrastructure / Feature flags / Audit: mock fixtures only — wiring to
  real backends (Prometheus, OpenFeature, append-only audit) is tracked
  as follow-ups in OPERATOR-PLAN.md.
- Placeholder pages (support/billing/reports/settings) via OpPlaceholder.
- Shared: Stat, MetricCell, OpPlaceholder components, /api/users proxy,
  PlatformUser type.
- .gitignore: scope the docker volumes data/ rule so apps/*/data/ is
  tracked again (operator carries mock fixtures there).
2026-05-24 08:17:26 +02:00
Ronni Baslund fbbb43e3e2 feat(operator): partner management with attach/detach (O.6)
- Partners list with name/domain/status/customers/margin + Create modal
- Partner detail: contract card, contact card, customers table, attach modal,
  terminate (soft-delete) danger card
- Operator proxies for /partners + /partners/:slug/tenants
- platform-api: add partnerId Prop to Tenant schema. The field was being
  silently dropped by Mongoose because the schema didn't declare it.
- tenants.service: rewrite update() to build $set/$unset explicitly and cast
  partnerId via new Types.ObjectId(). Handles detach via $unset so the field
  vanishes from the doc cleanly.
2026-05-24 08:02:00 +02:00
Ronni Baslund 8e81730372 feat(operator): tenant list + 7-tab detail with real lifecycle (O.5)
Operator can now manage tenants end-to-end from the UI:

  - pages/tenants/index.vue — list with status/plan/domains/created/
    provisioning-state columns, search by slug or name, status chips
    with live counts (all/active/pending/suspended), click-through
    to detail
  - pages/tenants/[slug].vue — 7-tab detail (Overview, Users, Resources,
    Billing, Audit, Support, Danger zone)
  - 3 tabs hit real backends: Overview (identity + billing fields),
    Users (lazy-loaded via new GET /tenants/:slug/users endpoint),
    Resources (live provisioning state per integration + Reconcile button)
  - 3 tabs render mock fixtures with warn-tone "mock" badges: Billing
    (Stripe placeholder), Audit (sample log lines), Support (placeholder
    pending the ticket queue work)
  - Danger zone: 3 real-backend cards (Suspend / Resume / Soft-delete),
    each gated by a ConfirmDialog modal. Verified live — clicked
    Suspend on acme, status flipped to 'suspended' in Mongo, then
    Resumed back to 'active'

platform-api additions:
  - GET /tenants/:slug/users returns users with this tenant in their
    tenantIds, sorted by last login. Same authorization rule as the
    existing /tenants/:slug — platform admins always pass,
    non-admins must be a member of the tenant
  - tenants.module imports User schema for the new lookup

New components (apps/operator/components/):
  - Tabs.vue — horizontal strip with optional per-tab counts, v-model
  - ConfirmDialog.vue — Teleport-to-body modal, Escape/backdrop close,
    danger/primary tone for the confirm button

Server proxy infrastructure (apps/operator/server/):
  - utils/platform-api.ts — single helper encapsulating
    access-token-from-session + bearer-forward + error normalization.
    Every operator proxy route is now a one-liner against this helper
  - api/tenants/index.get.ts, [slug]/{index.get,index.patch,index.delete,
    users.get,suspend.post,resume.post,reconcile.post}.ts

Two real bugs found and fixed during the smoke test:

  - Mongoose subdocument `_id` leaks into JSON when iterating
    tenant.provisioningStatus. Switched to an explicit
    `['authentik', 'stalwart', 'ocis']` whitelist in both v-fors
  - Documents created before provisioningErrors was added (like the
    acme tenant) don't have the field at all in JSON. Use optional
    chaining (`tenant.provisioningErrors?.[k]`) instead of bracket
    access. Without it: 'Cannot read properties of undefined (reading
    "authentik")' during the Resources tab render
2026-05-24 07:44:23 +02:00
Ronni Baslund 55b1c133e3 feat(operator): scaffold apps/operator Nuxt app + multi-issuer JWT (O.3)
New Nuxt 3 app at apps/operator/ — internal admin portal on its own domain
(operator.dezky.local), own OAuth client (dezky-operator), own session
secrets, own cookies. Customer and operator surfaces can't decrypt each
other's session state.

OAuth flow verified end-to-end:
  - GET / → middleware redirect to /auth/login
  - User clicks Sign in → /auth/oidc/login → bounces to Authentik with
    client_id=dezky-operator, scope includes 'groups'
  - Authentik checks dezky-platform-admins group binding (added in O.1),
    silent-reauths via the existing auth.dezky.local session
  - Returns to /auth/oidc/callback with code, exchanges for token,
    creates session cookie on operator.dezky.local
  - Lands on pages/index.vue placeholder dashboard

Smoke test 'Create partner "test-partner"' button on the placeholder home
exercises the full operator-only authorization chain:
  - 1st call: 200, partner created in Mongo
  - 2nd call: 409 'already exists' (idempotency holds, token still valid)
  - Same call from the customer portal: 403 'requires operator-scoped
    token' (audience guard rejects dezky-portal aud)

JwtAuthGuard now multi-issuer in addition to multi-audience. Each
Authentik OAuth provider mints tokens with its own per-app iss URL
(.../application/o/<slug>/), so the guard accepts a comma-separated
AUTHENTIK_ISSUER. The audience-only fix from O.2 wasn't sufficient —
issuer is validated separately by jose.jwtVerify and was still pinned
to dezky-portal alone, yielding 'unexpected iss claim value' rejections.

Compose changes: new 'operator' service (Node 20 alpine, pnpm install +
nuxt dev, mkcert CA mount, traefik labels for operator.dezky.local +
TLS); new operator_node_modules volume; operator.dezky.local added to
traefik's Docker network aliases. Distinct OPERATOR_NUXT_OIDC_* session
secrets pulled from .env (gitignored, generated via openssl).

Real operator screens (sidebar, topbar, tenants, partners, etc.) come
in O.4. This commit is pure scaffolding + the security boundary proof.
2026-05-24 07:20:16 +02:00