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 = """ -

- Sign in with Gitea -

- """ 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 = """

Local login

diff --git a/app/routes/users.py b/app/routes/users.py index 53b3e26..f2f25d1 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import require_user from app.db.audit import log_audit_event from app.db.users import ROLES, count_enabled_admins, get_user, list_users, set_user_enabled, update_user_role +from app.gitea_provisioning import sync_gitea_user from app.templates.layout import page router = APIRouter() @@ -31,6 +32,17 @@ def render_enabled_pill(enabled) -> str: return 'disabled' +def render_sync_pill(status: str) -> str: + status = (status or "not_required").lower() + if status == "synced": + return 'synced' + if status == "error": + return 'error' + if status == "syncing": + return 'syncing' + return 'not required' + + def render_role_overview() -> str: role_rows = ( ("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."), @@ -83,6 +95,15 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend created_at = html.escape(managed_user.get("created_at", "") or "") last_login_at = html.escape(managed_user.get("last_login_at", "") or "") enabled = bool(managed_user.get("is_enabled", True)) + gitea_user_id = managed_user.get("gitea_user_id") + gitea_sync_status = managed_user.get("gitea_sync_status", "not_required") or "not_required" + gitea_sync_error = html.escape(managed_user.get("gitea_sync_error", "") or "") + gitea_synced_at = html.escape(managed_user.get("gitea_synced_at", "") or "") + gitea_details = f"ID {html.escape(str(gitea_user_id))}" if gitea_user_id else "No account" + if gitea_synced_at: + gitea_details += f"
{gitea_synced_at}" + if gitea_sync_error: + gitea_details += f'
{gitea_sync_error}' toggle_label = "Disable" if enabled else "Enable" toggle_action = "disable" if enabled else "enable" @@ -101,18 +122,22 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
{render_enabled_pill(enabled)} + {render_sync_pill(gitea_sync_status)}
{gitea_details} {created_at} {last_login_at}
+
+ +
""" if not rows: - rows = 'No users found.' + rows = 'No users found.' return page( "Users", @@ -135,6 +160,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend Provider Role Status + Gitea sync Created Last login Actions @@ -165,6 +191,8 @@ def update_role_action(user_id: int, role: str = Form(...), user=Depends(require ) update_user_role(user_id, role) + target = get_user(user_id) or target + sync_result = sync_gitea_user(target) if old_role != role: log_audit_event( user, @@ -175,10 +203,16 @@ def update_role_action(user_id: int, role: str = Form(...), user=Depends(require "username": target.get("username"), "old_role": old_role, "new_role": role, + "gitea_sync_status": sync_result.get("status"), }, ) - return RedirectResponse(url="/portal/admin/users?message=" + quote("User role updated."), status_code=303) + if sync_result.get("status") == "error": + return RedirectResponse( + url="/portal/admin/users?error=" + quote(f"User role updated, but Gitea sync failed: {sync_result.get('error')}"), + status_code=303, + ) + return RedirectResponse(url="/portal/admin/users?message=" + quote("User role updated and Gitea synchronized."), status_code=303) @router.post("/admin/users/{user_id}/disable") @@ -194,14 +228,21 @@ def disable_user_action(user_id: int, user=Depends(require_user)): ) set_user_enabled(user_id, False) + target = get_user(user_id) or target + sync_result = sync_gitea_user(target) log_audit_event( user, action="user.disabled", target_type="user", target_id=user_id, - metadata={"username": target.get("username")}, + metadata={"username": target.get("username"), "gitea_sync_status": sync_result.get("status")}, ) - return RedirectResponse(url="/portal/admin/users?message=" + quote("User disabled."), status_code=303) + if sync_result.get("status") == "error": + return RedirectResponse( + url="/portal/admin/users?error=" + quote(f"User disabled, but Gitea sync failed: {sync_result.get('error')}"), + status_code=303, + ) + return RedirectResponse(url="/portal/admin/users?message=" + quote("User disabled and Gitea synchronized."), status_code=303) @router.post("/admin/users/{user_id}/enable") @@ -212,11 +253,41 @@ def enable_user_action(user_id: int, user=Depends(require_user)): raise HTTPException(status_code=404, detail="User not found") set_user_enabled(user_id, True) + target = get_user(user_id) or target + sync_result = sync_gitea_user(target) log_audit_event( user, action="user.enabled", target_type="user", target_id=user_id, - metadata={"username": target.get("username")}, + metadata={"username": target.get("username"), "gitea_sync_status": sync_result.get("status")}, ) - return RedirectResponse(url="/portal/admin/users?message=" + quote("User enabled."), status_code=303) + if sync_result.get("status") == "error": + return RedirectResponse( + url="/portal/admin/users?error=" + quote(f"User enabled, but Gitea sync failed: {sync_result.get('error')}"), + status_code=303, + ) + return RedirectResponse(url="/portal/admin/users?message=" + quote("User enabled and Gitea synchronized."), status_code=303) + + +@router.post("/admin/users/{user_id}/sync-gitea") +def sync_gitea_user_action(user_id: int, user=Depends(require_user)): + require_admin(user) + target = get_user(user_id) + if not target: + raise HTTPException(status_code=404, detail="User not found") + + sync_result = sync_gitea_user(target) + log_audit_event( + user, + action="user.gitea.synced", + target_type="user", + target_id=user_id, + metadata={"username": target.get("username"), **sync_result}, + ) + if sync_result.get("status") == "error": + return RedirectResponse( + url="/portal/admin/users?error=" + quote(f"Gitea sync failed: {sync_result.get('error')}"), + status_code=303, + ) + return RedirectResponse(url="/portal/admin/users?message=" + quote("Gitea synchronized."), status_code=303)