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

@ -2,17 +2,17 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: dex
name: authentik
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/dex
path: argocd/manifests/authentik
destination:
server: https://ringtail.tail8d86e.ts.net:6443
namespace: dex
namespace: authentik
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -0,0 +1,72 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: authentik-blueprints
namespace: authentik
data:
grafana.yaml: |
version: 1
metadata:
name: BlumeOps Grafana SSO
labels:
blueprints.goauthentik.io/description: "Grafana OIDC provider and application"
entries:
# admins group — gates access to admin-only applications
- model: authentik_core.group
id: admins-group
identifiers:
name: admins
attrs:
name: admins
# OAuth2 provider for Grafana
- model: authentik_providers_oauth2.oauth2provider
id: grafana-provider
identifiers:
name: Grafana
attrs:
name: Grafana
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: grafana
client_secret: !Env AUTHENTIK_GRAFANA_CLIENT_SECRET
redirect_uris:
- matching_mode: strict
url: https://grafana.ops.eblu.me/login/generic_oauth
- matching_mode: strict
url: https://grafana.tail8d86e.ts.net/login/generic_oauth
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
sub_mode: hashed_user_id
include_claims_in_id_token: true
# Grafana application — linked to the OAuth2 provider
- model: authentik_core.application
id: grafana-app
identifiers:
slug: grafana
attrs:
name: Grafana
slug: grafana
provider: !KeyOf grafana-provider
meta_launch_url: https://grafana.ops.eblu.me
policy_engine_mode: any
# Policy binding — restrict Grafana to admins group
- model: authentik_policies.policybinding
identifiers:
order: 0
target: !KeyOf grafana-app
group: !KeyOf admins-group
attrs:
target: !KeyOf grafana-app
group: !KeyOf admins-group
order: 0
enabled: true
negate: false
timeout: 30

View file

@ -0,0 +1,31 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-redis
namespace: authentik
spec:
replicas: 1
selector:
matchLabels:
app: authentik
component: redis
template:
metadata:
labels:
app: authentik
component: redis
spec:
containers:
- name: redis
image: docker.io/library/redis:7-alpine
ports:
- name: redis
containerPort: 6379
resources:
requests:
memory: "64Mi"
cpu: "25m"
limits:
memory: "128Mi"
cpu: "100m"

View file

@ -0,0 +1,79 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-server
namespace: authentik
spec:
replicas: 1
selector:
matchLabels:
app: authentik
component: server
template:
metadata:
labels:
app: authentik
component: server
spec:
containers:
- name: server
image: registry.ops.eblu.me/blumeops/authentik:v1.1.2-nix
args: ["server"]
ports:
- name: http
containerPort: 9000
- name: https
containerPort: 9443
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-config
key: secret-key
- name: AUTHENTIK_POSTGRESQL__HOST
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-host
- name: AUTHENTIK_POSTGRESQL__PORT
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-port
- name: AUTHENTIK_POSTGRESQL__NAME
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-name
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-user
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-password
- name: AUTHENTIK_REDIS__HOST
value: authentik-redis
livenessProbe:
httpGet:
path: /-/health/live/
port: 9000
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /-/health/ready/
port: 9000
initialDelaySeconds: 15
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1000m"

View file

@ -0,0 +1,75 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-worker
namespace: authentik
spec:
replicas: 1
selector:
matchLabels:
app: authentik
component: worker
template:
metadata:
labels:
app: authentik
component: worker
spec:
containers:
- name: worker
image: registry.ops.eblu.me/blumeops/authentik:v1.1.2-nix
args: ["worker"]
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-config
key: secret-key
- name: AUTHENTIK_POSTGRESQL__HOST
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-host
- name: AUTHENTIK_POSTGRESQL__PORT
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-port
- name: AUTHENTIK_POSTGRESQL__NAME
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-name
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-user
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-config
key: postgresql-password
- name: AUTHENTIK_REDIS__HOST
value: authentik-redis
- name: AUTHENTIK_GRAFANA_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: authentik-config
key: grafana-client-secret
volumeMounts:
- name: blueprints
mountPath: /blueprints/custom
readOnly: true
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1000m"
volumes:
- name: blueprints
configMap:
name: authentik-blueprints

View file

@ -0,0 +1,43 @@
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-config
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-blumeops
target:
name: authentik-config
creationPolicy: Owner
data:
- secretKey: secret-key
remoteRef:
key: "Authentik (blumeops)"
property: secret-key
- secretKey: postgresql-host
remoteRef:
key: "Authentik (blumeops)"
property: postgresql-host
- secretKey: postgresql-port
remoteRef:
key: "Authentik (blumeops)"
property: postgresql-port
- secretKey: postgresql-name
remoteRef:
key: "Authentik (blumeops)"
property: postgresql-name
- secretKey: postgresql-user
remoteRef:
key: "Authentik (blumeops)"
property: postgresql-user
- secretKey: postgresql-password
remoteRef:
key: "Authentik (blumeops)"
property: postgresql-password
- secretKey: grafana-client-secret
remoteRef:
key: "Authentik (blumeops)"
property: grafana-client-secret

