feat(auth): enforce operator/partner platform isolation

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.
This commit is contained in:
Ronni Baslund
2026-05-30 15:48:01 +02:00
parent da1b77ba5d
commit 0b269e7ea7
7 changed files with 480 additions and 2 deletions
+115
View File
@@ -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=<must match what the operator container uses>
```
**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`.