| author | |
| committer | |
| log | b75574032f70c6b0613824112a4959f12e6ab028 |
| tree | cbe561ed11dbc4c0c26c5860056d7a0ffb8058db |
| parent | 5e5766a9141ea0439e3e4b414e88749d908626c5 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Map snow to ~clover only for Shale, preserving the identity provider subject. Verify MCP account linking against the effective client alias. Override Jellyfin backgrounds after its stock skin loads.
Assisted-by: gpt-6.1-sol6 files changed, 57 insertions(+), 3 deletions(-)
dashboard/src/shale.rs+1-1| ... | ... | @@ -706,7 +706,7 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response { |
| 706 | 706 | if parameters.get("code").is_none_or(|code| code.is_empty() || code.len() > 4096) || parameters.contains_key("error") { |
| 707 | 707 | return Err(Error::new(400, "Shale sign-in was declined or incomplete. Start linking again.")); |
| 708 | 708 | } |
| 709 | let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}", string(&link["owner"])), "method":"GET", "body":null})).await?; | |
| 709 | let identity = host::call(json!({"operation":"iam.request", "path":format!("/users/{}/shale-username", string(&link["owner"])), "method":"GET", "body":null})).await?; | |
| 710 | 710 | if identity["body"]["enabled"] != true { |
| 711 | 711 | return Err(Error::new(403, "This dashboard account is disabled. Contact its administrator.")); |
| 712 | 712 | } |
service/jellyfin/theme/theme.css+3| ... | ... | @@ -60,3 +60,6 @@ |
| 60 | 60 | height: 100%; |
| 61 | 61 | } |
| 62 | 62 | .selectArrow { margin: 0; } |
| 63 | ||
| 64 | html .backgroundContainer:not(.withBackdrop) { background-color: var(--main-background); } | |
| 65 | html .backgroundContainer.withBackdrop { background-color: var(--main-background-transparent); } |
service/keycloak/provide.py+39| ... | ... | @@ -55,6 +55,45 @@ current = keycloak.request(f"{path}/{uuid}") |
| 55 | 55 | attributes = {**(current.get("attributes") or {}), **desired["attributes"]} |
| 56 | 56 | if any(current.get(key) != value for key, value in desired.items() if key != "attributes") or current.get("attributes", {}) != attributes: |
| 57 | 57 | keycloak.request(f"{path}/{uuid}", "PUT", {**current, **desired, "attributes": attributes}) |
| 58 | aliases = request.get("usernameAliases", {}) | |
| 59 | if aliases: | |
| 60 | if len(set(aliases.values())) != len(aliases): | |
| 61 | raise ValueError("Shale username aliases must be unique") | |
| 62 | roles_path = f"{path}/{uuid}/roles" | |
| 63 | roles = {role["name"]: role for role in keycloak.request(roles_path)} | |
| 64 | if set(roles) - set(aliases.values()): | |
| 65 | raise ValueError("username alias clients cannot also carry permission roles") | |
| 66 | for username, alias in aliases.items(): | |
| 67 | users = keycloak.request("/admin/realms/master/users?" + urllib.parse.urlencode({"username": username, "exact": "true"})) | |
| 68 | if len(users) != 1: | |
| 69 | raise ValueError(f"expected one alias account: {username}") | |
| 70 | if alias not in roles: | |
| 71 | keycloak.request(roles_path, "POST", {"name": alias, "description": "Shale username alias"}) | |
| 72 | roles[alias] = keycloak.request(roles_path + "/" + urllib.parse.quote(alias, safe="")) | |
| 73 | owners = keycloak.request(roles_path + "/" + urllib.parse.quote(alias, safe="") + "/users") | |
| 74 | if any(owner["id"] != users[0]["id"] for owner in owners): | |
| 75 | raise ValueError(f"username alias already assigned: {alias}") | |
| 76 | assigned = keycloak.request(f"/admin/realms/master/users/{users[0]['id']}/role-mappings/clients/{uuid}") | |
| 77 | if any(role["name"] != alias for role in assigned): | |
| 78 | raise ValueError(f"multiple username aliases for {username}") | |
| 79 | if not assigned: | |
| 80 | keycloak.request(f"/admin/realms/master/users/{users[0]['id']}/role-mappings/clients/{uuid}", "POST", [roles[alias]]) | |
| 81 | mapper = { | |
| 82 | "name": "Shale username alias", "protocol": "openid-connect", | |
| 83 | "protocolMapper": "oidc-usermodel-client-role-mapper", | |
| 84 | "config": {"usermodel.clientRoleMapping.clientId": client_id, | |
| 85 | "claim.name": "preferred_username", "jsonType.label": "String", | |
| 86 | "multivalued": "false", "access.token.claim": "true", | |
| 87 | "id.token.claim": "true", "userinfo.token.claim": "true"}, | |
| 88 | } | |
| 89 | mappers_path = f"{path}/{uuid}/protocol-mappers/models" | |
| 90 | matches = [item for item in keycloak.request(mappers_path) if item["name"] == mapper["name"]] | |
| 91 | if len(matches) > 1: | |
| 92 | raise ValueError("duplicate Shale username mapper") | |
| 93 | if not matches: | |
| 94 | keycloak.request(mappers_path, "POST", mapper) | |
| 95 | elif any(matches[0].get(key) != value for key, value in mapper.items()): | |
| 96 | keycloak.request(f"{mappers_path}/{matches[0]['id']}", "PUT", {**mapper, "id": matches[0]["id"]}) | |
| 58 | 97 | secret = keycloak.request(f"{path}/{uuid}/client-secret")["value"] |
| 59 | 98 | if not secret: |
| 60 | 99 | raise ValueError(f"Keycloak client has no secret: {client_id}") |
service/keycloak/service.pkl+1| ... | ... | @@ -11,6 +11,7 @@ class OpenIDClient extends service.Requirement { |
| 11 | 11 | clientId: String(isNotEmpty) |
| 12 | 12 | name: String |
| 13 | 13 | redirectUris: Listing<String> = new { "*" } |
| 14 | usernameAliases: Map<String, String> = new {} | |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | 17 | local database = new postgres.Database { name = "keycloak_next" } |
service/shale/service.pkl+1| ... | ... | @@ -9,6 +9,7 @@ requirements { |
| 9 | 9 | new keycloak.OpenIDClient { |
| 10 | 10 | clientId = module.id |
| 11 | 11 | name = module.meta.name |
| 12 | usernameAliases { ["snow"] = "clover" } | |
| 12 | 13 | } |
| 13 | 14 | } |
| 14 | 15 |
tools/dashboard-run.py+12-2| ... | ... | @@ -381,11 +381,12 @@ def iam_validate(request): |
| 381 | 381 | "/roles": {"GET"}, "/users?max=1000": {"GET"}, "/users": {"POST"}, |
| 382 | 382 | "": {"GET", "PUT", "DELETE"}, "/sessions": {"GET"}, "/credentials": {"GET"}, |
| 383 | 383 | "/role-mappings/realm": {"GET", "POST", "DELETE"}, "/logout": {"POST"}, |
| 384 | "/shale-username": {"GET"}, | |
| 384 | 385 | "/execute-actions-email": {"PUT"}, "/reset-password": {"PUT"}, |
| 385 | 386 | } |
| 386 | 387 | user = re.fullmatch(r"/users/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(.*)", path) |
| 387 | 388 | suffix = user[2] if user else path |
| 388 | if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password"}: | |
| 389 | if user and suffix not in {"", "/sessions", "/credentials", "/role-mappings/realm", "/logout", "/execute-actions-email", "/reset-password", "/shale-username"}: | |
| 389 | 390 | raise Error(400, "Choose a supported user operation.") |
| 390 | 391 | if path.startswith("/users?username="): |
| 391 | 392 | try: |
| ... | ... | @@ -398,7 +399,7 @@ def iam_validate(request): |
| 398 | 399 | suffix = "/users?max=1000" |
| 399 | 400 | if (method not in allowed.get(suffix, set()) or not user and suffix == "" |
| 400 | 401 | or method in {"GET", "DELETE", "POST"} and suffix not in {"/users", "/role-mappings/realm"} and body is not None |
| 401 | or method == "GET" and body is not None): | |
| 402 | or method == "GET" and body is not None or suffix == "/shale-username" and not user): | |
| 402 | 403 | raise Error(400, "Choose a supported user operation.") |
| 403 | 404 | if method == "PUT" and suffix == "/reset-password": |
| 404 | 405 | if (not isinstance(body, dict) or set(body) != {"type", "value", "temporary"} |
| ... | ... | @@ -442,6 +443,15 @@ def iam(request): |
| 442 | 443 | profile = client.request("/admin/realms/master/users/" + user[1]) |
| 443 | 444 | if profile["username"] == "admin": |
| 444 | 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"]}} | |
| 445 | 455 | if isinstance(body, dict) and "attributes" in body: |
| 446 | 456 | attributes = dict(profile.get("attributes", {})) |
| 447 | 457 | picture = body["attributes"]["picture"] |