From f63d5c4f83bfc969527285af11d8b087db3d9fd4 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 17 Oct 2025 17:43:14 -0600 Subject: [PATCH] feat: admin enrollment controls and prune scheduler --- Data/Server/Modules/admin/__init__.py | 1 + Data/Server/Modules/admin/routes.py | 263 +++++++++++++++++++++++ Data/Server/Modules/enrollment/routes.py | 2 + Data/Server/Modules/jobs/__init__.py | 1 + Data/Server/Modules/jobs/prune.py | 69 ++++++ Data/Server/server.py | 16 ++ 6 files changed, 352 insertions(+) create mode 100644 Data/Server/Modules/admin/__init__.py create mode 100644 Data/Server/Modules/admin/routes.py create mode 100644 Data/Server/Modules/jobs/__init__.py create mode 100644 Data/Server/Modules/jobs/prune.py diff --git a/Data/Server/Modules/admin/__init__.py b/Data/Server/Modules/admin/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Data/Server/Modules/admin/__init__.py @@ -0,0 +1 @@ + diff --git a/Data/Server/Modules/admin/routes.py b/Data/Server/Modules/admin/routes.py new file mode 100644 index 0000000..97625d1 --- /dev/null +++ b/Data/Server/Modules/admin/routes.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import secrets +import sqlite3 +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, List, Optional + +from flask import Blueprint, jsonify, request + + +VALID_TTL_HOURS = {1, 3, 6, 12, 24} + + +def register( + app, + *, + db_conn_factory: Callable[[], sqlite3.Connection], + require_admin: Callable[[], Optional[Any]], + current_user: Callable[[], Optional[Dict[str, str]]], + log: Callable[[str, str], None], +) -> None: + blueprint = Blueprint("admin", __name__) + + def _now() -> datetime: + return datetime.now(tz=timezone.utc) + + def _iso(dt: datetime) -> str: + return dt.isoformat() + + def _lookup_user_id(cur: sqlite3.Cursor, username: str) -> Optional[str]: + if not username: + return None + cur.execute( + "SELECT id FROM users WHERE LOWER(username) = LOWER(?)", + (username,), + ) + row = cur.fetchone() + if row: + return str(row[0]) + return None + + @blueprint.before_request + def _check_admin(): + result = require_admin() + if result is not None: + return result + return None + + @blueprint.route("/api/admin/enrollment-codes", methods=["GET"]) + def list_enrollment_codes(): + status_filter = request.args.get("status") + conn = db_conn_factory() + try: + cur = conn.cursor() + sql = """ + SELECT id, code, expires_at, created_by_user_id, used_at, used_by_guid + FROM enrollment_install_codes + """ + params: List[str] = [] + if status_filter == "active": + sql += " WHERE used_at IS NULL AND expires_at > ?" + params.append(_iso(_now())) + elif status_filter == "expired": + sql += " WHERE used_at IS NULL AND expires_at <= ?" + params.append(_iso(_now())) + elif status_filter == "used": + sql += " WHERE used_at IS NOT NULL" + sql += " ORDER BY expires_at ASC" + cur.execute(sql, params) + rows = cur.fetchall() + finally: + conn.close() + + records = [] + for row in rows: + records.append( + { + "id": row[0], + "code": row[1], + "expires_at": row[2], + "created_by_user_id": row[3], + "used_at": row[4], + "used_by_guid": row[5], + } + ) + return jsonify({"codes": records}) + + @blueprint.route("/api/admin/enrollment-codes", methods=["POST"]) + def create_enrollment_code(): + payload = request.get_json(force=True, silent=True) or {} + ttl_hours = int(payload.get("ttl_hours") or 1) + if ttl_hours not in VALID_TTL_HOURS: + return jsonify({"error": "invalid_ttl"}), 400 + + user = current_user() or {} + username = user.get("username") or "" + + conn = db_conn_factory() + try: + cur = conn.cursor() + created_by = _lookup_user_id(cur, username) or username or "system" + code_value = _generate_install_code() + expires_at = _now() + timedelta(hours=ttl_hours) + record_id = str(uuid.uuid4()) + cur.execute( + """ + INSERT INTO enrollment_install_codes ( + id, code, expires_at, created_by_user_id + ) + VALUES (?, ?, ?, ?) + """, + (record_id, code_value, _iso(expires_at), created_by), + ) + conn.commit() + finally: + conn.close() + + log("server", f"installer code created id={record_id} by={username} ttl={ttl_hours}h") + return jsonify( + { + "id": record_id, + "code": code_value, + "expires_at": _iso(expires_at), + } + ) + + @blueprint.route("/api/admin/enrollment-codes/", methods=["DELETE"]) + def delete_enrollment_code(code_id: str): + conn = db_conn_factory() + try: + cur = conn.cursor() + cur.execute( + "DELETE FROM enrollment_install_codes WHERE id = ? AND used_at IS NULL", + (code_id,), + ) + deleted = cur.rowcount + conn.commit() + finally: + conn.close() + + if not deleted: + return jsonify({"error": "not_found"}), 404 + log("server", f"installer code deleted id={code_id}") + return jsonify({"status": "deleted"}) + + @blueprint.route("/api/admin/device-approvals", methods=["GET"]) + def list_device_approvals(): + status = request.args.get("status", "pending") + conn = db_conn_factory() + try: + cur = conn.cursor() + params: List[str] = [] + sql = """ + SELECT + id, + approval_reference, + guid, + hostname_claimed, + ssl_key_fingerprint_claimed, + enrollment_code_id, + status, + client_nonce, + server_nonce, + created_at, + updated_at, + approved_by_user_id + FROM device_approvals + """ + if status: + sql += " WHERE status = ?" + params.append(status) + sql += " ORDER BY created_at ASC" + cur.execute(sql, params) + rows = cur.fetchall() + finally: + conn.close() + + approvals = [] + for row in rows: + approvals.append( + { + "id": row[0], + "approval_reference": row[1], + "guid": row[2], + "hostname_claimed": row[3], + "ssl_key_fingerprint_claimed": row[4], + "enrollment_code_id": row[5], + "status": row[6], + "client_nonce": row[7], + "server_nonce": row[8], + "created_at": row[9], + "updated_at": row[10], + "approved_by_user_id": row[11], + } + ) + return jsonify({"approvals": approvals}) + + def _set_approval_status(approval_id: str, status: str, guid: Optional[str] = None): + user = current_user() or {} + username = user.get("username") or "" + + conn = db_conn_factory() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT status + FROM device_approvals + WHERE id = ? + """, + (approval_id,), + ) + row = cur.fetchone() + if not row: + return {"error": "not_found"}, 404 + existing_status = (row[0] or "").strip().lower() + if existing_status != "pending": + return {"error": "approval_not_pending"}, 409 + approved_by = _lookup_user_id(cur, username) or username or "system" + cur.execute( + """ + UPDATE device_approvals + SET status = ?, + guid = COALESCE(?, guid), + approved_by_user_id = ?, + updated_at = ? + WHERE id = ? + """, + ( + status, + guid, + approved_by, + _iso(_now()), + approval_id, + ), + ) + conn.commit() + finally: + conn.close() + log("server", f"device approval {approval_id} -> {status} by {username}") + return {"status": status}, 200 + + @blueprint.route("/api/admin/device-approvals//approve", methods=["POST"]) + def approve_device(approval_id: str): + payload = request.get_json(force=True, silent=True) or {} + guid = payload.get("guid") + if guid: + guid = str(guid).strip() + result, status_code = _set_approval_status(approval_id, "approved", guid=guid) + return jsonify(result), status_code + + @blueprint.route("/api/admin/device-approvals//deny", methods=["POST"]) + def deny_device(approval_id: str): + result, status_code = _set_approval_status(approval_id, "denied") + return jsonify(result), status_code + + app.register_blueprint(blueprint) + + +def _generate_install_code() -> str: + raw = secrets.token_hex(16).upper() + return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4)) diff --git a/Data/Server/Modules/enrollment/routes.py b/Data/Server/Modules/enrollment/routes.py index 359c5ea..bae65a7 100644 --- a/Data/Server/Modules/enrollment/routes.py +++ b/Data/Server/Modules/enrollment/routes.py @@ -401,6 +401,8 @@ def register( return jsonify({"status": "denied", "reason": "operator_denied"}) if status == "expired": return jsonify({"status": "expired"}) + if status == "completed": + return jsonify({"status": "approved", "detail": "finalized"}) if status != "approved": return jsonify({"status": status or "unknown"}), 400 diff --git a/Data/Server/Modules/jobs/__init__.py b/Data/Server/Modules/jobs/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Data/Server/Modules/jobs/__init__.py @@ -0,0 +1 @@ + diff --git a/Data/Server/Modules/jobs/prune.py b/Data/Server/Modules/jobs/prune.py new file mode 100644 index 0000000..ad3b7b3 --- /dev/null +++ b/Data/Server/Modules/jobs/prune.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Callable + +import eventlet +from flask_socketio import SocketIO + + +def start_prune_job( + socketio: SocketIO, + *, + db_conn_factory: Callable[[], any], + log: Callable[[str, str], None], +) -> None: + def _job_loop(): + while True: + try: + _run_once(db_conn_factory, log) + except Exception as exc: + log("server", f"prune job failure: {exc}") + eventlet.sleep(24 * 60 * 60) + + socketio.start_background_task(_job_loop) + + +def _run_once(db_conn_factory: Callable[[], any], log: Callable[[str, str], None]) -> None: + now_iso = datetime.now(tz=timezone.utc).isoformat() + conn = db_conn_factory() + try: + cur = conn.cursor() + cur.execute( + """ + DELETE FROM enrollment_install_codes + WHERE used_at IS NULL + AND expires_at < ? + """, + (now_iso,), + ) + codes_pruned = cur.rowcount or 0 + + cur.execute( + """ + UPDATE device_approvals + SET status = 'expired', + updated_at = ? + WHERE status = 'pending' + AND ( + EXISTS ( + SELECT 1 + FROM enrollment_install_codes c + WHERE c.id = device_approvals.enrollment_code_id + AND c.expires_at < ? + ) + OR created_at < ? + ) + """, + (now_iso, now_iso, now_iso), + ) + approvals_marked = cur.rowcount or 0 + + conn.commit() + finally: + conn.close() + + if codes_pruned: + log("server", f"prune job removed {codes_pruned} expired enrollment codes") + if approvals_marked: + log("server", f"prune job expired {approvals_marked} device approvals") diff --git a/Data/Server/server.py b/Data/Server/server.py index 98c2d88..ab6d50e 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -58,6 +58,8 @@ from Modules.crypto import certificates, signing from Modules.enrollment import routes as enrollment_routes from Modules.enrollment.nonce_store import NonceCache from Modules.tokens import routes as token_routes +from Modules.admin import routes as admin_routes +from Modules.jobs.prune import start_prune_job try: from cryptography.fernet import Fernet # type: ignore @@ -4866,6 +4868,20 @@ agent_routes.register( script_signer=SCRIPT_SIGNER, ) +admin_routes.register( + app, + db_conn_factory=_db_conn, + require_admin=_require_admin, + current_user=_current_user, + log=_write_service_log, +) + +start_prune_job( + socketio, + db_conn_factory=_db_conn, + log=_write_service_log, +) + def ensure_default_admin(): """Ensure at least one admin user exists.