diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index e16b272..d70a60a 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -66,6 +66,10 @@ def bootstrap() -> EngineRuntime: else: logger.info("migrations-skipped") + with sqlite_connection.connection_scope(settings.database_path) as conn: + sqlite_migrations.ensure_default_admin(conn) + logger.info("default-admin-ensured") + app = create_app(settings, db_factory=db_factory) services = build_service_container(settings, db_factory=db_factory, logger=logger.getChild("services")) app.extensions["engine_services"] = services diff --git a/Data/Engine/config/environment.py b/Data/Engine/config/environment.py index 14cde00..04211f8 100644 --- a/Data/Engine/config/environment.py +++ b/Data/Engine/config/environment.py @@ -122,6 +122,10 @@ def _resolve_static_root(project_root: Path) -> Path: project_root / "Engine" / "web-interface", project_root / "Data" / "Engine" / "WebUI" / "build", project_root / "Data" / "Engine" / "WebUI", + project_root / "Server" / "web-interface" / "build", + project_root / "Server" / "web-interface", + project_root / "Server" / "WebUI" / "build", + project_root / "Server" / "WebUI", project_root / "Data" / "Server" / "web-interface" / "build", project_root / "Data" / "Server" / "web-interface", project_root / "Data" / "Server" / "WebUI" / "build", diff --git a/Data/Engine/repositories/sqlite/__init__.py b/Data/Engine/repositories/sqlite/__init__.py index 869829f..8b44e59 100644 --- a/Data/Engine/repositories/sqlite/__init__.py +++ b/Data/Engine/repositories/sqlite/__init__.py @@ -9,7 +9,7 @@ from .connection import ( connection_factory, connection_scope, ) -from .migrations import apply_all +from .migrations import apply_all, ensure_default_admin __all__ = [ "SQLiteConnectionFactory", @@ -18,6 +18,7 @@ __all__ = [ "connection_factory", "connection_scope", "apply_all", + "ensure_default_admin", ] try: # pragma: no cover - optional dependency shim diff --git a/Data/Engine/repositories/sqlite/migrations.py b/Data/Engine/repositories/sqlite/migrations.py index 4dddca0..34d3c77 100644 --- a/Data/Engine/repositories/sqlite/migrations.py +++ b/Data/Engine/repositories/sqlite/migrations.py @@ -15,6 +15,10 @@ from typing import List, Optional, Sequence, Tuple DEVICE_TABLE = "devices" +_DEFAULT_ADMIN_USERNAME = "admin" +_DEFAULT_ADMIN_PASSWORD_SHA512 = ( + "e6c83b282aeb2e022844595721cc00bbda47cb24537c1779f9bb84f04039e1676e6ba8573e588da1052510e3aa0a32a9e55879ae22b0c2d62136fc0a3e85f8bb" +) def apply_all(conn: sqlite3.Connection) -> None: @@ -30,6 +34,8 @@ def apply_all(conn: sqlite3.Connection) -> None: _ensure_github_token_table(conn) _ensure_scheduled_jobs_table(conn) _ensure_scheduled_job_run_tables(conn) + _ensure_users_table(conn) + _ensure_default_admin(conn) conn.commit() @@ -504,4 +510,86 @@ def _normalized_guid(value: Optional[str]) -> str: return "" return str(value).strip() -__all__ = ["apply_all"] +def _ensure_users_table(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + display_name TEXT, + password_sha512 TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'Admin', + last_login INTEGER, + created_at INTEGER, + updated_at INTEGER, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + mfa_secret TEXT + ) + """ + ) + + try: + cur.execute("PRAGMA table_info(users)") + columns = [row[1] for row in cur.fetchall()] + if "mfa_enabled" not in columns: + cur.execute("ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0") + if "mfa_secret" not in columns: + cur.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT") + except sqlite3.Error: + # Aligning the schema is best-effort; older deployments may lack ALTER + # TABLE privileges but can continue using existing columns. + pass + + +def _ensure_default_admin(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM users WHERE LOWER(role)='admin'") + row = cur.fetchone() + if row and (row[0] or 0): + return + + now = int(datetime.now(timezone.utc).timestamp()) + cur.execute( + "SELECT COUNT(*) FROM users WHERE LOWER(username)=LOWER(?)", + (_DEFAULT_ADMIN_USERNAME,), + ) + existing = cur.fetchone() + if not existing or not (existing[0] or 0): + cur.execute( + """ + INSERT INTO users ( + username, display_name, password_sha512, role, + last_login, created_at, updated_at, mfa_enabled, mfa_secret + ) VALUES (?, ?, ?, 'Admin', 0, ?, ?, 0, NULL) + """, + ( + _DEFAULT_ADMIN_USERNAME, + "Administrator", + _DEFAULT_ADMIN_PASSWORD_SHA512, + now, + now, + ), + ) + else: + cur.execute( + """ + UPDATE users + SET role='Admin', + updated_at=? + WHERE LOWER(username)=LOWER(?) + AND LOWER(role)!='admin' + """, + (now, _DEFAULT_ADMIN_USERNAME), + ) + + +def ensure_default_admin(conn: sqlite3.Connection) -> None: + """Guarantee that at least one admin account exists.""" + + _ensure_users_table(conn) + _ensure_default_admin(conn) + conn.commit() + + +__all__ = ["apply_all", "ensure_default_admin"] diff --git a/Data/Engine/tests/test_config_environment.py b/Data/Engine/tests/test_config_environment.py index 03ff2ba..7631925 100644 --- a/Data/Engine/tests/test_config_environment.py +++ b/Data/Engine/tests/test_config_environment.py @@ -63,6 +63,23 @@ def test_static_root_falls_back_to_legacy_source(tmp_path, monkeypatch): monkeypatch.delenv("BOREALIS_ROOT", raising=False) +def test_static_root_considers_runtime_copy(tmp_path, monkeypatch): + """Runtime Server/WebUI copies should be considered when Data assets are missing.""" + + runtime_source = tmp_path / "Server" / "WebUI" + runtime_source.mkdir(parents=True) + (runtime_source / "index.html").write_text("runtime", encoding="utf-8") + + monkeypatch.setenv("BOREALIS_ROOT", str(tmp_path)) + monkeypatch.delenv("BOREALIS_STATIC_ROOT", raising=False) + + settings = load_environment() + + assert settings.flask.static_root == runtime_source.resolve() + + monkeypatch.delenv("BOREALIS_ROOT", raising=False) + + def test_resolve_project_root_defaults_to_repository(monkeypatch): """The project root should resolve to the repository checkout.""" diff --git a/Data/Engine/tests/test_sqlite_migrations.py b/Data/Engine/tests/test_sqlite_migrations.py index 6361616..56d4b1f 100644 --- a/Data/Engine/tests/test_sqlite_migrations.py +++ b/Data/Engine/tests/test_sqlite_migrations.py @@ -1,3 +1,4 @@ +import hashlib import sqlite3 import unittest @@ -24,6 +25,56 @@ class MigrationTests(unittest.TestCase): self.assertIn("scheduled_jobs", tables) self.assertIn("scheduled_job_runs", tables) self.assertIn("github_token", tables) + self.assertIn("users", tables) + + cursor.execute( + "SELECT username, role, password_sha512 FROM users WHERE LOWER(username)=LOWER(?)", + ("admin",), + ) + row = cursor.fetchone() + self.assertIsNotNone(row) + if row: + self.assertEqual(row[0], "admin") + self.assertEqual(row[1].lower(), "admin") + self.assertEqual(row[2], hashlib.sha512(b"Password").hexdigest()) + finally: + conn.close() + + def test_ensure_default_admin_promotes_existing_user(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute( + """ + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + display_name TEXT, + password_sha512 TEXT, + role TEXT, + last_login INTEGER, + created_at INTEGER, + updated_at INTEGER, + mfa_enabled INTEGER DEFAULT 0, + mfa_secret TEXT + ) + """ + ) + conn.execute( + "INSERT INTO users (username, display_name, password_sha512, role) VALUES (?, ?, ?, ?)", + ("admin", "Custom", "hash", "user"), + ) + conn.commit() + + migrations.ensure_default_admin(conn) + + cursor = conn.cursor() + cursor.execute( + "SELECT role, password_sha512 FROM users WHERE LOWER(username)=LOWER(?)", + ("admin",), + ) + role, password_hash = cursor.fetchone() + self.assertEqual(role.lower(), "admin") + self.assertEqual(password_hash, "hash") finally: conn.close()