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.
This commit is contained in:
Ronni Baslund
2026-05-24 08:47:56 +02:00
parent c71e782dc0
commit 19e1a4fca3
4 changed files with 148 additions and 22 deletions
@@ -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<string, unknown> {
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,
}
})
@@ -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<string, unknown> {
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,
}
})
+48 -9
View File
@@ -141,17 +141,56 @@ await authentikClient.coreUsersCreate({
}) })
``` ```
## Operator portal — out-of-band track ## Operator portal — out-of-band track — shipped (O.0O.9)
`operator.dezky.local` (internal admin portal — separate Nuxt app, separate `operator.dezky.local` is live as a separate Nuxt app with its own
Authentik OAuth client, real CRUD for tenants + partners). Plan and decisions `dezky-operator` Authentik OAuth client. Full plan and execution log in
captured in [`OPERATOR-PLAN.md`](./OPERATOR-PLAN.md). [`OPERATOR-PLAN.md`](./OPERATOR-PLAN.md).
Touches platform-api substantially: What landed:
- Service rename `services/provisioning` `services/platform-api` (prep) - `services/provisioning` renamed to `services/platform-api`
- New `Partner` schema + CRUD endpoints - Audience-aware JwtAuthGuard accepts both `dezky-portal` and `dezky-operator`
- Tenant lifecycle actions (suspend/resume/plan change) - `Partner` schema + CRUD endpoints, `Tenant.partnerId` ref
- Audience-aware JwtAuthGuard for operator-only mutations - 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) ## Phase 5: Custom webmail (week 3-4)
+37 -13
View File
@@ -474,17 +474,41 @@ forward as bearer to platform-api.
overrides in tokens.css. overrides in tokens.css.
- [x] Layout wires ⌘K + ⌘[ globally. Topbar reads env from `useTweaks`. - [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 All smokes ran end-to-end on 2026-05-24 against the live local stack.
- [ ] Confirm JWT audience is `dezky-operator` (decode in DevTools, post
response back) - [x] Signed in to `operator.dezky.local` as akadmin via the
- [ ] Create a real Partner via the UI, see it in Mongo `dezky-operator` OAuth client.
- [ ] Attach the `acme` tenant to that partner; verify count goes 0 → 1 - [x] JWT audience confirmed via `GET /api/_verify-token`:
- [ ] Suspend a tenant from the Danger tab; confirm `status: 'suspended'` ```
in Mongo iss: https://auth.dezky.local/application/o/dezky-operator/
- [ ] Sign in to `app.dezky.local` simultaneously in another browser aud: dezky-operator
profile, confirm the customer portal still works and that customer sub: bc865e33...
token's `aud` is `dezky-portal` groups: [authentik Admins, dezky, dezky-platform-admins]
- [ ] Tick all the relevant follow-up tasks in NEXT-STEPS.md as remaining ```
work, file separate issues if anything was deferred - [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