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:
@@ -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
|
||||
# ────────────────────────────────────────
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
// Shown when an authenticated-but-non-operator session reaches the operator
|
||||
// portal (see middleware/require-platform-admin.global.ts). The account is
|
||||
// valid in Authentik but lacks platformAdmin — e.g. a partner or tenant user
|
||||
// who completed the dezky-operator OIDC flow.
|
||||
//
|
||||
// We do NOT leave them parked here with a live session: that's the
|
||||
// shared-workstation risk the full-sign-out rule guards against. We trigger the
|
||||
// same full sign-out the UserMenu uses — clearing both the local
|
||||
// nuxt-oidc-auth session and the Authentik IdP session (so this also ends their
|
||||
// SSO session in any other tab — intended for an elevated context).
|
||||
//
|
||||
// A short delay lets them actually read why before the redirect through
|
||||
// Authentik fires; the button is an immediate-out fallback. The timer is
|
||||
// cleared on unmount so a manual click can't double-fire it.
|
||||
|
||||
definePageMeta({ layout: 'blank', auth: false, oidcAuth: { enabled: false } })
|
||||
|
||||
const SIGN_OUT_DELAY_MS = 2200
|
||||
|
||||
function signOut() {
|
||||
return navigateTo('/api/auth/sign-out', { external: true })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setTimeout(signOut, SIGN_OUT_DELAY_MS)
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="card">
|
||||
<div class="badge">
|
||||
<UiIcon name="shield" :size="22" />
|
||||
</div>
|
||||
<p class="eyebrow">dezky · ops</p>
|
||||
<h1>Not an operator account</h1>
|
||||
<p class="lead">
|
||||
This account doesn't have operator access. For your security we're
|
||||
signing you out completely — sign in with an operator account to
|
||||
continue.
|
||||
</p>
|
||||
<button class="primary" type="button" @click="signOut">Sign out now</button>
|
||||
<p class="hint">operator.dezky.local</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 36px 36px 32px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(240, 88, 88, 0.12);
|
||||
color: var(--bad);
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.55;
|
||||
margin: 14px 0 26px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:hover { filter: brightness(0.96); }
|
||||
|
||||
.hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-mute);
|
||||
margin: 22px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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('/')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user