Deploy Authentik identity provider (C2 Mikado) (#227)

## Summary
C2 Mikado chain for deploying Authentik as the SSO identity provider, replacing Dex.

This PR will evolve over multiple sessions. Each iteration adds documentation (prerequisite cards) and eventually code as leaf nodes are resolved.

## Current Mikado State
- **Goal:** `deploy-authentik` (active)
- **Leaf prerequisites:**
  - `build-authentik-container` — Build Nix container image
  - `provision-authentik-database` — Create PostgreSQL database on CNPG cluster
  - `create-authentik-secrets` — Create 1Password item with credentials

## Process refinements
- Updated agent-change-process with lessons from first attempt: reset code before committing cards, open PRs early

## Test plan
- [ ] `mise run docs-mikado` shows correct dependency chain
- [ ] Leaf nodes can be worked independently
- [ ] Container builds on ringtail
- [ ] Authentik starts and reaches healthy state
- [ ] Forgejo OAuth2 connector works

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/227
This commit is contained in:
Erich Blume 2026-02-20 12:55:59 -08:00
commit 71cb256527
46 changed files with 848 additions and 395 deletions

View file

@ -0,0 +1 @@
Deploy Authentik identity provider on ringtail k3s cluster, replacing Dex as the SSO provider. Includes Nix-built container, CNPG database, Redis, and Caddy routing at `authentik.ops.eblu.me`.

View file

@ -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 |

View file

@ -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

View file

@ -49,16 +49,39 @@ Like C1 but designed to survive agent context loss across sessions:
1. **Goal card:** Create a how-to doc in `docs/how-to/` describing the desired end state
- Add `status: active` to frontmatter
2. **Attempt the change**, amending the working commit. On failure, revert the broken change and:
- Create/update prerequisite cards as how-to docs with `status: active`
2. **Attempt the change** — GitOps may require pushing code to test (e.g., ArgoCD sync). When the attempt fails:
- **First**, reset the failed code changes (the branch should not carry broken code forward)
- **Then**, create/update prerequisite cards as how-to docs with `status: active`
- Add `requires: [prerequisite-stem, ...]` to the goal card's frontmatter
- Commit the doc updates (the documentation IS the Mikado graph)
- Commit only the doc updates (the documentation IS the Mikado graph)
3. **Work leaf nodes first** — cards with `status: active` and no unmet `requires`
4. **New agent sessions** pick up state by running `mise run docs-mikado`
5. When a card's change succeeds, remove `status: active` (or the entire field) from its frontmatter
4. **Re-attempt the goal** after leaf nodes are resolved — code from the attempt comes back here
5. **New agent sessions** pick up state by running `mise run docs-mikado`
6. When a card's change succeeds, remove `status: active` (or the entire field) from its frontmatter
Documentation IS the Mikado graph. Each card captures what was learned from failed attempts, so the next agent session doesn't repeat mistakes.
### Handling failed attempts
When an attempt fails and you discover prerequisites, the branch must be cleaned up before documenting what you learned:
1. Reset to before the code attempt (`git reset --hard`)
2. Commit the new prerequisite cards and frontmatter updates
3. If you already committed docs mixed with code, cherry-pick the doc commits onto a clean base rather than reverting (avoids noisy add/revert history)
The branch between attempts should contain only documentation. Code returns when prerequisites are satisfied and the next attempt succeeds.
### Build artifacts and tags
Mikado resets apply to branch code, not build artifacts. Container images in the registry and git tags created by `container-tag-and-release` are independent of branch lifecycle:
- **Git tags** point to commit SHAs, not branches — they survive branch deletion and force-pushes.
- **Registry images** are build outputs cached in zot — a wrong image is overwritten by the next release.
- **If a build succeeds but deployment fails**, the image is fine; the problem is elsewhere. Document what you learned, bump the version, and try again.
- **If a build fails in CI**, no image is pushed. Delete the git tag (`git tag -d <tag> && git push --delete origin <tag>`) and fix the nix/dockerfile before re-releasing.
Tag freely during leaf node work. The build IS the verification step — deferring it creates a chicken-and-egg where the card can't be marked complete without a built image.
## Card Conventions
### Frontmatter
@ -81,7 +104,8 @@ tags:
### Writing Cards
- Cards live in `docs/how-to/` — they're how-to docs with lifecycle metadata
- **Mikado cards are not plans.** Plans are designed upfront; Mikado cards are discovered through failed attempts. Don't put Mikado prerequisite cards in `docs/how-to/plans/`.
- Cards live in a topic subdirectory under `docs/how-to/` (e.g., `docs/how-to/authentik/` for the deploy-authentik chain). The goal card may live in `plans/` if it started as a plan.
- Keep cards brief (<30 seconds to read)
- Link to other cards rather than inlining their content
- Document what was learned from failures, not just what to do
@ -89,6 +113,8 @@ tags:
### Git Discipline
- Single feature branch per C1/C2 change
- **Create a PR early** — open a draft PR after the first doc commit so the user can review the Mikado graph as it evolves between iterations.
- **Push after every iteration** — after completing a leaf node or documenting a failed attempt, push to origin. This is the save point for multi-session work.
- Amend a single working commit as you iterate; keep the branch history clean
- GitOps requires pushing to test — if a pushed commit breaks, revert it promptly
- Commit doc updates noting what was learned from failures

View file

@ -0,0 +1,34 @@
---
title: Build Authentik Container Image
modified: 2026-02-20
tags:
- how-to
- authentik
---
# Build Authentik Container Image
Build and publish a Nix-based container image for Authentik to the local registry.
## Context
Discovered while attempting [[deploy-authentik]]: the deployment references `registry.ops.eblu.me/blumeops/authentik:v1.0.0-nix` which doesn't exist. Authentik's nixpkgs package (`pkgs.authentik`) provides the `ak` wrapper which orchestrates a Go server binary and Python Django worker.
## What to Do
1. Verify `containers/authentik/default.nix` builds on ringtail (the Nix builder runs there)
2. The `ak` entrypoint needs bash (included via `bashInteractive`) and orchestrates both `server` and `worker` subcommands
3. Tag and release: `mise run container-tag-and-release authentik v1.0.0`
4. Verify the `-nix` tagged image appears in the registry
## What We Learned
- The entrypoint is `ak` (bash wrapper), not `authentik` (Go binary)
- `ak server` runs the Go HTTP server, `ak worker` runs the Python Django worker
- `pkgs.authentik` bundles Go binary, Python environment, and static assets via `wrapProgram`
- nixpkgs has v2025.10.1, upstream latest is 2025.12.4 — acceptable for initial deployment
- Container needs `bashInteractive` since `ak` is a bash script
## Related
- [[deploy-authentik]] — Parent goal

View file

@ -0,0 +1,32 @@
---
title: Create Authentik Secrets
modified: 2026-02-20
tags:
- how-to
- authentik
- secrets
---
# Create Authentik Secrets
Create the 1Password item that the ExternalSecret references for Authentik configuration.
## What Was Done
1. Created 1Password item "Authentik (blumeops)" in vault `blumeops` (category: database) with fields:
- `secret-key`: random 68-character base64 string (for `AUTHENTIK_SECRET_KEY`)
- `postgresql-host`: `pg.ops.eblu.me`
- `postgresql-port`: `5432`
- `postgresql-name`: `authentik`
- `postgresql-user`: `authentik`
- `postgresql-password`: random 44-character base64 string
2. ExternalSecret `blumeops-pg-authentik` in databases namespace resolves successfully (verified during [[provision-authentik-database]])
## Notes
- The database password in this 1Password item is the same one used by the CNPG managed role via `external-secret-authentik.yaml`. Both the database ExternalSecret and the future Authentik deployment ExternalSecret reference the same 1Password item but different fields.
## Related
- [[deploy-authentik]] — Parent goal
- [[provision-authentik-database]] — Database provisioning (uses `postgresql-password` field)

View file

@ -0,0 +1,63 @@
---
title: Deploy Authentik Identity Provider
modified: 2026-02-20
requires:
- build-authentik-container
- provision-authentik-database
- create-authentik-secrets
- migrate-grafana-to-authentik
tags:
- how-to
- authentik
- security
- oidc
---
# 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).
## Architecture Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Identity model** | Authentik is source of truth | Central user/group management, not Forgejo-upstream like Dex |
| **Cluster** | [[ringtail]] (k3s) | IdP independent of main services cluster, same as Dex |
| **Database** | CNPG `blumeops-pg` on [[indri]] | Cross-cluster via Caddy L4 (`pg.ops.eblu.me`), no new operator needed |
| **Redis** | Co-deployed in authentik namespace | Required for caching/sessions/task queue |
| **Containers** | Nix-built (`dockerTools.buildLayeredImage`) | Supply chain control, consistent with Dex/ntfy pattern |
| **Manifests** | Kustomize (no Helm) | Consistent with all other BlumeOps services |
| **Networking** | Tailscale Ingress + Caddy reverse proxy | Same pattern as Dex |
| **IaC** | Authentik Blueprints (YAML in ConfigMap) | GitOps-native, config stored in repo |
## What Was Done
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)
5. ExternalSecret pulls config from 1Password
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
- **Admin:** https://authentik.ops.eblu.me/if/admin/
- **Tailscale:** https://authentik.tail8d86e.ts.net
## Future Work (not blocking this card)
- **Forgejo federation:** Make Forgejo an OIDC client of Authentik (deferred — needs careful `eblume` account migration)
- **Cross-cluster metrics:** Prometheus on indri scraping authentik on ringtail
- **Redis image:** Replace upstream `redis:7-alpine` with Nix-built container
## Related
- [[authentik]] — OIDC identity provider
- [[federated-login]] — How authentication works across BlumeOps
- [[adopt-oidc-provider]] — Dex deployment plan (completed)
- [[ringtail]] — Target cluster
- [[agent-change-process]] — C2 methodology used for this change

