diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 411381e..b0fc046 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -79,9 +79,6 @@ caddy_services: - name: nvr host: "nvr.{{ caddy_domain }}" backend: "https://nvr.tail8d86e.ts.net" - - name: dex - host: "dex.{{ caddy_domain }}" - backend: "https://dex.tail8d86e.ts.net" - name: authentik host: "authentik.{{ caddy_domain }}" backend: "https://authentik.tail8d86e.ts.net" diff --git a/argocd/apps/dex.yaml b/argocd/apps/dex.yaml deleted file mode 100644 index 2da0939..0000000 --- a/argocd/apps/dex.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: dex - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/dex - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: dex - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/manifests/dex/deployment.yaml b/argocd/manifests/dex/deployment.yaml deleted file mode 100644 index ba02856..0000000 --- a/argocd/manifests/dex/deployment.yaml +++ /dev/null @@ -1,53 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: dex - namespace: dex -spec: - replicas: 1 - selector: - matchLabels: - app: dex - template: - metadata: - labels: - app: dex - spec: - containers: - - name: dex - image: registry.ops.eblu.me/blumeops/dex:v1.0.0-nix - ports: - - name: http - containerPort: 5556 - volumeMounts: - - name: config - mountPath: /etc/dex/cfg - readOnly: true - - name: data - mountPath: /var/dex - livenessProbe: - httpGet: - path: /healthz - port: 5556 - initialDelaySeconds: 5 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /healthz - port: 5556 - initialDelaySeconds: 3 - periodSeconds: 10 - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "100m" - volumes: - - name: config - secret: - secretName: dex-config - - name: data - emptyDir: {} diff --git a/argocd/manifests/dex/external-secret.yaml b/argocd/manifests/dex/external-secret.yaml deleted file mode 100644 index 3b9e685..0000000 --- a/argocd/manifests/dex/external-secret.yaml +++ /dev/null @@ -1,55 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: dex-config - namespace: dex -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: dex-config - creationPolicy: Owner - template: - data: - config.yaml: | - issuer: https://dex.ops.eblu.me - storage: - type: sqlite3 - config: - file: /var/dex/dex.db - web: - http: 0.0.0.0:5556 - oauth2: - skipApprovalScreen: true - connectors: - - type: gitea - id: forgejo - name: Forgejo - config: - baseURL: https://forge.ops.eblu.me - clientID: "{{ .forgejoClientID }}" - clientSecret: "{{ .forgejoClientSecret }}" - redirectURI: https://dex.ops.eblu.me/callback - staticClients: - - id: grafana - name: Grafana - secret: "{{ .grafanaClientSecret }}" - redirectURIs: - - "https://grafana.ops.eblu.me/login/generic_oauth" - - "https://grafana.tail8d86e.ts.net/login/generic_oauth" - data: - - secretKey: forgejoClientID - remoteRef: - key: "Dex (blumeops)" - property: forgejo-client-id - - secretKey: forgejoClientSecret - remoteRef: - key: "Dex (blumeops)" - property: forgejo-client-secret - - secretKey: grafanaClientSecret - remoteRef: - key: "Dex (blumeops)" - property: grafana-client-secret diff --git a/argocd/manifests/dex/ingress-tailscale.yaml b/argocd/manifests/dex/ingress-tailscale.yaml deleted file mode 100644 index 4fc1958..0000000 --- a/argocd/manifests/dex/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: dex-tailscale - namespace: dex - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Dex" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "mdi-shield-key" - gethomepage.dev/description: "OIDC identity provider" - gethomepage.dev/href: "https://dex.ops.eblu.me" - gethomepage.dev/pod-selector: "app=dex" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: dex - port: - number: 5556 - tls: - - hosts: - - dex diff --git a/argocd/manifests/dex/kustomization.yaml b/argocd/manifests/dex/kustomization.yaml deleted file mode 100644 index cffcba8..0000000 --- a/argocd/manifests/dex/kustomization.yaml +++ /dev/null @@ -1,9 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: dex -resources: - - external-secret.yaml - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml diff --git a/argocd/manifests/dex/service.yaml b/argocd/manifests/dex/service.yaml deleted file mode 100644 index f29a20d..0000000 --- a/argocd/manifests/dex/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: dex - namespace: dex -spec: - selector: - app: dex - ports: - - name: http - port: 5556 - targetPort: 5556 diff --git a/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml b/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml deleted file mode 100644 index c2c4261..0000000 --- a/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: grafana-dex-oauth - namespace: monitoring -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: grafana-dex-oauth - creationPolicy: Owner - template: - data: - GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "{{ .clientSecret }}" - data: - - secretKey: clientSecret - remoteRef: - key: "Dex (blumeops)" - property: grafana-client-secret diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index 7322144..845e837 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -6,8 +6,8 @@ namespace: monitoring resources: - ingress-tailscale.yaml - external-secret-admin.yaml - - external-secret-authentik-oauth.yaml - external-secret-teslamate-datasource.yaml + - external-secret-authentik-oauth.yaml # Dashboard ConfigMaps - discovered by Grafana sidecar via label grafana_dashboard=1 - dashboards/configmap-borgmatic.yaml - dashboards/configmap-devpi.yaml diff --git a/containers/dex/default.nix b/containers/dex/default.nix deleted file mode 100644 index 08f6926..0000000 --- a/containers/dex/default.nix +++ /dev/null @@ -1,28 +0,0 @@ -# Nix-built Dex OIDC identity provider -# Uses nixpkgs dex-oidc package with Kubernetes CRD storage backend -# Built with dockerTools.buildLayeredImage for efficient layer caching -{ pkgs ? import { } }: - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/dex"; - tag = "latest"; - - contents = [ - pkgs.dex-oidc - pkgs.cacert - pkgs.tzdata - ]; - - config = { - Entrypoint = [ "${pkgs.dex-oidc}/bin/dex" ]; - Cmd = [ "serve" "/etc/dex/cfg/config.yaml" ]; - Env = [ - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "TZDIR=${pkgs.tzdata}/share/zoneinfo" - ]; - ExposedPorts = { - "5556/tcp" = { }; - }; - User = "65534"; - }; -} diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md index 59ffb0a..1f46eaa 100644 --- a/docs/explanation/explanation.md +++ b/docs/explanation/explanation.md @@ -21,5 +21,5 @@ Understanding-oriented content explaining the "why" behind BlumeOps design decis | Article | Description | |---------|-------------| | [[architecture]] | How all the pieces fit together | -| [[federated-login]] | How SSO works across BlumeOps (Dex + Forgejo) | +| [[federated-login]] | How SSO works across BlumeOps (Authentik) | | [[security-model]] | Network security, secrets, and access control | diff --git a/docs/explanation/federated-login.md b/docs/explanation/federated-login.md index 0537ebb..ea89477 100644 --- a/docs/explanation/federated-login.md +++ b/docs/explanation/federated-login.md @@ -1,7 +1,7 @@ --- title: Federated Login -modified: 2026-02-19 -last-reviewed: 2026-02-19 +modified: 2026-02-20 +last-reviewed: 2026-02-20 tags: - explanation - security @@ -23,65 +23,53 @@ Without centralized authentication, every service manages its own users independ - **No single sign-on** — logging into Grafana doesn't help you access ArgoCD - **Inconsistent security** — some services have auth, some don't, and there's no central audit trail -## The Solution: Dex + Forgejo +## The Solution: Authentik -BlumeOps uses a two-layer federated authentication model: +BlumeOps uses [[authentik]] as the central OIDC identity provider. Authentik is the **source of truth** for user identity — users are created and managed in Authentik, and services authenticate against it via OpenID Connect. -1. **[[dex]]** is the OIDC identity provider (IdP). Services like [[grafana]] delegate their login flow to Dex using the OpenID Connect protocol. Dex issues standardized tokens that carry user identity. - -2. **[[forgejo]]** is the upstream identity source. Dex doesn't store users itself — it delegates authentication to Forgejo via OAuth2. Users log in with their Forgejo credentials. - -This separation is intentional. Dex handles the OIDC protocol (token issuance, discovery endpoints, client registration), while Forgejo handles user management (accounts, passwords, 2FA). Each does what it's good at. +This is a deliberate choice: Authentik provides a full-featured identity management UI, Blueprint-driven GitOps configuration, and support for multiple authentication protocols. Services like [[grafana]] delegate their login flow to Authentik using OIDC, and Authentik issues standardized tokens that carry user identity. ## The Login Flow -When a user clicks "Sign in with Dex" on Grafana: +When a user clicks "Sign in with Authentik" on Grafana: ``` -1. Grafana redirects browser to Dex (dex.ops.eblu.me/auth) -2. Dex redirects browser to Forgejo (forge.ops.eblu.me/login/oauth/authorize) -3. User logs in at Forgejo (or is already logged in) -4. Forgejo redirects back to Dex (dex.ops.eblu.me/callback) -5. Dex issues an OIDC token -6. Dex redirects back to Grafana (grafana.ops.eblu.me/login/generic_oauth) -7. Grafana accepts the token, user is logged in +1. Grafana redirects browser to Authentik (authentik.ops.eblu.me/application/o/authorize/) +2. User logs in at Authentik (or is already logged in) +3. Authentik issues an OIDC token +4. Authentik redirects back to Grafana (grafana.ops.eblu.me/login/generic_oauth) +5. Grafana accepts the token, user is logged in ``` -After step 3, if the user is already logged into Forgejo, the remaining steps happen instantly — it feels like a single click. - -## Why Not Just Use Forgejo Directly? - -Forgejo supports OAuth2 provider mode, so services could authenticate against it directly. Dex adds a layer of indirection, which provides: - -- **Protocol translation** — Dex speaks OIDC (a standardized protocol) to downstream services. Not all services speak the same OAuth2 dialect that Forgejo does, but most speak OIDC. -- **Connector flexibility** — Dex can federate to multiple identity sources simultaneously. If a Google or GitHub connector is added later, downstream services don't change at all — they still talk to Dex. -- **Separation of concerns** — Forgejo is a git forge first. Its OAuth2 provider is a secondary feature. Dex is purpose-built for identity federation and handles edge cases (token refresh, JWKS rotation, discovery) more robustly. - -For a single-user homelab, the indirection is admittedly overkill today. But it keeps the architecture clean for future growth — adding a second identity source or a new downstream service is a config change, not an architecture change. +If the user is already logged into Authentik, the flow happens instantly — it feels like a single click. ## Break-Glass Access -Every service that uses Dex SSO also keeps a local admin login. If Dex goes down (or ringtail is offline), recovery works through: +Every service that uses Authentik SSO also keeps a local admin login. If Authentik goes down (or ringtail is offline), recovery works through: 1. SSH to indri 2. Log into ArgoCD with local admin password (from 1Password) 3. Fix whatever is broken -Dex is additive — it's a convenience layer, not a hard dependency. Services never lose their local auth capability. +Authentik is additive — it's a convenience layer, not a hard dependency. Services never lose their local auth capability. ## Cross-Cluster Communication -Dex runs on [[ringtail]]'s k3s cluster while most services run on indri's minikube. This is deliberate — the IdP is independent of the main services cluster. Communication happens via the Tailscale network: +Authentik runs on [[ringtail]]'s k3s cluster while most services run on indri's minikube. This is deliberate — the IdP is independent of the main services cluster. Communication happens via the Tailscale network: -- Grafana (minikube) → `dex.ops.eblu.me` → Caddy (indri) → Tailscale → Dex (ringtail k3s) -- Browser redirects go through `dex.ops.eblu.me` and `forge.ops.eblu.me`, both resolved via Caddy +- Grafana (minikube) → `authentik.ops.eblu.me` → Caddy (indri) → Tailscale → Authentik (ringtail k3s) +- Browser redirects go through `authentik.ops.eblu.me`, resolved via Caddy No k8s-internal DNS crosses cluster boundaries. Everything uses the `*.ops.eblu.me` domain. +## Future Work + +- **Forgejo OIDC:** Make Forgejo an OIDC client of Authentik (deferred — existing `eblume` account needs careful migration) +- **Additional services:** ArgoCD, Miniflux, Immich, Zot (see [[harden-zot-registry]]) + ## Related -- [[dex]] - OIDC identity provider reference -- [[forgejo]] - Upstream OAuth2 provider +- [[authentik]] - OIDC identity provider reference - [[grafana]] - First OIDC client - [[security-model]] - Network security and access control -- [[adopt-oidc-provider]] - Implementation plan (completed) +- [[deploy-authentik]] - Deployment how-to diff --git a/docs/how-to/authentik/deploy-authentik.md b/docs/how-to/authentik/deploy-authentik.md index d9ae037..224701d 100644 --- a/docs/how-to/authentik/deploy-authentik.md +++ b/docs/how-to/authentik/deploy-authentik.md @@ -1,6 +1,5 @@ --- title: Deploy Authentik Identity Provider -status: active modified: 2026-02-20 requires: - build-authentik-container @@ -16,7 +15,7 @@ tags: # Deploy Authentik Identity Provider -Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity provider. Authentik is the **source of truth** for user identity in BlumeOps. Users are created and managed in Authentik; services authenticate against it via OIDC. Forgejo federation is deferred to a future effort (existing `eblume` account has extensive automations that need careful migration). +Replace Dex with [Authentik](https://goauthentik.io/) as the SSO identity provider. Authentik is the **source of truth** for user identity in BlumeOps. Users are created and managed in Authentik; services authenticate against it via OIDC. Forgejo federation is deferred to a future effort (existing `eblume` account has extensive automations that need careful migration). ## Architecture Decisions @@ -33,7 +32,7 @@ Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity pr ## What Was Done -1. Built Nix container image (`v1.1.0-nix`) — `pkgs.authentik` + `coreutils` + `bashInteractive` +1. Built Nix container image (`v1.1.2-nix`) — `pkgs.authentik` + `coreutils` + `bashInteractive` + entrypoint wrapper for blueprint symlinks 2. Created 1Password item "Authentik (blumeops)" with secret key and DB credentials 3. Provisioned `authentik` database and CNPG managed role on `blumeops-pg` 4. Deployed to ringtail k3s: server, worker, Redis (3 deployments) @@ -41,6 +40,8 @@ Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity pr 6. Tailscale Ingress at `authentik.tail8d86e.ts.net` 7. Caddy reverse proxy at `authentik.ops.eblu.me` 8. Completed first-run wizard (admin account created) +9. Migrated Grafana OIDC from Dex to Authentik (Blueprint-driven) +10. Decommissioned Dex (ArgoCD app deleted, manifests removed, Caddy entry removed) ## URLs @@ -55,7 +56,7 @@ Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity pr ## Related -- [[dex]] — Current IdP (to be replaced by [[migrate-grafana-to-authentik]]) +- [[authentik]] — OIDC identity provider - [[federated-login]] — How authentication works across BlumeOps - [[adopt-oidc-provider]] — Dex deployment plan (completed) - [[ringtail]] — Target cluster diff --git a/docs/how-to/authentik/migrate-grafana-to-authentik.md b/docs/how-to/authentik/migrate-grafana-to-authentik.md index 495bdb9..3729cc2 100644 --- a/docs/how-to/authentik/migrate-grafana-to-authentik.md +++ b/docs/how-to/authentik/migrate-grafana-to-authentik.md @@ -1,6 +1,5 @@ --- title: Migrate Grafana to Authentik -status: active modified: 2026-02-20 tags: - how-to @@ -12,65 +11,38 @@ tags: Move Grafana's OIDC authentication from Dex to Authentik, then decommission Dex. -## Context +## What Was Done -Discovered while attempting [[deploy-authentik]]: Authentik is deployed and running, but no services use it yet. Grafana is the first client to migrate. Once Grafana is off Dex, Dex has no remaining clients and can be decommissioned. +### Blueprint loading fix -## What to Do +The Nix-built container hardcoded `blueprints_dir` to its Nix store path, making custom blueprints invisible. Fixed by adding a wrapper entrypoint that symlinks built-in blueprint dirs from `/nix/store/*authentik-django*/blueprints/` into `/blueprints/` at container start, with `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` set in the container env. The `/blueprints` dir is created world-writable by `extraCommands` so user 65534 can write symlinks. Also fixed the `!Env` tag syntax in the blueprint YAML — `!Env` takes a scalar, not a sequence (`!Env FOO` not `!Env [FOO]`). -### Authentik configuration (via API, then capture as Blueprint) +### Authentik configuration (via Blueprint) -1. Create an `admins` group in Authentik -2. Ensure user `blume.erich@gmail.com` is in the `admins` group -3. Create an OAuth2/OIDC provider for Grafana (client ID: `grafana`, redirect URIs for both `grafana.ops.eblu.me` and `grafana.tail8d86e.ts.net`) -4. Create an Application for Grafana linked to the provider, gated to the `admins` group -5. Store the client secret in 1Password "Authentik (blumeops)" as `grafana-client-secret` -6. Capture the configuration as an Authentik Blueprint YAML in the manifests +- Blueprint at `argocd/manifests/authentik/configmap-blueprint.yaml` defines: `admins` group, Grafana OAuth2 provider (client ID: `grafana`), Grafana application, and policy binding +- Blueprint mounted as ConfigMap into worker at `/blueprints/custom/` +- `grafana-client-secret` stored in 1Password "Authentik (blumeops)" +- API token stored as `api-token` in same item ### Grafana configuration -1. Update `argocd/manifests/grafana/values.yaml` — change `auth.generic_oauth` from Dex to Authentik endpoints -2. Replace `external-secret-dex-oauth.yaml` with one that pulls from "Authentik (blumeops)" instead of "Dex (blumeops)" -3. Sync Grafana via ArgoCD and verify SSO login works +- `values.yaml` updated to point at Authentik OIDC endpoints (`authentik.ops.eblu.me`) +- `external-secret-authentik-oauth.yaml` pulls client secret from "Authentik (blumeops)" +- Old Dex OAuth user deleted from Grafana (different `auth_id` caused "user already exists") ### Dex decommission -1. Delete ArgoCD app `dex` -2. Remove `argocd/manifests/dex/` and `argocd/apps/dex.yaml` -3. Remove `dex` entry from Caddy reverse proxy (`ansible/roles/caddy/defaults/main.yml`) -4. Provision Caddy to apply the change +- ArgoCD app `dex` deleted (cascade removed k8s resources from ringtail) +- Removed `argocd/manifests/dex/`, `argocd/apps/dex.yaml`, `external-secret-dex-oauth.yaml` +- Removed `dex` entry from Caddy reverse proxy config -## What Was Done So Far +## Lessons Learned -### Completed - -- API token created and stored in 1Password "Authentik (blumeops)" field `api-token` -- `grafana-client-secret` generated and stored in 1Password "Authentik (blumeops)" -- Blueprint YAML created at `argocd/manifests/authentik/configmap-blueprint.yaml` defining: admins group, Grafana OAuth2 provider, Grafana application, and policy binding -- Blueprint ConfigMap mounted into worker at `/blueprints/custom/` -- ExternalSecret updated to pull `grafana-client-secret` from 1Password -- Grafana `values.yaml` updated to point at Authentik OIDC endpoints -- `external-secret-authentik-oauth.yaml` created to replace `external-secret-dex-oauth.yaml` - -### Blocked: Blueprint not loading - -**Root cause:** The Nix-built container hardcodes `blueprints_dir` to `/nix/store/3h1g...authentik-django-2025.10.1/blueprints` in its `default.yml`. Custom blueprints mounted at `/blueprints/custom/` are invisible because that path is not on the search path. - -**Fix options:** -1. Set env var `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` and mount custom blueprints alongside copies/symlinks of the built-in ones — risky, could break built-in blueprints if the path doesn't include them. -2. Mount the custom blueprint ConfigMap directly into the Nix store blueprints path (e.g., `/nix/store/.../blueprints/custom/`) — fragile, path changes on rebuild. -3. Use the API to apply the configuration and skip file-based blueprints for now. Store the API calls in a mise task for reproducibility. -4. Patch the Nix container to set a writable `blueprints_dir` or create a wrapper that symlinks. - -**Recommendation:** Option 4 (patch container) or option 1 (override env var) are the cleanest. Need to test whether `AUTHENTIK_BLUEPRINTS_DIR` is respected and whether built-in blueprints still load from the Nix store path when overridden. - -## Notes - -- Authentik API token stored as `api-token` in 1Password "Authentik (blumeops)". -- The `admins` group and Grafana provider/application created via API during investigation were cleaned up (deleted). +- `buildLayeredImage`'s `extraCommands` can't access Nix store paths from `contents` — they're in separate layers. Use a runtime entrypoint wrapper for symlinks instead. +- Authentik `!Env` tag takes a bare scalar (`!Env FOO`), not a YAML sequence (`!Env [FOO]`). The `!Find` tag does use sequences. +- When migrating OAuth providers, the subject ID (`auth_id`) changes. Existing Grafana users must be deleted before the new provider can recreate them. ## Related - [[deploy-authentik]] — Parent goal - [[grafana]] — Grafana reference -- [[dex]] — Current IdP being replaced diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index 4d1c364..4e594ef 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -67,7 +67,7 @@ Migration and transition plans for upcoming infrastructure changes. ## Authentik -Mikado chain for replacing Dex with Authentik. Track progress with `mise run docs-mikado deploy-authentik`. +Mikado chain for deploying Authentik. Track progress with `mise run docs-mikado deploy-authentik`. - [[deploy-authentik]] - [[build-authentik-container]] diff --git a/docs/how-to/plans/completed/adopt-oidc-provider.md b/docs/how-to/plans/completed/adopt-oidc-provider.md index 73d5568..627a6c5 100644 --- a/docs/how-to/plans/completed/adopt-oidc-provider.md +++ b/docs/how-to/plans/completed/adopt-oidc-provider.md @@ -10,7 +10,7 @@ tags: # Plan: Adopt OIDC Identity Provider -> **Status:** Completed (2026-02-19) — Phase 1 (Dex + Grafana) +> **Status:** Completed (2026-02-19) — Phase 1 (Dex + Grafana). Dex was subsequently replaced by [[authentik]] (see [[deploy-authentik]]). > **PR:** #222 ## Background @@ -98,7 +98,7 @@ Key design decisions: ## Related -- [[dex]] - Service reference card +- [[authentik]] - Current OIDC identity provider (replaced Dex) - [[federated-login]] - How authentication works across BlumeOps - [[harden-zot-registry]] - Future OIDC client - [[forgejo]] - Upstream OAuth2 provider diff --git a/docs/how-to/plans/completed/completed.md b/docs/how-to/plans/completed/completed.md index d3b1c9f..3ebfb06 100644 --- a/docs/how-to/plans/completed/completed.md +++ b/docs/how-to/plans/completed/completed.md @@ -15,4 +15,4 @@ Plans that have been fully implemented and verified. Kept for historical referen | [[adopt-dagger-ci]] | 2026-02-11 | Adopt Dagger as CI/CD build engine (Phases 1–3) | | [[segment-home-network]] | 2026-02-14 | Manual three-network segmentation for UniFi Express 7 | | [[operationalize-reolink-camera]] | 2026-02-15 | Deploy Frigate NVR stack with Mosquitto, Ntfy, and frigate-notify | -| [[adopt-oidc-provider]] | 2026-02-19 | Deploy Dex OIDC identity provider with Forgejo backend and Grafana SSO | +| [[adopt-oidc-provider]] | 2026-02-19 | Deploy OIDC identity provider with Grafana SSO (initially Dex, replaced by Authentik) | diff --git a/docs/how-to/plans/harden-zot-registry.md b/docs/how-to/plans/harden-zot-registry.md index 78efd70..dc4a516 100644 --- a/docs/how-to/plans/harden-zot-registry.md +++ b/docs/how-to/plans/harden-zot-registry.md @@ -65,7 +65,7 @@ Zot supports native OIDC authentication with a built-in API key feature designed "oidc": { "name": "BlumeOps", "credentialsFile": "/Users/erichblume/.config/zot/oidc-credentials.json", - "issuer": "https://dex.ops.eblu.me", + "issuer": "https://authentik.ops.eblu.me", "scopes": ["openid", "profile", "email"] } } @@ -97,7 +97,7 @@ Zot supports native OIDC authentication with a built-in API key feature designed The OIDC credentials file (client ID and secret) is deployed by Ansible from 1Password — never committed to the repo. **CI push flow after setup:** -1. Log in to zot UI via browser (OIDC redirect to Dex) +1. Log in to zot UI via browser (OIDC redirect to Authentik) 2. Generate an API key: `POST /zot/auth/apikey` with label `forgejo-ci`, scoped to `blumeops/**` 3. Store the key in 1Password (`op://blumeops/zot-ci-apikey/credential`) 4. CI uses the key: `docker login -u eblume -p zak_... registry.ops.eblu.me` @@ -122,7 +122,7 @@ The push-side approach is pragmatic: it prevents accidental overwrites in the no The `ansible/roles/zot/` role needs: -- **New template:** `oidc-credentials.json.j2` (client ID and secret for the Dex OIDC client) +- **New template:** `oidc-credentials.json.j2` (client ID and secret for the Authentik OIDC client) - **Updated config template:** `config.json.j2` gains `http.auth` (openid + apikey) and `accessControl` sections - **Updated config template:** `config.json.j2` gains `externalUrl` set to `https://registry.ops.eblu.me` (required for OIDC callback redirects behind Caddy) - **New variables:** `zot_oidc_client_id` and `zot_oidc_client_secret` sourced from 1Password in the playbook's `pre_tasks` @@ -146,7 +146,7 @@ The minikube containerd config (`ansible/roles/minikube/tasks/main.yml`) current ## Execution Steps 1. **Prerequisite: OIDC provider is running** (see [[adopt-oidc-provider]]) - - Dex (or chosen provider) is deployed and serving `https://dex.ops.eblu.me` + - Authentik (or chosen provider) is deployed and serving `https://authentik.ops.eblu.me` - A zot OIDC client is registered with the provider 2. **Update Ansible role** @@ -161,7 +161,7 @@ The minikube containerd config (`ansible/roles/minikube/tasks/main.yml`) current - Verify unauthenticated push fails: `skopeo copy ... docker://registry.ops.eblu.me/blumeops/test:fail` (should get 401) 4. **Set up OIDC login and generate CI API key** - - Log in to zot UI via browser (OIDC flow through Dex) + - Log in to zot UI via browser (OIDC flow through Authentik) - Generate an API key for CI use, store in 1Password - Verify authenticated push works: `docker login -u eblume -p zak_... registry.ops.eblu.me` @@ -178,7 +178,7 @@ The minikube containerd config (`ansible/roles/minikube/tasks/main.yml`) current - [ ] Anonymous pull works (k8s pods, containerd, curl) - [ ] Pull-through caching still works (pull an uncached image from docker.io) - [ ] Unauthenticated push is rejected (401) -- [ ] OIDC browser login works (redirect to Dex and back) +- [ ] OIDC browser login works (redirect to Authentik and back) - [ ] API key generation works from zot UI - [ ] Authenticated push with API key succeeds - [ ] Pushing a duplicate version tag fails (immutability check) diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index 940246d..70b4ebe 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -68,7 +68,7 @@ Sync order: `1password-connect-ringtail` -> `external-secrets-crds-ringtail` -> | [[frigate]] | `frigate` | NVR with GPU-accelerated detection (RTX 4080) | | [[frigate]]-notify | `frigate` | MQTT-to-ntfy alert bridge | | Mosquitto | `mqtt` | MQTT broker for Frigate events | -| [[dex]] | `dex` | OIDC identity provider (Forgejo-backed) | +| [[authentik]] | `authentik` | OIDC identity provider | | [[ntfy]] | `ntfy` | Push notification server | | nvidia-device-plugin | `nvidia-device-plugin` | Exposes GPU to pods via CDI + nvidia RuntimeClass | diff --git a/docs/reference/reference.md b/docs/reference/reference.md index 5c60599..f070857 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -37,7 +37,7 @@ Individual service reference cards with URLs and configuration details. | [[zot]] | Container registry | indri | | [[devpi]] | PyPI caching proxy | k8s | | [[cv]] | Resume / CV site | k8s | -| [[dex]] | OIDC identity provider | k8s (ringtail) | +| [[authentik]] | OIDC identity provider | k8s (ringtail) | | [[docs]] | Documentation site (Quartz) | k8s | | [[flyio-proxy]] | Public reverse proxy (Fly.io + Tailscale) | Fly.io | | [[automounter]] | SMB share automounter | indri | diff --git a/docs/reference/services/authentik.md b/docs/reference/services/authentik.md new file mode 100644 index 0000000..f52f56a --- /dev/null +++ b/docs/reference/services/authentik.md @@ -0,0 +1,78 @@ +--- +title: Authentik +modified: 2026-02-20 +tags: + - service + - security + - oidc +--- + +# Authentik + +OIDC identity provider for BlumeOps. Authentik is the **source of truth** for user identity — users are created and managed in Authentik, and services authenticate against it via OIDC. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **URL** | https://authentik.ops.eblu.me | +| **Admin UI** | https://authentik.ops.eblu.me/if/admin/ | +| **Tailscale URL** | https://authentik.tail8d86e.ts.net | +| **Namespace** | `authentik` | +| **Cluster** | k3s (ringtail) | +| **Image** | `registry.ops.eblu.me/blumeops/authentik:v1.1.2-nix` | +| **Manifests** | `argocd/manifests/authentik/` | +| **Container build** | `containers/authentik/default.nix` | + +## Architecture + +Authentik runs on [[ringtail]]'s k3s cluster, isolated from the main services on indri's minikube. This means the IdP is independent of the minikube cluster lifecycle. + +Three deployments: +- **server** — HTTP/HTTPS interface, handles OIDC flows +- **worker** — Background tasks, blueprint application +- **redis** — Caching, sessions, task queue + +## Database + +Uses the shared CNPG `blumeops-pg` cluster on [[indri]], accessed cross-cluster via `pg.ops.eblu.me:5432`. Database `authentik` with managed role. + +## Blueprints + +Authentik configuration is managed via Blueprints (YAML) stored as a ConfigMap mounted into the worker at `/blueprints/custom/`. Current blueprints define: + +- `admins` group +- Grafana OAuth2 provider (client ID: `grafana`) +- Grafana application with group-based policy binding + +Blueprint file: `argocd/manifests/authentik/configmap-blueprint.yaml` + +## OIDC Clients + +| Client | Status | +|--------|--------| +| [[grafana]] | Active | + +Future clients: [[forgejo]], [[argocd]], [[miniflux]], [[zot]] + +## Secrets + +Injected via [[external-secrets]] from the "Authentik (blumeops)" 1Password item. + +| 1Password Field | Purpose | +|-----------------|---------| +| `secret-key` | Authentik secret key | +| `db-password` | PostgreSQL password | +| `grafana-client-secret` | OIDC client secret for Grafana | +| `api-token` | Authentik API token | + +## Container Image + +Nix-built via `dockerTools.buildLayeredImage`. The entrypoint wrapper symlinks built-in blueprint directories from the Nix store into `/blueprints/` at runtime, allowing custom blueprints to coexist with defaults. `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` overrides the hardcoded Nix store path. + +## Related + +- [[federated-login]] - How authentication works across BlumeOps +- [[grafana]] - First OIDC client +- [[deploy-authentik]] - Deployment how-to +- [[external-secrets]] - Secrets injection from 1Password diff --git a/docs/reference/services/dex.md b/docs/reference/services/dex.md deleted file mode 100644 index 09b0b30..0000000 --- a/docs/reference/services/dex.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Dex -modified: 2026-02-19 -tags: - - service - - security - - oidc ---- - -# Dex - -OIDC identity provider for BlumeOps. Dex federates authentication — downstream services (Grafana, future ArgoCD, etc.) delegate login to Dex, and Dex delegates to [[forgejo]] as the upstream OAuth2 provider. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://dex.ops.eblu.me | -| **Tailscale URL** | https://dex.tail8d86e.ts.net | -| **Namespace** | `dex` | -| **Cluster** | k3s (ringtail) | -| **Image** | `registry.ops.eblu.me/blumeops/dex:v1.0.0-nix` | -| **Upstream** | https://github.com/dexidp/dex | -| **Manifests** | `argocd/manifests/dex/` | -| **Container build** | `containers/dex/default.nix` | - -## Architecture - -Dex runs on [[ringtail]]'s k3s cluster, isolated from the main services on indri's minikube. This means the IdP is independent of the minikube cluster lifecycle — if minikube goes down, Dex stays up and services can still authenticate once restored. - -``` -User Browser - | - v -Grafana (indri/minikube) --OIDC--> Dex (ringtail/k3s) --OAuth2--> Forgejo (indri/native) - ^ | - | | - +---------------------- redirect back with token -------------------+ -``` - -Cross-cluster communication works because Grafana reaches Dex via `https://dex.ops.eblu.me` (Caddy → Tailscale → ringtail), not k8s-internal DNS. - -## Identity Source - -Dex uses a **Gitea connector** pointed at [[forgejo]] (`https://forge.ops.eblu.me`). Users authenticate with their Forgejo credentials. There are no static passwords — user management happens entirely in Forgejo. - -This means adding a new user to BlumeOps SSO is just creating a Forgejo account. - -## Storage - -SQLite3 with an `emptyDir` volume. This stores refresh tokens and auth codes. A pod restart invalidates active sessions (users re-login), which is acceptable for a homelab. No PVC needed. - -## OIDC Clients - -| Client | Redirect URIs | Status | -|--------|---------------|--------| -| [[grafana]] | `grafana.ops.eblu.me/login/generic_oauth`, `grafana.tail8d86e.ts.net/login/generic_oauth` | Active | - -Future clients: [[argocd]], [[forgejo]], [[miniflux]], [[zot]] - -## Secrets - -All sensitive configuration is injected via [[external-secrets]] from the "Dex (blumeops)" 1Password item. The entire `config.yaml` is templated in the ExternalSecret — nothing sensitive is committed to git. - -| 1Password Field | Purpose | -|-----------------|---------| -| `forgejo-client-id` | OAuth2 app client ID from Forgejo | -| `forgejo-client-secret` | OAuth2 app client secret from Forgejo | -| `grafana-client-secret` | OIDC client secret for Grafana | - -## Endpoints - -| Path | Purpose | -|------|---------| -| `/.well-known/openid-configuration` | OIDC discovery | -| `/auth` | Authorization (browser redirect) | -| `/token` | Token exchange | -| `/userinfo` | User info | -| `/keys` | JWKS (public keys) | -| `/callback` | OAuth2 callback from Forgejo | -| `/healthz` | Health check | - -## Related - -- [[federated-login]] - How authentication works across BlumeOps -- [[forgejo]] - Upstream OAuth2 provider -- [[grafana]] - First OIDC client -- [[routing]] - How Dex is exposed via Caddy -- [[external-secrets]] - Secrets injection from 1Password diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index cbbed04..7db0515 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -77,11 +77,9 @@ The Ansible role authenticates to the Forgejo API using a Personal Access Token This is a bootstrapping requirement - the PAT enables IaC for all other secrets. -## OAuth2 Provider for Dex +## Identity Provider -Forgejo acts as the upstream OAuth2 provider for [[dex]], the BlumeOps OIDC identity provider. An OAuth2 application is registered in Forgejo's Site Administration with a redirect URI pointing to Dex's callback (`https://dex.ops.eblu.me/callback`). Client credentials are stored in 1Password ("Dex (blumeops)"). - -This means Forgejo accounts are the source of truth for BlumeOps SSO identity. Adding a user to any Dex-integrated service (currently [[grafana]]) is just creating a Forgejo account. +[[authentik]] is the BlumeOps OIDC identity provider and source of truth for user identity. Forgejo will eventually authenticate against Authentik as an OIDC client, with user provisioning managed in Authentik. This migration is deferred — the existing `eblume` account has extensive automations that need careful migration. ## Future: Public Access @@ -104,5 +102,5 @@ See [[expose-service-publicly]] for the full howto and dynamic service checklist ## Related - [[argocd]] - Uses Forgejo as git source -- [[dex]] - OIDC identity provider (Forgejo is the upstream OAuth2 source) +- [[authentik]] - OIDC identity provider - [[zot]] - Container registry for built images diff --git a/docs/reference/services/grafana.md b/docs/reference/services/grafana.md index d6a38e7..ef48321 100644 --- a/docs/reference/services/grafana.md +++ b/docs/reference/services/grafana.md @@ -24,10 +24,10 @@ Dashboards and visualization for BlumeOps observability. Grafana supports two login methods: -- **SSO via [[dex]]** — federated login through [[forgejo]] (`auth.generic_oauth`). Users click "Sign in with Dex", authenticate at Forgejo, and are redirected back as Admin. -- **Local admin** — break-glass login using the password from 1Password ("Grafana (blumeops)"). Always available if Dex is down. +- **SSO via [[authentik]]** — OIDC login through Authentik (`auth.generic_oauth`). Users click "Sign in with Authentik", authenticate at Authentik, and are redirected back as Admin. +- **Local admin** — break-glass login using the password from 1Password ("Grafana (blumeops)"). Always available if Authentik is down. -The OIDC client secret is injected via [[external-secrets]] (`grafana-dex-oauth` secret in monitoring namespace). +The OIDC client secret is injected via [[external-secrets]] (`grafana-authentik-oauth` secret in monitoring namespace). ## Datasources @@ -57,7 +57,7 @@ Optional annotation: `grafana_folder: "FolderName"` ## Related -- [[dex]] - OIDC identity provider for SSO +- [[authentik]] - OIDC identity provider for SSO - [[prometheus]] - Metrics datasource - [[loki]] - Logs datasource - [[alloy|Alloy]] - Data collector diff --git a/mise-tasks/services-check b/mise-tasks/services-check index f21cae8..d09b237 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -81,7 +81,7 @@ check_http "Immich" "https://photos.ops.eblu.me/" check_http "Navidrome" "https://dj.ops.eblu.me/" check_http "CV" "https://cv.ops.eblu.me/" check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health" -check_http "Dex" "https://dex.ops.eblu.me/healthz" +check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/" check_http "Frigate" "https://nvr.ops.eblu.me/api/version" echo "" @@ -96,7 +96,7 @@ echo "" echo "Ringtail k3s pods:" check_service "mosquitto" "kubectl --context=k3s-ringtail -n mqtt get pods -l app=mosquitto -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "ntfy" "kubectl --context=k3s-ringtail -n ntfy get pods -l app=ntfy -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "dex" "kubectl --context=k3s-ringtail -n dex get pods -l app=dex -o jsonpath='{.items[0].status.phase}' | grep -q Running" +check_service "authentik" "kubectl --context=k3s-ringtail -n authentik get pods -l component=server -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "frigate" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "frigate-notify" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate-notify -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "nvidia-device-plugin" "kubectl --context=k3s-ringtail -n nvidia-device-plugin get pods -l app=nvidia-device-plugin -o jsonpath='{.items[0].status.phase}' | grep -q Running"