From b1263edac6ddb6206aae9fe595d1c1914aa10039 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:34:56 +0200 Subject: [PATCH] =?UTF-8?q?Zm=C4=9Bny:=20users=20migrace=20m=C3=A1=20nov?= =?UTF-8?q?=C3=A9=20sloupce=20gitea=5Fuser=5Fid,=20gitea=5Fsync=5Fstatus,?= =?UTF-8?q?=20gitea=5Fsync=5Ferror,=20gitea=5Fsynced=5Fat.=20P=C5=99idal?= =?UTF-8?q?=20jsem=20gitea=5Fprovisioning.py,=20kter=C3=BD=20p=C5=99es=20G?= =?UTF-8?q?itea=20API=20vytv=C3=A1=C5=99=C3=AD,=20aktivuje,=20deaktivuje?= =?UTF-8?q?=20a=20synchronizuje=20=C3=BA=C4=8Dty.=20Role=20viewer=20Gitea?= =?UTF-8?q?=20=C3=BA=C4=8Det=20nem=C3=A1;=20developer=20a=20admin=20=C3=BA?= =?UTF-8?q?=C4=8Det=20maj=C3=AD.=20Zm=C4=9Bny=20role=20a=20enable/disable?= =?UTF-8?q?=20v=20administraci=20spou=C5=A1t=C3=AD=20sync=20do=20Gitea.=20?= =?UTF-8?q?Admin=20UI=20v=20routes/users.py=20zobrazuje=20stav=20Gitea=20s?= =?UTF-8?q?ynchronizace=20a=20m=C3=A1=20ru=C4=8Dn=C3=AD=20Sync=20Gitea.=20?= =?UTF-8?q?Google=20login=20po=20p=C5=99ihl=C3=A1=C5=A1en=C3=AD=20dorovn?= =?UTF-8?q?=C3=A1v=C3=A1=20Gitea=20stav=20podle=20port=C3=A1lov=C3=A9ho=20?= =?UTF-8?q?u=C5=BEivatele.=20Gitea=20OAuth=20login=20je=20v=20port=C3=A1lu?= =?UTF-8?q?=20vypnut=C3=BD;=20endpointy=20z=C5=AFstaly,=20ale=20vrac=C3=AD?= =?UTF-8?q?=20hl=C3=A1=C5=A1ku,=20=C5=BEe=20se=20m=C3=A1=20pou=C5=BE=C3=AD?= =?UTF-8?q?t=20Google.=20Lok=C3=A1ln=C3=AD=20login=20se=20zobrazuje/povol?= =?UTF-8?q?=C3=AD=20jen=20p=C5=99i=20explicitn=C3=ADm=20AUTH=5FMODE=3Dloca?= =?UTF-8?q?l.=20Gitea=20API=20token=20se=20=C4=8Dte=20z=20GITEA=5FADMIN=5F?= =?UTF-8?q?TOKEN,=20p=C5=99=C3=ADpadn=C4=9B=20fallback=20GITEA=5FAPI=5FTOK?= =?UTF-8?q?EN=20nebo=20GITEA=5FTOKEN.=20Pod=C5=99=C3=ADzen=C3=A9=20Gitea?= =?UTF-8?q?=20username=20je=20deterministicky=20portal-{user=5Fid},=20port?= =?UTF-8?q?=C3=A1lov=C3=A1=20tabulka=20z=C5=AFst=C3=A1v=C3=A1=20zdroj=20pr?= =?UTF-8?q?avdy.=20Ov=C4=9B=C5=99en=C3=AD:=20python=20-m=20py=5Fcompile=20?= =?UTF-8?q?pro=C5=A1el=20pro=20v=C5=A1echny=20upraven=C3=A9=20Python=20sou?= =?UTF-8?q?bory.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/auth.py | 11 ++- app/config.py | 8 ++ app/db/migrations.py | 11 ++- app/db/users.py | 59 +++++++++++++- app/gitea_provisioning.py | 162 ++++++++++++++++++++++++++++++++++++++ app/routes/auth.py | 24 +++--- app/routes/users.py | 83 +++++++++++++++++-- 7 files changed, 334 insertions(+), 24 deletions(-) create mode 100644 app/gitea_provisioning.py diff --git a/app/auth.py b/app/auth.py index ea1df49..3b24e41 100644 --- a/app/auth.py +++ b/app/auth.py @@ -18,7 +18,16 @@ def _user_select_columns(con) -> str: columns = _table_columns(con, "users") enabled_select = "COALESCE(is_enabled, 1) AS is_enabled" if "is_enabled" in columns else "1 AS is_enabled" optional_columns = [] - for column in ("last_login_at", "auth_provider", "provider_subject", "avatar_url"): + for column in ( + "last_login_at", + "auth_provider", + "provider_subject", + "avatar_url", + "gitea_user_id", + "gitea_sync_status", + "gitea_sync_error", + "gitea_synced_at", + ): if column in columns: optional_columns.append(column) else: diff --git a/app/config.py b/app/config.py index b44899f..96047b3 100644 --- a/app/config.py +++ b/app/config.py @@ -67,6 +67,14 @@ def get_gitea_server_url() -> str: return get_gitea_public_url() or INTERNAL_GITEA_URL +def get_gitea_admin_token() -> str: + return ( + read_env_value("GITEA_ADMIN_TOKEN", "") + or read_env_value("GITEA_API_TOKEN", "") + or read_env_value("GITEA_TOKEN", "") + ).strip() + + def get_gitea_redirect_uri() -> str: redirect_uri = read_env_value("GITEA_OAUTH_REDIRECT_URI", "").strip() if redirect_uri: diff --git a/app/db/migrations.py b/app/db/migrations.py index 1d132f3..c844f0c 100644 --- a/app/db/migrations.py +++ b/app/db/migrations.py @@ -23,7 +23,11 @@ def ensure_user_columns(con) -> None: last_login_at TEXT, auth_provider TEXT, provider_subject TEXT, - avatar_url TEXT + avatar_url TEXT, + gitea_user_id INTEGER, + gitea_sync_status TEXT NOT NULL DEFAULT 'not_required', + gitea_sync_error TEXT, + gitea_synced_at TEXT ) """ ) @@ -45,6 +49,10 @@ def ensure_user_columns(con) -> None: "auth_provider": "TEXT", "provider_subject": "TEXT", "avatar_url": "TEXT", + "gitea_user_id": "INTEGER", + "gitea_sync_status": "TEXT NOT NULL DEFAULT 'not_required'", + "gitea_sync_error": "TEXT", + "gitea_synced_at": "TEXT", } for column, definition in defaults.items(): if column not in columns: @@ -54,6 +62,7 @@ def ensure_user_columns(con) -> None: if "is_active" in columns and "is_enabled" in columns: con.execute("UPDATE users SET is_enabled = COALESCE(is_active, 1) WHERE is_enabled IS NULL") con.execute("UPDATE users SET role = 'viewer' WHERE role IS NULL OR role = ''") + con.execute("UPDATE users SET gitea_sync_status = 'not_required' WHERE gitea_sync_status IS NULL OR gitea_sync_status = ''") con.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL OR created_at = ''") con.execute("UPDATE users SET updated_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL OR updated_at = ''") con.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)") diff --git a/app/db/users.py b/app/db/users.py index 33bdbd4..1068f0a 100644 --- a/app/db/users.py +++ b/app/db/users.py @@ -19,6 +19,10 @@ def list_users() -> list[dict[str, Any]]: auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider" provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject" avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url" + gitea_user_id_select = "gitea_user_id" if "gitea_user_id" in columns else "NULL AS gitea_user_id" + gitea_sync_status_select = "gitea_sync_status" if "gitea_sync_status" in columns else "'not_required' AS gitea_sync_status" + gitea_sync_error_select = "gitea_sync_error" if "gitea_sync_error" in columns else "NULL AS gitea_sync_error" + gitea_synced_at_select = "gitea_synced_at" if "gitea_synced_at" in columns else "NULL AS gitea_synced_at" rows = con.execute( f""" @@ -34,7 +38,11 @@ def list_users() -> list[dict[str, Any]]: {last_login_select}, {auth_provider_select}, {provider_subject_select}, - {avatar_url_select} + {avatar_url_select}, + {gitea_user_id_select}, + {gitea_sync_status_select}, + {gitea_sync_error_select}, + {gitea_synced_at_select} FROM users ORDER BY id """ @@ -52,6 +60,10 @@ def get_user(user_id: int) -> dict[str, Any] | None: auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider" provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject" avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url" + gitea_user_id_select = "gitea_user_id" if "gitea_user_id" in columns else "NULL AS gitea_user_id" + gitea_sync_status_select = "gitea_sync_status" if "gitea_sync_status" in columns else "'not_required' AS gitea_sync_status" + gitea_sync_error_select = "gitea_sync_error" if "gitea_sync_error" in columns else "NULL AS gitea_sync_error" + gitea_synced_at_select = "gitea_synced_at" if "gitea_synced_at" in columns else "NULL AS gitea_synced_at" row = con.execute( f""" @@ -67,7 +79,11 @@ def get_user(user_id: int) -> dict[str, Any] | None: {last_login_select}, {auth_provider_select}, {provider_subject_select}, - {avatar_url_select} + {avatar_url_select}, + {gitea_user_id_select}, + {gitea_sync_status_select}, + {gitea_sync_error_select}, + {gitea_synced_at_select} FROM users WHERE id = ? """, @@ -136,3 +152,42 @@ def set_user_enabled(user_id: int, enabled: bool) -> None: ) con.commit() con.close() + + +def update_gitea_sync_state( + user_id: int, + status: str, + *, + gitea_user_id: int | None = None, + error: str = "", + synced: bool = False, +) -> None: + run_migrations() + con = get_connection() + if gitea_user_id is None: + con.execute( + """ + UPDATE users + SET gitea_sync_status = ?, + gitea_sync_error = ?, + gitea_synced_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE gitea_synced_at END, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (status, error, 1 if synced else 0, user_id), + ) + else: + con.execute( + """ + UPDATE users + SET gitea_user_id = ?, + gitea_sync_status = ?, + gitea_sync_error = ?, + gitea_synced_at = CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE gitea_synced_at END, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (gitea_user_id, status, error, 1 if synced else 0, user_id), + ) + con.commit() + con.close() diff --git a/app/gitea_provisioning.py b/app/gitea_provisioning.py new file mode 100644 index 0000000..81ed671 --- /dev/null +++ b/app/gitea_provisioning.py @@ -0,0 +1,162 @@ +import json +import secrets +import string +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request as UrlRequest +from urllib.request import urlopen + +from app.config import get_gitea_admin_token, get_gitea_server_url +from app.db.users import update_gitea_sync_state + +GITEA_ACCOUNT_ROLES = {"admin", "developer"} + + +def sync_gitea_user(user: dict) -> dict: + user_id = int(user["id"]) + desired_active = _requires_gitea_account(user) + username = _gitea_username(user_id) + + update_gitea_sync_state(user_id, "syncing") + try: + if desired_active: + gitea_user = _ensure_gitea_user(user, username) + _update_gitea_user(username, user, active=True) + update_gitea_sync_state( + user_id, + "synced", + gitea_user_id=int(gitea_user["id"]), + synced=True, + ) + return {"status": "synced", "action": "activated", "gitea_user_id": gitea_user["id"]} + + if not user.get("gitea_user_id"): + update_gitea_sync_state(user_id, "not_required", synced=True) + return {"status": "not_required", "action": "none"} + + gitea_user = _get_gitea_user(username, missing_ok=True) + if gitea_user: + _update_gitea_user(username, user, active=False) + update_gitea_sync_state( + user_id, + "not_required", + gitea_user_id=int(gitea_user["id"]), + synced=True, + ) + return {"status": "not_required", "action": "deactivated", "gitea_user_id": gitea_user["id"]} + + update_gitea_sync_state(user_id, "not_required", synced=True) + return {"status": "not_required", "action": "none"} + except Exception as exc: + error = str(exc)[:500] + update_gitea_sync_state(user_id, "error", error=error) + return {"status": "error", "error": error} + + +def _requires_gitea_account(user: dict) -> bool: + role = (user.get("role") or "").lower() + return role in GITEA_ACCOUNT_ROLES and bool(user.get("is_enabled", 1)) and bool(user.get("is_active", 1)) + + +def _gitea_username(user_id: int) -> str: + return f"portal-{user_id}" + + +def _ensure_gitea_user(user: dict, username: str) -> dict: + existing = _get_gitea_user(username, missing_ok=True) + if existing: + return existing + + return _create_gitea_user(user, username) + + +def _create_gitea_user(user: dict, username: str) -> dict: + payload = { + "username": username, + "login_name": username, + "source_id": 0, + "email": _email(user), + "full_name": _display_name(user), + "password": _random_password(), + "must_change_password": False, + "send_notify": False, + "visibility": "private", + } + return _request_json("POST", "/api/v1/admin/users", payload, expected=(201,)) + + +def _update_gitea_user(username: str, user: dict, active: bool) -> dict: + payload = { + "email": _email(user), + "full_name": _display_name(user), + "login_name": username, + "source_id": 0, + "active": active, + "admin": False, + "visibility": "private", + } + return _request_json("PATCH", f"/api/v1/admin/users/{quote(username)}", payload, expected=(200,)) + + +def _get_gitea_user(username: str, missing_ok: bool = False) -> dict | None: + try: + return _request_json("GET", f"/api/v1/admin/users/{quote(username)}", expected=(200,)) + except RuntimeError as exc: + if missing_ok and "HTTP 404" in str(exc): + return None + raise + + +def _request_json(method: str, path: str, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict: + base_url = get_gitea_server_url().rstrip("/") + token = get_gitea_admin_token() + if not base_url: + raise RuntimeError("Gitea server URL is not configured") + if not token: + raise RuntimeError("Gitea admin token is not configured") + + data = json.dumps(payload).encode("utf-8") if payload is not None else None + request = UrlRequest( + f"{base_url}{path}", + data=data, + headers={ + "Accept": "application/json", + "Authorization": f"token {token}", + "Content-Type": "application/json", + }, + method=method, + ) + try: + with urlopen(request, timeout=10) as response: + body = response.read().decode("utf-8") + if response.status not in expected: + raise RuntimeError(f"Gitea API returned HTTP {response.status}") + return json.loads(body) if body else {} + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Gitea API returned HTTP {exc.code}: {detail}") from exc + except (URLError, TimeoutError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Gitea API request failed: {exc}") from exc + + +def _email(user: dict) -> str: + email = (user.get("email") or "").strip().lower() + if email: + return email + return f"portal-{int(user['id'])}@localhost" + + +def _display_name(user: dict) -> str: + return (user.get("display_name") or user.get("username") or _email(user)).strip() + + +def _random_password() -> str: + alphabet = string.ascii_letters + string.digits + required = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + ] + required.extend(secrets.choice(alphabet) for _ in range(29)) + secrets.SystemRandom().shuffle(required) + return "".join(required) diff --git a/app/routes/auth.py b/app/routes/auth.py index d5f08c1..50c5e3a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -9,7 +9,7 @@ from urllib.request import urlopen from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse -from app.auth import authenticate_user, current_user, find_or_create_google_user, find_or_create_oauth_user, mark_last_login +from app.auth import authenticate_user, current_user, find_or_create_google_user, mark_last_login from app.config import ( get_auth_mode, get_gitea_public_url, @@ -21,6 +21,7 @@ from app.config import ( read_env_value, ) from app.db.audit import log_audit_event +from app.gitea_provisioning import sync_gitea_user from app.templates.layout import page router = APIRouter() @@ -39,7 +40,7 @@ def login_form(request: Request): @router.post("/login", response_class=HTMLResponse) def login(request: Request, username: str = Form(...), password: str = Form(...)): - if get_auth_mode() == "google": + if get_auth_mode() != "local": return _render_login("Local login is disabled. Use Google sign in.") user = authenticate_user(username, password) @@ -64,8 +65,7 @@ def login(request: Request, username: str = Form(...), password: str = Form(...) def gitea_login(request: Request): if current_user(request): return RedirectResponse(url="/portal/operations", status_code=303) - if get_auth_mode() != "mixed": - return _render_login("Gitea login is not enabled in this auth mode.") + return _render_login("Gitea login is disabled. Use Google sign in.") gitea_url = get_gitea_public_url() client_id = read_env_value("GITEA_OAUTH_CLIENT_ID", "") @@ -98,8 +98,7 @@ def gitea_login(request: Request): @router.get("/auth/gitea/callback", response_class=HTMLResponse) def gitea_callback(request: Request, code: str = "", state: str = "", error: str = ""): - if get_auth_mode() != "mixed": - return _render_login("Gitea login is not enabled in this auth mode.") + return _render_login("Gitea login is disabled. Use Google sign in.") expected_state = request.session.pop("gitea_oauth_state", None) if error: @@ -228,6 +227,8 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st _log_google_failure("disabled_user", username=username) return _render_login("Uživatel je v portálu vypnutý.") + sync_result = sync_gitea_user(user) + request.session.clear() request.session["user_id"] = user["id"] mark_last_login(int(user["id"])) @@ -242,6 +243,7 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st "email": user.get("email"), "role": user.get("role"), "provider": "google", + "gitea_sync_status": sync_result.get("status"), }, ) log_audit_event( @@ -249,7 +251,7 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st action="auth.google.login.success", target_type="user", target_id=user.get("id"), - metadata={"username": user.get("username"), "provider": "google"}, + metadata={"username": user.get("username"), "provider": "google", "gitea_sync_status": sync_result.get("status")}, ) return RedirectResponse(url="/portal", status_code=303) except Exception: @@ -397,12 +399,6 @@ def _render_login(error: str | None = None) -> str: error_html = f'
{html.escape(error)}
' auth_mode = get_auth_mode() gitea_login_html = "" - if auth_mode == "mixed" and is_gitea_oauth_button_enabled(): - gitea_login_html = """ - - """ google_login_html = "" if auth_mode in {"google", "mixed"} and is_google_oauth_button_enabled(): google_login_html = """ @@ -414,7 +410,7 @@ def _render_login(error: str | None = None) -> str: google_login_html = 'Google login is not fully configured.
' local_login_html = "" - if auth_mode in {"mixed", "local"}: + if auth_mode == "local": local_login_html = """