authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 01:55:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
log4af52cc7885ec42021a98021be0e06555a5c81d4
tree9086317fac82feecda17cfd2602c8c14a7ca091e
parentd61a846db111f203963efe7cda8ac7af96da1880
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Move all service sign-in to Snowglobe and upgrade Shale

Preserve existing subjects and Shale account IDs during issuer migration. Assisted-by: gpt-6

11 files changed, 76 insertions(+), 202 deletions(-)

dashboard/src/shale.rs+5-12
......@@ -9,8 +9,7 @@ use rmcp::{
99};
1010use scraper::{Html, Selector};
1111
12/// Shale rotates the session token while rendering comment deletion forms.
13/// The final deletion form therefore carries the token accepted by every issue form.
12/// r1758 rotates CSRF tokens per deletion form; the final token also works on r1763.
1413fn issue_csrf(document: &Html) -> Result<Option<String>> {
1514 let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap();
1615 let kind = Selector::parse("input[name=t]").unwrap();
......@@ -651,13 +650,10 @@ pub async fn manage(
651650 .and_then(|v| v.to_str().ok())
652651 .unwrap_or_default(),
653652 )?;
654 let issuer = url::Url::parse(&env(
655 "STUDIO_KEYCLOAK_URL",
656 &format!("https://auth.{}", env("STUDIO_DOMAIN", "studio.test")),
657 ))?;
653 let issuer = &app.auth.origin;
658654 let parameters = fields(authorization.query().unwrap_or_default())?;
659655 if authorization.origin() != issuer.origin()
660 || authorization.path() != "/realms/master/protocol/openid-connect/auth"
656 || authorization.path() != "/auth/oidc/authorize"
661657 || !authorization.username().is_empty()
662658 || authorization.password().is_some()
663659 || authorization.fragment().is_some()
......@@ -772,10 +768,7 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
772768 if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") {
773769 return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again."));
774770 }
775 let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}/shale-username", string(&link["owner"])), "method":"GET", "body":null})).await?;
776 if identity["body"]["enabled"] != true {
777 return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator."));
778 }
771 let expected_username = oidc::username(&app.auth, string(&link["owner"]), "shale")?;
779772 let (status, headers, _) = app.shale.get(&format!("/-/callback?{query}"), None).await?;
780773 if !status.is_redirection() {
781774 return Err(Error::new(502, "Shale couldn't finish sign-in. Link it again."));
......@@ -783,7 +776,7 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
783776 let session = session_cookie(&headers)?;
784777 let verified: Result<()> = async {
785778 let (status, _, body) = app.shale.get("/-/settings", Some(&session)).await?;
786 if status != StatusCode::OK || username(&body)? != identity["body"]["username"] {
779 if status != StatusCode::OK || username(&body)? != expected_username {
787780 return Err(Error::new(403, "Sign in to Shale with the same account as your dashboard, then link it again."));
788781 }
789782 let owner = string(&link["owner"]);
dashboard/web/types/model.ts+3-3
......@@ -20,7 +20,7 @@ export interface Me {
2020/** Cookie holding the comma-separated groups an admin previews the dashboard as. */
2121export const VIEW_AS = "view-as";
2222
23/** The Keycloak group that opens each dashboard section; null opens it to everyone. */
23/** The account group that opens each dashboard section; null opens it to everyone. */
2424const SECTION_GROUPS = { launcher: null, admin: "infra-admin", metrics: "metrics", media: "media-manage", vms: "vm" } as const;
2525export type Section = keyof typeof SECTION_GROUPS;
2626
......@@ -38,7 +38,7 @@ export interface ServiceSummary {
3838 url: string | null;
3939 /** Versioned image URLs per color scheme; the same URL twice when the service has one image. */
4040 icon: { light: string; dark: string } | null;
41 /** Keycloak group that sees this service in the launcher; null means everyone. */
41 /** Account group that sees this service in the launcher; null means everyone. */
4242 access: string | null;
4343 /** What the app is for, in a few words, for people who don't know it by name. */
4444 tagline: string | null;
......@@ -127,7 +127,7 @@ export interface ServiceDefinition {
127127 port: string;
128128 /** Hostnames the router sends here; empty for a service only other services reach. */
129129 hostnames: string[];
130 /** The Keycloak group that must sign in first; null for no sign-in gate. */
130 /** The account group that must sign in first; null for no sign-in gate. */
131131 authRole: string | null;
132132 /** `restartAfter` failures in a row restart `task`, counted once `grace` has passed since it started. */
133133 check: {
nixos/configuration.nix+2-3
......@@ -6,7 +6,7 @@ let
66 proxyToken = "/var/lib/studio/dashboard-proxy.token";
77 hostTools = lib.fileset.toSource {
88 root = ../.;
9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../service/keycloak/api.py ];
9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ];
1010 };
1111 nativePkl = pkgs.callPackage ./pkl.nix { };
1212in
......@@ -162,7 +162,6 @@ in
162162 STUDIO_AUTH_RP_ID = "auth.${config.environment.variables.STUDIO_DOMAIN}";
163163 STUDIO_FILE_ORIGIN = "https://file.${config.environment.variables.STUDIO_DOMAIN}";
164164 STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}";
165 STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}";
166165 STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}";
167166 STUDIO_SHALE_URL = "https://shale.${config.environment.variables.STUDIO_DOMAIN}";
168167 STUDIO_PUBLISHED_ROOT = "/srv/clover/Published";
......@@ -210,7 +209,7 @@ in
210209 --mount=type=bind,src=/srv/clover,dst=/srv/clover,bind-nonrecursive,bind-propagation=rslave,ro="$STUDIO_CLOVER_READ_ONLY" \
211210 --mount=type=bind,src=/srv/clover/Media,dst=/srv/clover/Media,bind-nonrecursive,bind-propagation=rslave,ro="$STUDIO_MEDIA_READ_ONLY" \
212211 ${lib.concatMapStringsSep " " (name: lib.escapeShellArg "--env=${name}") (builtins.attrNames environment)} \
213 ${lib.concatMapStringsSep " " (name: lib.escapeShellArg "--add-host=${name}.${config.environment.variables.STUDIO_DOMAIN}:host-gateway") [ "dashboard.internal" "auth" "keycloak" "jelly" "db" "shale" ]} \
212 ${lib.concatMapStringsSep " " (name: lib.escapeShellArg "--add-host=${name}.${config.environment.variables.STUDIO_DOMAIN}:host-gateway") [ "dashboard.internal" "auth" "jelly" "db" "shale" ]} \
214213 "''${optional[@]}" ${lib.escapeShellArg "${dashboard.image.imageName}:${dashboard.image.imageTag}"}
215214 '';
216215 ExecStop = "${pkgs.podman}/bin/podman stop --ignore --time=8 studio-dashboard";
service/dawarich/service.pkl+5-4
......@@ -1,7 +1,7 @@
11extends "../../config/Service.pkl"
22
33import "../../config/Service.pkl" as service
4import "../keycloak/service.pkl" as keycloak
4import "../../config/OpenID.pkl" as sso
55import "../postgres/service.pkl" as postgres
66
77local dawarichImage = "docker.io/freikin/dawarich@sha256:76ec5fa62f414a5ca9e6dd71a9a5b09088c0011075c41644ba35bbb67627a943"
......@@ -48,9 +48,10 @@ healthyDeadline = "20m"
4848
4949requirements {
5050 database
51 new keycloak.OpenIDClient {
51 new sso.Client {
5252 clientId = module.id
5353 name = module.meta.name
54 redirectUris { "https://\(module.containers["web"].http.hostname)/users/auth/openid_connect/callback" }
5455 }
5556}
5657
......@@ -74,7 +75,7 @@ containers {
7475
7576 ["web"] {
7677 image = dawarichImage
77 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
78 extraHosts { "\(sso.hostname):host-gateway" }
7879 entrypoint = "web-entrypoint.sh"
7980 args { "bin/rails"; "server"; "-p"; "3000"; "-b"; "::" }
8081 imageUser = true
......@@ -94,7 +95,7 @@ containers {
9495 ["WEB_CONCURRENCY"] = "1"
9596 ["OIDC_CLIENT_ID"] = "${secret.oidc.clientId}"
9697 ["OIDC_CLIENT_SECRET"] = "${secret.oidc.clientSecret}"
97 ["OIDC_ISSUER"] = "https://\(keycloak.container.http.hostname)/realms/master"
98 ["OIDC_ISSUER"] = sso.issuer
9899 ["OIDC_REDIRECT_URI"] = "https://\(module.containers["web"].http.hostname)/users/auth/openid_connect/callback"
99100 ["ALLOW_EMAIL_PASSWORD_REGISTRATION"] = "false"
100101 }
service/forward-auth/service.pkl+18-6
......@@ -1,15 +1,23 @@
11amends "../../config/Service.pkl"
22
33import "../../config/site.pkl" as site
4import "../keycloak/service.pkl" as keycloak
4import "../../config/OpenID.pkl" as sso
5
6local isPreview = read?("prop:preview") == "true"
57
68meta { name = "Forward Auth" }
79rollout = "overlapped"
810
911requirements {
10 new keycloak.OpenIDClient {
12 new sso.Client {
1113 clientId = module.id
1214 name = module.meta.name
15 redirectUris {
16 when (isPreview) { "https://\(module.id).\(site.domain)/snow.oauth2/callback" }
17 when (!isPreview) { for (host in new Listing<String> { "music"; "seedbox"; "ddns"; "redis"; "pg"; "snr"; "rdr"; "logs"; "metrics"; "traces"; "jkt" }) {
18 "https://\(host).\(site.domain)/snow.oauth2/callback"
19 } }
20 }
1321 }
1422}
1523
......@@ -21,11 +29,12 @@ container {
2129 image = "quay.io/oauth2-proxy/oauth2-proxy@sha256:56e3daedf765c7a1eea6e366fbe684be7d3084830ade14b6174570d3c7960954"
2230 cpu = 100
2331 memory = 256
24 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
32 extraHosts { "\(sso.hostname):host-gateway" }
2533
2634 http {
2735 containerPort = 4180
2836 checkPath = "/ping"
37 subdomain = if (isPreview) module.id else null
2938 }
3039
3140 volumes {
......@@ -39,9 +48,11 @@ container {
3948 ["OAUTH2_PROXY_CLIENT_ID"] = "${secret.oidc.clientId}"
4049 ["OAUTH2_PROXY_CLIENT_SECRET"] = "${secret.oidc.clientSecret}"
4150 ["OAUTH2_PROXY_COOKIE_SECRET"] = "${secret.own.cookie}"
42 ["OAUTH2_PROXY_PROVIDER"] = "keycloak-oidc"
43 ["OAUTH2_PROXY_OIDC_ISSUER_URL"] = "https://\(keycloak.container.http.hostname)/realms/master"
44 // OAuth2 Proxy requires this claim even when Keycloak accounts have no email.
51 ["OAUTH2_PROXY_PROVIDER"] = "oidc"
52 ["OAUTH2_PROXY_OIDC_ISSUER_URL"] = sso.issuer
53 ["OAUTH2_PROXY_SCOPE"] = "openid profile email groups"
54 ["OAUTH2_PROXY_OIDC_GROUPS_CLAIM"] = "groups"
55 // Existing accounts may have no email.
4556 ["OAUTH2_PROXY_OIDC_EMAIL_CLAIM"] = "preferred_username"
4657 ["OAUTH2_PROXY_CODE_CHALLENGE_METHOD"] = "S256"
4758 ["OAUTH2_PROXY_EMAIL_DOMAINS"] = "*"
......@@ -51,6 +62,7 @@ container {
5162 ["OAUTH2_PROXY_REVERSE_PROXY"] = "true"
5263 ["OAUTH2_PROXY_WHITELIST_DOMAINS"] = "*.\(site.domain)"
5364 ["OAUTH2_PROXY_COOKIE_DOMAINS"] = ".\(site.domain)"
65 when (isPreview) { ["OAUTH2_PROXY_COOKIE_NAME"] = "_snow_stage_\(module.id)" }
5466 ["OAUTH2_PROXY_SET_XAUTHREQUEST"] = "true"
5567 ["OAUTH2_PROXY_PASS_USER_HEADERS"] = "true"
5668 ["OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL"] = "true"
service/jellyfin/service.pkl+7-3
......@@ -1,7 +1,7 @@
11amends "../../config/Service.pkl"
22
33import "../../config/site.pkl" as site
4import "../keycloak/service.pkl" as keycloak
4import "../../config/OpenID.pkl" as sso
55
66meta { name = "Jellyfin" }
77// SQLite under /config requires one writer during rollout.
......@@ -14,9 +14,13 @@ setup = "configure.py"
1414secrets { ["admin_password"] {} }
1515
1616requirements {
17 new keycloak.OpenIDClient {
17 new sso.Client {
1818 clientId = module.id
1919 name = module.meta.name
20 redirectUris {
21 "https://\(module.container.http.hostname)/sso/OID/r/snow"
22 "https://\(module.container.http.hostname)/sso/OID/redirect/snow"
23 }
2024 }
2125}
2226
......@@ -24,7 +28,7 @@ container {
2428 image = "docker.io/jellyfin/jellyfin@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db"
2529 cpu = 500
2630 memory = 3072
27 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
31 extraHosts { "\(sso.hostname):host-gateway" }
2832
2933 http {
3034 containerPort = 8096
service/shale/prepare.py created+22
......@@ -0,0 +1,22 @@
1#!/usr/bin/env python3
2"""Keep Shale identities attached to their existing accounts when the issuer moves."""
3import json
4import os
5from pathlib import Path
6import sqlite3
7import sys
8
9data = json.load(sys.stdin)
10path = Path(data["hostRoot"]) / "data/astheno.shale.db"
11if path.exists():
12 domain = os.environ["STUDIO_DOMAIN"]
13 old, new = "auth." + domain + "/realms/master", "snowglobe." + domain
14 db = sqlite3.connect(path, timeout=30)
15 db.execute("BEGIN IMMEDIATE")
16 if db.execute("SELECT 1 FROM users old JOIN users new ON old.snowflake=new.snowflake WHERE old.provider=? AND new.provider=?", (old,new)).fetchone():
17 raise ValueError("duplicate Shale identity; resolve it before moving the issuer")
18 count = db.execute("UPDATE users SET provider=? WHERE provider=?", (new,old)).rowcount
19 db.commit()
20 assert db.execute("PRAGMA quick_check").fetchone() == ("ok",)
21 db.close()
22 print("Moved", count, "existing Shale identity bindings to Snowglobe")
service/shale/readme/issue-forms.js deleted-23
......@@ -1,23 +0,0 @@
1// Shale r1758 rotates the session's CSRF token while rendering each comment's
2// delete form, leaving the surrounding issue forms with the earlier token.
3(() => {
4 function normalize(root, initial = false) {
5 const forms = [...root.querySelectorAll('form[method="post" i]')];
6 const tokens = forms.flatMap(form => {
7 if (initial && form.querySelector('input[name="t"]')?.value !== 'delete') return [];
8 const input = form.querySelector('input[name="csrf_token"]');
9 return input?.value ? [input.value] : [];
10 });
11 const token = tokens.at(-1);
12 if (!token) return;
13 for (const input of document.querySelectorAll('form[method="post" i] input[name="csrf_token"]')) {
14 input.value = token;
15 }
16 }
17 function start() {
18 normalize(document, true);
19 document.body.addEventListener('htmx:afterSwap', event => normalize(event.detail.target));
20 }
21 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
22 else start();
23})();
service/shale/service.pkl+10-9
......@@ -1,21 +1,25 @@
11amends "../../config/Service.pkl"
22
3import "../keycloak/service.pkl" as keycloak
3import "../../config/OpenID.pkl" as sso
44
55meta {
66 name = "Shale"
77}
88requirements {
9 new keycloak.OpenIDClient {
9 new sso.Client {
1010 clientId = module.id
1111 name = module.meta.name
12 redirectUris { "https://\(module.container.http.hostname)/-/callback" }
1213 usernameAliases { ["snow"] = "clover" }
14 allowGuests = true
15 usernameRequired = true
1316 }
1417}
18prepare = "prepare.py"
1519
1620container {
17 image = "docker.io/astheno/shale@sha256:dfffceebe31fd3360b6dcf12caab664735415fdc32effd5082ac37b11864a232"
18 extraHosts { "\(keycloak.container.http.hostname):host-gateway" }
21 image = "docker.io/astheno/shale@sha256:d89f4d8fe0ee4bd8f57ef35e3cf19c91be158ee131c222bbc7d47920ac198b6f"
22 extraHosts { "\(sso.hostname):host-gateway" }
1923
2024 http {
2125 containerPort = 8000
......@@ -26,9 +30,6 @@ container {
2630 ["/-/studio-readme/"] = "readme"
2731 }
2832 headHtml {
29 ["/*/issues/*"] = """
30 <script defer src="/-/studio-readme/issue-forms.js"></script>
31 """
3233 ["/snowbound/"] = """
3334 <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js"></script>
3435 """
......@@ -51,10 +52,10 @@ container {
5152 ["DOMAIN"] = module.container.http.hostname
5253 ["HOME"] = "/data"
5354 ["SERVER_TITLE"] = "clover's git"
54 // The older Markdown parser shares a capture buffer between request workers.
55 // Concurrent Markdown rendering shares capture state between request workers.
5556 ["NPROC"] = "1"
5657 ["SESSION_SECRET"] = "${secret.own.session_secret}"
57 ["OAUTH2_CLIENT"] = "oidc,\(keycloak.container.http.hostname)/realms/master|${secret.oidc.clientId}|${secret.oidc.clientSecret}"
58 ["OAUTH2_CLIENT"] = "oidc,\(sso.hostname)|${secret.oidc.clientId}|${secret.oidc.clientSecret}"
5859 }
5960}
6061
tools/dashboard-run.py-137
......@@ -13,14 +13,12 @@ import sys
1313import time
1414import uuid
1515import urllib.error
16import urllib.parse
1716import urllib.request
1817
1918import release
2019
2120
2221FIELDS = {
23 "iam.request": {"path", "method", "body"},
2422 "deploy.current": set(), "deploy.main": set(), "deploy.history": set(), "deploy.stages": set(), "deploy.managed": set(),
2523 "deploy.release": {"release"}, "deploy.output": {"kind", "target"},
2624 "deploy.last": set(), "deploy.run": {"id"}, "deploy.start": {"action", "target"},
......@@ -31,8 +29,6 @@ ACTIONS = {"deploy", "destroy", "rollback", "start", "stop", "restart", "secret-
3129MAX_LOG = 1024 * 1024
3230MAX_METADATA = 65536
3331HOST_STATE = Path(os.environ.get("STUDIO_HOST_STATE_ROOT", "/var/lib/studio/host"))
34IAM_ROLES = {"infra-admin", "media", "media-manage"}
35IAM_ACTIONS = {"UPDATE_PASSWORD", "VERIFY_EMAIL", "UPDATE_PROFILE", "CONFIGURE_TOTP", "webauthn-register", "webauthn-register-passwordless"}
3632
3733
3834class Error(Exception):
......@@ -255,23 +251,6 @@ def start(action, target, key=None, value=None):
255251
256252def handle(request):
257253 operation = request["operation"]
258 if operation == "iam.request":
259 iam_validate(request)
260 process = subprocess.run([
261 "systemd-run", "--pipe", "--wait", "--collect", "--quiet",
262 "--unit=studio-iam-" + str(uuid.uuid4()), "--property=RuntimeMaxSec=25",
263 "--property=MemoryMax=128M", "--property=TasksMax=8", "--property=ProtectSystem=strict",
264 "--property=ProtectHome=yes", "--property=NoNewPrivileges=yes", "--property=CapabilityBoundingSet=",
265 "--property=RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX", "--property=IPAddressDeny=any",
266 "--property=IPAddressAllow=localhost", "--setenv=STUDIO_DOMAIN=" + os.environ["STUDIO_DOMAIN"],
267 "--setenv=STUDIO_API_TIMEOUT=5", "--", sys.executable, str(Path(__file__)), "--iam"],
268 input=json.dumps(request), capture_output=True, text=True, timeout=30, check=True)
269 response = json.loads(process.stdout)
270 if "error" in response:
271 raise Error(response["status"], response["error"])
272 return response["value"]
273 if operation.startswith("deploy.secret.") and request["service"] == "keycloak":
274 raise Error(403, "Keycloak credentials are managed by the host.")
275254 if operation == "deploy.secret.get":
276255 if not isinstance(request["key"], str):
277256 raise Error(400, "Choose a secret from this service's list.")
......@@ -373,108 +352,6 @@ def handle(request):
373352 return start(request["action"], request["target"])
374353
375354
376def iam_validate(request):
377 path, method, body = request["path"], request["method"], request["body"]
378 if not isinstance(path, str) or not isinstance(method, str):
379 raise Error(400, "Choose a supported user operation.")
380 allowed = {
381 "/roles": {"GET"}, "/users?max=1000": {"GET"}, "/users": {"POST"},
382 "": {"GET", "PUT", "DELETE"}, "/sessions": {"GET"}, "/credentials": {"GET"},
383 "/role-mappings/realm": {"GET", "POST", "DELETE"}, "/logout": {"POST"},
384 "/shale-username": {"GET"},
385 "/execute-actions-email": {"PUT"}, "/reset-password": {"PUT"},
386 }
387 user = re.fullmatch(r"/users/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(.*)", path)
388 suffix = user[2] if user else path
389 if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password", "/shale-username"}:
390 raise Error(400, "Choose a supported user operation.")
391 if path.startswith("/users?username="):
392 try:
393 query = urllib.parse.parse_qs(path.partition("?")[2], keep_blank_values=True, strict_parsing=True)
394 except ValueError:
395 raise Error(400, "Choose a username.") from None
396 if (set(query) != {"username", "exact"} or query["exact"] != ["true"]
397 or len(query["username"]) != 1 or not re.fullmatch(r"[a-z0-9][a-z0-9._@-]{0,254}", query["username"][0])):
398 raise Error(400, "Choose a username.")
399 suffix = "/users?max=1000"
400 if (method not in allowed.get(suffix, set()) or not user and suffix == ""
401 or method in {"GET", "DELETE", "POST"} and suffix not in {"/users", "/role-mappings/realm"} and body is not None
402 or method == "GET" and body is not None or suffix == "/shale-username" and not user):
403 raise Error(400, "Choose a supported user operation.")
404 if method == "PUT" and suffix == "/reset-password":
405 if (not isinstance(body, dict) or set(body) != {"type", "value", "temporary"}
406 or body["type"] != "password" or not isinstance(body["value"], str)
407 or not 8 <= len(body["value"]) <= 8192 or type(body["temporary"]) is not bool):
408 raise Error(400, "Enter a password and choose whether it is temporary.")
409 elif method == "PUT" and suffix == "/execute-actions-email":
410 if not isinstance(body, list) or not body or not all(isinstance(action, str) and action in IAM_ACTIONS for action in body):
411 raise Error(400, "Choose a sign-in action.")
412 elif suffix == "/role-mappings/realm" and method != "GET":
413 if (not isinstance(body, list) or len(body) != 1 or not isinstance(body[0], dict)
414 or not isinstance(body[0].get("name"), str) or body[0]["name"] not in IAM_ROLES or not isinstance(body[0].get("id"), str)):
415 raise Error(403, "Choose a dashboard group.")
416 elif method == "POST" and suffix == "/users" or method == "PUT" and suffix == "":
417 if not isinstance(body, dict) or not body or not set(body) <= {"username", "email", "firstName", "lastName", "enabled", "emailVerified", "requiredActions", "attributes"}:
418 raise Error(400, "Enter a user profile.")
419 for key, value in body.items():
420 if key in {"enabled", "emailVerified"}:
421 valid = type(value) is bool
422 elif key == "requiredActions":
423 valid = isinstance(value, list) and all(isinstance(action, str) and action in IAM_ACTIONS for action in value)
424 elif key == "attributes":
425 valid = isinstance(value, dict) and set(value) == {"picture"} and (value["picture"] is None or isinstance(value["picture"], list) and len(value["picture"]) == 1 and isinstance(value["picture"][0], str) and len(value["picture"][0]) <= 8192)
426 else:
427 valid = value is None and key != "username" or isinstance(value, str) and len(value) <= 8192
428 if key == "username":
429 valid = isinstance(value, str) and bool(re.fullmatch(r"[a-z0-9][a-z0-9._@-]{0,254}", value)) and value != "admin"
430 if not valid:
431 raise Error(400, "Enter a valid user profile.")
432 return user
433
434
435def iam(request):
436 user = iam_validate(request)
437 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "service/keycloak"))
438 from api import Keycloak
439 client = Keycloak("keycloak." + os.environ["STUDIO_DOMAIN"], secret("get", "keycloak", "password"),
440 attempts=1, cafile="/var/lib/studio/ca-bundle.crt")
441 path, method, body = request["path"], request["method"], request["body"]
442 if user:
443 profile = client.request("/admin/realms/master/users/" + user[1])
444 if profile["username"] == "admin":
445 raise Error(403, "The Keycloak administrator is managed outside the dashboard.")
446 if user[2] == "/shale-username":
447 clients = client.request("/admin/realms/master/clients?clientId=shale")
448 clients = [item for item in clients if item["clientId"] == "shale"]
449 if len(clients) != 1:
450 raise Error(502, "Shale's sign-in client is unavailable.")
451 aliases = client.request(f"/admin/realms/master/users/{user[1]}/role-mappings/clients/{clients[0]['id']}/composite")
452 if len(aliases) > 1:
453 raise Error(502, "This account has multiple Shale usernames.")
454 return {"body": {"username": aliases[0]["name"] if aliases else profile["username"], "enabled": profile["enabled"]}}
455 if isinstance(body, dict) and "attributes" in body:
456 attributes = dict(profile.get("attributes", {}))
457 picture = body["attributes"]["picture"]
458 if picture is None:
459 attributes.pop("picture", None)
460 else:
461 attributes["picture"] = picture
462 body = {**{key: profile[key] for key in ["username", "email", "firstName", "lastName"] if key in profile},
463 **body, "attributes": attributes}
464 if path.endswith("/role-mappings/realm") and method != "GET":
465 roles = client.request("/admin/realms/master/roles")
466 role = next((role for role in roles if role["name"] in IAM_ROLES and role["id"] == body[0]["id"] and role["name"] == body[0]["name"]), None)
467 if role is None:
468 raise Error(403, "Choose a dashboard group.")
469 body = [{"id": role["id"], "name": role["name"]}]
470 result = client.request("/admin/realms/master" + path, method, body, full=True)
471 if method == "GET" and path.startswith("/users?"):
472 result["body"] = [user for user in result["body"] if user["username"] != "admin"]
473 elif method == "GET" and (path == "/roles" or path.endswith("/role-mappings/realm")):
474 result["body"] = [role for role in result["body"] if role["name"] in IAM_ROLES]
475 return result
476
477
478355def worker(identity, action, target, key=None):
479356 if not re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", identity):
480357 raise ValueError("incorrect deployment run ID")
......@@ -544,20 +421,6 @@ def worker(identity, action, target, key=None):
544421
545422
546423if __name__ == "__main__":
547 if sys.argv[1:] == ["--iam"]:
548 try:
549 payload = sys.stdin.read(MAX_METADATA + 1)
550 if len(payload.encode()) > MAX_METADATA:
551 raise Error(400, "The user request is too large. Narrow the selection.")
552 result = {"value": iam(json.loads(payload))}
553 except Error as error:
554 result = {"error": str(error), "status": error.status}
555 except urllib.error.HTTPError as error:
556 result = {"error": "Keycloak refused this change. Reload the page and retry.", "status": error.code if error.code in {404, 409} else 502}
557 except Exception:
558 result = {"error": "Keycloak is unavailable. Check its service logs.", "status": 502}
559 print(json.dumps(result))
560 raise SystemExit(0)
561424 if len(sys.argv) == 5 and sys.argv[1] == "--secret" and sys.argv[2] in {"get", "set", "rotate"}:
562425 action, target, key = sys.argv[2:]
563426 try:
tools/shale-migration.md+4-2
......@@ -28,7 +28,7 @@ The importer preserves existing Shale identities and repository records. Clover'
2828
2929New repository access follows verified Forgejo visibility, with pushes restricted to the owner and issue submission disabled. When private Forgejo history joins an existing repository, public or unlisted permissions are tightened to private **before objects are copied**; existing `off` permissions remain off. An import failure must leave Shale stopped. Restoring only the previous database can re-expose imported private objects through its old public permissions, so recovery must preserve the tightened access or restore the complete service dataset.
3030
31The existing `snow` account's original OIDC provider and subject must survive the cutover. Keep the canonical `auth.paperclover.net/realms/master` issuer. Shale caches repository metadata and access at startup, so finish SQLite changes before starting the application. An isolated pinned-image test proved direct registration of a new repository: private anonymous web access returned 404 and Git discovery returned 401; after public permissions and a restart, its page and Git advertisement returned 200 with the original branch object ID. The test used copied data, `--network none`, and no published ports.
31Existing Shale account IDs and OIDC subjects must survive the cutover. A deliberate issuer move uses `service/shale/prepare.py` to rebind the provider before startup; duplicate bindings stop the migration. Shale caches repository metadata and access at startup, so finish SQLite changes before starting the application. An isolated pinned-image test proved direct registration of a new repository: private anonymous web access returned 404 and Git discovery returned 401; after public permissions and a restart, its page and Git advertisement returned 200 with the original branch object ID. The test used copied data, `--network none`, and no published ports.
3232
3333A full-data test imported all 19 retained Forgejo histories into a disposable copy of the existing Shale state, yielding 21 repository records. Its conservative fixture metadata marked every incoming repository private. Every copied object file and every source ref passed verification. The pinned application then denied anonymous access to a new private repository and a formerly public collision, while preserving an unaffected public repository. On that isolated copy, public test permissions proved HTTP pages and Git advertisements for `bgds`, `home-infra`, and `nix/config`; `home-infra` advertised the original Forgejo `main` and `vllm`. An actual smart HTTP fetch returned a 53-object pack with a verified pack checksum. Production visibility must come from the restored Forgejo metadata, not this test fixture.
3434
......@@ -54,7 +54,9 @@ Production issue import completed on October 5 under release `dda941e618934ad9`,
5454
5555This older build predates hidden form CSRF tokens. The router requires the exact HTTPS Origin for every Shale request using the `SessionID` cookie and a mutating method. The guard precedes all Shale handlers inside an explicit Caddy `route`; otherwise default directive ordering can bypass it. Missing, wrong, and suffix-forged origins returned 403 without a database change on the clone. The valid site origin allowed a status change, while Basic-auth Git requests still reached Shale. The MCP adapter permits tokenless issue forms only with the exact verified `r1616` structural footer and no CSRF-token input anywhere on the page. Newer or mixed-token markup remains strict.
5656
57The October 4 transport check inspected the image pinned in [service.pkl](../service/shale/service.pkl) in disposable containers without mounting real app data. Its embedded Git endpoint and account settings use HTTP and personal access tokens; no SSH listener, authorized-key interface, or forced-command handler was found. The [official installation](https://astheno.software/shale/installation/) and [configuration reference](https://astheno.software/shale/reference/environment/) also expose HTTP serving and OAuth login without SSH configuration. A `git` account must either use a Shale-aware SSH bridge or await native SSH support. Direct filesystem Git commands would bypass Shale's authorization.
57The October 5 `r1763-g9f5b1c7.zig.0.16.0` upgrade rehearsal passed anonymous reads of the formerly crashing issues, Unicode issue creation and comments, issue closing with a comment Delete form present, access denial, restart persistence, token revocation, and logout. The Rust adapter parsed its actual owner issue markup and accepted its status/comment CSRF fields. All migrated SQLite rows remained identical. Eight-worker concurrent Markdown rendering still crashed; keep `NPROC=1`, which passed 280 concurrent fenced-code requests. The injected issue-form script is no longer needed. Private evidence lives at `/var/lib/studio/shale-upgrade-0680d28c`; its disposable containers were removed after testing.
58
59The October 4 transport check inspected the then-pinned image in disposable containers without mounting real app data. Its embedded Git endpoint and account settings use HTTP and personal access tokens; no SSH listener, authorized-key interface, or forced-command handler was found. The [official installation](https://astheno.software/shale/installation/) and [configuration reference](https://astheno.software/shale/reference/environment/) also expose HTTP serving and OAuth login without SSH configuration. A `git` account must either use a Shale-aware SSH bridge or await native SSH support. Direct filesystem Git commands would bypass Shale's authorization.
5860
5961Zenith's Shale app directory contains a small SQLite database and 419 MB of owned repositories. `bash tools/import-shale.sh shale-preview-4eea0e3b` copied `data`, `repositories_owned`, and `repositories_mirrors` opaquely from the read-only `storage1/apps@hourly-2026-09-26_05-00` snapshot. It verified checksums and SQLite integrity, then restarted the preview. Both sides had 11 top-level owned repository directories; the preview had one healthy Nomad allocation and returned HTTPS 200. Repository contents were not inspected.
6062