feat: deploy Immich with Authentik OIDC, rock as admin

Self-hosted photo backup (Google Photos replacement) - raw manifests,
no Helm chart, self-contained under k8s/apps/immich including its own
CNPG Postgres. Media PVC shares the cp-3 HDD 2TB/2TB with
paperless-media.

Postgres is pg18, not this repo's usual 16.2: CNPG's official pgvector
extension image (ghcr.io/cloudnative-pg/pgvector) is only published
for pg18, loaded via CNPG's ImageVolume extension mechanism (operator
1.30.0 / k8s 1.36.1 both support it). Immich auto-manages CREATE
EXTENSION itself at startup.

OIDC via a new "immich_role" Authentik scope mapping (homelab-admins/
immich-admins -> "admin" claim, else "user"), consumed by Immich's
OAuth roleClaim setting which re-syncs isAdmin on every login - more
reliable than Immich's racy first-user-is-admin fallback. Config
composed into an immich-oidc Secret and mounted as IMMICH_CONFIG_FILE,
matching the paperless-oidc pattern. k8s RBAC (immich-operator Role +
oidc:immich-admins binding) mirrors paperless/rbac.yaml.

immich namespace pre-created in k8s/infra/databases/namespaces.yaml
(not just immich's own CreateNamespace=true) since the iam PostSync
job's RoleBinding needs it to exist before wave 8.
This commit is contained in:
Story Crater Bot
2026-08-25 18:21:28 -07:00
parent 1518ebc2dd
commit 61a1975669
13 changed files with 451 additions and 2 deletions
@@ -115,6 +115,20 @@ roleRef:
name: authentik-provisioner
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: authentik-provisioner
namespace: immich
subjects:
- kind: ServiceAccount
name: authentik-provisioner
namespace: iam
roleRef:
kind: ClusterRole
name: authentik-provisioner
apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1
kind: Job
metadata:
+72 -2
View File
@@ -173,6 +173,7 @@ GROUP_PERMISSIONS = {
"temporal-admins": ["temporal:read", "temporal:write"],
"llm-admins": ["llm:read", "llm:write"],
"paperless-admins": ["paperless:read", "paperless:write"],
"immich-admins": ["immich:read", "immich:write"],
"k8s-devops-admin": ["k8s:devops"],
}
perms = set()
@@ -193,6 +194,28 @@ permissions_mapping = get_or_create(
)
PERMISSIONS_MAPPING_PK = permissions_mapping["pk"]
# Immich reads a "immich_role" claim on every login (not just user-creation -
# fixed upstream in immich-app/immich#29991) and syncs isAdmin from it, so
# this is the actual mechanism that makes "rock" an Immich admin - not
# Immich's first-user-is-admin fallback, which races badly with OAuth login.
_IMMICH_ROLE_EXPR = (
"return {\"immich_role\": \"admin\" "
"if request.user.ak_groups.filter(name__in=[\"homelab-admins\", \"immich-admins\"]).exists() "
"else \"user\"}"
)
immich_role_mapping = get_or_create(
"/api/v3/propertymappings/provider/scope/",
"/api/v3/propertymappings/provider/scope/",
"scope_name=immich_role",
{
"name": "homelab: immich role claim",
"scope_name": "immich_role",
"expression": _IMMICH_ROLE_EXPR,
},
patch_existing={"expression": _IMMICH_ROLE_EXPR},
)
IMMICH_ROLE_MAPPING_PK = immich_role_mapping["pk"]
# MinIO maps OIDC users to a MinIO policy via a "policy" claim
# (MINIO_IDENTITY_OPENID_CLAIM_NAME=policy). Emit consoleAdmin (full admin) for
# homelab-admins members, readonly for everyone else. Without this claim MinIO
@@ -242,7 +265,7 @@ homelab_admins = get_or_create(
SERVICE_ADMIN_GROUP_NAMES = [
"grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins",
"portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins",
"paperless-admins", "k8s-devops-admin",
"paperless-admins", "immich-admins", "k8s-devops-admin",
]
service_admin_groups = {}
for group_name in SERVICE_ADMIN_GROUP_NAMES:
@@ -353,6 +376,22 @@ SERVICES = {
"launch_url": "https://paperless.riotpiao.com",
"display_name": "Paperless-ngx",
},
"immich": {
# No secret exists yet for immich - generate + store on first run.
"client_secret_source": ("immich", "immich-oidc", "CLIENT_SECRET"),
"generate_if_missing": True,
# /auth/login + /user-settings are Immich's own web callback routes;
# /api/oauth/mobile-redirect forwards to the app.immich:///oauth-callback
# custom scheme Authentik can't register directly (see docs.immich.app/
# administration/oauth - "custom scheme" workaround).
"redirect_uris": [
"https://immich.riotpiao.com/auth/login",
"https://immich.riotpiao.com/user-settings",
"https://immich.riotpiao.com/api/oauth/mobile-redirect",
],
"launch_url": "https://immich.riotpiao.com",
"display_name": "Immich",
},
}
app_pks_for_binding = []
@@ -360,7 +399,9 @@ app_pks_for_binding = []
for name, cfg in SERVICES.items():
# MinIO also needs the "policy" claim (via the minio scope mapping) so its
# MINIO_IDENTITY_OPENID_CLAIM_NAME=policy maps homelab-admins -> consoleAdmin.
provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else [])
# Immich needs "immich_role" so its OAuth roleClaim can grant admin.
provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) \
+ ([IMMICH_ROLE_MAPPING_PK] if name == "immich" else [])
ns, secret_name, key = cfg["client_secret_source"]
client_secret = kubectl_get_secret_key(ns, secret_name, key)
if client_secret is None:
@@ -409,6 +450,34 @@ for name, cfg in SERVICES.items():
"SOCIALACCOUNT_PROVIDERS_JSON": providers_json,
})
if name == "immich":
# Immich reads its whole system-config from IMMICH_CONFIG_FILE (a
# mounted JSON file, see k8s/apps/immich/deployment.yaml), not
# discrete env vars. "immich_role" must be in `scope` for Authentik
# to actually include that claim in the token (non-default scopes
# are opt-in per-client, same reason paperless requests "permissions"
# explicitly). roleClaim is re-evaluated on every login (immich-app/
# immich#29991) so this is the actual admin-grant mechanism for rock,
# not Immich's racy first-user-is-admin fallback.
immich_config_json = json.dumps({
"oauth": {
"enabled": True,
"issuerUrl": "https://authentik.riotpiao.com/application/o/immich/",
"clientId": "immich",
"clientSecret": client_secret,
"scope": "openid email profile immich_role",
"roleClaim": "immich_role",
"autoRegister": True,
"autoLaunch": False,
"buttonText": "Login with Authentik",
"mobileRedirectUri": "app.immich:///oauth-callback",
},
})
kubectl_create_secret("immich", "immich-oidc", {
"CLIENT_SECRET": client_secret,
"config.json": immich_config_json,
})
provider = get_or_create(
"/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/",
f"name={name}",
@@ -544,6 +613,7 @@ SERVICE_GROUP_TO_APP_SLUG = {
"forgejo-admins": "forgejo",
"homarr-admins": "homarr",
"paperless-admins": "paperless",
"immich-admins": "immich",
}
for group_name, app_slug in SERVICE_GROUP_TO_APP_SLUG.items():
app_pk = next((pk for n, pk in app_pks_for_binding if n == app_slug), None)