View file

@ -0,0 +1,26 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: authentik-tailscale
namespace: authentik
annotations:
tailscale.com/proxy-class: "default"
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Authentik"
gethomepage.dev/group: "Infrastructure"
gethomepage.dev/icon: "authentik"
gethomepage.dev/description: "Identity provider (SSO)"
gethomepage.dev/href: "https://authentik.ops.eblu.me"
gethomepage.dev/pod-selector: "app=authentik"
spec:
ingressClassName: tailscale
defaultBackend:
service:
name: authentik
port:
number: 9000
tls:
- hosts:
- authentik

View file

@ -0,0 +1,13 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: authentik
resources:
- external-secret.yaml
- configmap-blueprint.yaml
- deployment-server.yaml
- deployment-worker.yaml
- deployment-redis.yaml
- service.yaml
- service-redis.yaml
- ingress-tailscale.yaml

View file

@ -0,0 +1,14 @@
---
apiVersion: v1
kind: Service
metadata:
name: authentik-redis
namespace: authentik
spec:
selector:
app: authentik
component: redis
ports:
- name: redis
port: 6379
targetPort: 6379

View file

@ -0,0 +1,14 @@
---
apiVersion: v1
kind: Service
metadata:
name: authentik
namespace: authentik
spec:
selector:
app: authentik
component: server
ports:
- name: http
port: 9000
targetPort: 9000

View file

@ -55,6 +55,15 @@ spec:
createdb: true
passwordSecret:
name: blumeops-pg-teslamate
# authentik user for Authentik identity provider (runs on ringtail)
- name: authentik
login: true
connectionLimit: -1
ensure: present
inherit: true
createdb: true
passwordSecret:
name: blumeops-pg-authentik
# Resource limits for minikube environment
resources:

View file

@ -0,0 +1,28 @@
# ExternalSecret for Authentik database user password
#
# 1Password item: "Authentik (blumeops)" in blumeops vault
# Field: "postgresql-password"
#
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: blumeops-pg-authentik
namespace: databases
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-blumeops
target:
name: blumeops-pg-authentik
creationPolicy: Owner
template:
type: kubernetes.io/basic-auth
data:
username: authentik
password: "{{ .password }}"
data:
- secretKey: password
remoteRef:
key: Authentik (blumeops)
property: postgresql-password

View file

@ -11,3 +11,4 @@ resources:
- external-secret-eblume.yaml
- external-secret-borgmatic.yaml
- external-secret-teslamate.yaml
- external-secret-authentik.yaml

View file

@ -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: {}

View file

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

View file

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

View file

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

View file

@ -1,13 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: dex
namespace: dex
spec:
selector:
app: dex
ports:
- name: http
port: 5556
targetPort: 5556

View file

@ -2,7 +2,7 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: grafana-dex-oauth
name: grafana-authentik-oauth
namespace: monitoring
spec:
refreshInterval: 1h
@ -10,7 +10,7 @@ spec:
kind: ClusterSecretStore
name: onepassword-blumeops
target:
name: grafana-dex-oauth
name: grafana-authentik-oauth
creationPolicy: Owner
template:
data:
@ -18,5 +18,5 @@ spec:
data:
- secretKey: clientSecret
remoteRef:
key: "Dex (blumeops)"
key: "Authentik (blumeops)"
property: grafana-client-secret

View file

@ -6,8 +6,8 @@ namespace: monitoring
resources:
- ingress-tailscale.yaml
- external-secret-admin.yaml
- external-secret-dex-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

View file

@ -12,7 +12,7 @@ admin:
envFromSecrets:
- name: grafana-teslamate-datasource
optional: true
- name: grafana-dex-oauth
- name: grafana-authentik-oauth
optional: true
# Persistence with PVC for SQLite database
@ -32,13 +32,13 @@ grafana.ini:
allow_embedding: false
auth.generic_oauth:
enabled: true
name: Dex
name: Authentik
client_id: grafana
client_secret: $__env{GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET}
scopes: openid profile email
auth_url: https://dex.ops.eblu.me/auth
token_url: https://dex.ops.eblu.me/token
api_url: https://dex.ops.eblu.me/userinfo
auth_url: https://authentik.ops.eblu.me/application/o/authorize/
token_url: https://authentik.ops.eblu.me/application/o/token/
api_url: https://authentik.ops.eblu.me/application/o/userinfo/
allow_sign_up: true
role_attribute_path: "'Admin'"
auto_login: false