From 28c69accad2306d007dac3fc85adb3f9cac88599 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 16 Oct 2025 16:32:29 -0600 Subject: [PATCH] Add GitHub API token management --- .../Access_Management/Github_API_Token.jsx | 278 ++++++++++++++++++ Data/Server/WebUI/src/App.jsx | 12 + Data/Server/WebUI/src/Navigation_Sidebar.jsx | 13 +- Data/Server/server.py | 211 ++++++++++++- 4 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 Data/Server/WebUI/src/Access_Management/Github_API_Token.jsx diff --git a/Data/Server/WebUI/src/Access_Management/Github_API_Token.jsx b/Data/Server/WebUI/src/Access_Management/Github_API_Token.jsx new file mode 100644 index 0000000..7395977 --- /dev/null +++ b/Data/Server/WebUI/src/Access_Management/Github_API_Token.jsx @@ -0,0 +1,278 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + CircularProgress, + InputAdornment, + Link, + Paper, + TextField, + Typography +} from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; + +const paperSx = { + m: 2, + p: 0, + bgcolor: "#1e1e1e", + color: "#f5f7fa", + display: "flex", + flexDirection: "column", + flexGrow: 1, + minWidth: 0, + minHeight: 320 +}; + +const fieldSx = { + mt: 2, + "& .MuiOutlinedInput-root": { + bgcolor: "#181818", + color: "#f5f7fa", + "& fieldset": { borderColor: "#2a2a2a" }, + "&:hover fieldset": { borderColor: "#58a6ff" }, + "&.Mui-focused fieldset": { borderColor: "#58a6ff" } + }, + "& .MuiInputLabel-root": { color: "#bbb" }, + "& .MuiInputLabel-root.Mui-focused": { color: "#7db7ff" } +}; + +export default function GithubAPIToken({ isAdmin = false }) { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [token, setToken] = useState(""); + const [inputValue, setInputValue] = useState(""); + const [fetchError, setFetchError] = useState(""); + const [verification, setVerification] = useState({ + message: "", + valid: null, + status: "", + rateLimit: null, + error: "" + }); + + const hydrate = useCallback(async () => { + setLoading(true); + setFetchError(""); + try { + const resp = await fetch("/api/github/token"); + const data = await resp.json(); + if (!resp.ok) { + throw new Error(data?.error || `HTTP ${resp.status}`); + } + const storedToken = typeof data?.token === "string" ? data.token : ""; + setToken(storedToken); + setInputValue(storedToken); + setVerification({ + message: typeof data?.message === "string" ? data.message : "", + valid: data?.valid === true, + status: typeof data?.status === "string" ? data.status : "", + rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null, + error: typeof data?.error === "string" ? data.error : "" + }); + } catch (err) { + const message = err && typeof err.message === "string" ? err.message : String(err); + setFetchError(message); + setToken(""); + setInputValue(""); + setVerification({ message: "", valid: null, status: "", rateLimit: null, error: "" }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!isAdmin) return; + hydrate(); + }, [hydrate, isAdmin]); + + const handleSave = useCallback(async () => { + setSaving(true); + setFetchError(""); + try { + const resp = await fetch("/api/github/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: inputValue }) + }); + const data = await resp.json(); + if (!resp.ok) { + throw new Error(data?.error || `HTTP ${resp.status}`); + } + const storedToken = typeof data?.token === "string" ? data.token : ""; + setToken(storedToken); + setInputValue(storedToken); + setVerification({ + message: typeof data?.message === "string" ? data.message : "", + valid: data?.valid === true, + status: typeof data?.status === "string" ? data.status : "", + rateLimit: typeof data?.rate_limit === "number" ? data.rate_limit : null, + error: typeof data?.error === "string" ? data.error : "" + }); + } catch (err) { + const message = err && typeof err.message === "string" ? err.message : String(err); + setFetchError(message); + } finally { + setSaving(false); + } + }, [inputValue]); + + const dirty = useMemo(() => inputValue !== token, [inputValue, token]); + + const verificationMessage = useMemo(() => { + if (dirty) { + return { text: "Token has unsaved changes — save to verify.", color: "#f0c36d" }; + } + const message = verification.message || ""; + if (!message) { + return { text: "", color: "#bbb" }; + } + if (verification.valid) { + return { text: message, color: "#7dffac" }; + } + if ((verification.status || "").toLowerCase() === "missing") { + return { text: message, color: "#bbb" }; + } + return { text: message, color: "#ff8080" }; + }, [dirty, verification]); + + if (!isAdmin) { + return ( + + + Access denied + + + You do not have permission to manage the GitHub API token. + + + ); + } + + return ( + + + + + Github API Token + + + Using a Github Personal Access Token increased the Github API rate limits from 60/hr to 5000/hr. + + + + + + {loading && ( + + + Loading token… + + )} + + {fetchError && ( + + {fetchError} + + )} + + + + Navigate to{' '} + + https://github.com/settings/tokens + {' '} + > Personal Access Tokens > Tokens (Classic) > Generate New Token > New Personal Access Token (Classic) + + + Note: Borealis Automation Platform + + + Scope:{' '} + + public_repo + + + + Expiration:{' '} + + No Expiration + + + + setInputValue(event.target.value)} + fullWidth + variant="outlined" + sx={fieldSx} + disabled={saving || loading} + InputProps={{ + endAdornment: ( + + + + ) + }} + /> + + {verificationMessage.text && ( + + {verificationMessage.text} + + )} + + {!dirty && verification.rateLimit && ( + + Authenticated GitHub rate limit: {verification.rateLimit.toLocaleString()} requests / hour + + )} + + + ); +} diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 903a3cb..f04e0e8 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -44,6 +44,7 @@ import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List"; import CreateJob from "./Scheduling/Create_Job.jsx"; import CredentialList from "./Access_Management/Credential_List.jsx"; import UserManagement from "./Access_Management/Users.jsx"; +import GithubAPIToken from "./Access_Management/Github_API_Token.jsx"; import ServerInfo from "./Admin/Server_Info.jsx"; // Networking Imports @@ -215,6 +216,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; } case "access_credentials": return "/access_management/credentials"; + case "access_github_token": + return "/access_management/github_token"; case "access_users": return "/access_management/users"; case "server_info": @@ -267,6 +270,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; }; } if (path === "/access_management/users") return { page: "access_users", options: {} }; + if (path === "/access_management/github_token") return { page: "access_github_token", options: {} }; if (path === "/access_management/credentials") return { page: "access_credentials", options: {} }; if (path === "/admin/server_info") return { page: "server_info", options: {} }; return { page: "devices", options: {} }; @@ -434,6 +438,10 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; items.push({ label: "Access Management", page: "access_credentials" }); items.push({ label: "Credentials", page: "access_credentials" }); break; + case "access_github_token": + items.push({ label: "Access Management", page: "access_credentials" }); + items.push({ label: "GitHub API Token", page: "access_github_token" }); + break; case "access_users": items.push({ label: "Access Management", page: "access_credentials" }); items.push({ label: "Users", page: "access_users" }); @@ -869,6 +877,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; useEffect(() => { const requiresAdmin = currentPage === 'server_info' || currentPage === 'access_credentials' + || currentPage === 'access_github_token' || currentPage === 'access_users' || currentPage === 'ssh_devices' || currentPage === 'winrm_devices' @@ -1055,6 +1064,9 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; case "access_credentials": return ; + case "access_github_token": + return ; + case "access_users": return ; diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index bccb87b..471a817 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -22,7 +22,12 @@ import { Apps as AssembliesIcon } from "@mui/icons-material"; import { LocationCity as SitesIcon } from "@mui/icons-material"; -import { Dns as ServerInfoIcon, VpnKey as CredentialIcon, PersonOutline as UserIcon } from "@mui/icons-material"; +import { + Dns as ServerInfoIcon, + VpnKey as CredentialIcon, + PersonOutline as UserIcon, + GitHub as GitHubIcon +} from "@mui/icons-material"; function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { const [expandedNav, setExpandedNav] = useState({ @@ -296,7 +301,10 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { {/* Access Management */} {(() => { if (!isAdmin) return null; - const groupActive = currentPage === "access_credentials" || currentPage === "access_users"; + const groupActive = + currentPage === "access_credentials" || + currentPage === "access_users" || + currentPage === "access_github_token"; return ( } label="Credentials" pageKey="access_credentials" /> + } label="GitHub API Token" pageKey="access_github_token" /> } label="Users" pageKey="access_users" /> diff --git a/Data/Server/server.py b/Data/Server/server.py index 4065c82..af1b275 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -130,6 +130,134 @@ _REPO_HASH_INTERVAL = max(30, min(_REPO_HASH_INTERVAL, 3600)) _REPO_HASH_WORKER_STARTED = False _REPO_HASH_WORKER_LOCK = Lock() +DB_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "database.db")) +os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + +_GITHUB_TOKEN_CACHE: Dict[str, Any] = {"token": None, "loaded_at": 0.0, "known": False} +_GITHUB_TOKEN_LOCK = Lock() + + +def _set_cached_github_token(token: Optional[str]) -> None: + with _GITHUB_TOKEN_LOCK: + _GITHUB_TOKEN_CACHE["token"] = token if token else None + _GITHUB_TOKEN_CACHE["loaded_at"] = time.time() + _GITHUB_TOKEN_CACHE["known"] = True + + +def _load_github_token_from_db(*, force_refresh: bool = False) -> Optional[str]: + now = time.time() + with _GITHUB_TOKEN_LOCK: + if ( + not force_refresh + and _GITHUB_TOKEN_CACHE.get("known") + and now - (_GITHUB_TOKEN_CACHE.get("loaded_at") or 0.0) < 15.0 + ): + return _GITHUB_TOKEN_CACHE.get("token") # type: ignore[return-value] + + conn: Optional[sqlite3.Connection] = None + token: Optional[str] = None + try: + conn = sqlite3.connect(DB_PATH, timeout=5) + cur = conn.cursor() + cur.execute("SELECT token FROM github_token LIMIT 1") + row = cur.fetchone() + if row and row[0]: + candidate = str(row[0]).strip() + token = candidate or None + except sqlite3.OperationalError: + token = None + except Exception as exc: + _write_service_log("server", f"github token lookup failed: {exc}") + token = None + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + _set_cached_github_token(token) + return token + + +def _github_api_token(*, force_refresh: bool = False) -> Optional[str]: + token = _load_github_token_from_db(force_refresh=force_refresh) + if token: + return token + env_token = os.environ.get("BOREALIS_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN") + if env_token: + env_token = env_token.strip() + return env_token or None + return None + + +def _verify_github_token(token: Optional[str]) -> Dict[str, Any]: + if not token: + return { + "valid": False, + "message": "API Token Not Configured", + "status": "missing", + "rate_limit": None, + } + + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "Borealis-Server", + "Authorization": f"Bearer {token}", + } + try: + resp = requests.get( + f"https://api.github.com/repos/{_DEFAULT_REPO}/branches/{_DEFAULT_BRANCH}", + headers=headers, + timeout=20, + ) + limit_header = resp.headers.get("X-RateLimit-Limit") + try: + limit_value = int(limit_header) if limit_header is not None else None + except (TypeError, ValueError): + limit_value = None + + if resp.status_code == 200: + if limit_value is not None and limit_value >= 5000: + return { + "valid": True, + "message": "API Authentication Successful", + "status": "ok", + "rate_limit": limit_value, + } + return { + "valid": False, + "message": "API Token Invalid", + "status": "insufficient", + "rate_limit": limit_value, + "error": "Authenticated request did not elevate GitHub rate limits", + } + + if resp.status_code == 401: + return { + "valid": False, + "message": "API Token Invalid", + "status": "invalid", + "rate_limit": limit_value, + "error": resp.text[:200], + } + + return { + "valid": False, + "message": f"GitHub API error (HTTP {resp.status_code})", + "status": "error", + "rate_limit": limit_value, + "error": resp.text[:200], + } + except Exception as exc: + return { + "valid": False, + "message": f"API Token validation error: {exc}", + "status": "error", + "rate_limit": None, + "error": str(exc), + } + def _hydrate_repo_hash_cache_from_disk() -> None: try: @@ -226,7 +354,7 @@ def _fetch_repo_head(owner_repo: str, branch: str = 'main', *, ttl_seconds: int 'Accept': 'application/vnd.github+json', 'User-Agent': 'Borealis-Server' } - token = os.environ.get('BOREALIS_GITHUB_TOKEN') or os.environ.get('GITHUB_TOKEN') + token = _github_api_token(force_refresh=force_refresh) if token: headers['Authorization'] = f'Bearer {token}' @@ -1891,6 +2019,74 @@ def api_credentials_detail(credential_id: int): return jsonify({"credential": record}) +# ============================================================================= +# Section: Access Management - GitHub Token +# ============================================================================= + + +@app.route("/api/github/token", methods=["GET", "POST"]) +def api_github_token(): + chk = _require_admin() + if chk: + return chk + + if request.method == "GET": + token = _load_github_token_from_db(force_refresh=True) + verify = _verify_github_token(token) + message = verify.get("message") or ("API Token Invalid" if token else "API Token Not Configured") + return jsonify({ + "token": token or "", + "has_token": bool(token), + "valid": bool(verify.get("valid")), + "message": message, + "status": verify.get("status") or ("missing" if not token else "unknown"), + "rate_limit": verify.get("rate_limit"), + "error": verify.get("error"), + "checked_at": _now_ts(), + }) + + data = request.get_json(silent=True) or {} + token = str(data.get("token") or "").strip() + + conn = None + try: + conn = _db_conn() + cur = conn.cursor() + cur.execute("DELETE FROM github_token") + if token: + cur.execute("INSERT INTO github_token (token) VALUES (?)", (token,)) + conn.commit() + conn.close() + except Exception as exc: + if conn: + try: + conn.close() + except Exception: + pass + return jsonify({"error": f"Failed to store token: {exc}"}), 500 + + _set_cached_github_token(token or None) + + verify = _verify_github_token(token or None) + message = verify.get("message") or ("API Token Invalid" if token else "API Token Not Configured") + + try: + eventlet.spawn_n(_refresh_default_repo_hash, True) + except Exception: + pass + + return jsonify({ + "token": token, + "has_token": bool(token), + "valid": bool(verify.get("valid")), + "message": message, + "status": verify.get("status") or ("missing" if not token else "unknown"), + "rate_limit": verify.get("rate_limit"), + "error": verify.get("error"), + "checked_at": _now_ts(), + }) + + # ============================================================================= # Section: Server-Side Ansible Execution # ============================================================================= @@ -3564,10 +3760,6 @@ registered_agents: Dict[str, Dict] = {} agent_configurations: Dict[str, Dict] = {} latest_images: Dict[str, Dict] = {} -# Database initialization (merged into a single SQLite database) -DB_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "database.db")) -os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - DEVICE_TABLE = "devices" _DEVICE_JSON_LIST_FIELDS = { "memory": [], @@ -4565,6 +4757,15 @@ def init_db(): pass conn.commit() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS github_token ( + token TEXT + ) + """ + ) + conn.commit() + # Scheduled jobs table cur.execute( """