This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

modules.zitadel: declare projects, OIDC apps, grants and IdPs

lass.zitadel describes Zitadel's login policy, its external IdPs and one
project per service with OIDC apps and an access mode:
- public: no "check authorization on authentication", every registered
user may log in.
- declarative: the check is on and the project's user grants are exactly
the listed users (login name or verified email address). Removing a
user from the list revokes the grant; grants given in the console are
removed as well.
- managed: the check is on, the grants are left to the console.

zitadel-reconcile, a oneshot after zitadel, applies it at boot and on
every deploy that changes it. It authenticates with the PAT of a service
user (IAM_OWNER) that zitadel's setup creates with the first instance
(FirstInstance.Org.Machine, PatPath). Setup writes that file world
readable, so /var/lib/zitadel becomes a 0700 state directory. An empty Pat
section creates no token, hence the explicit expiration date (Zitadel's
own default).

A small Python script against the admin/management v1 REST APIs rather
than OpenTofu with the zitadel provider:
- "declarative" has to own all grants of a project, including ones made
in the console; terraform only removes what is in its state.
- Users are listed before they register. A failed user lookup would fail
the whole plan; the script skips such users until the next run.
- No state file that has to match Zitadel: objects are found by name, so
e.g. an app deleted in the console is created again.
- The provider talks gRPC to the external domain, through nginx and TLS;
the script talks plain HTTP to 127.0.0.1 with the Host header.
- Projects and apps removed from the config stay in Zitadel instead of
being destroyed together with their grants.

Zitadel shows a client secret only when it creates one. The script keeps a
root-only copy per app in /var/lib/zitadel-apps and renders the app's env
file from it (path, owner and variable names configurable); without a copy
for the current client id it generates a new secret. Services on other
hosts find their secret there too, for now.

The login policy moves from DefaultInstance, which only applies when the
instance is created, to lass.zitadel.loginPolicy. GitHub and Google logins
come from persisted prompt vars; an empty prompt leaves the IdP out, so
the first deploy works before the OAuth apps exist. Their callback is
/ui/login/login/externalidp/callback (login UI v1, the default in 2.71).
Users may create accounts with them and link them to existing ones.

The script waits for /admin/v1/healthz rather than /debug/ready: the HTTP
gateway's connection to the gRPC API comes up later.

