225 lines
13 KiB
Markdown
225 lines
13 KiB
Markdown
# Admin-Panel SSO via Authentik
|
|
|
|
Phase 09 Task #82. Status: **operational**.
|
|
|
|
This document covers operating the admin-panel SSO flow once Phase 09 #81
|
|
has shipped the Authentik instance.
|
|
|
|
## Overview
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Browser
|
|
participant Web as Web (admin host)
|
|
participant Authentik
|
|
participant DB
|
|
|
|
Browser->>Web: GET /auth/admin/login
|
|
Web->>Web: createAdminSsoChallenge() (PKCE+state+nonce)
|
|
Web-->>Browser: 302 -> Authentik authorize, Set-Cookie: __Host-hivegrid_admin_sso_challenge
|
|
Browser->>Authentik: GET /application/o/authorize/...
|
|
Authentik-->>Browser: Login + MFA, then 302 /auth/admin/callback?code=...&state=...
|
|
Browser->>Web: GET /auth/admin/callback (with cookie)
|
|
Web->>Web: consumeAdminSsoChallenge() -> verify state+nonce+sig
|
|
Web->>Authentik: POST /token (PKCE verifier + client_secret_post)
|
|
Authentik-->>Web: id_token + access_token
|
|
Web->>Web: extractAdminSsoClaims(idToken) + email_verified check
|
|
Web->>DB: jitProvisionAdmin() -> upsert User + AdminUser
|
|
Web->>DB: createAuditEvent('admin.login.sso.success')
|
|
Web-->>Browser: 302 /admin/dashboard + Set-Cookie session + id_token cookie
|
|
|
|
Note over Browser,Web: Logout (RP-initiated)
|
|
Browser->>Web: POST /auth/admin/logout
|
|
Web->>Web: readIdTokenFromRequest() + destroyAppSession()
|
|
Web-->>Browser: 302 Authentik end_session_endpoint?id_token_hint=...
|
|
Browser->>Authentik: end_session
|
|
Authentik-->>Browser: 302 post_logout_redirect_uri -> /login?logged_out=1
|
|
```
|
|
|
|
## Admin cookie scope
|
|
|
|
Production and every HTTPS deployment use host-only
|
|
`__Host-hivegrid_admin_sso_challenge` and
|
|
`__Host-hivegrid_admin_sso_id_token` cookies. Both are `Secure`, `HttpOnly`,
|
|
`SameSite=Lax`, `Path=/`, and never carry a `Domain` attribute. `Path=/` is
|
|
required by the browser-enforced `__Host-` contract; because the admin UI has
|
|
its own hostname, that dedicated host is the narrowest safe compatible scope.
|
|
The application does not accept the former unprefixed names on HTTPS, so a
|
|
parent-domain or sibling-host cookie cannot shadow an admin SSO cookie.
|
|
|
|
Local HTTP development uses the distinct unprefixed names
|
|
`hivegrid_admin_sso_challenge` and `hivegrid_admin_sso_id_token`, without
|
|
`Secure`. They are compatibility fallbacks only and are never read by an
|
|
HTTPS deployment.
|
|
|
|
## Required environment
|
|
|
|
See `.env.example` for the canonical commented block. Coolify deployment:
|
|
|
|
| Key | Source |
|
|
| ------------------------------------------ | ---------------------------------------------------------------- |
|
|
| `AUTHENTIK_ISSUER_URL` | `https://auth.<base-domain>/application/o/hivegrid-admin-panel/` |
|
|
| `AUTHENTIK_ADMIN_CLIENT_ID` | Blueprint client_id (default `hivegrid-admin-panel`) |
|
|
| `AUTHENTIK_ADMIN_CLIENT_SECRET` | Authentik admin UI (Coolify vault) |
|
|
| `AUTHENTIK_ADMIN_JIT_DATABASE_URL` | PgBouncer URL for the dedicated `hivegrid_authentik_jit` login |
|
|
| `AUTHENTIK_ADMIN_REDIRECT_URI` | `https://admin.<base-domain>/auth/admin/callback` |
|
|
| `AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI` | `https://admin.<base-domain>/login?logged_out=1` |
|
|
| `ADMIN_FALLBACK_LOGIN_ENABLED` | `false` (default), flip to `true` during recovery |
|
|
| `ADMIN_FALLBACK_LOGIN_EMAILS` | Comma-separated bootstrap emails |
|
|
|
|
The regular web `DATABASE_URL` remains authenticated as `hivegrid_app` and
|
|
cannot execute the Admin-JIT function. Only the verified
|
|
`/auth/admin/callback` path opens the separate
|
|
`AUTHENTIK_ADMIN_JIT_DATABASE_URL`; its `hivegrid_authentik_jit` role owns no
|
|
table or sequence privileges and receives `EXECUTE` only for
|
|
`hivegrid_provision_authentik_admin(...)`. PostgreSQL also checks
|
|
`session_user`, so switching roles or forwarding freely constructed claims
|
|
through the application connection fails closed.
|
|
|
|
Before deploying the RLS cutover into an existing environment, provision the
|
|
new login externally with the other managed roles. The non-superuser migrator
|
|
never creates or alters roles:
|
|
|
|
```sh
|
|
HIVEGRID_RLS_PROVISION_DATABASE_URL=<SUPERUSER_DATABASE_URL> \
|
|
HIVEGRID_RLS_MIGRATOR_PASSWORD=<MIGRATOR_PASSWORD> \
|
|
HIVEGRID_RLS_APP_PASSWORD=<APP_PASSWORD> \
|
|
HIVEGRID_RLS_WORKER_PASSWORD=<WORKER_PASSWORD> \
|
|
HIVEGRID_RLS_AUTHENTIK_JIT_PASSWORD=<AUTHENTIK_JIT_PASSWORD> \
|
|
pnpm run db:roles:provision
|
|
```
|
|
|
|
Then add the matching, PgBouncer-fronted
|
|
`AUTHENTIK_ADMIN_JIT_DATABASE_URL` only to the web resource. Run role
|
|
provisioning before `prisma migrate deploy`; the migration deliberately stops
|
|
with a prerequisite error when any managed role is absent or unsafe.
|
|
|
|
## Authentik group → role mapping
|
|
|
|
The OIDC `groups` claim drives the AdminUser role. Highest priority wins.
|
|
|
|
| Authentik group | Internal AdminUser role | Notes |
|
|
| ---------------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
|
| `hivegrid-admin` | `super_admin` | Full admin permissions; if user is in both `hivegrid-admin` and `hivegrid-staff`, this wins. |
|
|
| `hivegrid-staff` | `staff` | Limited admin permissions. |
|
|
| _other_ | _login rejected (403)_ | `admin.login.sso.failure` audit event with `reason=forbidden-group`. |
|
|
|
|
Granular permission checks remain in `apps/web/app/utils/permissions/`;
|
|
the role is the coarse switch.
|
|
|
|
## Onboarding a new admin
|
|
|
|
1. Create the user in Authentik (manual or via SCIM/LDAP if integrated).
|
|
2. Add the user to the `hivegrid-admin` or `hivegrid-staff` group.
|
|
3. The provider-specific authentication flow enrolls or validates TOTP.
|
|
4. User clicks "Sign in with SSO" on `https://admin.<domain>/login`.
|
|
5. First login triggers JIT-provisioning — AdminUser row appears in DB
|
|
with `ssoProvider='authentik'`, `ssoSubjectId=<sub>`, `ssoGroups=[...]`,
|
|
`lastSsoLoginAt=<now>`.
|
|
|
|
## Offboarding an admin
|
|
|
|
1. **Authentik** — remove the user from `hivegrid-admin`/`hivegrid-staff`
|
|
or deactivate the user.
|
|
2. Next SSO login will be rejected (`admin.login.sso.failure` with
|
|
`reason=forbidden-group`).
|
|
3. **Force-Logout** existing session: until the app session cookie expires,
|
|
the admin can still load admin pages. Force-revoke via:
|
|
- Operator CLI (planned, Phase 10): `pnpm cli admin:force-logout --userId=<id>`.
|
|
- Or: clear the user's session row directly in DB (search by `userId`).
|
|
- Or: rotate `SESSION_SECRET` (invalidates every session — last resort).
|
|
|
|
## Fallback (emergency) login
|
|
|
|
The email/password fallback bypasses Authentik. It is locked down by two
|
|
env-gates plus the existing rate-limit on `/login`:
|
|
|
|
- `ADMIN_FALLBACK_LOGIN_ENABLED=true`
|
|
- `ADMIN_FALLBACK_LOGIN_EMAILS` contains the requesting email (case-insensitive)
|
|
|
|
Every fallback attempt — success or failure — writes an
|
|
`admin.login.fallback.*` audit event with `severity=warn` in the metadata
|
|
(also visible as a Sentry breadcrumb when the reporter is configured).
|
|
|
|
### Disaster-recovery procedure
|
|
|
|
1. Verify Authentik is genuinely down. Sentry banner + `/auth/admin/login`
|
|
redirect failing both confirm.
|
|
2. In Coolify, set:
|
|
```
|
|
ADMIN_FALLBACK_LOGIN_ENABLED=true
|
|
ADMIN_FALLBACK_LOGIN_EMAILS=bootstrap@<base-domain>
|
|
```
|
|
Redeploy the web service.
|
|
3. Visit `https://admin.<base-domain>/login` — expand the disclosure
|
|
("Use emergency password login") and sign in with the bootstrap admin.
|
|
4. Resolve the Authentik outage (see `docs/operations/authentik-setup.md`).
|
|
5. **Immediately** revert the env flag back to `ADMIN_FALLBACK_LOGIN_ENABLED=false`
|
|
and redeploy. Verify the fallback toggle is no longer visible.
|
|
6. Resolve the Sentry alert / Slack-notification (when wired).
|
|
|
|
The drill MUST be exercised end-to-end at least once on staging before
|
|
production rollout.
|
|
|
|
## App-MFA gate (Phase 01 #26 TOTP)
|
|
|
|
`requireAdminMfaForAction(request, action, userId)` enforces a fresh TOTP
|
|
step-up challenge for the closed taxonomy:
|
|
|
|
```ts
|
|
const SENSITIVE_ADMIN_ACTIONS = ["impersonation.start", "tenant.delete", "permission.grant", "audit.export", "data.export"] as const;
|
|
```
|
|
|
|
The cache window matches the existing step-up state (10 minutes — see
|
|
`MFA_STEP_UP_MAX_AGE_MS`). Successful challenges write
|
|
`admin.mfa.app.challenge.success`; redirects to enrollment/challenge write
|
|
`admin.mfa.app.challenge.failure`. Enrollment + challenge UI is the
|
|
existing `/admin/security/mfa-*` route from Phase 01 #26 — no new TOTP
|
|
implementation here.
|
|
|
|
## Audit-event reference
|
|
|
|
| Action key | Severity | Emitted by |
|
|
| --------------------------------- | -------- | --------------------------------------------- |
|
|
| `admin.login.sso.success` | info | `/auth/admin/callback` after JIT success |
|
|
| `admin.login.sso.failure` | warn | Any `/auth/admin/*` early-return path |
|
|
| `admin.login.fallback.success` | warn | `/login` action (admin host, fallback path) |
|
|
| `admin.login.fallback.failure` | warn | `/login` action when gate rejects |
|
|
| `admin.logout.sso` | info | `/auth/admin/logout` |
|
|
| `admin.mfa.app.challenge.success` | info | `requireAdminMfaForAction` happy path |
|
|
| `admin.mfa.app.challenge.failure` | warn | `requireAdminMfaForAction` redirect/deny path |
|
|
|
|
All events carry `ip`, `userAgent`, `actorAdminUserId` (when resolvable)
|
|
and an action-specific `meta` JSON blob. The audit wrapper redacts any
|
|
suspicious key (authorization/cookie/password/secret/token) before
|
|
persisting to `AuditEvent`.
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Cause / Fix |
|
|
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
| `/auth/admin/login` redirects to `/login?error=sso_unavailable` | env vars missing or Authentik unreachable. Check Sentry for `AdminSsoConfigError` (`discovery-failed`). |
|
|
| `error=sso_state_invalid` after Authentik | Challenge cookie expired, signed under stale secret, or tampered. Have user retry; check `CRYPTO_SECRET` rotation. |
|
|
| `error=sso_forbidden_group` | User not in `hivegrid-admin`/`hivegrid-staff`. Add them in Authentik then retry. |
|
|
| `error=sso_email_not_verified` | Authentik user has not verified their email; enforce verification in Authentik provider settings. |
|
|
| `error=sso_mfa_required` | The ID token has no `mfa` AMR. Assign the dedicated HiveGrid authentication flow to the provider, verify that its TOTP validation stage uses `configure` for users without a device, and keep the authorization request at `max_age=1` so a prior Authentik session cannot bypass validation. A first enrollment creates the device; the following fresh authentication proves possession. Do not enable the password fallback. |
|
|
| `error=sso_token_exchange_failed` | Client secret mismatch, clock-skew, or redirect-URI drift. Compare `.env` vs Authentik UI. |
|
|
| JWKS-stale after key rotation | openid-client refreshes JWKS lazily; first failure forces a refetch. If repeated, restart the web container. |
|
|
| Group claim empty in ID-token | Authentik provider must include `groups` scope mapping — see blueprint `03-application-hivegrid-admin.yaml`. |
|
|
|
|
## Rollback / kill-switch
|
|
|
|
- **MFA-flow rollback**: restore the provider's previous `authentication_flow`
|
|
from the pre-change Authentik snapshot. Keep the application-side MFA claim
|
|
check fail-closed; do not use fallback login as a substitute for MFA.
|
|
- **Disable SSO entry-point only**: unset the Authentik env vars and
|
|
enable the fallback. `/auth/admin/login` will redirect with
|
|
`?error=sso_unavailable`; the UI shows only the password form.
|
|
- **Disable the whole flow**: revert the commits tagged
|
|
`09/admin-oidc-login: ...`. The Prisma migration is additive
|
|
(`AdminUser` SSO columns are nullable) so no data-loss.
|
|
- **Compromise (client_secret leak)**: rotate via Authentik admin UI →
|
|
update `AUTHENTIK_ADMIN_CLIENT_SECRET` in Coolify vault → redeploy.
|
|
Existing app sessions remain valid (they are independent of the
|
|
Authentik secret).
|