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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user