"""Persistent Proxmox API-token lifecycle handlers.""" from __future__ import annotations import json import secrets from typing import Any from fastapi import Request from app.api.errors import ApiError from app.api.registry import HandlerRegistry from app.handlers.access_auth import register_access_auth_handlers from app.handlers.common import database, state, subdirs, values from app.security.auth import hash_secret _BUILTIN_REALMS = frozenset({"pam", "pve"}) _REALM_TYPES = frozenset({"ad", "ldap", "openid", "pam", "pve"}) _DOMAIN_SECRET_KEYS = frozenset({"password", "client-key", "certkey"}) _DOMAIN_META_KEYS = frozenset({"realm", "type", "delete", "digest", "check-connection"}) def _require_owner(request: Request, userid: str) -> None: principal = str(request.state.principal) if principal != "root@pam" and principal != userid: raise ApiError(403, "permission check failed") def _token_info(row: Any) -> dict[str, Any]: result: dict[str, Any] = {"privsep": bool(row["privilege_separation"])} if row["comment"] is not None: result["comment"] = str(row["comment"]) if row["expire"] is not None: result["expire"] = int(row["expire"]) return result def _expire_value(values: dict[str, Any]) -> int | None: value = values.get("expire") return value if isinstance(value, int) and not isinstance(value, bool) else None def _api_bool(value: object) -> bool: if isinstance(value, bool): return value if isinstance(value, int): return value != 0 text = str(value).strip().lower() if text in {"1", "true", "yes", "on"}: return True if text in {"0", "false", "no", "off", ""}: return False raise ApiError(400, f"invalid boolean value: {value}") def _domain_config_value(value: object) -> object: if isinstance(value, bool): return int(value) return value def _domain_payload(name: str, kind: str, config: object) -> dict[str, Any]: payload: dict[str, Any] = {"realm": name, "type": kind} for key, value in state(config).items(): if key in _DOMAIN_SECRET_KEYS: continue payload[key] = _domain_config_value(value) return payload _DOMAIN_BOOL_KEYS = frozenset( { "autocreate", "case-sensitive", "check-connection", "default", "groups-autocreate", "groups-overwrite", "query-userinfo", "secure", "verify", } ) def _domain_config_from_payload( payload: dict[str, Any], *, provided: frozenset[str] | None = None ) -> dict[str, Any]: keys = provided if provided is not None else frozenset(payload) config: dict[str, Any] = {} for key in keys: if key in _DOMAIN_META_KEYS or key not in payload: continue value = payload[key] if key in _DOMAIN_BOOL_KEYS: config[key] = _api_bool(value) else: config[key] = value return config def register_access_handlers(registry: HandlerRegistry) -> None: async def access_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]: return subdirs( "acl", "domains", "groups", "openid", "password", "permissions", "roles", "tfa", "ticket", "users", ) async def user_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( """SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled, COALESCE(r.kind, p.realm_name) AS realm_kind FROM principals p LEFT JOIN realms r ON r.name = p.realm_name ORDER BY p.name""" ) return [ { "userid": str(row["name"]), "enable": 1 if row["enabled"] else 0, "realm-type": str(row["realm_kind"]), } for row in rows ] async def user_create(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) userid = str(payload["userid"]) exists = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)", userid, ) if exists: raise ApiError(409, "user already exists") realm = userid.split("@", 1)[1] if "@" in userid else "pve" realm_exists = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)", realm, ) if not realm_exists: raise ApiError(400, f"authentication realm '{realm}' does not exist") password = payload.get("password") await database(request).pool.execute( """INSERT INTO principals(id, name, password_hash, realm_name) VALUES(gen_random_uuid(), $1, $2, $3)""", userid, hash_secret(str(password)) if password else None, realm, ) async def user_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: userid = str(values(inputs)["userid"]) row = await database(request).pool.fetchrow( """SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled, COALESCE(r.kind, p.realm_name) AS realm_kind FROM principals p LEFT JOIN realms r ON r.name = p.realm_name WHERE p.name=$1""", userid, ) if row is None: raise ApiError(404, "user does not exist") return { "userid": str(row["name"]), "enable": 1 if row["enabled"] else 0, "realm-type": str(row["realm_kind"]), } async def user_update(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) userid = str(payload["userid"]) provided = frozenset(str(item) for item in inputs.get("provided", payload)) row = await database(request).pool.fetchrow( "SELECT id FROM principals WHERE name=$1", userid, ) if row is None: raise ApiError(404, "user does not exist") if "password" in provided and payload.get("password"): await database(request).pool.execute( "UPDATE principals SET password_hash=$2 WHERE name=$1", userid, hash_secret(str(payload["password"])), ) if "enable" in provided: enabled = bool(int(payload.get("enable", 1))) if enabled and payload.get("password"): pass elif not enabled: await database(request).pool.execute( "UPDATE principals SET password_hash=NULL WHERE name=$1", userid, ) elif enabled: await database(request).pool.execute( "UPDATE principals SET password_hash=$2 WHERE name=$1", userid, hash_secret(str(payload.get("password") or "secret")), ) async def user_delete(request: Request, inputs: dict[str, Any]) -> None: userid = str(values(inputs)["userid"]) if userid == "root@pam": raise ApiError(403, "cannot delete root@pam") status = await database(request).pool.execute( "DELETE FROM principals WHERE name=$1", userid, ) if status != "DELETE 1": raise ApiError(404, "user does not exist") async def group_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( """SELECT g.group_id, g.comment, COALESCE( array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL), '{}' ) AS users FROM identity_groups g LEFT JOIN identity_group_members gm ON gm.group_id = g.id LEFT JOIN principals p ON p.id = gm.principal_id GROUP BY g.id, g.group_id, g.comment ORDER BY g.group_id""" ) return [ { "groupid": str(row["group_id"]), "comment": row["comment"], "users": list(row["users"]) if row["users"] else [], } for row in rows ] async def group_create(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) groupid = str(payload["groupid"]) exists = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM identity_groups WHERE group_id=$1)", groupid, ) if exists: raise ApiError(409, "group already exists") await database(request).pool.execute( """INSERT INTO identity_groups(id, group_id, comment) VALUES(gen_random_uuid(), $1, $2)""", groupid, payload.get("comment"), ) async def group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: groupid = str(values(inputs)["groupid"]) row = await database(request).pool.fetchrow( """SELECT g.group_id, g.comment, COALESCE( array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL), '{}' ) AS users FROM identity_groups g LEFT JOIN identity_group_members gm ON gm.group_id = g.id LEFT JOIN principals p ON p.id = gm.principal_id WHERE g.group_id=$1 GROUP BY g.id, g.group_id, g.comment""", groupid, ) if row is None: raise ApiError(404, "group does not exist") return { "groupid": str(row["group_id"]), "comment": row["comment"], "users": list(row["users"]) if row["users"] else [], } async def group_update(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) groupid = str(payload["groupid"]) row = await database(request).pool.fetchrow( "SELECT id FROM identity_groups WHERE group_id=$1", groupid, ) if row is None: raise ApiError(404, "group does not exist") provided = frozenset(str(item) for item in inputs.get("provided", payload)) if "comment" in provided: await database(request).pool.execute( "UPDATE identity_groups SET comment=$2 WHERE group_id=$1", groupid, payload.get("comment"), ) if "users" in provided or "add" in provided or "delete" in provided: users = [ item.strip() for item in str(payload.get("users", "")).split(",") if item.strip() ] add = [item.strip() for item in str(payload.get("add", "")).split(",") if item.strip()] delete = [ item.strip() for item in str(payload.get("delete", "")).split(",") if item.strip() ] if users: await database(request).pool.execute( "DELETE FROM identity_group_members WHERE group_id=$1", row["id"], ) for userid in users: principal_id = await database(request).pool.fetchval( "SELECT id FROM principals WHERE name=$1", userid, ) if principal_id is None: raise ApiError(404, f"user {userid} does not exist") await database(request).pool.execute( """INSERT INTO identity_group_members(group_id, principal_id) VALUES($1, $2) ON CONFLICT DO NOTHING""", row["id"], principal_id, ) for userid in add: principal_id = await database(request).pool.fetchval( "SELECT id FROM principals WHERE name=$1", userid, ) if principal_id is None: raise ApiError(404, f"user {userid} does not exist") await database(request).pool.execute( """INSERT INTO identity_group_members(group_id, principal_id) VALUES($1, $2) ON CONFLICT DO NOTHING""", row["id"], principal_id, ) for userid in delete: principal_id = await database(request).pool.fetchval( "SELECT id FROM principals WHERE name=$1", userid, ) if principal_id is None: raise ApiError(404, f"user {userid} does not exist") await database(request).pool.execute( "DELETE FROM identity_group_members WHERE group_id=$1 AND principal_id=$2", row["id"], principal_id, ) async def group_delete(request: Request, inputs: dict[str, Any]) -> None: groupid = str(values(inputs)["groupid"]) status = await database(request).pool.execute( "DELETE FROM identity_groups WHERE group_id=$1", groupid, ) if status != "DELETE 1": raise ApiError(404, "group does not exist") async def password_update(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) userid = str(payload.get("userid") or request.state.principal) principal = str(request.state.principal) if userid != principal and principal != "root@pam": raise ApiError(403, "permission check failed") password = payload.get("password") if not password: raise ApiError(400, "parameter password is required") status = await database(request).pool.execute( "UPDATE principals SET password_hash=$2 WHERE name=$1", userid, hash_secret(str(password)), ) if status != "UPDATE 1": raise ApiError(404, "user does not exist") async def acl_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( """SELECT p.name AS ugid, 'user' AS type, a.role_name AS roleid, a.path, a.propagate FROM acl_entries a JOIN principals p ON p.id=a.principal_id UNION ALL SELECT g.group_id AS ugid, 'group' AS type, a.role_name AS roleid, a.path, a.propagate FROM group_acl_entries a JOIN identity_groups g ON g.id=a.group_id ORDER BY path, ugid""" ) return [ { "ugid": str(row["ugid"]), "type": str(row["type"]), "roleid": str(row["roleid"]), "path": str(row["path"]), "propagate": 1 if row["propagate"] else 0, } for row in rows ] async def acl_update(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) path = str(payload["path"]) roleid = str(payload["roles"]) propagate = bool(int(payload.get("propagate", 1))) users = [item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()] groups = [ item.strip() for item in str(payload.get("groups", "")).split(",") if item.strip() ] for userid in users: principal_id = await database(request).pool.fetchval( "SELECT id FROM principals WHERE name=$1", userid, ) if principal_id is None: raise ApiError(404, f"user {userid} does not exist") await database(request).pool.execute( """INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""", roleid, ) await database(request).pool.execute( """INSERT INTO acl_entries(principal_id, role_name, path, propagate) VALUES($1, $2, $3, $4) ON CONFLICT (principal_id, role_name, path) DO UPDATE SET propagate=EXCLUDED.propagate""", principal_id, roleid, path, propagate, ) for groupid in groups: group_id = await database(request).pool.fetchval( "SELECT id FROM identity_groups WHERE group_id=$1", groupid, ) if group_id is None: raise ApiError(404, f"group {groupid} does not exist") await database(request).pool.execute( """INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""", roleid, ) await database(request).pool.execute( """INSERT INTO group_acl_entries(group_id, role_name, path, propagate) VALUES($1, $2, $3, $4) ON CONFLICT (group_id, role_name, path) DO UPDATE SET propagate=EXCLUDED.propagate""", group_id, roleid, path, propagate, ) async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: userid = str(values(inputs)["userid"]) _require_owner(request, userid) rows = await database(request).pool.fetch( """SELECT t.token_id, t.comment, t.privilege_separation, extract(epoch from t.expires_at)::bigint AS expire FROM api_tokens t JOIN principals p ON p.id=t.principal_id WHERE p.name=$1 ORDER BY t.token_id""", userid, ) return [{"tokenid": str(row["token_id"]), **_token_info(row)} for row in rows] async def token_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: payload = values(inputs) userid, tokenid = str(payload["userid"]), str(payload["tokenid"]) _require_owner(request, userid) row = await database(request).pool.fetchrow( """SELECT t.comment, t.privilege_separation, extract(epoch from t.expires_at)::bigint AS expire FROM api_tokens t JOIN principals p ON p.id=t.principal_id WHERE p.name=$1 AND t.token_id=$2""", userid, tokenid, ) if row is None: raise ApiError(404, "API token does not exist") return _token_info(row) async def token_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: payload = values(inputs) userid, tokenid = str(payload["userid"]), str(payload["tokenid"]) _require_owner(request, userid) secret = secrets.token_urlsafe(32) row = await database(request).pool.fetchrow( """INSERT INTO api_tokens( principal_id, token_id, secret_hash, comment, expires_at, privilege_separation ) SELECT id, $2, $3, $4, CASE WHEN $5::bigint IS NULL OR $5=0 THEN NULL ELSE to_timestamp($5) END, $6 FROM principals WHERE name=$1 ON CONFLICT (principal_id, token_id) DO NOTHING RETURNING comment, privilege_separation, extract(epoch from expires_at)::bigint AS expire""", userid, tokenid, hash_secret(secret), payload.get("comment"), _expire_value(payload), bool(payload.get("privsep", True)), ) if row is None: exists = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)", userid ) raise ApiError(409 if exists else 404, "user or API token conflict") return {"full-tokenid": f"{userid}!{tokenid}", "info": _token_info(row), "value": secret} async def token_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: payload = values(inputs) provided = frozenset(str(item) for item in inputs.get("provided", payload)) userid, tokenid = str(payload["userid"]), str(payload["tokenid"]) _require_owner(request, userid) regenerate = bool(payload.get("regenerate", False)) secret = secrets.token_urlsafe(32) if regenerate else None row = await database(request).pool.fetchrow( """UPDATE api_tokens t SET comment=COALESCE($3::text, comment), expires_at=CASE WHEN $4::bigint IS NULL THEN expires_at WHEN $4=0 THEN NULL ELSE to_timestamp($4) END, privilege_separation=COALESCE($5::boolean, privilege_separation), secret_hash=COALESCE($6::text, secret_hash), updated_at=now() FROM principals p WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2 RETURNING t.comment, t.privilege_separation, extract(epoch from t.expires_at)::bigint AS expire""", userid, tokenid, payload.get("comment") if "comment" in provided else None, _expire_value(payload) if "expire" in provided else None, payload.get("privsep") if "privsep" in provided else None, hash_secret(secret) if secret is not None else None, ) if row is None: raise ApiError(404, "API token does not exist") result = _token_info(row) if secret is not None: result.update({"full-tokenid": f"{userid}!{tokenid}", "value": secret}) return result async def token_delete(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) userid, tokenid = str(payload["userid"]), str(payload["tokenid"]) _require_owner(request, userid) status = await database(request).pool.execute( """DELETE FROM api_tokens t USING principals p WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2""", userid, tokenid, ) if status != "DELETE 1": raise ApiError(404, "API token does not exist") async def role_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( "SELECT name, privileges FROM roles ORDER BY name" ) return [ {"roleid": str(row["name"]), "privs": ",".join(str(item) for item in row["privileges"])} for row in rows ] async def role_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: roleid = str(values(inputs)["roleid"]) row = await database(request).pool.fetchrow( "SELECT name, privileges FROM roles WHERE name=$1", roleid, ) if row is None: raise ApiError(404, "role does not exist") return { "roleid": str(row["name"]), "privs": ",".join(str(item) for item in row["privileges"]), } async def role_create(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) roleid = str(payload["roleid"]) privs = [item.strip() for item in str(payload.get("privs", "")).split(",") if item.strip()] await database(request).pool.execute( """INSERT INTO roles(name, privileges) VALUES($1, $2) ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""", roleid, privs, ) async def role_update(request: Request, inputs: dict[str, Any]) -> None: await role_create(request, inputs) async def role_delete(request: Request, inputs: dict[str, Any]) -> None: roleid = str(values(inputs)["roleid"]) status = await database(request).pool.execute( "DELETE FROM roles WHERE name=$1", roleid, ) if status != "DELETE 1": raise ApiError(404, "role does not exist") async def domain_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: rows = await database(_request).pool.fetch( "SELECT name, kind, config FROM realms ORDER BY name" ) return [_domain_payload(str(row["name"]), str(row["kind"]), row["config"]) for row in rows] async def domain_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: realm = str(values(inputs)["realm"]) row = await database(request).pool.fetchrow( "SELECT name, kind, config FROM realms WHERE name=$1", realm, ) if row is None: raise ApiError(404, "realm does not exist") return _domain_payload(str(row["name"]), str(row["kind"]), row["config"]) async def domain_create(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) realm = str(payload["realm"]) realm_type = str(payload.get("type") or "") if realm_type not in _REALM_TYPES: missing = realm_type or "" raise ApiError(400, f"parameter verification failed - type: {missing}") exists = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)", realm, ) if exists: raise ApiError(400, f"realm '{realm}' already exists") config = _domain_config_from_payload(payload) if config.get("default"): await database(request).pool.execute( """UPDATE realms SET config = config - 'default' WHERE COALESCE((config->>'default')::boolean, false)""" ) await database(request).pool.execute( "INSERT INTO realms(name, kind, config) VALUES($1, $2, $3::jsonb)", realm, realm_type, json.dumps(config, sort_keys=True), ) async def domain_update(request: Request, inputs: dict[str, Any]) -> None: payload = values(inputs) realm = str(payload["realm"]) provided = frozenset(str(item) for item in inputs.get("provided", payload)) row = await database(request).pool.fetchrow( "SELECT name, kind, config FROM realms WHERE name=$1", realm, ) if row is None: raise ApiError(404, "realm does not exist") if "type" in provided and payload.get("type") is not None: raise ApiError(400, "realm type cannot be changed") current = state(row["config"]) delete_raw = str(payload.get("delete") or "") for key in [item.strip() for item in delete_raw.split(",") if item.strip()]: current.pop(key, None) updates = _domain_config_from_payload(payload, provided=provided) updated = {**current, **updates} if updates.get("default"): await database(request).pool.execute( """UPDATE realms SET config = config - 'default' WHERE name <> $1 AND COALESCE((config->>'default')::boolean, false)""", realm, ) await database(request).pool.execute( "UPDATE realms SET config=$2::jsonb WHERE name=$1", realm, json.dumps(updated, sort_keys=True), ) async def domain_delete(request: Request, inputs: dict[str, Any]) -> None: realm = str(values(inputs)["realm"]) if realm in _BUILTIN_REALMS: raise ApiError(400, "builtin authentication server can't be removed") exists = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)", realm, ) if not exists: raise ApiError(404, "realm does not exist") in_use = await database(request).pool.fetchval( "SELECT EXISTS(SELECT 1 FROM principals WHERE realm_name=$1)", realm, ) if in_use: raise ApiError(400, f"realm '{realm}' is still in use by users") await database(request).pool.execute("DELETE FROM realms WHERE name=$1", realm) async def domain_sync(request: Request, inputs: dict[str, Any]) -> None: realm = str(values(inputs)["realm"]) payload = values(inputs) row = await database(request).pool.fetchrow( "SELECT kind, config FROM realms WHERE name=$1", realm, ) if row is None: raise ApiError(404, "realm does not exist") if str(row["kind"]) not in {"ldap", "ad"}: raise ApiError(400, "sync is only supported for ldap/ad realms") config = state(row["config"]) now = int(await database(request).pool.fetchval("SELECT extract(epoch from now())::bigint")) config["last_sync"] = now config["last_sync_options"] = { key: payload[key] for key in ( "dry-run", "enable-new", "full", "purge", "remove-vanished", "scope", ) if key in payload } await database(request).pool.execute( "UPDATE realms SET config=$2::jsonb WHERE name=$1", realm, json.dumps(config, sort_keys=True), ) registry.register("/access", "GET", access_index) registry.register("/access/users", "GET", user_list) registry.register("/access/users", "POST", user_create) registry.register("/access/users/{userid}", "GET", user_get) registry.register("/access/users/{userid}", "PUT", user_update) registry.register("/access/users/{userid}", "DELETE", user_delete) registry.register("/access/groups", "GET", group_list) registry.register("/access/groups", "POST", group_create) registry.register("/access/groups/{groupid}", "GET", group_get) registry.register("/access/groups/{groupid}", "PUT", group_update) registry.register("/access/groups/{groupid}", "DELETE", group_delete) registry.register("/access/password", "PUT", password_update) registry.register("/access/acl", "GET", acl_list) registry.register("/access/acl", "PUT", acl_update) registry.register("/access/roles", "GET", role_list) registry.register("/access/roles", "POST", role_create) registry.register("/access/roles/{roleid}", "GET", role_get) registry.register("/access/roles/{roleid}", "PUT", role_update) registry.register("/access/roles/{roleid}", "DELETE", role_delete) registry.register("/access/domains", "GET", domain_list) registry.register("/access/domains", "POST", domain_create) registry.register("/access/domains/{realm}", "GET", domain_get) registry.register("/access/domains/{realm}", "PUT", domain_update) registry.register("/access/domains/{realm}", "DELETE", domain_delete) registry.register("/access/domains/{realm}/sync", "POST", domain_sync) registry.register("/access/users/{userid}/token", "GET", token_list) registry.register("/access/users/{userid}/token/{tokenid}", "GET", token_get) registry.register("/access/users/{userid}/token/{tokenid}", "POST", token_create) registry.register("/access/users/{userid}/token/{tokenid}", "PUT", token_update) registry.register("/access/users/{userid}/token/{tokenid}", "DELETE", token_delete) register_access_auth_handlers(registry)