diff --git a/.env.example b/.env.example
index 24582d6..83cd43e 100644
--- a/.env.example
+++ b/.env.example
@@ -30,6 +30,19 @@ AUTHENTIK_BOOTSTRAP_PASSWORD=admin_change_this_after_first_login
# AUTHENTIK_BOOTSTRAP_TOKEN is used by the provisioning service to call Authentik API
AUTHENTIK_BOOTSTRAP_TOKEN=changeme_use_openssl_rand_hex_32
+# ────────────────────────────────────────
+# Operator OIDC (dezky-operator)
+# ────────────────────────────────────────
+# The operator app differs from the portal: its OAuth provider is provisioned
+# declaratively by the operator-application blueprint, which CONSUMES the secret
+# below (rather than Authentik generating one for you to copy out). You must set
+# a value BEFORE first boot — on a fresh environment the blueprint creates the
+# provider with exactly this secret, and the operator container authenticates
+# with the same value, so the two only agree if it's set here first.
+# Generate with: openssl rand -hex 64
+OPERATOR_OIDC_CLIENT_ID=dezky-operator
+OPERATOR_OIDC_CLIENT_SECRET=changeme_run_openssl_rand_hex_64
+
# ────────────────────────────────────────
# Stalwart Mail
# ────────────────────────────────────────
diff --git a/apps/operator/middleware/require-platform-admin.global.ts b/apps/operator/middleware/require-platform-admin.global.ts
new file mode 100644
index 0000000..948c38e
--- /dev/null
+++ b/apps/operator/middleware/require-platform-admin.global.ts
@@ -0,0 +1,52 @@
+// Authorization gate for the operator portal.
+//
+// nuxt-oidc-auth's global middleware only proves *authentication* — "does
+// this browser hold a valid dezky-operator session?". It says nothing about
+// whether the person is actually an operator. Without this middleware, anyone
+// who completes the dezky-operator OIDC flow (a partner, a tenant admin, any
+// Authentik user) lands on the full operator shell. The platform-api still
+// 403s their data calls via OperatorGuard, but *being on the operator app at
+// all* is the violation — and it leaves only the Authentik application policy
+// standing between a non-admin and the UI. This is the second, in-app layer.
+//
+// Runs after 00.auth.global (nuxt-oidc-auth), so by the time we get here the
+// session exists. We resolve the operator's own profile and require
+// platformAdmin=true. A signed-in non-admin is fully signed out (local +
+// Authentik IdP) — never silently left with a live operator session on what
+// may be a shared workstation — and shown /not-authorized.
+//
+// /api/me is SSR-safe via useRequestFetch (it forwards the session cookie),
+// so there's no flash of operator chrome before the redirect.
+
+export default defineNuxtRouteMiddleware(async (to) => {
+ // Public surfaces: the login bounce, the sign-out landing, and the
+ // not-authorized page itself must stay reachable without the check.
+ if (
+ to.path.startsWith('/auth/') ||
+ to.path === '/signed-out' ||
+ to.path === '/not-authorized'
+ ) {
+ return
+ }
+
+ const { fetchMe, isPlatformAdmin } = useMe()
+ const me = await fetchMe()
+
+ // fetchMe() collapses every failure to null — both "not signed in" and
+ // "signed in, but /api/me (→ platform-api) errored transiently". We let BOTH
+ // through here. The not-signed-in case is handled by the OIDC middleware's
+ // bounce to login. The API-error case is a DELIBERATE fail-OPEN: failing
+ // closed would bounce every operator to /not-authorized — and thus fully
+ // sign them out — on any platform-api restart, a self-inflicted mass-signout
+ // on routine deploys. The exposure from failing open is contained by the
+ // other two layers: Authentik's application policy stops a non-admin from
+ // ever obtaining a session (layer 1), and OperatorGuard 403s every data call
+ // regardless of what the UI renders (layer 3). See docs/AUTHENTIK-SETUP.md →
+ // "Operator portal isolation". (The partner middleware fails CLOSED instead
+ // because the partner surface has no layer-1 IdP gate.)
+ if (!me) return
+
+ if (!isPlatformAdmin.value) {
+ return navigateTo('/not-authorized')
+ }
+})
diff --git a/apps/operator/pages/not-authorized.vue b/apps/operator/pages/not-authorized.vue
new file mode 100644
index 0000000..3f23f7d
--- /dev/null
+++ b/apps/operator/pages/not-authorized.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
dezky · ops
+
Not an operator account
+
+ This account doesn't have operator access. For your security we're
+ signing you out completely — sign in with an operator account to
+ continue.
+
+
+
operator.dezky.local
+
+
+
+
+
diff --git a/apps/portal/middleware/partner-routing.global.ts b/apps/portal/middleware/partner-routing.global.ts
index bff495f..f4540c9 100644
--- a/apps/portal/middleware/partner-routing.global.ts
+++ b/apps/portal/middleware/partner-routing.global.ts
@@ -7,19 +7,36 @@
// fetched lazily via useMe() and cached in useState — first nav after sign-in
// pays one round-trip, subsequent navs read from cache.
//
+// The partner surface shares the dezky-portal OAuth client with ordinary
+// tenant users (a tenant admin authenticates here legitimately), so there is
+// no IdP-level gate the way the operator app has — this redirect plus the
+// platform-api's per-endpoint partnerId checks are the whole defense. Because
+// of that, /partner/* must fail CLOSED: if we can't positively confirm the
+// caller is partner staff (e.g. /api/me errored transiently, so `me` is null),
+// we keep them out rather than letting the page shell render. Data is always
+// backend-guarded, but the shell shouldn't show to a non-partner.
+//
// Auth pages (/auth/*, /signed-out) are skipped because they're public.
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path.startsWith('/auth/') || to.path === '/signed-out') return
+ const onPartnerSurface = to.path.startsWith('/partner')
+
const { fetchMe, isPartnerStaff } = useMe()
const me = await fetchMe()
- if (!me) return // Not signed in yet — OIDC middleware handles the bounce
+
+ // Couldn't resolve identity. For non-partner routes, defer to the OIDC
+ // middleware's bounce. For partner routes, fail closed — unconfirmed is
+ // not-partner.
+ if (!me) {
+ return onPartnerSurface ? navigateTo('/') : undefined
+ }
if (to.path === '/' && isPartnerStaff.value) {
return navigateTo('/partner')
}
- if (to.path.startsWith('/partner') && !isPartnerStaff.value) {
+ if (onPartnerSurface && !isPartnerStaff.value) {
return navigateTo('/')
}
})
diff --git a/docs/AUTHENTIK-SETUP.md b/docs/AUTHENTIK-SETUP.md
index cc6ab69..cf7871a 100644
--- a/docs/AUTHENTIK-SETUP.md
+++ b/docs/AUTHENTIK-SETUP.md
@@ -130,6 +130,121 @@ EOF
Note: Stalwart's OIDC integration is configured in `infrastructure/docker-compose/configs/stalwart/config.toml`. For local dev with internal users, OIDC is optional.
+### 3.5 Operator application (blueprint-managed)
+
+Unlike the providers above, the **operator** application is **not** created by
+hand — it's provisioned by an Authentik blueprint that ships in the repo:
+
+```
+infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml
+```
+
+The `authentik-worker` mounts the blueprints directory and applies the file
+automatically on boot. It provisions, in one place:
+
+- the `dezky-operator` OAuth2 provider (mirrors the portal provider's shape:
+ implicit-consent flow, default self-signed signing key, openid/email/profile
+ scope mappings, hashed sub, per-provider issuer)
+- the `dezky-operator` application (slug `dezky-operator`)
+- the `dezky-platform-admins` group
+- an expression policy `operator-require-platform-admin` and its binding to the
+ application — **this is what restricts who can log into the operator portal**
+ (see [Operator portal isolation](#operator-portal-isolation-three-layers)).
+
+**Create-only semantics.** The provider and application use Authentik's
+`state: created` — *create if absent, never update if present*. So:
+
+- On a **fresh** environment, the blueprint builds the whole operator app for
+ you. No manual clicks.
+- On an **existing** environment where the operator provider was made by hand,
+ the blueprint leaves the live provider (its client secret, its scope
+ mappings) **untouched**, and only adds/reconciles the group, policy, and
+ binding.
+
+The provider/app are matched by `client_id` / `slug` (globally unique, stable),
+so create-only reliably recognizes the existing objects regardless of their
+display name.
+
+**Env it reads.** On a fresh provision the blueprint sets the provider's
+client credentials from these vars, which the `authentik-server` and
+`authentik-worker` containers receive (see `docker-compose.yml`):
+
+```
+OPERATOR_OIDC_CLIENT_ID=dezky-operator
+OPERATOR_OIDC_CLIENT_SECRET=
+```
+
+**On a fresh environment, set `OPERATOR_OIDC_CLIENT_SECRET` in `.env` BEFORE
+first boot** (`.env.example` ships a placeholder with an `openssl rand -hex 64`
+note). This is the inverse of the portal flow: the portal's secret is
+*generated by Authentik* and copied out into `.env`, whereas the operator
+secret is *supplied by you* and consumed by the blueprint when it creates the
+provider. The operator container authenticates with the same
+`OPERATOR_OIDC_CLIENT_SECRET` (passed in as its `NUXT_OIDC_CLIENT_SECRET`), so
+the two only agree if the value is present before the blueprint runs — leave it
+empty and the blueprint provisions the provider with an empty secret and the
+OIDC handshake fails.
+
+On your existing environment these are already populated in `.env` (and the
+blueprint's `state: created` won't touch the live provider anyway), so nothing
+changes for you.
+
+The resulting issuer URL is `https://auth.dezky.local/application/o/dezky-operator/`.
+platform-api accepts both the portal and operator issuers/audiences — see
+`AUTHENTIK_ISSUER` / `AUTHENTIK_AUDIENCE` in `docker-compose.yml`.
+
+**Apply / verify:**
+
+```bash
+# Worker picks up the mount + env and applies the blueprint
+docker compose -f infrastructure/docker-compose/docker-compose.yml up -d authentik-worker
+
+# Watch it apply (look for the blueprint name / any !Find that failed to resolve)
+docker compose -f infrastructure/docker-compose/docker-compose.yml logs authentik-worker | grep -i blueprint
+```
+
+Then in the UI: **Applications → dezky-operator → Bindings** should list
+`operator-require-platform-admin` (enabled).
+
+## Operator portal isolation (three layers)
+
+The operator portal (`operator.dezky.local`) carries elevated, cross-tenant
+privilege. A partner or a tenant admin must **never** be able to log into it.
+That guarantee is defense-in-depth across three independent layers — a single
+misconfiguration in any one of them does not open the door:
+
+1. **IdP (Authentik) — stops the login itself.** The
+ `operator-require-platform-admin` policy bound to the `dezky-operator`
+ application (§3.5) is evaluated *before* Authentik issues the authorization.
+ A user who is not in `dezky-platform-admins` is denied during the OIDC flow
+ and never reaches the app. This is the primary gate.
+
+2. **Operator app — refuses to render for a non-admin.** The global middleware
+ `apps/operator/middleware/require-platform-admin.global.ts` resolves the
+ signed-in user's profile and requires `platformAdmin=true`. A non-admin who
+ somehow holds a valid operator session is routed to
+ `apps/operator/pages/not-authorized.vue`, which triggers a **full sign-out**
+ (local nuxt-oidc-auth session **and** the Authentik IdP session) — never
+ leaving a live operator session parked on a shared workstation.
+
+3. **platform-api — refuses the data.** `OperatorGuard`
+ (`services/platform-api/src/auth/operator.guard.ts`) requires **both** a
+ `dezky-operator` token audience **and** `platformAdmin=true` resolved from
+ the DB on every request. The DB check means revoking someone's admin status
+ takes effect immediately, without waiting for their token to expire.
+
+`dezky-platform-admins` is the single source of truth tying these together:
+Authentik gates membership (layer 1), and platform-api maps that group to the
+`platformAdmin` flag it enforces (layers 2 and 3).
+
+**Partner ⇄ customer note.** The partner surface lives *inside* the shared
+`dezky-portal` OAuth client (tenant admins authenticate there legitimately), so
+it cannot have an IdP-level gate like the operator app. Its isolation is the
+portal's fail-closed `/partner/*` route middleware plus platform-api's
+per-endpoint `partnerId` checks. If the partner surface ever needs the same
+IdP-level isolation as operator, it must become its own OAuth client/application
+(at which point this same blueprint pattern applies to it).
+
## 4. Get the API token for platform-api
platform-api needs to call Authentik's API to create tenants, users, and applications. `.env` holds a pre-generated value in `AUTHENTIK_BOOTSTRAP_TOKEN`, but Authentik 2025.10 does **not** materialize that env var into a usable API token on first boot. You need to create the token once and bind it to `akadmin`.
diff --git a/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml b/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml
new file mode 100644
index 0000000..c0d1be9
--- /dev/null
+++ b/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml
@@ -0,0 +1,139 @@
+# Provisions the dezky-operator OIDC application AND restricts it to platform
+# admins — the IdP-level half of operator isolation, captured as code instead
+# of manual UI clicks.
+#
+# WHY: The operator portal's first line of defense is *which Authentik users
+# may complete its OIDC flow*. Without an application policy binding, ANY
+# Authentik user — a partner, a tenant admin, an end user — can authorize
+# against dezky-operator and land on the operator UI. platform-api's
+# OperatorGuard still 403s their data, and the operator app's
+# require-platform-admin middleware signs them out, but those are layers 2 and
+# 3. This blueprint is layer 1: Authentik itself refuses the login unless the
+# user is in `dezky-platform-admins` (the same group platform-api treats as the
+# source of truth for platformAdmin).
+#
+# CREATE-ONLY for the provider + application. The dezky-operator provider was
+# originally created by hand; its client_secret and scope mappings are live and
+# must not be clobbered. `state: created` means "create if absent, never update
+# if present" — so a FRESH environment gets the app built from this file, while
+# an EXISTING environment keeps its hand-made provider exactly as-is. The group,
+# policy, and binding use `state: present` so they reconcile (and the binding is
+# enforced) on both fresh and existing environments.
+#
+# The provider/app are matched by client_id / slug (both globally unique and
+# stable) rather than display name, so `state: created` reliably recognizes the
+# existing objects regardless of what they were named in the UI.
+#
+# Applied by the authentik-worker, which mounts this dir at /blueprints/custom
+# and reads OPERATOR_OIDC_CLIENT_ID / OPERATOR_OIDC_CLIENT_SECRET from its env
+# (see docker-compose.yml). On a fresh env the !Env client_secret below must
+# match what the operator container uses (same .env var) so the two agree.
+#
+# VERIFY after apply (Authentik UI):
+# Applications → dezky-operator → Bindings lists
+# "operator-require-platform-admin" (enabled). Then: a non-admin test user is
+# denied at login; an admin user still gets in.
+
+version: 1
+metadata:
+ name: dezky-operator-application
+ labels:
+ blueprints.goauthentik.io/instantiate: "true"
+
+entries:
+ # ── Source of truth for operator privilege ───────────────────────────────
+ # find-or-create; safe to reconcile whether made by hand or by platform-api's
+ # bootstrap. Identified by name to match platform-api's
+ # PLATFORM_ADMIN_BOOTSTRAP_GROUP (default "dezky-platform-admins").
+ - model: authentik_core.group
+ state: present
+ identifiers:
+ name: dezky-platform-admins
+ attrs:
+ name: dezky-platform-admins
+
+ # ── OAuth2 / OIDC provider ───────────────────────────────────────────────
+ # Create-only: never mutate the live, hand-made provider. Mirrors the portal
+ # provider's shape (see docs/AUTHENTIK-SETUP.md §3.3): implicit-consent flow,
+ # the default self-signed signing key, the standard openid/email/profile
+ # scope mappings (the default `profile` mapping carries the groups claim that
+ # platformAdmin bootstrap relies on), hashed sub, per-provider issuer.
+ - id: operator-oauth2-provider
+ model: authentik_providers_oauth2.oauth2provider
+ state: created
+ identifiers:
+ client_id: !Env [OPERATOR_OIDC_CLIENT_ID, dezky-operator]
+ attrs:
+ name: dezky-operator
+ client_type: confidential
+ client_id: !Env [OPERATOR_OIDC_CLIENT_ID, dezky-operator]
+ client_secret: !Env OPERATOR_OIDC_CLIENT_SECRET
+ authorization_flow:
+ !Find [
+ authentik_flows.flow,
+ [slug, default-provider-authorization-implicit-consent],
+ ]
+ signing_key:
+ !Find [
+ authentik_crypto.certificatekeypair,
+ [name, "authentik Self-signed Certificate"],
+ ]
+ redirect_uris:
+ - matching_mode: strict
+ url: https://operator.dezky.local/auth/oidc/callback
+ property_mappings:
+ - !Find [
+ authentik_providers_oauth2.scopemapping,
+ [managed, "goauthentik.io/providers/oauth2/scope-openid"],
+ ]
+ - !Find [
+ authentik_providers_oauth2.scopemapping,
+ [managed, "goauthentik.io/providers/oauth2/scope-email"],
+ ]
+ - !Find [
+ authentik_providers_oauth2.scopemapping,
+ [managed, "goauthentik.io/providers/oauth2/scope-profile"],
+ ]
+ sub_mode: hashed_user_id
+ issuer_mode: per_provider
+
+ # ── Application ──────────────────────────────────────────────────────────
+ # Create-only. Slug must stay `dezky-operator` — the issuer URL
+ # (.../application/o/dezky-operator/) and platform-api's accepted audience
+ # both derive from it.
+ - id: operator-application
+ model: authentik_core.application
+ state: created
+ identifiers:
+ slug: dezky-operator
+ attrs:
+ name: Dezky Operator
+ slug: dezky-operator
+ provider: !KeyOf operator-oauth2-provider
+ meta_launch_url: https://operator.dezky.local
+ meta_description: Internal Dezky operator control plane. Platform admins only.
+
+ # ── Access policy: require membership in dezky-platform-admins ────────────
+ - id: operator-require-platform-admin
+ model: authentik_policies_expression.expressionpolicy
+ state: present
+ identifiers:
+ name: operator-require-platform-admin
+ attrs:
+ name: operator-require-platform-admin
+ expression: |
+ return ak_is_group_member(request.user, name="dezky-platform-admins")
+
+ # ── Bind the policy to the application ────────────────────────────────────
+ # Authentik evaluates application policy bindings before issuing the
+ # authorization, so a non-member is denied during the OIDC flow and never
+ # reaches the operator app. `present` so it's enforced on fresh AND existing
+ # environments.
+ - model: authentik_policies.policybinding
+ state: present
+ identifiers:
+ target: !KeyOf operator-application
+ policy: !KeyOf operator-require-platform-admin
+ attrs:
+ enabled: true
+ order: 0
diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml
index 0dd603b..2ad3add 100644
--- a/infrastructure/docker-compose/docker-compose.yml
+++ b/infrastructure/docker-compose/docker-compose.yml
@@ -206,6 +206,12 @@ services:
AUTHENTIK_BOOTSTRAP_EMAIL: admin@dezky.local
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
+ # Consumed by the operator-application blueprint's !Env references when
+ # it create-provisions the dezky-operator OAuth provider on a fresh
+ # environment. On an existing environment the provider already exists and
+ # the blueprint's state:created leaves it untouched, so these are unused.
+ OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
+ OPERATOR_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
volumes:
- authentik_media:/media
- authentik_certs:/certs
@@ -249,10 +255,16 @@ services:
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
+ # The worker is what discovers and applies blueprints, so it needs both
+ # the blueprints mount (below) and the !Env values the operator-application
+ # blueprint references when provisioning the provider on a fresh env.
+ OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
+ OPERATOR_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
volumes:
- authentik_media:/media
- authentik_certs:/certs
- authentik_templates:/templates
+ - ./configs/authentik/blueprints:/blueprints/custom:ro
networks: [dezky]
depends_on:
postgres: