diff --git a/apps/operator/server/api/_verify-token.get.ts b/apps/operator/server/api/_verify-token.get.ts new file mode 100644 index 0000000..94b18db --- /dev/null +++ b/apps/operator/server/api/_verify-token.get.ts @@ -0,0 +1,32 @@ +// Throwaway verification endpoint for O.9: decodes the access token currently +// stored in the operator's nuxt-oidc-auth session and returns the claims we +// care about (iss, aud, sub, exp, groups). NEVER returns the raw token. Safe +// to leave deployed since it requires a valid operator session and only +// echoes claims the user can already see in their JWT. + +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +function decodeJwtClaims(token: string): Record { + const parts = token.split('.') + if (parts.length < 2) throw new Error('Not a JWT') + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4) + return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) +} + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'No session' }) + + const claims = decodeJwtClaims(accessToken) + return { + iss: claims.iss, + aud: claims.aud, + sub: claims.sub, + email: claims.email, + groups: claims.groups, + exp: claims.exp, + iat: claims.iat, + } +}) diff --git a/apps/portal/server/api/_verify-token.get.ts b/apps/portal/server/api/_verify-token.get.ts new file mode 100644 index 0000000..ebec4f6 --- /dev/null +++ b/apps/portal/server/api/_verify-token.get.ts @@ -0,0 +1,31 @@ +// Throwaway verification endpoint mirroring the operator one. Decodes the +// portal access token from the nuxt-oidc-auth session and echoes the claims +// that matter (iss/aud/sub/groups/exp). Useful for confirming that signing +// in here yields aud=dezky-portal, distinct from the operator's dezky-operator. + +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +function decodeJwtClaims(token: string): Record { + const parts = token.split('.') + if (parts.length < 2) throw new Error('Not a JWT') + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4) + return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) +} + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'No session' }) + + const claims = decodeJwtClaims(accessToken) + return { + iss: claims.iss, + aud: claims.aud, + sub: claims.sub, + email: claims.email, + groups: claims.groups, + exp: claims.exp, + iat: claims.iat, + } +}) diff --git a/docs/NEXT-STEPS.md b/docs/NEXT-STEPS.md index 433a6f4..17c13d6 100644 --- a/docs/NEXT-STEPS.md +++ b/docs/NEXT-STEPS.md @@ -141,17 +141,56 @@ await authentikClient.coreUsersCreate({ }) ``` -## Operator portal — out-of-band track +## Operator portal — out-of-band track — shipped (O.0–O.9) -`operator.dezky.local` (internal admin portal — separate Nuxt app, separate -Authentik OAuth client, real CRUD for tenants + partners). Plan and decisions -captured in [`OPERATOR-PLAN.md`](./OPERATOR-PLAN.md). +`operator.dezky.local` is live as a separate Nuxt app with its own +`dezky-operator` Authentik OAuth client. Full plan and execution log in +[`OPERATOR-PLAN.md`](./OPERATOR-PLAN.md). -Touches platform-api substantially: -- Service rename `services/provisioning` → `services/platform-api` (prep) -- New `Partner` schema + CRUD endpoints -- Tenant lifecycle actions (suspend/resume/plan change) -- Audience-aware JwtAuthGuard for operator-only mutations +What landed: +- `services/provisioning` renamed to `services/platform-api` +- Audience-aware JwtAuthGuard accepts both `dezky-portal` and `dezky-operator` +- `Partner` schema + CRUD endpoints, `Tenant.partnerId` ref +- Tenant lifecycle (suspend / resume) gated by OperatorGuard +- Operator UI: Overview (real KPIs), Tenants (7-tab detail w/ Danger), + Partners (attach/detach), Users, Operator team. Visual-only Infrastructure, + Feature flags, Audit. Placeholders for Support/Billing/Reports/Settings. +- Interactions: ⌘K command palette, impersonation stub (modal + banner), + incident modal, tweaks panel (theme/density/env) + +### Follow-ups before operator hits production + +In rough priority order — bulk lifted from OPERATOR-PLAN.md: + +- [ ] **Real impersonation flow** — OAuth Token Exchange (RFC 8693), + `act` claim on customer portal, audit on entry+exit, banner with + origin operator identity +- [ ] **Real audit log collection** — `platform_audit` Mongo collection, + written by platform-api on every privileged action; stream from there + instead of `data/fixtures.ts` +- [ ] **Feature flag backend** — `Flag` schema + per-tenant rollout state + + a tiny flag-eval client every service imports +- [ ] **Incident management backend** — `Incident` schema + paging + (PagerDuty / OpsGenie / custom). Until then, IncidentModal is mock. +- [ ] **Support ticket queue** — `SupportTicket` schema + email-in + ingestion from a dedicated mailbox via Stalwart +- [ ] **Self-serve Partner portal at `partner.dezky.local`** — own Nuxt + app, own OAuth client, scoped to a partner's own customers +- [ ] **Real environment switcher** — currently cosmetic; would need + separate API endpoints per env, separate Authentik tenants +- [ ] **Real on-call indicator** — integration with the paging system from + the incident backend +- [ ] **Operator workspace impersonation in OCIS/Stalwart** — operator + tooling reaches into the customer's files + mail for support, with + the same audit trail +- [ ] **MRR aggregation on Partner** when Subscription gains real pricing +- [ ] **MFA-required Authentik policy** on the `dezky-operator` provider + (deferred from O.1) +- [ ] **Delete throwaway endpoints** added during verification: + `apps/operator/server/api/_verify-token.get.ts`, + `apps/portal/server/api/_verify-token.get.ts`, + `apps/operator/server/api/operator-smoke-test.post.ts`, + `apps/portal/server/api/partners/index.post.ts` ## Phase 5: Custom webmail (week 3-4) diff --git a/docs/OPERATOR-PLAN.md b/docs/OPERATOR-PLAN.md index 243c1bf..39e7199 100644 --- a/docs/OPERATOR-PLAN.md +++ b/docs/OPERATOR-PLAN.md @@ -474,17 +474,41 @@ forward as bearer to platform-api. overrides in tokens.css. - [x] Layout wires ⌘K + ⌘[ globally. Topbar reads env from `useTweaks`. -### O.9 · Verification +### O.9 · Verification ✓ -- [ ] Sign in to `operator.dezky.local` as akadmin via the new OAuth client -- [ ] Confirm JWT audience is `dezky-operator` (decode in DevTools, post - response back) -- [ ] Create a real Partner via the UI, see it in Mongo -- [ ] Attach the `acme` tenant to that partner; verify count goes 0 → 1 -- [ ] Suspend a tenant from the Danger tab; confirm `status: 'suspended'` - in Mongo -- [ ] Sign in to `app.dezky.local` simultaneously in another browser - profile, confirm the customer portal still works and that customer - token's `aud` is `dezky-portal` -- [ ] Tick all the relevant follow-up tasks in NEXT-STEPS.md as remaining - work, file separate issues if anything was deferred +All smokes ran end-to-end on 2026-05-24 against the live local stack. + +- [x] Signed in to `operator.dezky.local` as akadmin via the + `dezky-operator` OAuth client. +- [x] JWT audience confirmed via `GET /api/_verify-token`: + ``` + iss: https://auth.dezky.local/application/o/dezky-operator/ + aud: dezky-operator + sub: bc865e33... + groups: [authentik Admins, dezky, dezky-platform-admins] + ``` +- [x] Created `verify-msp` Partner via the UI ("New partner" modal) — verified + in Mongo: `_id: 6a129d6a44c0f44fddda34bf`, `marginPct: 15`. +- [x] Attached `acme` tenant via the Attach modal on the partner detail page; + Mongo confirmed `tenants.acme.partnerId == partners.verify-msp._id` and + the customers count in the UI rose from 0 → 1. +- [x] Suspended `acme` from the Danger tab — Mongo confirmed + `tenants.acme.status == 'suspended'`. Resumed it back to `active` + afterwards so the dev tenant stays usable. +- [x] Signed in to `app.dezky.local` in a parallel tab; `GET /api/_verify-token` + there returned `aud: dezky-portal`, `iss: .../dezky-portal/`. Both + sessions coexist; each app uses its own per-app issuer + audience. + `GET /api/me` on the portal still returns profile + tenants + + subscriptions correctly. +- [x] Follow-up tasks rolled into NEXT-STEPS.md under + "Follow-ups before operator hits production". + +### Throwaway artifacts left behind for now + +These were added during O.9 verification and can be ripped out when the +relevant production gates land: + +- `apps/operator/server/api/_verify-token.get.ts` — JWT claim echo +- `apps/portal/server/api/_verify-token.get.ts` — JWT claim echo +- `apps/operator/server/api/operator-smoke-test.post.ts` — O.3-era audience check +- `apps/portal/server/api/partners/index.post.ts` — O.2-era audience-deny verifier