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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
+37
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user