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:
@@ -16,3 +16,11 @@ apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperless
|
||||
---
|
||||
# Needed here (not just immich's own CreateNamespace=true at wave 8) because
|
||||
# k8s/infra/iam's PostSync job (wave 3) has a RoleBinding targeting this
|
||||
# namespace - same ordering reason as paperless above.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: immich
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user