diff --git a/dankclaw/observability/quadlet/grafana.container b/dankclaw/observability/quadlet/grafana.container index a790d89..112b724 100644 --- a/dankclaw/observability/quadlet/grafana.container +++ b/dankclaw/observability/quadlet/grafana.container @@ -47,9 +47,24 @@ Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET} # client_id query parameter — /application/o//authorize/ does not exist. Environment=GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://auth.franksdank.cloud/application/o/authorize/ Environment=GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://auth.franksdank.cloud/application/o/token/ -# ?api=1 = introspection-style userinfo: returns custom claims (groups) too. -Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/?api=1 -Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=false +# Standard OIDC userinfo. Grafana v13 fetches /emails for the address +# ONLY when the token carries no `email` claim (authentik has no /emails path and +# 404s it, failing the login) — so the claim must actually be there: the fix is +# the grafana-email-scope mapping (see scripts/authentik-grafana-sso-fix.sh), +# not a Grafana flag. Do NOT append ?api=1 to this URL: Grafana then GETs +# <...>/userinfo/?api=1/emails, a 403. +Environment=GF_AUTH_GENERIC_OAUTH_API_URL=https://auth.franksdank.cloud/application/o/userinfo/ +# authentik's scopes are openid/email/profile. `user:email` (a GitLab-ism) is +# not a known scope here; requesting it makes the authorize view crash. +# Quoted: quadlet word-splits Environment=, so an unquoted spaced list +# truncates SCOPES to its first token. +Environment="GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile" +Environment=GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH=email +Environment=GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH=sub +Environment=GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH=preferred_username +# First SSO login auto-provisions the user. Safe: unauthenticated traffic never +# reaches this callback — the Anubis -> Authentik guard fronts every request. +Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true Environment=GF_AUTH_GENERIC_OAUTH_AUTO_UPSERT=true Environment=GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_ORG=true Environment=GF_AUTH_GENERIC_OAUTH_ORG_ID=1 diff --git a/scripts/authentik-grafana-sso-fix.sh b/scripts/authentik-grafana-sso-fix.sh new file mode 100755 index 0000000..d31ce8b --- /dev/null +++ b/scripts/authentik-grafana-sso-fix.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Repair the Grafana OAuth2 relying-party SSO on Dankclaw's authentik. +# +# grafana.franksdank.cloud logs in through authentik as an OAuth2 relying party. +# Two DB-side facts (the provider was created by hand via `ak shell`, never +# repo-managed) silently broke that login, and BOTH had previously been +# mis-recorded here as a single "the property-mapping evaluator is broken, so +# userinfo returns only `sub`" myth. They are neither, and the evaluator is fine: +# +# 1. provider.authorization_flow was NULL. The authorize view builds a flow +# planner from it and crashes with +# `AttributeError: 'NoneType' object has no attribute 'slug'` — an HTTP 500 +# "Server Error — Powered by authentik", before any claim is touched. +# Fix: point it at default-provider-authorization-implicit-consent, the +# flow every other provider in this instance already uses. +# 2. The `grafana-email-scope` ScopeMapping's expression was the bare string +# `user.email`. authentik merges a scope mapping's result into the token as +# a MAPPING: a returned dict adds its keys as claims; a returned string is +# keyed under the mapping's NAME ("grafana-email-scope"), so no `email` +# claim is ever emitted. Grafana, finding no email, falls back to +# GET /emails, authentik 404s that path, and the login dies with +# "Login failed: InternalError". Fix: return +# {"email": user.email, "email_verified": True} — the shape the stock +# OpenID 'email' mapping uses. The dict-valued `grafana-profile-scope` +# mapping has always worked, which is the proof the evaluator is fine. +# +# This is authentik DB state. It is NOT in any deployable file: a blueprint +# re-import, or an ak-shell re-creation of the grafana provider, reverts BOTH +# facts and the symptom returns verbatim. Re-run this after any such rebuild. +# Every step is idempotent. +# +# Grafana's own half — SCOPES="openid email profile", a standard userinfo API_URL +# with no `?api=1`, and ALLOW_SIGN_UP=true — lives in +# dankclaw/observability/quadlet/grafana.container and deploys normally. +# +# Usage: authentik-grafana-sso-fix.sh (override host: AUTHENTIK_HOST=…) +set -euo pipefail +HOST="${AUTHENTIK_HOST:-dankclaw}" + +ssh "$HOST" 'podman exec -i authentik-server ak shell' <<'PYEOF' +from authentik.flows.models import Flow +from authentik.providers.oauth2.models import OAuth2Provider + +p = OAuth2Provider.objects.filter(name="grafana").first() +if not p: + raise SystemExit("no OAuth2Provider named 'grafana'") + +# 1. The authorization flow every other provider uses. +want = Flow.objects.filter(slug="default-provider-authorization-implicit-consent").first() +if not want: + raise SystemExit("default-provider-authorization-implicit-consent flow is missing") +if p.authorization_flow_id != want.pk: + p.authorization_flow = want + p.save() + print(f"set grafana.authorization_flow -> {want.slug}") +else: + print(f"grafana.authorization_flow already {want.slug}") + +# 2. The email scope mapping must RETURN A DICT, not a bare string. +m = p.property_mappings.filter(name="grafana-email-scope").first() +if not m: + raise SystemExit("grafana-email-scope mapping is missing") +fixed = 'return {"email": user.email, "email_verified": True}' +if m.expression.strip() != fixed: + m.expression = fixed + m.save() + print("repaired grafana-email-scope -> returns {email, email_verified}") +else: + print("grafana-email-scope expression already correct") + +# 3. The id_token must carry the scope claims; Grafana reads identity from it. +if not p.include_claims_in_id_token: + p.include_claims_in_id_token = True + p.save() + print("enabled include_claims_in_id_token") +else: + print("include_claims_in_id_token already on") +PYEOF + +echo "Done. Verify: sign in at https://grafana.franksdank.cloud via Authentik;" +echo "the round trip must land on / and /api/user must answer 200 with an email." diff --git a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md index a3e1f4e..596ac89 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md @@ -81,7 +81,7 @@ Two provisioning rules, each verified live by an outage: appears. Create the Application with `provider_id` set (the UI does this automatically; raw API/ORM drives must do it explicitly). -## OAuth2 relying-party quirks (this fork, verified live 2026-08-31) +## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20) The Grafana RP provider (`grafana`, pk 9, confidential client) was created and verified end-to-end via `ak shell` ORM drives. This fork's OAuth2 provider @@ -100,23 +100,32 @@ implementation diverges from upstream in ways that bit us; all verified live: - **Provider resolution is by `client_id` query param** on the global `/application/o/authorize/` route; per-slug authorize paths do not exist in this build. Token/userinfo live at `/application/o/token/` and - `/application/o/userinfo/?api=1`. -- **Property-mapping evaluator is broken → userinfo returns `sub` only.** - `PropertyMappingEvaluator.evaluate(*args, **kwargs)` forwards kwargs to - `BaseEvaluator.evaluate(expression_source)`, so the first context kwarg - becomes the *expression source* instead of the context; every call raises a - swallowed `PropertyMappingExpressionException` and the claim is skipped. - Scope-grant filtering has the same class of bug: `ScopeMapping` has no - provider FK (linkage lives in the `ProviderPropertyMapping` through model) - so `filter(provider=...)` always yields an empty queryset and tokens asked - to carry scopes get zero. Net effect: userinfo/introspection answers with - `{sub}` only — email/groups/entitlements claims cannot be delivered by this - build until the evaluator is patched or upgraded. Clients consuming this - provider must key identity off `sub`. -- Scope mappings for this provider exist — `grafana-email-scope` and - `grafana-profile-scope` are both rows in `authentik_core_propertymapping` - (read-only `SELECT`, 2026-10-18) — and will start emitting claims the moment - the evaluator bug is fixed; no config change is needed then. + `/application/o/userinfo/` — do **not** append `?api=1` to a Grafana API URL. +- **NULL `authorization_flow` → authorize 500, `AttributeError: 'NoneType' + object has no attribute 'slug'`.** A provider created via raw `ak shell` + `create()` leaves `authorization_flow` unset; the authorize view then crashes + building the flow planner (`flows/planner.py`) and returns "Server Error — + Powered by authentik" before any claim is built. This was previously + mis-attributed to a broken evaluator; the crash is the missing flow. Fix: set + `authorization_flow = default-provider-authorization-implicit-consent` (the + flow every other provider in this instance already uses). +- **A scope mapping that returns a non-dict emits no claim.** authentik merges a + granted scope mapping's evaluated result into the token **as a mapping**: a + dict contributes its keys as claims; a bare string is keyed under the + mapping's NAME, so nothing usable appears. `grafana-email-scope` returned the + bare string `user.email`, so no `email` claim landed — the evaluator was + never broken (the dict-valued `grafana-profile-scope` mapping has always + delivered, and the stock OpenID mappings return dicts). Fix: + `return {"email": user.email, "email_verified": True}`. With no email, Grafana + v13 falls back to GET `/emails`; authentik 404s it → "Login failed: + InternalError". +- **Grafana reads identity from the standard userinfo endpoint** (`API_URL` with + no `?api=1`); no `USE_ID_TOKEN` is needed once the `email` claim exists. +- **Net (2026-10-20):** the evaluator is fine; email/profile claims reach RP + clients. Both IdP fixes are idempotent in + `scripts/authentik-grafana-sso-fix.sh` (re-run after an ak-shell re-creation of + the provider or a blueprint import, which revert them). Grafana's half plus the + verified outcome (Viewer, `login = `, email present) are in [[Grafana]]. ## LDAP provider objects (provisioned via `ak shell` ORM) diff --git a/serverAppConfigs-docs/Infrastructure/Grafana.md b/serverAppConfigs-docs/Infrastructure/Grafana.md index a10bb1a..5d1f028 100644 --- a/serverAppConfigs-docs/Infrastructure/Grafana.md +++ b/serverAppConfigs-docs/Infrastructure/Grafana.md @@ -4,7 +4,7 @@ description: "Grafana visualizes the metrics that Prometheus collects from every tags: - infrastructure - grafana -timestamp: 2026-09-11 +timestamp: 2026-10-20 --- # Grafana @@ -64,19 +64,28 @@ Grafana responds on `/api/health` with 200 and `"database":"ok"`; measured 2026- - Change the default admin credentials at first boot (env or UI). - Dashboards provisioned as code (dashboard JSON plus a YAML provider, `shared/grafana/provisioning/dashboards/`) survive container recreation; UI-created ones live in the data volume — back it up. - **Auth = OAuth2 relying-party, not auth_proxy.** The embedded outpost carries - no provider token (`token=None`), so no identity header ever reaches Grafana; - RP mode is topology-independent. The full verified contract (provider pk 9, - client_id `grafana-21ca190cde75d60f`, global authorize/token/userinfo paths, - Basic client auth, form-encoded token body, and the fork's userinfo-claims - bug that leaves identity at the `sub` hash only) is in - [[Authentik Outpost Reference]] → "OAuth2 relying-party quirks". Consequences for Grafana: - The unit sets `ALLOW_SIGN_UP=false`, `AUTO_UPSERT=true`, `ALLOW_ASSIGN_ORG=true` - `ORG_ID=1` and **no** role-attribute mapping (`grafana.container:52-55`), so - users land as Viewer with `login = `; no email/groups/role claims - reach Grafana until the evaluator bug is fixed — do not write role mappings - that depend on those claims. The local admin account stays the only Admin. - (The Viewer-default outcome and the `` login are not readable from - the host without the database or an admin password. *(unverified 2026-10-18)*) + no provider token (`token=None`), so no identity header reaches Grafana; the + login is Grafana's own "Sign in with Authentik" button against the `grafana` + confidential-client provider. **Verified end-to-end 2026-10-20** after four + independent breakages had made it fail. The two Authentik-side causes (a NULL + `authorization_flow` → authorize 500; an email scope mapping that returned a + bare string so no `email` claim was emitted) and the full wire contract live in + [[Authentik Outpost Reference]] → "OAuth2 relying-party quirks"; the IdP half is + re-applied by `scripts/authentik-grafana-sso-fix.sh` (idempotent — re-run after + an ak-shell re-creation of the provider or a blueprint import, both of which + revert it silently). The Grafana half is the unit's own env (`grafana.container`): + `SCOPES="openid email profile"` (a GitLab-style `user:email` scope crashes the + authorize view), the standard userinfo `API_URL` with **no `?api=1`**, the + attribute paths `email`/`sub`/`preferred_username`, and `ALLOW_SIGN_UP=true` + (safe: the guard fronts the callback). Grafana v13 only GETs `/emails` + when the token carries no `email` claim — so it is the email claim itself (an + IdP fix, above) that makes the login land, not any Grafana toggle. +- **What a new login becomes:** a Viewer in org 1 (`ALLOW_ASSIGN_ORG=true`, + `ORG_ID=1`, no role-attribute mapping), with `login = ` but the real + `email`. The local admin account stays the only Admin. The landing that the + edge-only 13-sso spec never checked is now asserted by + `tests/e2e/specs/20-grafana-sso.spec.ts` (RP round-trip → `/api/user` 200 with + an email). - **Never point this unit's `EnvironmentFile` at the repo-root `.env`.** That file is a gluetun env file and it carries `HOME=/root`; systemd imports it for the service, podman then looks for its configuration under `/root` diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 16f925d..611bffa 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -29,8 +29,10 @@ export default defineConfig({ // green suite with a test that does not exist. 17- joined on 2026-09-16 // (the hermes vhost, whose upstream is a host address rather than a // container name, so it needs its own pass condition). 18- joined on - // 2026-09-24 (OpenWebUI SSO through authentik). - testMatch: /(10|12|13|16|17|18)-.*\.spec\.ts/, + // 2026-09-24 (OpenWebUI SSO through authentik). 20- joined on 2026-10-21 + // (Grafana's OAuth2 relying-party login, which the edge-only 13- never + // follows). + testMatch: /(10|12|13|16|17|18|20)-.*\.spec\.ts/, use: { storageState: ".auth/qa-e2e.json" }, dependencies: ["setup"], }, diff --git a/tests/e2e/specs/20-grafana-sso.spec.ts b/tests/e2e/specs/20-grafana-sso.spec.ts new file mode 100644 index 0000000..d8c850d --- /dev/null +++ b/tests/e2e/specs/20-grafana-sso.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +// Grafana (grafana.franksdank.cloud) is an OAuth2 RELYING PARTY, not an +// outpost-fronted proxy: the guard chain (13-sso) only proves an authenticated +// session reaches Grafana's login page. The login itself happens through +// Grafana's own "Sign in with Authentik" button, which the edge test never +// follows — that is exactly how the RP login could be dead (500 on authorize, +// or an email-claim gap bouncing back to /login) while 13- still passed green. +// This spec drives the real RP round-trip and asserts a live Grafana session. + +const HOST = "grafana.franksdank.cloud"; +const CHALLENGE = "Making sure you're not a bot"; + +test("SSO: Grafana relying-party login lands a real session", async ({ page, request }) => { + test.setTimeout(180_000); + + // Warm the guard: the vhost runs its own Anubis proof-of-work on first visit. + await page.goto(`https://${HOST}/`); + await expect + .poll( + async () => !(await page.locator("body").innerText()).includes(CHALLENGE), + { timeout: 90_000 }, + ) + .toBe(true); + + // The "Sign in with Authentik" button targets this RP start URL exactly. + // Going straight there is the same code path without depending on the SPA's + // button markup. + await page.goto(`https://${HOST}/login/generic_oauth`); + + // The chain: grafana -> auth (implicit consent, the session is already + // there) -> back to grafana with the code -> grafana provisions the user and + // redirects to "/". A dead provider/claims config bounces back to /login. + await expect + .poll( + async () => { + const u = new URL(page.url()); + return u.hostname === HOST && u.pathname !== "/login"; + }, + { timeout: 90_000 }, + ) + .toBe(true); + + const body = await page.locator("body").innerText(); + expect(body).not.toContain("Login failed"); + expect(body.toLowerCase()).not.toContain("powered by authentik"); // IdP flow shell + expect(body).not.toContain(CHALLENGE); + + // A genuine logged-in session exists: /api/user answers 200 with an email, + // not 401. This is the assertion the edge-only spec could never make. + const resp = await page.request.get(`https://${HOST}/api/user`); + expect(resp.status()).toBe(200); + const user = await resp.json(); + expect(typeof user.email).toBe("string"); + expect(user.email.length).toBeGreaterThan(0); +});