+500 -9
+66 -9
configs/zitadel.nix
··· 22 22 masterkeyFile = cfg.masterKeyFile; 23 23 inherit (cfg) tlsMode; 24 24 }; 25 + idpCredentials = name: url: { 26 + prompts.client_id = { 27 + description = '' 28 + ${name} login on ${domain}: client id of an OAuth app (${url}) 29 + with the callback URL 30 + https://${domain}/ui/login/login/externalidp/callback 31 + Leave empty to not offer ${name} logins. 32 + ''; 33 + persist = true; 34 + }; 35 + prompts.client_secret = { 36 + description = "${name} login on ${domain}: client secret of that OAuth app"; 37 + type = "hidden"; 38 + persist = true; 39 + }; 40 + }; 25 41 in 26 42 { 27 43 # Zitadel: identity provider for lassul.us services (OIDC). Anyone can 28 44 # register; users sign in with a passkey or an external login (GitHub, 29 - # Google, other OIDC) linked to their account. The external IdPs and OIDC 30 - # apps are managed in the console at https://id.lassul.us/ui/console. 45 + # Google, other OIDC) linked to their account. The login policy, the GitHub 46 + # and Google logins and the services' projects and OIDC apps come from 47 + # lass.zitadel (modules/zitadel); the zitadel-reconcile service applies 48 + # them. Further IdPs and the grants of "managed" projects are done in the 49 + # console at https://id.lassul.us/ui/console. 50 + # 51 + # Example projects for services on other hosts, once they speak OIDC (the 52 + # client secret ends up in /var/lib/zitadel-apps/<project>-<app>.env here): 53 + # lass.zitadel.projects.assist = { 54 + # access = "declarative"; 55 + # users = [ "lass@lassul.us" ]; 56 + # apps.web.redirectUris = [ "https://assist.lassul.us/<callback>" ]; 57 + # }; 58 + # lass.zitadel.projects.flix = { 59 + # access = "managed"; 60 + # # jellyfin-plugin-sso with a provider named id.lassul.us 61 + # apps.jellyfin.redirectUris = [ "https://flix.lassul.us/sso/OID/redirect/id.lassul.us" ]; 62 + # }; 31 63 32 64 clan.core.vars.generators.zitadel = { 33 65 files."masterkey".owner = "zitadel"; ··· 68 100 Admin.Username = "zitadel"; 69 101 Admin.ExistingDatabase = "zitadel"; 70 102 }; 71 - # Applied once, when the first instance is created. 72 - DefaultInstance.LoginPolicy = { 73 - AllowRegister = true; 74 - AllowExternalIDP = true; 75 - AllowUsernamePassword = true; 76 - PasswordlessType = 1; # passkeys allowed 77 - }; 78 103 }; 79 104 steps.FirstInstance = { 80 105 InstanceName = "lassul.us"; ··· 93 118 }; 94 119 }; 95 120 }; 121 + 122 + lass.zitadel.loginPolicy = { 123 + allowRegister = true; 124 + allowExternalIdp = true; 125 + allowUsernamePassword = true; 126 + passwordlessType = "PASSWORDLESS_TYPE_ALLOWED"; 127 + }; 128 + lass.zitadel.idps = { 129 + GitHub = { 130 + type = "github"; 131 + clientIdFile = config.clan.core.vars.generators.zitadel-github.files.client_id.path; 132 + clientSecretFile = config.clan.core.vars.generators.zitadel-github.files.client_secret.path; 133 + scopes = [ 134 + "read:user" 135 + "user:email" 136 + ]; 137 + }; 138 + Google = { 139 + type = "google"; 140 + clientIdFile = config.clan.core.vars.generators.zitadel-google.files.client_id.path; 141 + clientSecretFile = config.clan.core.vars.generators.zitadel-google.files.client_secret.path; 142 + scopes = [ 143 + "openid" 144 + "profile" 145 + "email" 146 + ]; 147 + }; 148 + }; 149 + clan.core.vars.generators.zitadel-github = 150 + idpCredentials "GitHub" "https://github.com/settings/applications/new"; 151 + clan.core.vars.generators.zitadel-google = 152 + idpCredentials "Google" "https://console.cloud.google.com/apis/credentials"; 96 153 97 154 # The database and its owner come from ensureDatabases/ensureUsers, so the 98 155 # zitadel role needs no CREATEROLE/CREATEDB. `zitadel init zitadel` creates
+1
modules/default.nix
··· 4 4 ./fetchWallpaper.nix 5 5 ./sync-containers3.nix 6 6 ./parley.nix 7 + ./zitadel 7 8 # copied from stockholm, see the header 8 9 ./reaktor2.nix 9 10 ];
+195
modules/zitadel/default.nix
··· 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + ... 6 + }: 7 + let 8 + cfg = config.lass.zitadel; 9 + zitadel = config.services.zitadel; 10 + appsDir = "/var/lib/zitadel-apps"; 11 + # Written by zitadel's setup when it creates the first instance. 12 + patFile = "/var/lib/zitadel/reconcile.pat"; 13 + 14 + reconcileConfig = pkgs.writeText "zitadel-reconcile.json" ( 15 + builtins.toJSON { 16 + url = "http://127.0.0.1:${toString zitadel.settings.Port}"; 17 + domain = zitadel.settings.ExternalDomain; 18 + stateDir = appsDir; 19 + inherit patFile; 20 + inherit (cfg) loginPolicy idps; 21 + projects = lib.mapAttrs (_: project: { 22 + inherit (project) access users; 23 + apps = lib.mapAttrs (_: app: { 24 + inherit (app) redirectUris envFile; 25 + authMethodType = "OIDC_AUTH_METHOD_TYPE_${lib.toUpper app.authMethod}"; 26 + }) project.apps; 27 + }) cfg.projects; 28 + } 29 + ); 30 + in 31 + { 32 + options.lass.zitadel = { 33 + loginPolicy = lib.mkOption { 34 + type = lib.types.attrsOf lib.types.anything; 35 + default = { }; 36 + example = { 37 + allowRegister = true; 38 + }; 39 + description = '' 40 + Fields of the instance's default login policy, as in Zitadel's admin 41 + API (PUT /admin/v1/policies/login). Unlisted fields keep their value. 42 + ''; 43 + }; 44 + 45 + idps = lib.mkOption { 46 + default = { }; 47 + description = '' 48 + External identity providers, by display name, added to the login 49 + policy. Users may log in, register and link accounts with them. An 50 + IdP whose credential files are empty is skipped. 51 + ''; 52 + type = lib.types.attrsOf ( 53 + lib.types.submodule { 54 + options = { 55 + type = lib.mkOption { 56 + type = lib.types.enum [ 57 + "github" 58 + "google" 59 + ]; 60 + }; 61 + clientIdFile = lib.mkOption { type = lib.types.str; }; 62 + clientSecretFile = lib.mkOption { type = lib.types.str; }; 63 + scopes = lib.mkOption { type = lib.types.listOf lib.types.str; }; 64 + }; 65 + } 66 + ); 67 + }; 68 + 69 + projects = lib.mkOption { 70 + default = { }; 71 + description = '' 72 + Zitadel projects, one per service, in the default organization. 73 + Projects and apps removed from here are left in Zitadel. 74 + ''; 75 + type = lib.types.attrsOf ( 76 + lib.types.submodule ( 77 + { name, ... }: 78 + let 79 + project = name; 80 + in 81 + { 82 + options = { 83 + access = lib.mkOption { 84 + type = lib.types.enum [ 85 + "public" 86 + "declarative" 87 + "managed" 88 + ]; 89 + description = '' 90 + Who may log in to the project's apps: 91 + - public: every user of the instance. 92 + - declarative: the users listed in `users`. All other user 93 + grants of the project are removed. 94 + - managed: users with a user grant on the project, given by 95 + an admin in the console. 96 + ''; 97 + }; 98 + users = lib.mkOption { 99 + type = lib.types.listOf lib.types.str; 100 + default = [ ]; 101 + description = '' 102 + Login names or verified email addresses of the users that 103 + get a user grant on the project (access = "declarative"). 104 + Users who have not registered yet are skipped until the 105 + next run. 106 + ''; 107 + }; 108 + apps = lib.mkOption { 109 + default = { }; 110 + description = "OIDC web apps of the project."; 111 + type = lib.types.attrsOf ( 112 + lib.types.submodule ( 113 + { name, ... }: 114 + { 115 + options = { 116 + redirectUris = lib.mkOption { type = lib.types.listOf lib.types.str; }; 117 + authMethod = lib.mkOption { 118 + type = lib.types.enum [ 119 + "basic" 120 + "post" 121 + ]; 122 + default = "basic"; 123 + description = "How the app sends its client secret to the token endpoint."; 124 + }; 125 + envFile = { 126 + path = lib.mkOption { 127 + type = lib.types.str; 128 + default = "${appsDir}/${project}-${name}.env"; 129 + description = "Env file with the app's client id and secret."; 130 + }; 131 + owner = lib.mkOption { 132 + type = lib.types.str; 133 + default = "root"; 134 + }; 135 + clientIdVariable = lib.mkOption { 136 + type = lib.types.str; 137 + default = "CLIENT_ID"; 138 + }; 139 + clientSecretVariable = lib.mkOption { 140 + type = lib.types.str; 141 + default = "CLIENT_SECRET"; 142 + }; 143 + }; 144 + }; 145 + } 146 + ) 147 + ); 148 + }; 149 + }; 150 + } 151 + ) 152 + ); 153 + }; 154 + }; 155 + 156 + config = lib.mkIf zitadel.enable { 157 + assertions = lib.mapAttrsToList (name: project: { 158 + assertion = project.users == [ ] || project.access == "declarative"; 159 + message = "lass.zitadel.projects.${name}.users needs access = \"declarative\""; 160 + }) cfg.projects; 161 + 162 + services.zitadel.steps.FirstInstance = { 163 + PatPath = patFile; 164 + Org.Machine.Machine = { 165 + Username = "reconcile"; 166 + Name = "zitadel-reconcile"; 167 + }; 168 + # Zitadel's default for tokens without expiry; an empty Pat section 169 + # creates no token. 170 + Org.Machine.Pat.ExpirationDate = "9999-01-01T00:00:00Z"; 171 + }; 172 + systemd.services.zitadel.serviceConfig = { 173 + StateDirectory = "zitadel"; 174 + StateDirectoryMode = "0700"; 175 + }; 176 + 177 + systemd.services.zitadel-reconcile = { 178 + description = "Apply lass.zitadel to Zitadel"; 179 + wantedBy = [ "multi-user.target" ]; 180 + wants = [ "zitadel.service" ]; 181 + after = [ "zitadel.service" ]; 182 + environment.PYTHONUNBUFFERED = "1"; 183 + serviceConfig = { 184 + Type = "oneshot"; 185 + RemainAfterExit = true; 186 + ExecStart = "${ 187 + pkgs.writers.writePython3 "zitadel-reconcile" { } ./reconcile.py 188 + } ${reconcileConfig}"; 189 + StateDirectory = "zitadel-apps"; 190 + StateDirectoryMode = "0711"; 191 + UMask = "0077"; 192 + }; 193 + }; 194 + }; 195 + }
+238
modules/zitadel/reconcile.py
··· 1 + # Applies lass.zitadel (modules/zitadel/default.nix) to Zitadel through its 2 + # admin and management APIs. Objects are found by name, created when missing 3 + # and updated when they differ. Projects and apps missing from the config 4 + # are left alone. 5 + import json 6 + import os 7 + import pwd 8 + import sys 9 + import time 10 + import urllib.error 11 + import urllib.request 12 + 13 + [config_path] = sys.argv[1:] 14 + with open(config_path) as f: 15 + config = json.load(f) 16 + 17 + 18 + def request(method, path, body=None, token=None): 19 + headers = {"Host": config["domain"], "Content-Type": "application/json"} 20 + if token: 21 + headers["Authorization"] = "Bearer " + token 22 + req = urllib.request.Request( 23 + config["url"] + path, 24 + method=method, 25 + headers=headers, 26 + data=None if body is None else json.dumps(body).encode(), 27 + ) 28 + with urllib.request.urlopen(req) as res: 29 + return json.load(res) 30 + 31 + 32 + # Goes through the HTTP gateway to the gRPC API, which comes up after 33 + # /debug/ready already answers. 34 + while True: 35 + try: 36 + request("GET", "/admin/v1/healthz") 37 + break 38 + except (urllib.error.URLError, ConnectionError): 39 + time.sleep(1) 40 + 41 + with open(config["patFile"]) as f: 42 + pat = f.read().strip() 43 + 44 + 45 + def call(method, path, body=None): 46 + try: 47 + return request(method, path, body, pat) 48 + except urllib.error.HTTPError as e: 49 + sys.exit(f"{method} {path}: {e.code} {e.read().decode()}") 50 + 51 + 52 + def search(path, body={}): 53 + results = [] 54 + while True: 55 + res = call("POST", path, {**body, "query": {"offset": len(results)}}) 56 + page = res.get("result", []) 57 + results += page 58 + total = int(res.get("details", {}).get("totalResult", 0)) 59 + if not page or len(results) >= total: 60 + return results 61 + 62 + 63 + def by_name(objects, name): 64 + return next((o for o in objects if o["name"] == name), None) 65 + 66 + 67 + # The APIs omit fields with zero values. 68 + ZERO = { 69 + "appType": "OIDC_APP_TYPE_WEB", 70 + "authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC", 71 + } 72 + 73 + 74 + def differs(current, wanted): 75 + return any( 76 + current.get(k, ZERO.get(k, False)) != v for k, v in wanted.items() 77 + ) 78 + 79 + 80 + # login policy and external IdPs (instance defaults) 81 + 82 + policy = call("GET", "/admin/v1/policies/login")["policy"] 83 + wanted = config["loginPolicy"] 84 + if differs(policy, wanted): 85 + call("PUT", "/admin/v1/policies/login", {**policy, **wanted}) 86 + 87 + providers = search("/admin/v1/idps/templates/_search") 88 + linked = { 89 + i["idpId"] for i in search("/admin/v1/policies/login/idps/_search") 90 + } 91 + for name, idp in config["idps"].items(): 92 + with open(idp["clientIdFile"]) as f: 93 + client_id = f.read().strip() 94 + with open(idp["clientSecretFile"]) as f: 95 + client_secret = f.read().strip() 96 + if not client_id or not client_secret: 97 + print(f"idp {name}: no credentials, skipped") 98 + continue 99 + body = { 100 + "name": name, 101 + "clientId": client_id, 102 + "clientSecret": client_secret, 103 + "scopes": idp["scopes"], 104 + "providerOptions": { 105 + "isLinkingAllowed": True, 106 + "isCreationAllowed": True, 107 + "isAutoUpdate": True, 108 + }, 109 + } 110 + existing = by_name(providers, name) 111 + if existing: 112 + idp_id = existing["id"] 113 + call("PUT", f"/admin/v1/idps/{idp['type']}/{idp_id}", body) 114 + else: 115 + idp_id = call("POST", f"/admin/v1/idps/{idp['type']}", body)["id"] 116 + print(f"idp {name}: created") 117 + if idp_id not in linked: 118 + call("POST", "/admin/v1/policies/login/idps", {"idpId": idp_id}) 119 + print(f"idp {name}: added to the login policy") 120 + 121 + 122 + # projects, OIDC apps and user grants (default organization) 123 + 124 + def find_user(name): 125 + method = "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE" 126 + users = search("/management/v1/users/_search", {"queries": [ 127 + {"loginNameQuery": {"loginName": name, "method": method}}, 128 + ]}) 129 + if not users: 130 + # only verified addresses: anyone can register with any address 131 + users = [ 132 + u for u in search("/management/v1/users/_search", {"queries": [ 133 + {"emailQuery": {"emailAddress": name, "method": method}}, 134 + ]}) 135 + if u.get("human", {}).get("email", {}).get("isEmailVerified") 136 + ] 137 + if len(users) != 1: 138 + print(f"user {name}: {len(users)} matches, skipped", file=sys.stderr) 139 + return None 140 + return users[0]["id"] 141 + 142 + 143 + def write_file(path, uid, content): 144 + tmp = path + ".tmp" 145 + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o400) 146 + with os.fdopen(fd, "w") as f: 147 + f.write(content) 148 + os.chown(tmp, uid, -1) 149 + os.rename(tmp, path) 150 + 151 + 152 + projects = search("/management/v1/projects/_search") 153 + for project_name, project in config["projects"].items(): 154 + check = project["access"] != "public" 155 + existing = by_name(projects, project_name) 156 + if existing is None: 157 + project_id = call("POST", "/management/v1/projects", { 158 + "name": project_name, 159 + "projectRoleCheck": check, 160 + })["id"] 161 + print(f"project {project_name}: created") 162 + else: 163 + project_id = existing["id"] 164 + if differs(existing, {"projectRoleCheck": check}): 165 + call("PUT", f"/management/v1/projects/{project_id}", { 166 + **existing, 167 + "projectRoleCheck": check, 168 + }) 169 + print(f"project {project_name}: projectRoleCheck={check}") 170 + 171 + base = f"/management/v1/projects/{project_id}/apps" 172 + apps = search(f"{base}/_search") 173 + for app_name, app in project["apps"].items(): 174 + oidc_config = { 175 + "redirectUris": app["redirectUris"], 176 + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], 177 + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], 178 + "appType": "OIDC_APP_TYPE_WEB", 179 + "authMethodType": app["authMethodType"], 180 + } 181 + state_file = f"{config['stateDir']}/{project_name}-{app_name}.json" 182 + secret = None 183 + existing = by_name(apps, app_name) 184 + if existing is None: 185 + new = {"name": app_name, **oidc_config} 186 + res = call("POST", f"{base}/oidc", new) 187 + app_id, client_id = res["appId"], res["clientId"] 188 + secret = res["clientSecret"] 189 + print(f"app {project_name}/{app_name}: created") 190 + else: 191 + app_id = existing["id"] 192 + client_id = existing["oidcConfig"]["clientId"] 193 + if differs(existing["oidcConfig"], oidc_config): 194 + call("PUT", f"{base}/{app_id}/oidc_config", oidc_config) 195 + print(f"app {project_name}/{app_name}: updated") 196 + if os.path.exists(state_file): 197 + with open(state_file) as f: 198 + saved = json.load(f) 199 + if saved["clientId"] == client_id: 200 + secret = saved["clientSecret"] 201 + # Zitadel shows a secret only when it creates it, so without a saved 202 + # copy for this client the app gets a new one. 203 + if secret is None: 204 + path = f"{base}/{app_id}/oidc_config/_generate_client_secret" 205 + secret = call("POST", path, {})["clientSecret"] 206 + print(f"app {project_name}/{app_name}: new client secret") 207 + write_file(state_file, 0, json.dumps({ 208 + "clientId": client_id, 209 + "clientSecret": secret, 210 + })) 211 + env = app["envFile"] 212 + uid = pwd.getpwnam(env["owner"]).pw_uid 213 + write_file(env["path"], uid, ( 214 + f"{env['clientIdVariable']}={client_id}\n" 215 + f"{env['clientSecretVariable']}={secret}\n" 216 + )) 217 + 218 + if project["access"] != "declarative": 219 + continue 220 + users = {find_user(name): name for name in project["users"]} 221 + users.pop(None, None) 222 + grants = search("/management/v1/users/grants/_search", {"queries": [ 223 + {"projectIdQuery": {"projectId": project_id}}, 224 + ]}) 225 + for grant in grants: 226 + if grant["userId"] in users: 227 + del users[grant["userId"]] 228 + else: 229 + call( 230 + "DELETE", 231 + f"/management/v1/users/{grant['userId']}/grants/{grant['id']}", 232 + ) 233 + print(f"project {project_name}: revoked {grant['userName']}") 234 + for user_id, name in users.items(): 235 + call("POST", f"/management/v1/users/{user_id}/grants", { 236 + "projectId": project_id, 237 + }) 238 + print(f"project {project_name}: granted {name}")