View file

@ -0,0 +1,48 @@
---
title: Migrate Grafana to Authentik
modified: 2026-02-20
tags:
- how-to
- authentik
- grafana
---
# Migrate Grafana to Authentik
Move Grafana's OIDC authentication from Dex to Authentik, then decommission Dex.
## What Was Done
### Blueprint loading fix
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 Blueprint)
- 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
- `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
- 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
## Lessons Learned
- `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

View file

@ -0,0 +1,30 @@
---
title: Provision Authentik Database
modified: 2026-02-20
tags:
- how-to
- authentik
- postgresql
---
# Provision Authentik Database
Create a PostgreSQL database and user for Authentik on the existing CNPG cluster.
## What Was Done
1. Added `authentik` managed role to `blumeops-pg` CNPG cluster (`argocd/manifests/databases/blumeops-pg.yaml`) — non-superuser with `createdb` and `login`
2. Created ExternalSecret `blumeops-pg-authentik` pulling password from 1Password item "Authentik (blumeops)" field `postgresql-password`
3. Synced CNPG cluster — role reconciled with password set
4. Created `authentik` database owned by `authentik` user
5. Verified cross-cluster connectivity: ringtail pod → `pg.ops.eblu.me:5432` (Caddy L4)
## Resolved Questions
- **Hostname:** `pg.ops.eblu.me` via Caddy L4 plugin (not MagicDNS)
- **Permissions:** Non-superuser with `createdb` — Authentik manages its own schema via migrations
## Related
- [[deploy-authentik]] — Parent goal
- [[postgresql]] — CNPG cluster reference

