diff --git a/Data/Engine/Unit_Tests/conftest.py b/Data/Engine/Unit_Tests/conftest.py index 97b30b21..356a6f5e 100644 --- a/Data/Engine/Unit_Tests/conftest.py +++ b/Data/Engine/Unit_Tests/conftest.py @@ -94,7 +94,8 @@ CREATE TABLE IF NOT EXISTS enrollment_install_codes ( used_by_guid TEXT, max_uses INTEGER, use_count INTEGER, - last_used_at TEXT + last_used_at TEXT, + site_id INTEGER ); CREATE TABLE IF NOT EXISTS enrollment_install_codes_persistent ( id TEXT PRIMARY KEY, @@ -109,7 +110,8 @@ CREATE TABLE IF NOT EXISTS enrollment_install_codes_persistent ( last_used_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, archived_at TEXT, - consumed_at TEXT + consumed_at TEXT, + site_id INTEGER ); CREATE TABLE IF NOT EXISTS device_approvals ( id TEXT PRIMARY KEY, @@ -118,6 +120,7 @@ CREATE TABLE IF NOT EXISTS device_approvals ( hostname_claimed TEXT, ssl_key_fingerprint_claimed TEXT, enrollment_code_id TEXT, + site_id INTEGER, status TEXT, client_nonce TEXT, server_nonce TEXT, @@ -145,7 +148,8 @@ CREATE TABLE IF NOT EXISTS sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, description TEXT, - created_at INTEGER + created_at INTEGER, + enrollment_code_id TEXT ); CREATE TABLE IF NOT EXISTS device_sites ( device_hostname TEXT PRIMARY KEY, @@ -270,9 +274,51 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ "2025-10-01T00:00:00Z", ), ) + site_code_id = "SITE-CODE-0001" + site_code_value = "SITE-MAIN-CODE" + site_code_created = "2025-01-01T00:00:00Z" + site_code_expires = "2030-01-01T00:00:00Z" cur.execute( - "INSERT INTO sites (id, name, description, created_at) VALUES (?, ?, ?, ?)", - (1, "Main Lab", "Primary integration site", 1_700_000_000), + """ + INSERT INTO enrollment_install_codes ( + id, + code, + expires_at, + created_by_user_id, + used_at, + used_by_guid, + max_uses, + use_count, + last_used_at, + site_id + ) VALUES (?, ?, ?, ?, NULL, NULL, 0, 0, NULL, ?) + """, + (site_code_id, site_code_value, site_code_expires, "admin", 1), + ) + cur.execute( + """ + INSERT INTO enrollment_install_codes_persistent ( + id, + code, + created_at, + expires_at, + created_by_user_id, + used_at, + used_by_guid, + max_uses, + last_known_use_count, + last_used_at, + is_active, + archived_at, + consumed_at, + site_id + ) VALUES (?, ?, ?, ?, ?, NULL, NULL, 0, 0, NULL, 1, NULL, NULL, ?) + """, + (site_code_id, site_code_value, site_code_created, site_code_expires, "admin", 1), + ) + cur.execute( + "INSERT INTO sites (id, name, description, created_at, enrollment_code_id) VALUES (?, ?, ?, ?, ?)", + (1, "Main Lab", "Primary integration site", 1_700_000_000, site_code_id), ) cur.execute( "INSERT INTO device_sites (device_hostname, site_id, assigned_at) VALUES (?, ?, ?)", @@ -294,6 +340,7 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ hostname_claimed, ssl_key_fingerprint_claimed, enrollment_code_id, + site_id, status, client_nonce, server_nonce, @@ -302,7 +349,7 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ updated_at, approved_by_user_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( "approval-1", @@ -310,7 +357,8 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ None, "pending-device", "aa:bb:cc:dd", - None, + site_code_id, + 1, "pending", "client-nonce", "server-nonce", diff --git a/Data/Engine/Unit_Tests/test_devices_api.py b/Data/Engine/Unit_Tests/test_devices_api.py index 1270c279..0861b037 100644 --- a/Data/Engine/Unit_Tests/test_devices_api.py +++ b/Data/Engine/Unit_Tests/test_devices_api.py @@ -57,7 +57,7 @@ def _patch_repo_call(monkeypatch: pytest.MonkeyPatch, calls: dict) -> None: def test_list_devices(engine_harness: EngineTestHarness) -> None: - client = engine_harness.app.test_client() + client = _client_with_admin_session(engine_harness) response = client.get("/api/devices") assert response.status_code == 200 payload = response.get_json() @@ -70,7 +70,7 @@ def test_list_devices(engine_harness: EngineTestHarness) -> None: def test_list_agents(engine_harness: EngineTestHarness) -> None: - client = engine_harness.app.test_client() + client = _client_with_admin_session(engine_harness) response = client.get("/api/agents") assert response.status_code == 200 payload = response.get_json() @@ -82,7 +82,7 @@ def test_list_agents(engine_harness: EngineTestHarness) -> None: def test_device_details(engine_harness: EngineTestHarness) -> None: - client = engine_harness.app.test_client() + client = _client_with_admin_session(engine_harness) response = client.get("/api/device/details/test-device") assert response.status_code == 200 payload = response.get_json() @@ -165,7 +165,7 @@ def test_repo_current_hash_allows_device_token(engine_harness: EngineTestHarness def test_agent_hash_list_permissions(engine_harness: EngineTestHarness) -> None: - client = engine_harness.app.test_client() + client = _client_with_admin_session(engine_harness) forbidden = client.get("/api/agent/hash_list", environ_base={"REMOTE_ADDR": "192.0.2.10"}) assert forbidden.status_code == 403 allowed = client.get("/api/agent/hash_list", environ_base={"REMOTE_ADDR": "127.0.0.1"}) @@ -208,21 +208,20 @@ def test_sites_lifecycle(engine_harness: EngineTestHarness) -> None: assert delete_resp.status_code == 200 -def test_admin_enrollment_code_flow(engine_harness: EngineTestHarness) -> None: +def test_site_enrollment_code_rotation(engine_harness: EngineTestHarness) -> None: client = _client_with_admin_session(engine_harness) - create_resp = client.post( - "/api/admin/enrollment-codes", - json={"ttl_hours": 1, "max_uses": 2}, - ) - assert create_resp.status_code == 201 - code_id = create_resp.get_json()["id"] + sites_resp = client.get("/api/sites") + assert sites_resp.status_code == 200 + sites = sites_resp.get_json()["sites"] + assert sites and sites[0]["enrollment_code"] + site_id = sites[0]["id"] + original_code = sites[0]["enrollment_code"] - list_resp = client.get("/api/admin/enrollment-codes") - codes = list_resp.get_json()["codes"] - assert any(code["id"] == code_id for code in codes) - - delete_resp = client.delete(f"/api/admin/enrollment-codes/{code_id}") - assert delete_resp.status_code == 200 + rotate_resp = client.post("/api/sites/rotate_code", json={"site_id": site_id}) + assert rotate_resp.status_code == 200 + rotated = rotate_resp.get_json() + assert rotated["id"] == site_id + assert rotated["enrollment_code"] and rotated["enrollment_code"] != original_code def test_admin_device_approvals(engine_harness: EngineTestHarness) -> None: diff --git a/Data/Engine/Unit_Tests/test_enrollment_api.py b/Data/Engine/Unit_Tests/test_enrollment_api.py index 8b8371f0..43eb5a3c 100644 --- a/Data/Engine/Unit_Tests/test_enrollment_api.py +++ b/Data/Engine/Unit_Tests/test_enrollment_api.py @@ -31,19 +31,29 @@ def _iso(dt: datetime) -> str: return dt.astimezone(timezone.utc).isoformat() -def _seed_install_code(db_path: os.PathLike[str], code: str) -> str: +def _seed_install_code(db_path: os.PathLike[str], code: str, site_id: int = 1) -> str: record_id = str(uuid.uuid4()) baseline = _now() issued_at = _iso(baseline) expires_at = _iso(baseline + timedelta(days=1)) with sqlite3.connect(str(db_path)) as conn: + columns = {row[1] for row in conn.execute("PRAGMA table_info(sites)")} + if "enrollment_code_id" not in columns: + conn.execute("ALTER TABLE sites ADD COLUMN enrollment_code_id TEXT") + conn.execute( + """ + INSERT OR IGNORE INTO sites (id, name, description, created_at, enrollment_code_id) + VALUES (?, ?, ?, ?, ?) + """, + (site_id, f"Test Site {site_id}", "Seeded site", int(baseline.timestamp()), record_id), + ) conn.execute( """ INSERT INTO enrollment_install_codes ( - id, code, expires_at, used_at, used_by_guid, max_uses, use_count, last_used_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + id, code, expires_at, used_at, used_by_guid, max_uses, use_count, last_used_at, site_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (record_id, code, expires_at, None, None, 1, 0, None), + (record_id, code, expires_at, None, None, 1, 0, None, site_id), ) conn.execute( """ @@ -60,8 +70,9 @@ def _seed_install_code(db_path: os.PathLike[str], code: str) -> str: last_used_at, is_active, archived_at, - consumed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + consumed_at, + site_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( record_id, @@ -77,8 +88,17 @@ def _seed_install_code(db_path: os.PathLike[str], code: str) -> str: 1, None, None, + site_id, ), ) + conn.execute( + """ + UPDATE sites + SET enrollment_code_id = ? + WHERE id = ? + """, + (record_id, site_id), + ) conn.commit() return record_id @@ -124,7 +144,7 @@ def test_enrollment_request_creates_pending_approval(engine_harness: EngineTestH cur = conn.cursor() cur.execute( """ - SELECT hostname_claimed, ssl_key_fingerprint_claimed, client_nonce, status, enrollment_code_id + SELECT hostname_claimed, ssl_key_fingerprint_claimed, client_nonce, status, enrollment_code_id, site_id FROM device_approvals WHERE approval_reference = ? """, @@ -133,11 +153,12 @@ def test_enrollment_request_creates_pending_approval(engine_harness: EngineTestH row = cur.fetchone() assert row is not None - hostname_claimed, fingerprint, stored_client_nonce, status, stored_code_id = row + hostname_claimed, fingerprint, stored_client_nonce, status, stored_code_id, stored_site_id = row assert hostname_claimed == "agent-node-01" assert stored_client_nonce == client_nonce_b64 assert status == "pending" assert stored_code_id == install_code_id + assert stored_site_id == 1 expected_fingerprint = crypto_keys.fingerprint_from_spki_der(public_der) assert fingerprint == expected_fingerprint @@ -206,7 +227,7 @@ def test_enrollment_poll_finalizes_when_approved(engine_harness: EngineTestHarne with sqlite3.connect(str(harness.db_path)) as conn: cur = conn.cursor() cur.execute( - "SELECT guid, status FROM device_approvals WHERE approval_reference = ?", + "SELECT guid, status, site_id FROM device_approvals WHERE approval_reference = ?", (approval_reference,), ) approval_row = cur.fetchone() @@ -215,6 +236,11 @@ def test_enrollment_poll_finalizes_when_approved(engine_harness: EngineTestHarne (final_guid,), ) device_row = cur.fetchone() + cur.execute( + "SELECT site_id FROM device_sites WHERE device_hostname = ?", + (device_row[0] if device_row else None,), + ) + site_row = cur.fetchone() cur.execute( "SELECT COUNT(*) FROM refresh_tokens WHERE guid = ?", (final_guid,), @@ -241,15 +267,18 @@ def test_enrollment_poll_finalizes_when_approved(engine_harness: EngineTestHarne persistent_row = cur.fetchone() assert approval_row is not None - approval_guid, approval_status = approval_row + approval_guid, approval_status, approval_site_id = approval_row assert approval_status == "completed" assert approval_guid == final_guid + assert approval_site_id == 1 assert device_row is not None hostname, fingerprint, token_version = device_row assert hostname == "agent-node-02" assert fingerprint == crypto_keys.fingerprint_from_spki_der(public_der) assert token_version >= 1 + assert site_row is not None + assert site_row[0] == 1 assert refresh_count == 1 assert install_row is not None diff --git a/Data/Engine/database.py b/Data/Engine/database.py index 8055e91c..9851d202 100644 --- a/Data/Engine/database.py +++ b/Data/Engine/database.py @@ -10,8 +10,11 @@ from __future__ import annotations import logging +import secrets import sqlite3 import time +import uuid +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional, Sequence @@ -24,6 +27,15 @@ _DEFAULT_ADMIN_HASH = ( ) +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +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)) + + def initialise_engine_database(database_path: str, *, logger: Optional[logging.Logger] = None) -> None: """Ensure the Engine database has the required schema and default admin account.""" @@ -46,6 +58,7 @@ def initialise_engine_database(database_path: str, *, logger: Optional[logging.L _ensure_activity_history(conn, logger=logger) _ensure_device_list_views(conn, logger=logger) _ensure_sites(conn, logger=logger) + _ensure_site_enrollment_codes(conn, logger=logger) _ensure_users_table(conn, logger=logger) _ensure_default_admin(conn, logger=logger) _ensure_ansible_recaps(conn, logger=logger) @@ -92,7 +105,8 @@ def _restore_persisted_enrollment_codes(conn: sqlite3.Connection, *, logger: Opt used_by_guid, max_uses, use_count, - last_used_at + last_used_at, + site_id ) SELECT p.id, @@ -103,7 +117,8 @@ def _restore_persisted_enrollment_codes(conn: sqlite3.Connection, *, logger: Opt p.used_by_guid, p.max_uses, p.last_known_use_count, - p.last_used_at + p.last_used_at, + p.site_id FROM enrollment_install_codes_persistent AS p WHERE p.is_active = 1 ON CONFLICT(id) DO UPDATE @@ -114,7 +129,8 @@ def _restore_persisted_enrollment_codes(conn: sqlite3.Connection, *, logger: Opt used_by_guid = excluded.used_by_guid, max_uses = excluded.max_uses, use_count = excluded.use_count, - last_used_at = excluded.last_used_at + last_used_at = excluded.last_used_at, + site_id = excluded.site_id """ ) conn.commit() @@ -185,7 +201,8 @@ def _ensure_sites(conn: sqlite3.Connection, *, logger: Optional[logging.Logger]) id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, description TEXT, - created_at INTEGER + created_at INTEGER, + enrollment_code_id TEXT ) """ ) @@ -199,6 +216,10 @@ def _ensure_sites(conn: sqlite3.Connection, *, logger: Optional[logging.Logger]) ) """ ) + cur.execute("PRAGMA table_info(sites)") + columns = {row[1] for row in cur.fetchall()} + if "enrollment_code_id" not in columns: + cur.execute("ALTER TABLE sites ADD COLUMN enrollment_code_id TEXT") except Exception as exc: if logger: logger.error("Failed to ensure site tables: %s", exc, exc_info=True) @@ -208,6 +229,147 @@ def _ensure_sites(conn: sqlite3.Connection, *, logger: Optional[logging.Logger]) cur.close() +def _ensure_site_enrollment_codes(conn: sqlite3.Connection, *, logger: Optional[logging.Logger]) -> None: + cur = conn.cursor() + try: + cur.execute("SELECT id, enrollment_code_id FROM sites") + sites = cur.fetchall() + if not sites: + return + + now = datetime.now(tz=timezone.utc) + long_expiry = _iso(now + timedelta(days=3650)) + for site_id, current_code_id in sites: + active_code_id: Optional[str] = None + if current_code_id: + cur.execute( + "SELECT id, site_id FROM enrollment_install_codes WHERE id = ?", + (current_code_id,), + ) + existing = cur.fetchone() + if existing: + active_code_id = current_code_id + if existing[1] is None: + cur.execute( + "UPDATE enrollment_install_codes SET site_id = ? WHERE id = ?", + (site_id, current_code_id), + ) + cur.execute( + "UPDATE enrollment_install_codes_persistent SET site_id = COALESCE(site_id, ?) WHERE id = ?", + (site_id, current_code_id), + ) + + if not active_code_id: + cur.execute( + """ + SELECT id, code, created_at, expires_at, max_uses, last_known_use_count, last_used_at, site_id + FROM enrollment_install_codes_persistent + WHERE site_id = ? AND is_active = 1 + ORDER BY datetime(created_at) DESC + LIMIT 1 + """, + (site_id,), + ) + row = cur.fetchone() + if row: + active_code_id = row[0] + if row[7] is None: + cur.execute( + "UPDATE enrollment_install_codes_persistent SET site_id = ? WHERE id = ?", + (site_id, active_code_id), + ) + cur.execute( + """ + INSERT OR REPLACE INTO enrollment_install_codes ( + id, + code, + expires_at, + created_by_user_id, + used_at, + used_by_guid, + max_uses, + use_count, + last_used_at, + site_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[3] or long_expiry, + "system", + None, + None, + row[4] or 0, + row[5] or 0, + row[6], + site_id, + ), + ) + + if not active_code_id: + new_id = str(uuid.uuid4()) + code_value = _generate_install_code() + issued_at = _iso(now) + cur.execute( + """ + INSERT OR REPLACE INTO enrollment_install_codes ( + id, + code, + expires_at, + created_by_user_id, + used_at, + used_by_guid, + max_uses, + use_count, + last_used_at, + site_id + ) + VALUES (?, ?, ?, 'system', NULL, NULL, 0, 0, NULL, ?) + """, + (new_id, code_value, long_expiry, site_id), + ) + cur.execute( + """ + INSERT OR REPLACE INTO enrollment_install_codes_persistent ( + id, + code, + created_at, + expires_at, + created_by_user_id, + used_at, + used_by_guid, + max_uses, + last_known_use_count, + last_used_at, + is_active, + archived_at, + consumed_at, + site_id + ) + VALUES (?, ?, ?, ?, 'system', NULL, NULL, 0, 0, NULL, 1, NULL, NULL, ?) + """, + (new_id, code_value, issued_at, long_expiry, site_id), + ) + active_code_id = new_id + + if active_code_id and active_code_id != current_code_id: + cur.execute( + "UPDATE sites SET enrollment_code_id = ? WHERE id = ?", + (active_code_id, site_id), + ) + conn.commit() + except Exception as exc: + conn.rollback() + if logger: + logger.error("Failed to ensure site enrollment codes: %s", exc, exc_info=True) + else: + raise + finally: + cur.close() + + def _ensure_users_table(conn: sqlite3.Connection, *, logger: Optional[logging.Logger]) -> None: cur = conn.cursor() try: diff --git a/Data/Engine/database_migrations.py b/Data/Engine/database_migrations.py index ab036c7d..924764ec 100644 --- a/Data/Engine/database_migrations.py +++ b/Data/Engine/database_migrations.py @@ -155,7 +155,8 @@ def _ensure_install_code_table(conn: sqlite3.Connection) -> None: used_by_guid TEXT, max_uses INTEGER NOT NULL DEFAULT 1, use_count INTEGER NOT NULL DEFAULT 0, - last_used_at TEXT + last_used_at TEXT, + site_id INTEGER ) """ ) @@ -188,6 +189,13 @@ def _ensure_install_code_table(conn: sqlite3.Connection) -> None: ADD COLUMN last_used_at TEXT """ ) + if "site_id" not in columns: + cur.execute( + """ + ALTER TABLE enrollment_install_codes + ADD COLUMN site_id INTEGER + """ + ) def _ensure_install_code_persistence_table(conn: sqlite3.Connection) -> None: @@ -207,7 +215,8 @@ def _ensure_install_code_persistence_table(conn: sqlite3.Connection) -> None: last_used_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, archived_at TEXT, - consumed_at TEXT + consumed_at TEXT, + site_id INTEGER ) """ ) @@ -274,6 +283,13 @@ def _ensure_install_code_persistence_table(conn: sqlite3.Connection) -> None: ADD COLUMN last_used_at TEXT """ ) + if "site_id" not in columns: + cur.execute( + """ + ALTER TABLE enrollment_install_codes_persistent + ADD COLUMN site_id INTEGER + """ + ) def _ensure_device_approval_table(conn: sqlite3.Connection) -> None: @@ -287,6 +303,7 @@ def _ensure_device_approval_table(conn: sqlite3.Connection) -> None: hostname_claimed TEXT NOT NULL, ssl_key_fingerprint_claimed TEXT NOT NULL, enrollment_code_id TEXT NOT NULL, + site_id INTEGER, status TEXT NOT NULL, client_nonce TEXT NOT NULL, server_nonce TEXT NOT NULL, @@ -297,6 +314,16 @@ def _ensure_device_approval_table(conn: sqlite3.Connection) -> None: ) """ ) + cur.execute("PRAGMA table_info(device_approvals)") + columns = {row[1] for row in cur.fetchall()} + if "site_id" not in columns: + cur.execute( + """ + ALTER TABLE device_approvals + ADD COLUMN site_id INTEGER + """ + ) + cur.execute( """ CREATE INDEX IF NOT EXISTS idx_da_status @@ -309,6 +336,12 @@ def _ensure_device_approval_table(conn: sqlite3.Connection) -> None: ON device_approvals(ssl_key_fingerprint_claimed, status) """ ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_da_site + ON device_approvals(site_id) + """ + ) def _create_devices_table(cur: sqlite3.Cursor) -> None: diff --git a/Data/Engine/services/API/devices/approval.py b/Data/Engine/services/API/devices/approval.py index afe1d8cf..7ae41f6e 100644 --- a/Data/Engine/services/API/devices/approval.py +++ b/Data/Engine/services/API/devices/approval.py @@ -357,19 +357,23 @@ class AdminDeviceService: da.hostname_claimed, da.ssl_key_fingerprint_claimed, da.enrollment_code_id, + da.site_id, da.status, da.client_nonce, da.server_nonce, da.created_at, da.updated_at, da.approved_by_user_id, - u.username AS approved_by_username + u.username AS approved_by_username, + s.name AS site_name FROM device_approvals AS da LEFT JOIN users AS u ON ( CAST(da.approved_by_user_id AS TEXT) = CAST(u.id AS TEXT) OR LOWER(da.approved_by_user_id) = LOWER(u.username) ) + LEFT JOIN sites AS s + ON s.id = da.site_id """ status_norm = (status_filter or "").strip().lower() if status_norm and status_norm != "all": @@ -409,17 +413,19 @@ class AdminDeviceService: "hostname_claimed": hostname, "ssl_key_fingerprint_claimed": fingerprint_claimed, "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], + "site_id": row[6], + "status": row[7], + "client_nonce": row[8], + "server_nonce": row[9], + "created_at": row[10], + "updated_at": row[11], + "approved_by_user_id": row[12], "hostname_conflict": conflict, "alternate_hostname": alternate, "conflict_requires_prompt": requires_prompt, "fingerprint_match": fingerprint_match, - "approved_by_username": row[12], + "approved_by_username": row[13], + "site_name": row[14], } ) finally: @@ -578,4 +584,3 @@ def register_admin_endpoints(app, adapters: "EngineServiceAdapters") -> None: return jsonify(payload), status app.register_blueprint(blueprint) - diff --git a/Data/Engine/services/API/devices/management.py b/Data/Engine/services/API/devices/management.py index 251587d5..817ac33b 100644 --- a/Data/Engine/services/API/devices/management.py +++ b/Data/Engine/services/API/devices/management.py @@ -20,6 +20,7 @@ # - GET /api/sites/device_map (Token Authenticated) - Provides hostname to site assignment mapping data. # - POST /api/sites/assign (Token Authenticated (Admin)) - Assigns a set of devices to a given site. # - POST /api/sites/rename (Token Authenticated (Admin)) - Renames an existing site record. +# - POST /api/sites/rotate_code (Token Authenticated (Admin)) - Rotates the static enrollment code for a site. # - GET /api/repo/current_hash (Device or Token Authenticated) - Fetches the current agent repository hash (with caching). # - GET/POST /api/agent/hash (Device Authenticated) - Retrieves or updates an agent hash record bound to the authenticated device. # - GET /api/agent/hash_list (Token Authenticated (Admin + Loopback)) - Returns stored agent hash metadata for localhost diagnostics. @@ -31,10 +32,11 @@ from __future__ import annotations import json import logging import os +import secrets import sqlite3 import time import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple @@ -118,6 +120,11 @@ def _is_internal_request(remote_addr: Optional[str]) -> bool: return False +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)) + + def _row_to_site(row: Tuple[Any, ...]) -> Dict[str, Any]: return { "id": row[0], @@ -125,6 +132,11 @@ def _row_to_site(row: Tuple[Any, ...]) -> Dict[str, Any]: "description": row[2] or "", "created_at": row[3] or 0, "device_count": row[4] or 0, + "enrollment_code_id": row[5], + "enrollment_code": row[6] or "", + "enrollment_code_expires_at": row[7] or "", + "enrollment_code_last_used_at": row[8] or "", + "enrollment_code_use_count": row[9] or 0, } @@ -1103,26 +1115,83 @@ class DeviceManagementService: # Site management helpers # ------------------------------------------------------------------ + def _site_select_sql(self) -> str: + return """ + SELECT s.id, + s.name, + s.description, + s.created_at, + COALESCE(ds.cnt, 0) AS device_count, + s.enrollment_code_id, + ic.code, + ic.expires_at, + ic.last_used_at, + ic.use_count + FROM sites AS s + LEFT JOIN ( + SELECT site_id, COUNT(*) AS cnt + FROM device_sites + GROUP BY site_id + ) AS ds ON ds.site_id = s.id + LEFT JOIN enrollment_install_codes AS ic + ON ic.id = s.enrollment_code_id + """ + + def _fetch_site_row(self, cur: sqlite3.Cursor, site_id: int) -> Optional[Tuple[Any, ...]]: + cur.execute(self._site_select_sql() + " WHERE s.id = ?", (site_id,)) + return cur.fetchone() + + def _issue_site_enrollment_code(self, cur: sqlite3.Cursor, site_id: int, *, creator: str) -> Dict[str, Any]: + now = datetime.now(tz=timezone.utc) + issued_iso = now.isoformat() + expires_iso = (now + timedelta(days=3650)).isoformat() + code_id = str(uuid.uuid4()) + code_value = _generate_install_code() + creator_value = creator or "system" + cur.execute( + """ + INSERT INTO enrollment_install_codes ( + id, code, expires_at, created_by_user_id, used_at, used_by_guid, + max_uses, use_count, last_used_at, site_id + ) + VALUES (?, ?, ?, ?, NULL, NULL, 0, 0, NULL, ?) + """, + (code_id, code_value, expires_iso, creator_value, site_id), + ) + cur.execute( + """ + INSERT OR REPLACE INTO enrollment_install_codes_persistent ( + id, + code, + created_at, + expires_at, + created_by_user_id, + used_at, + used_by_guid, + max_uses, + last_known_use_count, + last_used_at, + is_active, + archived_at, + consumed_at, + site_id + ) + VALUES (?, ?, ?, ?, ?, NULL, NULL, 0, 0, NULL, 1, NULL, NULL, ?) + """, + (code_id, code_value, issued_iso, expires_iso, creator_value, site_id), + ) + return { + "id": code_id, + "code": code_value, + "created_at": issued_iso, + "expires_at": expires_iso, + } + def list_sites(self) -> Tuple[Dict[str, Any], int]: conn = self._db_conn() try: cur = conn.cursor() - cur.execute( - """ - SELECT s.id, - s.name, - s.description, - s.created_at, - COALESCE(ds.cnt, 0) AS device_count - FROM sites AS s - LEFT JOIN ( - SELECT site_id, COUNT(*) AS cnt - FROM device_sites - GROUP BY site_id - ) AS ds ON ds.site_id = s.id - ORDER BY LOWER(s.name) ASC - """ - ) + cur.execute(self._site_select_sql() + " ORDER BY LOWER(s.name) ASC") rows = cur.fetchall() sites = [_row_to_site(row) for row in rows] return {"sites": sites}, 200 @@ -1136,6 +1205,8 @@ class DeviceManagementService: if not name: return {"error": "name is required"}, 400 now = int(time.time()) + user = self._current_user() or {} + creator = user.get("username") or "system" conn = self._db_conn() try: cur = conn.cursor() @@ -1144,14 +1215,16 @@ class DeviceManagementService: (name, description, now), ) site_id = cur.lastrowid + code_info = self._issue_site_enrollment_code(cur, site_id, creator=creator) + cur.execute("UPDATE sites SET enrollment_code_id = ? WHERE id = ?", (code_info["id"], site_id)) conn.commit() - cur.execute( - "SELECT id, name, description, created_at, 0 FROM sites WHERE id = ?", - (site_id,), - ) - row = cur.fetchone() + row = self._fetch_site_row(cur, site_id) if not row: return {"error": "creation_failed"}, 500 + self.service_log( + "server", + f"site created id={site_id} code_id={code_info['id']} by={creator}", + ) return _row_to_site(row), 201 except sqlite3.IntegrityError: conn.rollback() @@ -1171,13 +1244,19 @@ class DeviceManagementService: try: norm_ids.append(int(value)) except Exception: - continue + return {"error": "invalid id"}, 400 if not norm_ids: return {"status": "ok", "deleted": 0}, 200 conn = self._db_conn() try: cur = conn.cursor() placeholders = ",".join("?" * len(norm_ids)) + cur.execute( + f"SELECT id FROM enrollment_install_codes WHERE site_id IN ({placeholders})", + tuple(norm_ids), + ) + code_ids = [row[0] for row in cur.fetchall() if row and row[0]] + now_iso = datetime.now(tz=timezone.utc).isoformat() cur.execute( f"DELETE FROM device_sites WHERE site_id IN ({placeholders})", tuple(norm_ids), @@ -1187,6 +1266,30 @@ class DeviceManagementService: tuple(norm_ids), ) deleted = cur.rowcount + cur.execute( + f""" + UPDATE enrollment_install_codes_persistent + SET is_active = 0, + archived_at = COALESCE(archived_at, ?) + WHERE site_id IN ({placeholders}) + """, + (now_iso, *norm_ids), + ) + if code_ids: + code_placeholders = ",".join("?" * len(code_ids)) + cur.execute( + f"DELETE FROM enrollment_install_codes WHERE id IN ({code_placeholders})", + tuple(code_ids), + ) + cur.execute( + f""" + UPDATE enrollment_install_codes_persistent + SET is_active = 0, + archived_at = COALESCE(archived_at, ?) + WHERE id IN ({code_placeholders}) + """, + (now_iso, *code_ids), + ) conn.commit() return {"status": "ok", "deleted": deleted}, 200 except Exception as exc: @@ -1287,20 +1390,7 @@ class DeviceManagementService: return {"error": "site not found"}, 404 conn.commit() cur.execute( - """ - SELECT s.id, - s.name, - s.description, - s.created_at, - COALESCE(ds.cnt, 0) AS device_count - FROM sites AS s - LEFT JOIN ( - SELECT site_id, COUNT(*) AS cnt - FROM device_sites - GROUP BY site_id - ) ds ON ds.site_id = s.id - WHERE s.id = ? - """, + self._site_select_sql() + " WHERE s.id = ?", (site_id_int,), ) row = cur.fetchone() @@ -1317,6 +1407,56 @@ class DeviceManagementService: finally: conn.close() + def rotate_site_enrollment_code(self, site_id: Any) -> Tuple[Dict[str, Any], int]: + try: + site_id_int = int(site_id) + except Exception: + return {"error": "invalid site_id"}, 400 + + user = self._current_user() or {} + creator = user.get("username") or "system" + now_iso = datetime.now(tz=timezone.utc).isoformat() + + conn = self._db_conn() + try: + cur = conn.cursor() + cur.execute("SELECT enrollment_code_id FROM sites WHERE id = ?", (site_id_int,)) + row = cur.fetchone() + if not row: + return {"error": "site not found"}, 404 + existing_code_id = row[0] + if existing_code_id: + cur.execute("DELETE FROM enrollment_install_codes WHERE id = ?", (existing_code_id,)) + cur.execute( + """ + UPDATE enrollment_install_codes_persistent + SET is_active = 0, + archived_at = COALESCE(archived_at, ?) + WHERE id = ? + """, + (now_iso, existing_code_id), + ) + code_info = self._issue_site_enrollment_code(cur, site_id_int, creator=creator) + cur.execute( + "UPDATE sites SET enrollment_code_id = ? WHERE id = ?", + (code_info["id"], site_id_int), + ) + conn.commit() + site_row = self._fetch_site_row(cur, site_id_int) + if not site_row: + return {"error": "site not found"}, 404 + self.service_log( + "server", + f"site enrollment code rotated site_id={site_id_int} code_id={code_info['id']} by={creator}", + ) + return _row_to_site(site_row), 200 + except Exception as exc: + conn.rollback() + self.logger.debug("Failed to rotate site enrollment code", exc_info=True) + return {"error": str(exc)}, 500 + finally: + conn.close() + def repo_current_hash(self) -> Tuple[Dict[str, Any], int]: refresh_flag = (request.args.get("refresh") or "").strip().lower() force_refresh = refresh_flag in {"1", "true", "yes", "force", "refresh"} @@ -1786,6 +1926,16 @@ def register_management(app, adapters: "EngineServiceAdapters") -> None: payload, status = service.rename_site(data.get("id"), (data.get("new_name") or "").strip()) return jsonify(payload), status + @blueprint.route("/api/sites/rotate_code", methods=["POST"]) + def _sites_rotate_code(): + requirement = service._require_admin() + if requirement: + payload, status = requirement + return jsonify(payload), status + data = request.get_json(silent=True) or {} + payload, status = service.rotate_site_enrollment_code(data.get("site_id")) + return jsonify(payload), status + @blueprint.route("/api/repo/current_hash", methods=["GET"]) def _repo_current_hash(): requirement = service._require_device_or_login() diff --git a/Data/Engine/services/API/enrollment/routes.py b/Data/Engine/services/API/enrollment/routes.py index abca4020..42688e06 100644 --- a/Data/Engine/services/API/enrollment/routes.py +++ b/Data/Engine/services/API/enrollment/routes.py @@ -103,7 +103,8 @@ def register( used_by_guid, max_uses, use_count, - last_used_at + last_used_at, + site_id FROM enrollment_install_codes WHERE code = ? """, @@ -121,6 +122,7 @@ def register( "max_uses", "use_count", "last_used_at", + "site_id", ] record = dict(zip(keys, row)) return record @@ -140,16 +142,16 @@ def register( if expiry <= _now(): return False, None try: - max_uses = int(record.get("max_uses") or 1) + max_uses_raw = record.get("max_uses") + max_uses = int(max_uses_raw) if max_uses_raw is not None else 0 except Exception: - max_uses = 1 - if max_uses < 1: - max_uses = 1 + max_uses = 0 + unlimited = max_uses <= 0 try: use_count = int(record.get("use_count") or 0) except Exception: use_count = 0 - if use_count < max_uses: + if unlimited or use_count < max_uses: return True, None guid = normalize_guid(record.get("used_by_guid")) @@ -392,6 +394,24 @@ def register( try: cur = conn.cursor() install_code = _load_install_code(cur, enrollment_code) + site_id = install_code.get("site_id") if install_code else None + if site_id is None: + log( + "server", + "enrollment request rejected missing_site_binding " + f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}", + context_hint, + ) + return jsonify({"error": "invalid_enrollment_code"}), 400 + cur.execute("SELECT 1 FROM sites WHERE id = ?", (site_id,)) + if cur.fetchone() is None: + log( + "server", + "enrollment request rejected missing_site_owner " + f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}", + context_hint, + ) + return jsonify({"error": "invalid_enrollment_code"}), 400 valid_code, reuse_guid = _install_code_valid(install_code, fingerprint, cur) if not valid_code: log( @@ -427,6 +447,7 @@ def register( SET hostname_claimed = ?, guid = ?, enrollment_code_id = ?, + site_id = ?, client_nonce = ?, server_nonce = ?, agent_pubkey_der = ?, @@ -437,6 +458,7 @@ def register( hostname, reuse_guid, install_code["id"], + install_code.get("site_id"), client_nonce_b64, server_nonce_b64, agent_pubkey_der, @@ -451,11 +473,11 @@ def register( """ INSERT INTO device_approvals ( id, approval_reference, guid, hostname_claimed, - ssl_key_fingerprint_claimed, enrollment_code_id, + ssl_key_fingerprint_claimed, enrollment_code_id, site_id, status, client_nonce, server_nonce, agent_pubkey_der, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?) """, ( record_id, @@ -464,6 +486,7 @@ def register( hostname, fingerprint, install_code["id"], + install_code.get("site_id"), client_nonce_b64, server_nonce_b64, agent_pubkey_der, @@ -535,7 +558,7 @@ def register( cur.execute( """ SELECT id, guid, hostname_claimed, ssl_key_fingerprint_claimed, - enrollment_code_id, status, client_nonce, server_nonce, + enrollment_code_id, site_id, status, client_nonce, server_nonce, agent_pubkey_der, created_at, updated_at, approved_by_user_id FROM device_approvals WHERE approval_reference = ? @@ -553,6 +576,7 @@ def register( hostname_claimed, fingerprint, enrollment_code_id, + site_id, status, client_nonce_stored, server_nonce_b64, @@ -643,6 +667,17 @@ def register( device_record = _ensure_device_record(cur, effective_guid, hostname_claimed, fingerprint) _store_device_key(cur, effective_guid, fingerprint) + if site_id: + assigned_at = int(time.time()) + cur.execute( + """ + INSERT INTO device_sites(device_hostname, site_id, assigned_at) + VALUES (?, ?, ?) + ON CONFLICT(device_hostname) + DO UPDATE SET site_id=excluded.site_id, assigned_at=excluded.assigned_at + """, + (device_record.get("hostname"), site_id, assigned_at), + ) # Mark install code used if enrollment_code_id: @@ -656,13 +691,14 @@ def register( except Exception: prior_count = 0 try: - allowed_uses = int(usage_row[1]) if usage_row else 1 + allowed_uses = int(usage_row[1]) if usage_row else 0 except Exception: - allowed_uses = 1 + allowed_uses = 0 + unlimited = allowed_uses <= 0 if allowed_uses < 1: allowed_uses = 1 new_count = prior_count + 1 - consumed = new_count >= allowed_uses + consumed = False if unlimited else new_count >= allowed_uses cur.execute( """ UPDATE enrollment_install_codes @@ -767,4 +803,3 @@ def _mask_code(code: str) -> str: if len(trimmed) <= 6: return "***" return f"{trimmed[:3]}***{trimmed[-3:]}" - diff --git a/Data/Engine/web-interface/src/App.jsx b/Data/Engine/web-interface/src/App.jsx index b2caeeca..98a39543 100644 --- a/Data/Engine/web-interface/src/App.jsx +++ b/Data/Engine/web-interface/src/App.jsx @@ -48,7 +48,6 @@ import GithubAPIToken from "./Access_Management/Github_API_Token.jsx"; import ServerInfo from "./Admin/Server_Info.jsx"; import PageTemplate from "./Admin/Page_Template.jsx"; import LogManagement from "./Admin/Log_Management.jsx"; -import EnrollmentCodes from "./Devices/Enrollment_Codes.jsx"; import DeviceApprovals from "./Devices/Device_Approvals.jsx"; // Networking Imports @@ -230,8 +229,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; return "/admin/server_info"; case "page_template": return "/admin/page_template"; - case "admin_enrollment_codes": - return "/admin/enrollment-codes"; case "admin_device_approvals": return "/admin/device-approvals"; default: @@ -286,7 +283,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; if (path === "/access_management/credentials") return { page: "access_credentials", options: {} }; if (path === "/admin/server_info") return { page: "server_info", options: {} }; if (path === "/admin/page_template") return { page: "page_template", options: {} }; - if (path === "/admin/enrollment-codes") return { page: "admin_enrollment_codes", options: {} }; if (path === "/admin/device-approvals") return { page: "admin_device_approvals", options: {} }; return { page: "devices", options: {} }; } catch { @@ -512,10 +508,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; items.push({ label: "Developer Tools" }); items.push({ label: "Page Template", page: "page_template" }); break; - case "admin_enrollment_codes": - items.push({ label: "Admin Settings", page: "server_info" }); - items.push({ label: "Installer Codes", page: "admin_enrollment_codes" }); - break; case "admin_device_approvals": items.push({ label: "Admin Settings", page: "server_info" }); items.push({ label: "Device Approvals", page: "admin_device_approvals" }); @@ -1045,7 +1037,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; useEffect(() => { const requiresAdmin = currentPage === 'server_info' - || currentPage === 'admin_enrollment_codes' || currentPage === 'admin_device_approvals' || currentPage === 'access_credentials' || currentPage === 'access_github_token' @@ -1199,9 +1190,6 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; case "page_template": return ; - case "admin_enrollment_codes": - return ; - case "admin_device_approvals": return ; diff --git a/Data/Engine/web-interface/src/Devices/Device_Approvals.jsx b/Data/Engine/web-interface/src/Devices/Device_Approvals.jsx index ceb0dd91..9bd45828 100644 --- a/Data/Engine/web-interface/src/Devices/Device_Approvals.jsx +++ b/Data/Engine/web-interface/src/Devices/Device_Approvals.jsx @@ -294,6 +294,12 @@ export default function DeviceApprovals() { minWidth: 100, Width: 100, }, + { + headerName: "Site", + field: "site_name", + valueGetter: (p) => p.data?.site_name || (p.data?.site_id ? `Site ${p.data.site_id}` : "—"), + minWidth: 160, + }, { headerName: "Created", field: "created_at", valueFormatter: (p) => formatDateTime(p.value), minWidth: 160 }, { headerName: "Updated", field: "updated_at", valueFormatter: (p) => formatDateTime(p.value), minWidth: 160 }, { diff --git a/Data/Engine/web-interface/src/Devices/Enrollment_Codes.jsx b/Data/Engine/web-interface/src/Devices/Enrollment_Codes.jsx deleted file mode 100644 index 1ab6efc3..00000000 --- a/Data/Engine/web-interface/src/Devices/Enrollment_Codes.jsx +++ /dev/null @@ -1,372 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { - Box, - Paper, - Typography, - Button, - Stack, - Alert, - FormControl, - InputLabel, - MenuItem, - Select, - CircularProgress, - Tooltip -} from "@mui/material"; -import { - ContentCopy as CopyIcon, - DeleteOutline as DeleteIcon, - Refresh as RefreshIcon, - Key as KeyIcon, -} from "@mui/icons-material"; -import { AgGridReact } from "ag-grid-react"; -import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; -// IMPORTANT: Do NOT import global AG Grid CSS here to avoid overriding other pages. -// We rely on the project's existing CSS and themeQuartz class name like other MagicUI pages. -ModuleRegistry.registerModules([AllCommunityModule]); - -// Match the palette used on other pages (see Site_List / Device_List) -const MAGIC_UI = { - shellBg: - "radial-gradient(120% 120% at 0% 0%, rgba(76, 186, 255, 0.16), transparent 55%), " + - "radial-gradient(120% 120% at 100% 0%, rgba(214, 130, 255, 0.18), transparent 60%), #040711", - panelBg: - "linear-gradient(135deg, rgba(10, 16, 31, 0.98) 0%, rgba(6, 10, 24, 0.94) 60%, rgba(15, 6, 26, 0.96) 100%)", - panelBorder: "rgba(148, 163, 184, 0.35)", - textBright: "#e2e8f0", - textMuted: "#94a3b8", - accentA: "#7dd3fc", - accentB: "#c084fc", -}; - -// Generate a scoped Quartz theme class (same pattern as other pages) -const gridTheme = themeQuartz.withParams({ - accentColor: "#8b5cf6", - backgroundColor: "#070b1a", - browserColorScheme: "dark", - fontFamily: { googleFont: "IBM Plex Sans" }, - foregroundColor: "#f4f7ff", - headerFontSize: 13, -}); -const themeClassName = gridTheme.themeName || "ag-theme-quartz"; - -const TTL_PRESETS = [ - { value: 1, label: "1 hour" }, - { value: 3, label: "3 hours" }, - { value: 6, label: "6 hours" }, - { value: 12, label: "12 hours" }, - { value: 24, label: "24 hours" }, -]; - -const determineStatus = (record) => { - if (!record) return "expired"; - const maxUses = Number.isFinite(record?.max_uses) ? record.max_uses : 1; - const useCount = Number.isFinite(record?.use_count) ? record.use_count : 0; - if (useCount >= Math.max(1, maxUses || 1)) return "used"; - if (!record.expires_at) return "expired"; - const expires = new Date(record.expires_at); - if (Number.isNaN(expires.getTime())) return "expired"; - return expires.getTime() > Date.now() ? "active" : "expired"; -}; - -const formatDateTime = (value) => { - if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); -}; - -const maskCode = (code) => { - if (!code) return "—"; - const parts = code.split("-"); - if (parts.length <= 1) { - const prefix = code.slice(0, 4); - return `${prefix}${"•".repeat(Math.max(0, code.length - prefix.length))}`; - } - return parts - .map((part, idx) => (idx === 0 || idx === parts.length - 1 ? part : "•".repeat(part.length))) - .join("-"); -}; - -export default function EnrollmentCodes() { - const [codes, setCodes] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [feedback, setFeedback] = useState(null); - const [statusFilter, setStatusFilter] = useState("all"); - const [ttlHours, setTtlHours] = useState(6); - const [generating, setGenerating] = useState(false); - const [maxUses, setMaxUses] = useState(2); - const gridRef = useRef(null); - - const fetchCodes = useCallback(async () => { - setLoading(true); - setError(""); - try { - const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`; - const resp = await fetch(`/api/admin/enrollment-codes${query}`, { credentials: "include" }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error || `Request failed (${resp.status})`); - } - const data = await resp.json(); - setCodes(Array.isArray(data.codes) ? data.codes : []); - } catch (err) { - setError(err.message || "Unable to load codes"); - } finally { - setLoading(false); - } - }, [statusFilter]); - - useEffect(() => { fetchCodes(); }, [fetchCodes]); - - const handleGenerate = useCallback(async () => { - setGenerating(true); - try { - const resp = await fetch("/api/admin/enrollment-codes", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ttl_hours: ttlHours, max_uses: maxUses }), - }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error || `Request failed (${resp.status})`); - } - await fetchCodes(); - setFeedback({ type: "success", message: "New installer code created" }); - } catch (err) { - setFeedback({ type: "error", message: err.message }); - } finally { - setGenerating(false); - } - }, [ttlHours, maxUses, fetchCodes]); - - const handleCopy = (code) => { - if (!code) return; - try { - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(code); - setFeedback({ type: "success", message: "Code copied to clipboard" }); - } - } catch (_) {} - }; - - const handleDelete = async (id) => { - if (!id) return; - if (!window.confirm("Delete this installer code?")) return; - try { - const resp = await fetch(`/api/admin/enrollment-codes/${id}`, { - method: "DELETE", - credentials: "include", - }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(body.error || `Request failed (${resp.status})`); - } - await fetchCodes(); - setFeedback({ type: "success", message: "Code deleted" }); - } catch (err) { - setFeedback({ type: "error", message: err.message }); - } - }; - - const columns = useMemo(() => [ - { - headerName: "Status", - field: "status", - cellRenderer: (params) => { - const status = determineStatus(params.data); - const color = - status === "active" ? "#34d399" : - status === "used" ? "#7dd3fc" : - "#fbbf24"; - return {status}; - }, - minWidth: 100 - }, - { - headerName: "Installer Code", - field: "code", - cellRenderer: (params) => ( - {maskCode(params.value)} - ), - minWidth: 340 - }, - { headerName: "Expires At", - field: "expires_at", - valueFormatter: p => formatDateTime(p.value) - }, - { headerName: "Created By", field: "created_by_user_id" }, - { - headerName: "Usage", - valueGetter: (p) => `${p.data.use_count || 0} / ${p.data.max_uses || 1}`, - cellStyle: { fontFamily: "monospace" }, - width: 120 - }, - { headerName: "Last Used", field: "last_used_at", valueFormatter: p => formatDateTime(p.value) }, - { headerName: "Used By GUID", field: "used_by_guid" }, - { - headerName: "Actions", - cellRenderer: (params) => { - const record = params.data; - const disableDelete = (record.use_count || 0) !== 0; - return ( - - - - - - - - - - - - - ); - }, - width: 160 - } - ], []); - - const defaultColDef = useMemo(() => ({ - sortable: true, - filter: true, - resizable: true, - flex: 1, - minWidth: 140, - }), []); - - return ( - - {/* Hero header */} - - - - - - Enrollment Installer Codes - - - - - - - - - - {/* Controls */} - - - Status - - - - - Duration - - - - - Allowed Uses - - - - - {feedback && ( - - setFeedback(null)}> - {feedback.message} - - - )} - {error && ( - - {error} - - )} - - {/* Grid wrapper — all overrides are SCOPED to this instance via inline CSS vars */} - - - - - ); -} diff --git a/Data/Engine/web-interface/src/Navigation_Sidebar.jsx b/Data/Engine/web-interface/src/Navigation_Sidebar.jsx index 337e9da4..a71c41ed 100644 --- a/Data/Engine/web-interface/src/Navigation_Sidebar.jsx +++ b/Data/Engine/web-interface/src/Navigation_Sidebar.jsx @@ -24,7 +24,6 @@ import { VpnKey as CredentialIcon, PersonOutline as UserIcon, GitHub as GitHubIcon, - Key as KeyIcon, Dashboard as PageTemplateIcon, AdminPanelSettings as AdminPanelSettingsIcon, ReceiptLong as LogsIcon, @@ -61,7 +60,6 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { "winrm_devices", "agent_devices", "admin_device_approvals", - "admin_enrollment_codes", ].includes(currentPage), automation: ["jobs", "assemblies", "community"].includes(currentPage), filters: ["filters", "groups"].includes(currentPage), @@ -194,12 +192,6 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { label="Device Approvals" pageKey="admin_device_approvals" /> - } - label="Enrollment Codes" - pageKey="admin_enrollment_codes" - indent - /> } label="Devices" diff --git a/Data/Engine/web-interface/src/Sites/Site_List.jsx b/Data/Engine/web-interface/src/Sites/Site_List.jsx index 2fbc3e41..2fa73ae1 100644 --- a/Data/Engine/web-interface/src/Sites/Site_List.jsx +++ b/Data/Engine/web-interface/src/Sites/Site_List.jsx @@ -6,12 +6,15 @@ import { Button, IconButton, Tooltip, + CircularProgress, } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import LocationCityIcon from "@mui/icons-material/LocationCity"; import DeleteIcon from "@mui/icons-material/DeleteOutline"; import EditIcon from "@mui/icons-material/Edit"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { AgGridReact } from "ag-grid-react"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; import { CreateSiteDialog, ConfirmDeleteDialog, RenameSiteDialog } from "../Dialogs.jsx"; @@ -69,6 +72,7 @@ export default function SiteList({ onOpenDevicesForSite }) { const [deleteOpen, setDeleteOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [renameValue, setRenameValue] = useState(""); + const [rotatingId, setRotatingId] = useState(null); const gridRef = useRef(null); const fetchSites = useCallback(async () => { @@ -83,6 +87,42 @@ export default function SiteList({ onOpenDevicesForSite }) { useEffect(() => { fetchSites(); }, [fetchSites]); + const handleCopy = useCallback(async (code) => { + const value = (code || "").trim(); + if (!value) return; + try { + await navigator.clipboard.writeText(value); + } catch { + window.prompt("Copy enrollment code", value); + } + }, []); + + const handleRotate = useCallback(async (site) => { + if (!site?.id) return; + const confirmRotate = window.confirm( + "Are you sure you want to rotate the enrollment code associated with this site? " + + "If there are automations that deploy agents to endpoints, the enrollment code associated with them will need to also be updated." + ); + if (!confirmRotate) return; + setRotatingId(site.id); + try { + const resp = await fetch("/api/sites/rotate_code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ site_id: site.id }), + }); + if (resp.ok) { + const updated = await resp.json(); + setRows((prev) => prev.map((row) => (row.id === site.id ? { ...row, ...updated } : row))); + } + } catch { + // Silently fail the rotate if the request errors; grid will refresh on next fetch. + } finally { + setRotatingId(null); + fetchSites(); + } + }, [fetchSites]); + const columnDefs = useMemo(() => [ { headerName: "", @@ -105,9 +145,51 @@ export default function SiteList({ onOpenDevicesForSite }) { ), }, + { + headerName: "Agent Enrollment Code", + field: "enrollment_code", + minWidth: 320, + flex: 1.2, + cellRenderer: (params) => { + const code = params.value || "—"; + const site = params.data || {}; + const busy = rotatingId === site.id; + return ( + + + + handleRotate(site)} + disabled={busy} + sx={{ color: MAGIC_UI.accentA, border: "1px solid rgba(148,163,184,0.35)" }} + > + {busy ? : } + + + + + {code} + + + + handleCopy(code)} + disabled={!code || code === "—"} + sx={{ color: MAGIC_UI.textMuted }} + > + + + + + + ); + }, + }, { headerName: "Description", field: "description", minWidth: 220 }, { headerName: "Devices", field: "device_count", minWidth: 120 }, - ], [onOpenDevicesForSite]); + ], [onOpenDevicesForSite, handleRotate, handleCopy, rotatingId]); const defaultColDef = useMemo(() => ({ sortable: true,