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
-
-
-
- : null}
- onClick={handleGenerate}
- sx={{ background: "linear-gradient(135deg,#7dd3fc,#c084fc)", borderRadius: 999 }}
- >
- {generating ? "Generating…" : "Generate Code"}
-
- } onClick={fetchCodes} disabled={loading}>
- Refresh
-
-
-
-
-
- {/* 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,