View file

@ -63,5 +63,14 @@ Migration and transition plans for upcoming infrastructure changes.
| [[harden-zot-registry]] | Add authentication and tag immutability to zot registry |
| [[forgejo-actions-dashboard]] | Grafana dashboard for Forgejo Actions CI metrics |
| [[upgrade-grafana-helm-chart]] | Upgrade Grafana Helm chart from 8.8.2 to 11.x |
| [[deploy-authentik]] | Deploy Authentik identity provider to replace Dex |
| [[operationalize-reolink-camera]] | Cloud-free NVR with Frigate and ring buffer recording |
## Authentik
Mikado chain for deploying Authentik. Track progress with `mise run docs-mikado deploy-authentik`.
- [[deploy-authentik]]
- [[build-authentik-container]]
- [[provision-authentik-database]]
- [[create-authentik-secrets]]
- [[migrate-grafana-to-authentik]]

View file

@ -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

View file

@ -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 13) |
| [[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) |

View file

@ -1,40 +0,0 @@
---
title: Deploy Authentik Identity Provider
status: active
modified: 2026-02-20
tags:
- how-to
- plans
- authentik
- security
- oidc
---
# Deploy Authentik Identity Provider
Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity provider. Authentik adds central user/group management, multi-protocol support (OIDC, SAML, LDAP), self-service flows, and an admin UI that Dex lacks. Forgejo remains the upstream identity source via OAuth2 connector.
## Architecture Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Cluster** | [[ringtail]] (k3s) | IdP independent of main services cluster, same as Dex |
| **Database** | CNPG `blumeops-pg` on [[indri]] | Cross-cluster via Tailscale, no new operator needed |
| **Redis** | Co-deployed in authentik namespace | Required for caching/sessions/task queue |
| **Containers** | Nix-built (`dockerTools.buildLayeredImage`) | Supply chain control, consistent with Dex/ntfy pattern |
| **Manifests** | Kustomize (no Helm) | Consistent with all other BlumeOps services |
| **Networking** | Tailscale Ingress + Caddy reverse proxy | Same pattern as Dex |
## Open Questions
- **nixpkgs:** Verify `pkgs.authentik` exists. If not, packaging from source is a significant sub-task.
- **Cross-cluster metrics:** Prometheus on indri scraping authentik on ringtail needs a new pattern (Dex has no metrics collection today).
- **Dex decommission:** Separate effort after all OIDC clients migrate to Authentik.
## Related
- [[dex]] — Current IdP (to be replaced)
- [[federated-login]] — How authentication works across BlumeOps
- [[adopt-oidc-provider]] — Dex deployment plan (completed)
- [[ringtail]] — Target cluster
- [[agent-change-process]] — C2 methodology used for this change

View file

@ -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)

View file

@ -21,5 +21,5 @@ Plans differ from regular how-to guides in that they describe work that has been
| [[harden-zot-registry]] | Planned | Add authentication and tag immutability to zot registry |
| [[forgejo-actions-dashboard]] | Planned | Grafana dashboard and custom Prometheus exporter for Forgejo Actions CI metrics |
| [[upgrade-grafana-helm-chart]] | Planned | Upgrade Grafana Helm chart from 8.8.2 to 11.x (3 phases) |
| [[deploy-authentik]] | Active (C2) | Deploy Authentik identity provider to replace Dex for full SSO and user management |
| [[deploy-authentik]] | Active (C2) | Deploy Authentik IdP — Mikado chain tracked in `how-to/authentik/` |
| [[operationalize-reolink-camera]] | Planned | Cloud-free NVR with Frigate, object detection, and ring buffer recording to sifaka |

View file

@ -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 |

View file

@ -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 |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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