From 0b269e7ea70a3ae572504f07e110a4def09d42a5 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sat, 30 May 2026 15:48:01 +0200 Subject: [PATCH] feat(auth): enforce operator/partner platform isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partner or tenant admin could complete the dezky-operator OIDC flow and land on the operator portal. The platform-api OperatorGuard already 403s their data, but the login/UI layer had no authorization check at all — the only gate was a manual Authentik UI setting with nothing in git enforcing it. Close it with defense-in-depth across three independent layers: 1. IdP — operator-application.yaml blueprint binds an ak_is_group_member("dezky-platform-admins") policy to the dezky-operator app, so Authentik denies the OIDC flow for non-admins. The blueprint also provisions the provider + application (state: created, so a fresh env is built from code while an existing hand-made provider is left untouched). Wire OPERATOR_OIDC_* into both authentik containers and mount the blueprints dir on the worker (it applies blueprints, and previously lacked the mount). 2. Operator app — require-platform-admin.global.ts requires platformAdmin and routes a non-admin to not-authorized.vue, which triggers a full sign-out (local + Authentik IdP) for shared-workstation safety. Fails open on a transient /api/me error by design, to avoid mass-signout on platform-api restarts; layers 1 and 3 contain the exposure. 3. platform-api — OperatorGuard (unchanged) requires dezky-operator audience plus platformAdmin resolved from the DB on every request. Also harden the partner surface: it shares the dezky-portal client with tenant users so it has no IdP gate, and its /partner/* route middleware now fails CLOSED when identity can't be confirmed. Docs (AUTHENTIK-SETUP.md) and .env.example updated; the operator client secret must be set before first boot since the blueprint now consumes it. --- .env.example | 13 ++ .../require-platform-admin.global.ts | 52 +++++++ apps/operator/pages/not-authorized.vue | 130 ++++++++++++++++ .../middleware/partner-routing.global.ts | 21 ++- docs/AUTHENTIK-SETUP.md | 115 +++++++++++++++ .../blueprints/operator-application.yaml | 139 ++++++++++++++++++ .../docker-compose/docker-compose.yml | 12 ++ 7 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 apps/operator/middleware/require-platform-admin.global.ts create mode 100644 apps/operator/pages/not-authorized.vue create mode 100644 infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml 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 @@ + + + + + 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: