diff --git a/.gitignore b/.gitignore index 686bbf4..478675f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ imports/ *.dump *.bak *.csv +!sql/ +!sql/*.sql # Python __pycache__/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..dbaf6e3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,64 @@ +# MinerOps Target Architecture + +MinerOps will move from its current working checkpoint toward a modular +operations platform without a big-bang rewrite. Every milestone must leave the +production application deployable and preserve verified workflows. + +## Boundaries + +- The hub owns users, authorization, fleet inventory, repairs, locations, + reporting, command policy, audit history, and long-term telemetry. +- Site agents own network-local discovery, polling, and miner command + execution. +- The hub must not require a route to miner VLANs. +- WhatsMiner protocols are adapters behind one capability-based interface. + +## Backend + +The target backend is organized into FastAPI routers, domain services, and +database repositories for: + +- identity and access +- fleet inventory +- telemetry and retention +- repairs and parts +- locations +- agents and command queues +- WhatsMiner protocol capabilities +- system health + +Alembic is the only authority for persistent schema changes. Runtime table +creation remains temporarily for backward compatibility and will be removed +after migrations and tests cover every current path. + +## Frontend + +The target frontend is a typed, responsive application with a shared design +system. Authentication is established before protected pages render. Views +adapt to desktop, tablet, and phone layouts and expose actions according to +server-enforced permissions. + +## Identity + +MinerOps application accounts are distinct from WhatsMiner device accounts. +The default application roles will be Admin, Manager, Technician, and Read +Only. Authorization is permission-based and enforced by backend endpoints. + +## Safety + +- Scanning and real controls are disabled by default in development. +- Site-agent commands are authenticated, durable, expiring, idempotent, and + audited. +- Destructive operations support dry-run behavior and explicit authorization. +- Protocol adapters are tested with sanitized fixtures before real controls are + enabled. + +## Migration Order + +1. Reproducible schema and development baseline. +2. Accounts, roles, secure sessions, and login gate. +3. Responsive application shell and shared UI components. +4. Optimized dashboard loading. +5. WhatsMiner 4028 and 4433 protocol adapters. +6. Incremental extraction of repairs, locations, agents, commands, and health + from the legacy application module. diff --git a/README.md b/README.md index 35240a8..6027fb1 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,16 @@ docker compose --env-file .env.dev -f compose.dev.yaml ps curl --fail --silent http://127.0.0.1:18000/api/health ``` +Run the disposable clean-database baseline: + +```bash +./scripts/test-dev-baseline.sh +``` + +The baseline uses a separate Compose project and port, verifies migrations, +schema, pages, retention rollups, and safety interlocks, then deletes only its +own temporary volumes. + Stop development services without deleting their data: ```bash diff --git a/backend/alembic/versions/0006_operational_schema_baseline.py b/backend/alembic/versions/0006_operational_schema_baseline.py new file mode 100644 index 0000000..7e18404 --- /dev/null +++ b/backend/alembic/versions/0006_operational_schema_baseline.py @@ -0,0 +1,478 @@ +"""capture the operational schema baseline + +Revision ID: 0006_operational_schema_baseline +Revises: 0005_miner_inventory +Create Date: 2026-07-27 + +This migration adopts tables that older MinerOps builds created at runtime. +The IF NOT EXISTS clauses allow it to run against both an existing deployment +and a new database. +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0006_operational_schema_baseline" +down_revision: Union[str, None] = "0005_miner_inventory" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE miners + ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT, + ADD COLUMN IF NOT EXISTS psu_model TEXT, + ADD COLUMN IF NOT EXISTS customer_sn TEXT, + ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS api3_api_version TEXT, + ADD COLUMN IF NOT EXISTS api3_api_switch TEXT, + ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS api3_last_error TEXT, + ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT, + ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT, + ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ; + + ALTER TABLE miner_status_snapshots + ADD COLUMN IF NOT EXISTS ip_address INET, + ADD COLUMN IF NOT EXISTS mac_address TEXT, + ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION; + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS miner_maintenance_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + event_status TEXT NOT NULL DEFAULT 'completed', + event_title TEXT NOT NULL, + description TEXT, + event_at TIMESTAMPTZ NOT NULL DEFAULT now(), + old_mac TEXT, + new_mac TEXT, + old_ip INET, + new_ip INET, + component_type TEXT, + component_slot TEXT, + component_serial TEXT, + cost_amount NUMERIC(12, 2), + downtime_minutes INTEGER, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id + ON miner_maintenance_events(miner_id); + CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at + ON miner_maintenance_events(event_at DESC); + CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type + ON miner_maintenance_events(event_type); + + CREATE TABLE IF NOT EXISTS location_positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID REFERENCES rooms(id) ON DELETE SET NULL, + room_name TEXT NOT NULL, + rack_name TEXT NOT NULL, + shelf_name TEXT NOT NULL, + shelf_index INTEGER, + position_index INTEGER NOT NULL, + position_label TEXT, + position_kind TEXT NOT NULL DEFAULT 'miner_slot', + position_status TEXT NOT NULL DEFAULT 'available', + expected_mac TEXT, + miner_id UUID REFERENCES miners(id) ON DELETE SET NULL, + equipment_label TEXT, + notes TEXT, + source_file TEXT, + source_sheet TEXT, + source_cell TEXT, + raw_value TEXT, + conflict_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + DO $$ + BEGIN + ALTER TABLE location_positions + ADD CONSTRAINT uq_location_positions_slot + UNIQUE (room_name, rack_name, shelf_name, position_index); + EXCEPTION + WHEN duplicate_object OR duplicate_table THEN NULL; + END $$; + + CREATE INDEX IF NOT EXISTS idx_location_positions_room + ON location_positions(room_name); + CREATE INDEX IF NOT EXISTS idx_location_positions_rack + ON location_positions(room_name, rack_name); + CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac + ON location_positions(expected_mac); + CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id + ON location_positions(miner_id); + + CREATE TABLE IF NOT EXISTS miner_location_states ( + miner_id UUID PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE, + asset_state TEXT NOT NULL DEFAULT 'waiting_placement', + location_position_id UUID REFERENCES location_positions(id) ON DELETE SET NULL, + state_notes TEXT, + updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state + ON miner_location_states(asset_state); + + CREATE TABLE IF NOT EXISTS location_import_issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + import_batch_id UUID NOT NULL, + issue_type TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + message TEXT NOT NULL, + mac_address TEXT, + room_name TEXT, + rack_name TEXT, + shelf_name TEXT, + position_index INTEGER, + source_file TEXT, + source_cell TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch + ON location_import_issues(import_batch_id); + CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac + ON location_import_issues(mac_address); + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS miner_command_events ( + id TEXT PRIMARY KEY, + miner_id TEXT NOT NULL, + command_type TEXT NOT NULL, + command_status TEXT NOT NULL, + dry_run BOOLEAN NOT NULL DEFAULT true, + requested_by_user_id TEXT, + requested_by_username TEXT, + target_ip TEXT, + target_mac TEXT, + request_payload JSONB, + response_payload JSONB, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created + ON miner_command_events(miner_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_api_credentials ( + miner_id TEXT PRIMARY KEY, + api_account TEXT NOT NULL DEFAULT 'super', + api_password TEXT NOT NULL, + password_source TEXT NOT NULL DEFAULT 'minerops', + updated_by_username TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS minerops_site_agents ( + agent_id TEXT PRIMARY KEY, + site_name TEXT NOT NULL, + hostname TEXT, + version TEXT, + ip_address TEXT, + last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_payload JSONB NOT NULL DEFAULT '{}'::jsonb + ); + + CREATE TABLE IF NOT EXISTS minerops_agent_snapshots ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL, + site_name TEXT NOT NULL, + snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll', + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + miner_count INTEGER NOT NULL DEFAULT 0, + payload JSONB NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received + ON minerops_agent_snapshots(received_at DESC); + + CREATE TABLE IF NOT EXISTS minerops_agent_command_results ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL, + site_name TEXT NOT NULL, + command_id TEXT, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL, + payload JSONB NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received + ON minerops_agent_command_results(received_at DESC); + + CREATE TABLE IF NOT EXISTS minerops_agent_command_queue ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + command_type TEXT NOT NULL, + command_status TEXT NOT NULL DEFAULT 'pending', + requested_by_user_id TEXT, + requested_by_username TEXT, + request_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + result_payload JSONB, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + claimed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status + ON minerops_agent_command_queue(agent_id, command_status, created_at); + + CREATE TABLE IF NOT EXISTS minerops_agent_discoveries ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + site_name TEXT NOT NULL, + room_name TEXT, + ip_address INET, + mac_address TEXT, + model TEXT, + hostname TEXT, + miner_sn TEXT, + raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + discovery_status TEXT NOT NULL DEFAULT 'new', + matched_miner_id UUID, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac + ON minerops_agent_discoveries(mac_address); + CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip + ON minerops_agent_discoveries(ip_address); + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions ( + id TEXT PRIMARY KEY, + name TEXT, + target_type TEXT NOT NULL, + target_value TEXT, + action TEXT NOT NULL DEFAULT 'performance_mode', + performance_mode TEXT, + repeat_rule TEXT NOT NULL DEFAULT 'once', + scheduled_for TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent', + requested_by_user_id TEXT, + requested_by_username TEXT, + queued_command_count INTEGER NOT NULL DEFAULT 0, + last_queued_command_count INTEGER NOT NULL DEFAULT 0, + run_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due + ON minerops_scheduled_bulk_actions(status, scheduled_for); + CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created + ON minerops_scheduled_bulk_actions(created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + miner_id UUID, + ip_address INET, + mac_address TEXT, + model TEXT, + pool_index INTEGER, + url TEXT, + worker_user TEXT, + pool_status TEXT, + stratum_active BOOLEAN, + accepted DOUBLE PRECISION, + rejected DOUBLE PRECISION, + stale DOUBLE PRECISION, + get_failures DOUBLE PRECISION, + remote_failures DOUBLE PRECISION, + pool_rejected_percent DOUBLE PRECISION, + pool_stale_percent DOUBLE PRECISION, + last_share_time_epoch DOUBLE PRECISION, + raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, + agent_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + ALTER TABLE miner_pool_snapshots + ADD COLUMN IF NOT EXISTS model TEXT, + ADD COLUMN IF NOT EXISTS pool_index INTEGER, + ADD COLUMN IF NOT EXISTS url TEXT, + ADD COLUMN IF NOT EXISTS worker_user TEXT, + ADD COLUMN IF NOT EXISTS pool_status TEXT, + ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN, + ADD COLUMN IF NOT EXISTS accepted DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS rejected DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS stale DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS get_failures DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS remote_failures DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS pool_stale_percent DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS last_share_time_epoch DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS agent_id TEXT; + + DO $$ + DECLARE + id_type TEXT; + BEGIN + SELECT data_type INTO id_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'miner_pool_snapshots' + AND column_name = 'id'; + + IF id_type = 'text' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text; + ELSIF id_type = 'uuid' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT gen_random_uuid(); + END IF; + END $$; + + CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created + ON miner_pool_snapshots(miner_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created + ON miner_pool_snapshots(ip_address, created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots ( + id BIGSERIAL PRIMARY KEY, + miner_id UUID, + agent_id TEXT, + site_name TEXT, + ip_address INET, + mac_address TEXT, + api_supported BOOLEAN, + api_error TEXT, + api_version TEXT, + api_switch TEXT, + model TEXT, + miner_sn TEXT, + power_sn TEXT, + psu_model TEXT, + device_info JSONB, + miner_setting JSONB, + miner_status JSONB, + fan_setting JSONB, + custom_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_api3_enrichment_miner_created + ON miner_api3_enrichment_snapshots(miner_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots ( + id BIGSERIAL PRIMARY KEY, + miner_id UUID, + ip_address INET, + mac_address TEXT, + slot INTEGER, + pcb_sn TEXT, + hash_average_ths DOUBLE PRECISION, + factory_hash_ths DOUBLE PRECISION, + freq DOUBLE PRECISION, + effective_chips INTEGER, + chip_temp_min_c DOUBLE PRECISION, + chip_temp_avg_c DOUBLE PRECISION, + chip_temp_max_c DOUBLE PRECISION, + raw_hashboard_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_api3_hashboards_miner_created + ON miner_api3_hashboard_snapshots(miner_id, created_at DESC); + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS miner_status_hourly_rollups ( + miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, + bucket_at TIMESTAMPTZ NOT NULL, + sample_count BIGINT NOT NULL, + avg_mhs_av DOUBLE PRECISION, + max_mhs_av DOUBLE PRECISION, + avg_power_w DOUBLE PRECISION, + max_chip_temp_c DOUBLE PRECISION, + avg_chip_temp_c DOUBLE PRECISION, + avg_pool_rejected_percent DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (miner_id, bucket_at) + ); + + CREATE INDEX IF NOT EXISTS idx_miner_status_hourly_bucket + ON miner_status_hourly_rollups(bucket_at); + + CREATE TABLE IF NOT EXISTS miner_status_daily_rollups ( + miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, + bucket_date DATE NOT NULL, + sample_count BIGINT NOT NULL, + avg_mhs_av DOUBLE PRECISION, + max_mhs_av DOUBLE PRECISION, + avg_power_w DOUBLE PRECISION, + max_chip_temp_c DOUBLE PRECISION, + avg_chip_temp_c DOUBLE PRECISION, + avg_pool_rejected_percent DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (miner_id, bucket_date) + ); + + CREATE INDEX IF NOT EXISTS idx_miner_status_daily_bucket + ON miner_status_daily_rollups(bucket_date); + + CREATE TABLE IF NOT EXISTS minerops_retention_runs ( + id BIGSERIAL PRIMARY KEY, + mode TEXT NOT NULL, + raw_status_retention_days INTEGER NOT NULL, + hourly_rollup_rows_before BIGINT NOT NULL DEFAULT 0, + hourly_rollup_rows_after BIGINT NOT NULL DEFAULT 0, + daily_rollup_rows_before BIGINT NOT NULL DEFAULT 0, + daily_rollup_rows_after BIGINT NOT NULL DEFAULT 0, + raw_status_rows_before BIGINT NOT NULL DEFAULT 0, + raw_status_rows_after BIGINT NOT NULL DEFAULT 0, + raw_status_rows_eligible_delete BIGINT NOT NULL DEFAULT 0, + raw_status_rows_deleted BIGINT NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ NOT NULL, + notes TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_retention_runs_started + ON minerops_retention_runs(started_at DESC); + """ + ) + + +def downgrade() -> None: + raise RuntimeError( + "0006 adopts pre-existing operational tables and is intentionally irreversible" + ) diff --git a/backend/alembic/versions/0007_identity_and_roles.py b/backend/alembic/versions/0007_identity_and_roles.py new file mode 100644 index 0000000..83d6224 --- /dev/null +++ b/backend/alembic/versions/0007_identity_and_roles.py @@ -0,0 +1,161 @@ +"""add application roles and permission matrix + +Revision ID: 0007_identity_and_roles +Revises: 0006_operational_schema_baseline +Create Date: 2026-07-27 +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0007_identity_and_roles" +down_revision: Union[str, None] = "0006_operational_schema_baseline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +PERMISSIONS = ( + ("dashboard.view", "View fleet dashboards"), + ("miners.view", "View miners and telemetry"), + ("miners.update", "Update miner inventory and details"), + ("miners.control", "Run approved miner control operations"), + ("repairs.view", "View repair queues and history"), + ("repairs.manage", "Manage repairs, notes, parts, and status"), + ("locations.view", "View rooms, racks, shelves, and placements"), + ("locations.manage", "Manage layouts and miner placements"), + ("agents.view", "View site agents and command status"), + ("agents.manage", "Queue and manage site-agent work"), + ("system_health.view", "View system health and retention status"), + ("users.manage", "Create and manage MinerOps accounts"), + ("credentials.manage", "Manage protected miner credentials"), + ("scan_ranges.manage", "Manage scan ranges"), + ("scans.run", "Run discovery and polling operations"), + ("exports.create", "Export operational data"), + ("audit.view", "View audit history"), +) + +ROLE_PERMISSIONS = { + "Manager": ( + "dashboard.view", + "miners.view", + "miners.update", + "miners.control", + "repairs.view", + "repairs.manage", + "locations.view", + "locations.manage", + "agents.view", + "agents.manage", + "system_health.view", + "scan_ranges.manage", + "scans.run", + "exports.create", + "audit.view", + ), + "Technician": ( + "dashboard.view", + "miners.view", + "miners.update", + "miners.control", + "repairs.view", + "repairs.manage", + "locations.view", + "locations.manage", + "agents.view", + "scans.run", + "exports.create", + ), + "Read Only": ( + "dashboard.view", + "miners.view", + "repairs.view", + "locations.view", + "agents.view", + "system_health.view", + ), +} + + +def upgrade() -> None: + connection = op.get_bind() + + for key, description in PERMISSIONS: + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """, + (key, description), + ) + + connection.exec_driver_sql( + """ + INSERT INTO roles (name, description) + VALUES + ('Manager', 'Operational manager with fleet and workflow control'), + ('Read Only', 'Read-only access to operational views') + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description + """ + ) + + connection.exec_driver_sql( + """ + UPDATE roles + SET description = CASE name + WHEN 'Admin' THEN 'Full MinerOps administrator' + WHEN 'Technician' THEN 'Technician for repair and fleet operations' + ELSE description + END + WHERE name IN ('Admin', 'Technician') + """ + ) + + connection.exec_driver_sql( + """ + UPDATE users + SET role_id = (SELECT id FROM roles WHERE name = 'Read Only') + WHERE role_id IN ( + SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor') + ) + """ + ) + connection.exec_driver_sql( + "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor'))" + ) + connection.exec_driver_sql("DELETE FROM roles WHERE name IN ('Viewer', 'Auditor')") + + connection.exec_driver_sql( + "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Admin', 'Manager', 'Technician', 'Read Only'))" + ) + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + CROSS JOIN permissions + WHERE roles.name = 'Admin' + ON CONFLICT DO NOTHING + """ + ) + + for role_name, keys in ROLE_PERMISSIONS.items(): + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = ANY(%s) + WHERE roles.name = %s + ON CONFLICT DO NOTHING + """, + (list(keys), role_name), + ) + + +def downgrade() -> None: + raise RuntimeError("0007 changes live role assignments and is intentionally irreversible") diff --git a/backend/app/identity.py b/backend/app/identity.py new file mode 100644 index 0000000..9d7ef3c --- /dev/null +++ b/backend/app/identity.py @@ -0,0 +1,1006 @@ +from datetime import datetime, timedelta, timezone +from html import escape +from typing import Any, Callable +from urllib.parse import quote + +import jwt +import psycopg +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel, EmailStr, Field + +from app.config import settings + + +router = APIRouter() +password_hasher = PasswordHasher() +SESSION_COOKIE = "minerops_session" +COOKIE_TOKEN_HINT = "cookie-session" +DEFAULT_ROLES = ("Admin", "Manager", "Technician", "Read Only") + + +class SetupAdminRequest(BaseModel): + username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") + display_name: str = Field(min_length=1, max_length=120) + email: EmailStr | None = None + password: str = Field(min_length=12, max_length=256) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=256) + + +class CreateUserRequest(BaseModel): + username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") + display_name: str = Field(min_length=1, max_length=120) + email: EmailStr | None = None + role: str + password: str = Field(min_length=12, max_length=256) + + +class UpdateUserRequest(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=120) + email: EmailStr | None = None + role: str | None = None + is_active: bool | None = None + + +class ResetPasswordRequest(BaseModel): + password: str = Field(min_length=12, max_length=256) + + +def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict: + return {"data": data, "meta": meta or {}, "error": error} + + +def get_db_connection(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +def create_access_token(user_id: str, username: str, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "username": username, + "role": role, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()), + "type": "access", + } + return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") + + +def create_session_token(user_id: str, username: str, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "username": username, + "role": role, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=settings.jwt_refresh_token_days)).timestamp()), + "type": "session", + } + return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") + + +def decode_auth_token(token: str) -> dict: + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) + if payload.get("type") not in {"access", "session"}: + raise HTTPException(status_code=401, detail="Invalid token type") + return payload + except jwt.ExpiredSignatureError as exc: + raise HTTPException(status_code=401, detail="Session expired") from exc + except jwt.InvalidTokenError as exc: + raise HTTPException(status_code=401, detail="Invalid session") from exc + + +def token_from_request(request: Request, authorization: str | None = None) -> str: + header = authorization or request.headers.get("authorization") or "" + if header.lower().startswith("bearer "): + token = header.split(" ", 1)[1].strip() + if token and token != COOKIE_TOKEN_HINT: + return token + + cookie_token = request.cookies.get(SESSION_COOKIE, "").strip() + if cookie_token: + return cookie_token + + raise HTTPException(status_code=401, detail="Login required") + + +def get_auth_token( + request: Request, + authorization: str | None = Header(default=None), +) -> str: + return token_from_request(request, authorization) + + +def load_user_for_token(token: str) -> dict: + payload = decode_auth_token(token) + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT + users.id::text AS id, + users.username, + users.display_name, + users.email, + users.is_active, + roles.name AS role, + COALESCE( + jsonb_agg(DISTINCT permissions.key) + FILTER (WHERE permissions.key IS NOT NULL), + '[]'::jsonb + ) AS permissions + FROM users + JOIN roles ON roles.id = users.role_id + LEFT JOIN role_permissions ON role_permissions.role_id = roles.id + LEFT JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE users.id = %s + GROUP BY users.id, roles.name + """, + (payload["sub"],), + ) + user = cur.fetchone() + + if not user or not user["is_active"]: + raise HTTPException(status_code=401, detail="User not found or inactive") + + result = dict(user) + result["permissions"] = list(result.get("permissions") or []) + return result + finally: + if conn is not None: + conn.close() + + +def get_current_user(token: str = Depends(get_auth_token)) -> dict: + return load_user_for_token(token) + + +def user_has_permission(user: dict, permission: str) -> bool: + return user.get("role") == "Admin" or permission in set(user.get("permissions") or []) + + +def require_permission(permission: str) -> Callable: + def dependency(current_user: dict = Depends(get_current_user)) -> dict: + if not user_has_permission(current_user, permission): + raise HTTPException(status_code=403, detail="You do not have permission for this action") + return current_user + + return dependency + + +def request_uses_https(request: Request) -> bool: + forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip() + return forwarded == "https" or request.url.scheme == "https" + + +def set_session_cookie(response: Response, request: Request, token: str) -> None: + response.set_cookie( + key=SESSION_COOKIE, + value=token, + max_age=settings.jwt_refresh_token_days * 24 * 60 * 60, + httponly=True, + secure=request_uses_https(request), + samesite="lax", + path="/", + ) + + +def clear_session_cookie(response: Response) -> None: + response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="lax") + + +def audit_event( + cur, + actor_user_id: str | None, + action: str, + entity_id: str | None, + details: dict, +) -> None: + cur.execute( + """ + INSERT INTO audit_logs ( + actor_user_id, action, entity_type, entity_id, new_value_json + ) + VALUES (%s, %s, 'user', %s, %s) + """, + ( + actor_user_id, + action, + entity_id, + psycopg.types.json.Jsonb(details), + ), + ) + + +def serialize_user(row: dict) -> dict: + result = dict(row) + for key in ("created_at", "updated_at", "last_login_at"): + if result.get(key) is not None: + result[key] = result[key].isoformat() + return result + + +def validate_role(role: str) -> str: + cleaned = str(role or "").strip() + if cleaned not in DEFAULT_ROLES: + raise HTTPException(status_code=400, detail="Invalid account role") + return cleaned + + +@router.get("/api/auth/setup-status") +def setup_status(): + conn = None + try: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM users") + user_count = cur.fetchone()[0] + return api_response(data={"setup_required": user_count == 0, "user_count": user_count}) + finally: + if conn is not None: + conn.close() + + +@router.post("/api/auth/setup-admin") +def setup_admin(payload: SetupAdminRequest, request: Request, response: Response): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute("SELECT COUNT(*) AS user_count FROM users") + if cur.fetchone()["user_count"] > 0: + raise HTTPException(status_code=409, detail="Initial setup is already complete") + + cur.execute("SELECT id FROM roles WHERE name = 'Admin'") + role = cur.fetchone() + if not role: + raise HTTPException(status_code=500, detail="Admin role is missing") + + cur.execute( + """ + INSERT INTO users (username, display_name, email, password_hash, role_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id::text, username, display_name, email + """, + ( + payload.username.strip(), + payload.display_name.strip(), + str(payload.email) if payload.email else None, + password_hasher.hash(payload.password), + role["id"], + ), + ) + user = dict(cur.fetchone()) + audit_event( + cur, + user["id"], + "user.created", + user["id"], + {"username": user["username"], "role": "Admin", "source": "initial-setup"}, + ) + conn.commit() + + session_token = create_session_token(user["id"], user["username"], "Admin") + set_session_cookie(response, request, session_token) + return api_response(data={"user": {**user, "role": "Admin"}}) + except HTTPException: + if conn is not None: + conn.rollback() + raise + except psycopg.errors.UniqueViolation as exc: + if conn is not None: + conn.rollback() + raise HTTPException(status_code=409, detail="Username already exists") from exc + finally: + if conn is not None: + conn.close() + + +@router.post("/api/auth/login") +def login(payload: LoginRequest, request: Request, response: Response): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT users.id::text AS id, users.username, users.display_name, + users.email, users.password_hash, users.is_active, + roles.name AS role + FROM users + JOIN roles ON roles.id = users.role_id + WHERE lower(users.username) = lower(%s) + """, + (payload.username.strip(),), + ) + user = cur.fetchone() + if not user or not user["is_active"]: + raise HTTPException(status_code=401, detail="Invalid username or password") + + try: + password_hasher.verify(user["password_hash"], payload.password) + except VerifyMismatchError as exc: + raise HTTPException(status_code=401, detail="Invalid username or password") from exc + + access_token = create_access_token(user["id"], user["username"], user["role"]) + session_token = create_session_token(user["id"], user["username"], user["role"]) + cur.execute( + "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s", + (user["id"],), + ) + audit_event( + cur, + user["id"], + "user.login", + user["id"], + {"username": user["username"]}, + ) + conn.commit() + set_session_cookie(response, request, session_token) + return api_response( + data={ + "access_token": access_token, + "token_type": "bearer", + "expires_in": settings.jwt_access_token_minutes * 60, + "user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "email": user["email"], + "role": user["role"], + }, + } + ) + except HTTPException: + if conn is not None: + conn.rollback() + raise + finally: + if conn is not None: + conn.close() + + +@router.post("/api/auth/logout") +def logout(response: Response): + clear_session_cookie(response) + return api_response(data={"logged_out": True}) + + +@router.get("/api/auth/me") +def me(current_user: dict = Depends(get_current_user)): + return api_response(data=current_user) + + +@router.get("/api/admin/roles") +def list_roles(current_user: dict = Depends(require_permission("users.manage"))): + del current_user + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT roles.name, roles.description, + COALESCE(jsonb_agg(permissions.key ORDER BY permissions.key) + FILTER (WHERE permissions.key IS NOT NULL), '[]'::jsonb) AS permissions + FROM roles + LEFT JOIN role_permissions ON role_permissions.role_id = roles.id + LEFT JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE roles.name = ANY(%s) + GROUP BY roles.id + ORDER BY array_position(%s::text[], roles.name) + """, + (list(DEFAULT_ROLES), list(DEFAULT_ROLES)), + ) + rows = [dict(row) for row in cur.fetchall()] + return api_response(data=rows) + finally: + if conn is not None: + conn.close() + + +@router.get("/api/admin/users") +def list_users(current_user: dict = Depends(require_permission("users.manage"))): + del current_user + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT users.id::text AS id, users.username, users.display_name, + users.email, users.is_active, users.last_login_at, + users.created_at, users.updated_at, roles.name AS role + FROM users + JOIN roles ON roles.id = users.role_id + ORDER BY users.is_active DESC, lower(users.display_name), lower(users.username) + """ + ) + rows = [serialize_user(dict(row)) for row in cur.fetchall()] + return api_response(data=rows) + finally: + if conn is not None: + conn.close() + + +@router.post("/api/admin/users", status_code=201) +def create_user( + payload: CreateUserRequest, + current_user: dict = Depends(require_permission("users.manage")), +): + role_name = validate_role(payload.role) + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,)) + role = cur.fetchone() + if not role: + raise HTTPException(status_code=400, detail="Role is not available") + + cur.execute( + "SELECT 1 FROM users WHERE lower(username) = lower(%s)", + (payload.username.strip(),), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="Username already exists") + + cur.execute( + """ + INSERT INTO users (username, display_name, email, password_hash, role_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id::text, username, display_name, email, is_active, + created_at, updated_at + """, + ( + payload.username.strip(), + payload.display_name.strip(), + str(payload.email) if payload.email else None, + password_hasher.hash(payload.password), + role["id"], + ), + ) + user = serialize_user(dict(cur.fetchone())) + user["role"] = role_name + audit_event( + cur, + current_user["id"], + "user.created", + user["id"], + {"username": user["username"], "role": role_name}, + ) + conn.commit() + return api_response(data=user) + except HTTPException: + if conn is not None: + conn.rollback() + raise + except psycopg.errors.UniqueViolation as exc: + if conn is not None: + conn.rollback() + raise HTTPException(status_code=409, detail="Username already exists") from exc + finally: + if conn is not None: + conn.close() + + +def active_admin_count(cur) -> int: + cur.execute( + """ + SELECT COUNT(*) + FROM users + JOIN roles ON roles.id = users.role_id + WHERE users.is_active = true AND roles.name = 'Admin' + """ + ) + return cur.fetchone()[0] + + +@router.patch("/api/admin/users/{user_id}") +def update_user( + user_id: str, + payload: UpdateUserRequest, + current_user: dict = Depends(require_permission("users.manage")), +): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT users.id::text AS id, users.username, users.display_name, + users.email, users.is_active, roles.name AS role + FROM users JOIN roles ON roles.id = users.role_id + WHERE users.id = %s + """, + (user_id,), + ) + existing = cur.fetchone() + if not existing: + raise HTTPException(status_code=404, detail="User not found") + + new_role = validate_role(payload.role) if payload.role is not None else existing["role"] + new_active = payload.is_active if payload.is_active is not None else existing["is_active"] + removing_admin = existing["role"] == "Admin" and (new_role != "Admin" or not new_active) + if removing_admin and active_admin_count(cur) <= 1: + raise HTTPException(status_code=409, detail="The last active administrator cannot be disabled or demoted") + if user_id == current_user["id"] and not new_active: + raise HTTPException(status_code=409, detail="You cannot disable your own account") + + cur.execute("SELECT id FROM roles WHERE name = %s", (new_role,)) + role = cur.fetchone() + cur.execute( + """ + UPDATE users + SET display_name = %s, email = %s, role_id = %s, + is_active = %s, updated_at = now() + WHERE id = %s + RETURNING id::text, username, display_name, email, is_active, + last_login_at, created_at, updated_at + """, + ( + payload.display_name.strip() if payload.display_name is not None else existing["display_name"], + str(payload.email) if payload.email else None, + role["id"], + new_active, + user_id, + ), + ) + user = serialize_user(dict(cur.fetchone())) + user["role"] = new_role + audit_event( + cur, + current_user["id"], + "user.updated", + user_id, + { + "username": user["username"], + "role": new_role, + "is_active": new_active, + }, + ) + conn.commit() + return api_response(data=user) + except HTTPException: + if conn is not None: + conn.rollback() + raise + finally: + if conn is not None: + conn.close() + + +@router.post("/api/admin/users/{user_id}/reset-password") +def reset_password( + user_id: str, + payload: ResetPasswordRequest, + current_user: dict = Depends(require_permission("users.manage")), +): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + UPDATE users SET password_hash = %s, updated_at = now() + WHERE id = %s + RETURNING id::text, username + """, + (password_hasher.hash(payload.password), user_id), + ) + user = cur.fetchone() + if not user: + raise HTTPException(status_code=404, detail="User not found") + audit_event( + cur, + current_user["id"], + "user.password_reset", + user_id, + {"username": user["username"]}, + ) + conn.commit() + return api_response(data={"user_id": user_id, "password_reset": True}) + except HTTPException: + if conn is not None: + conn.rollback() + raise + finally: + if conn is not None: + conn.close() + + +def protected_page(path: str) -> bool: + exact = {"/dashboard", "/repairs", "/locations", "/system-health", "/accounts"} + prefixes = ("/miners/", "/agent/", "/tools/") + return path in exact or path.startswith(prefixes) + + +def required_page_permission(path: str) -> str: + if path == "/accounts": + return "users.manage" + if path == "/system-health": + return "system_health.view" + if path == "/repairs": + return "repairs.view" + if path == "/locations": + return "locations.view" + if path.startswith("/agent/"): + return "agents.view" + if path.startswith("/miners/"): + return "miners.view" + return "dashboard.view" + + +def machine_agent_request(path: str) -> bool: + exact = { + "/api/agent/checkin", + "/api/agent/config", + "/api/agent/miner-snapshots", + "/api/agent/pool-snapshots", + "/api/agent/commands/pending", + "/api/agent/commands/result", + "/api/agent/discoveries", + "/api/agent/miner-enrichment", + "/api/agent/miner-enrichment-v2", + "/api/agent/miner-enrichment-v3", + "/api/agent/api3-enrichment", + } + return path in exact + + +def required_api_permission(method: str, path: str) -> str | None: + if path.startswith("/api/admin/"): + return "users.manage" + if method == "GET": + if path.startswith("/api/system/"): + return "system_health.view" + if path.startswith("/api/repairs") or path.startswith("/api/maintenance"): + return "repairs.view" + if path.startswith("/api/location") or path in {"/api/rooms", "/api/sites"}: + return "locations.view" + return None + + if path.startswith("/api/repairs") or "/maintenance-events" in path: + return "repairs.manage" + if path.startswith("/api/location") or path.startswith("/api/rooms"): + return "locations.manage" + if "/commands/" in path or path.endswith("/agent-control"): + return "miners.control" + if path.endswith("/api3/change-password"): + return "credentials.manage" + if path.startswith("/api/agent/") or path.startswith("/api/dashboard/scheduled-bulk-actions"): + return "agents.manage" + if path.startswith("/api/scan-ranges"): + return "scan_ranges.manage" if method in {"POST", "PATCH", "DELETE"} else "scans.run" + if path.startswith("/api/imports") or path.startswith("/api/tools"): + return "miners.update" + if path.startswith("/api/miners"): + return "miners.update" + return None + + +def request_user_or_none(request: Request) -> dict | None: + try: + return load_user_for_token(token_from_request(request)) + except HTTPException: + return None + + +def install_identity_middleware(app) -> None: + @app.middleware("http") + async def identity_gate(request: Request, call_next): + path = request.url.path + + if path == "/": + destination = "/dashboard" if request_user_or_none(request) else "/login" + return RedirectResponse(destination, status_code=303) + + if path == "/login": + user = request_user_or_none(request) + if user: + next_path = request.query_params.get("next") or "/dashboard" + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "/dashboard" + return RedirectResponse(next_path, status_code=303) + return await call_next(request) + + if protected_page(path): + user = request_user_or_none(request) + if not user: + destination = "/login?next=" + quote(path, safe="/") + response = RedirectResponse(destination, status_code=303) + clear_session_cookie(response) + return response + permission = required_page_permission(path) + if not user_has_permission(user, permission): + return HTMLResponse( + "

Access denied

Your account does not have permission to view this page.

Return to dashboard

", + status_code=403, + ) + request.state.current_user = user + + if path.startswith("/api/") and not path.startswith("/api/auth/") and not machine_agent_request(path): + permission = required_api_permission(request.method.upper(), path) + if permission: + user = request_user_or_none(request) + if not user: + return JSONResponse({"detail": "Login required"}, status_code=401) + if not user_has_permission(user, permission): + return JSONResponse( + {"detail": "You do not have permission for this action"}, + status_code=403, + ) + request.state.current_user = user + + return await call_next(request) + + +LOGIN_HTML = r""" + + + + +Sign in | MinerOps + + + +
MinerOps
+
+
+

Sign in

Use your MinerOps account to continue.

+
+
+
+
+
+ +

Your session is stored in a protected browser cookie and expires automatically.

+
+ +
+
+ + +""" + + +ACCOUNTS_HTML = r""" + + + + +Accounts | MinerOps + + + +
MinerOps
+
+

Accounts

Manage access to MinerOps and assign operational roles.

+
+
AdminFull system and account access
+
ManagerFleet, workflow, and reporting control
+
TechnicianRepair, location, and miner operations
+
Read OnlyOperational visibility without changes
+
+
+
AccountRoleStatusLast loginActions
Loading accounts...
+
+

Add account

+ +
+
+
+
+
+ +
+
+

Reset password

+ + +""" + + +@router.get("/login", response_class=HTMLResponse) +def login_page(): + return LOGIN_HTML + + +@router.get("/accounts", response_class=HTMLResponse) +def accounts_page(): + return ACCOUNTS_HTML diff --git a/backend/app/main.py b/backend/app/main.py index 5af3f21..8d2276e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,10 +10,18 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, status from pydantic import BaseModel, EmailStr, Field from app.config import settings +from app.identity import ( + COOKIE_TOKEN_HINT, + SESSION_COOKIE, + get_current_user, + install_identity_middleware, + router as identity_router, +) app = FastAPI(title=settings.app_name) -password_hasher = PasswordHasher() +app.include_router(identity_router) +install_identity_middleware(app) def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict: @@ -74,83 +82,6 @@ def check_redis() -> str: return f"error: {exc}" -class SetupAdminRequest(BaseModel): - username: str = Field(min_length=3, max_length=64) - display_name: str = Field(min_length=1, max_length=120) - email: EmailStr | None = None - password: str = Field(min_length=10, max_length=256) - - -class LoginRequest(BaseModel): - username: str - password: str - - -def create_access_token(user_id: str, username: str, role: str) -> str: - now = datetime.now(timezone.utc) - payload = { - "sub": user_id, - "username": username, - "role": role, - "iat": int(now.timestamp()), - "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()), - "type": "access", - } - return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") - - -def decode_access_token(token: str) -> dict: - try: - payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) - if payload.get("type") != "access": - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") - return payload - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - - -def get_bearer_token(authorization: str | None = Header(default=None)) -> str: - if not authorization or not authorization.lower().startswith("bearer "): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") - return authorization.split(" ", 1)[1].strip() - - -def get_current_user(token: str = Depends(get_bearer_token)) -> dict: - payload = decode_access_token(token) - user_id = payload["sub"] - - conn = None - try: - conn = get_db_connection() - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute( - """ - SELECT - users.id::text AS id, - users.username, - users.display_name, - users.email, - users.is_active, - roles.name AS role - FROM users - JOIN roles ON roles.id = users.role_id - WHERE users.id = %s; - """, - (user_id,), - ) - user = cur.fetchone() - - if not user or not user["is_active"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") - - return dict(user) - finally: - if conn is not None: - conn.close() - - @app.get("/api/health") def health_check(): database_status = check_database() @@ -171,196 +102,6 @@ def health_check(): ) -@app.get("/api/auth/setup-status") -def setup_status(): - conn = None - try: - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM users;") - user_count = cur.fetchone()[0] - - return api_response( - data={ - "setup_required": user_count == 0, - "user_count": user_count, - } - ) - finally: - if conn is not None: - conn.close() - - -@app.post("/api/auth/setup-admin") -def setup_admin(request: SetupAdminRequest): - conn = None - try: - conn = get_db_connection() - conn.autocommit = False - - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute("SELECT COUNT(*) AS user_count FROM users;") - user_count = cur.fetchone()["user_count"] - - if user_count > 0: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Initial admin setup has already been completed", - ) - - cur.execute("SELECT id FROM roles WHERE name = 'Admin';") - admin_role = cur.fetchone() - if not admin_role: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Admin role is missing", - ) - - password_hash = password_hasher.hash(request.password) - - cur.execute( - """ - INSERT INTO users (username, display_name, email, password_hash, role_id) - VALUES (%s, %s, %s, %s, %s) - RETURNING id::text, username, display_name, email; - """, - ( - request.username.strip(), - request.display_name.strip(), - request.email, - password_hash, - admin_role["id"], - ), - ) - user = cur.fetchone() - - cur.execute( - """ - INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) - VALUES (%s, 'user.created', 'user', %s, %s); - """, - ( - user["id"], - user["id"], - psycopg.types.json.Jsonb( - { - "username": user["username"], - "display_name": user["display_name"], - "role": "Admin", - "source": "setup-admin", - } - ), - ), - ) - - conn.commit() - - return api_response( - data={ - "user_id": user["id"], - "username": user["username"], - "display_name": user["display_name"], - } - ) - except HTTPException: - if conn is not None: - conn.rollback() - raise - except Exception as exc: - if conn is not None: - conn.rollback() - raise HTTPException(status_code=500, detail=str(exc)) - finally: - if conn is not None: - conn.close() - - -@app.post("/api/auth/login") -def login(request: LoginRequest): - conn = None - try: - conn = get_db_connection() - conn.autocommit = False - - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute( - """ - SELECT - users.id::text AS id, - users.username, - users.display_name, - users.email, - users.password_hash, - users.is_active, - roles.name AS role - FROM users - JOIN roles ON roles.id = users.role_id - WHERE lower(users.username) = lower(%s); - """, - (request.username.strip(),), - ) - user = cur.fetchone() - - if not user or not user["is_active"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") - - try: - password_hasher.verify(user["password_hash"], request.password) - except VerifyMismatchError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") - - access_token = create_access_token(user["id"], user["username"], user["role"]) - - cur.execute( - "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;", - (user["id"],), - ) - - cur.execute( - """ - INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) - VALUES (%s, 'user.login', 'user', %s, %s); - """, - ( - user["id"], - user["id"], - psycopg.types.json.Jsonb({"username": user["username"]}), - ), - ) - - conn.commit() - - return api_response( - data={ - "access_token": access_token, - "token_type": "bearer", - "expires_in": settings.jwt_access_token_minutes * 60, - "user": { - "id": user["id"], - "username": user["username"], - "display_name": user["display_name"], - "email": user["email"], - "role": user["role"], - }, - } - ) - except HTTPException: - if conn is not None: - conn.rollback() - raise - except Exception as exc: - if conn is not None: - conn.rollback() - raise HTTPException(status_code=500, detail=str(exc)) - finally: - if conn is not None: - conn.close() - - -@app.get("/api/auth/me") -def me(current_user: dict = Depends(get_current_user)): - return api_response(data=current_user) - class CreateRoomRequest(BaseModel): site_id: str | None = None name: str = Field(min_length=1, max_length=120) @@ -4303,7 +4044,7 @@ def minerops_ensure_pool_info_table(): cur.execute( """ CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( - id TEXT PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), miner_id UUID, ip_address INET, mac_address TEXT, @@ -4326,6 +4067,25 @@ def minerops_ensure_pool_info_table(): created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); + DO $$ + DECLARE + id_type TEXT; + BEGIN + SELECT data_type INTO id_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'miner_pool_snapshots' + AND column_name = 'id'; + + IF id_type = 'text' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text; + ELSIF id_type = 'uuid' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT gen_random_uuid(); + END IF; + END $$; + CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created ON miner_pool_snapshots (miner_id, created_at DESC); @@ -4443,7 +4203,6 @@ async def agent_pool_snapshots(request: Request): cur.execute( """ INSERT INTO miner_pool_snapshots ( - id, miner_id, ip_address, mac_address, @@ -4465,7 +4224,6 @@ async def agent_pool_snapshots(request: Request): agent_id ) VALUES ( - %s, %s, %s::inet, %s, @@ -4488,7 +4246,6 @@ async def agent_pool_snapshots(request: Request): ); """, ( - str(_uuid.uuid4()), miner_id, ip, mac, @@ -6241,22 +5998,25 @@ MINEROPS_SHELL_AUTH_JS = r""" } async function getCurrentUser() { - const token = getToken(); - - if (!token) { - return null; - } - try { const response = await fetch("/api/auth/me", { + credentials: "same-origin", headers: authHeaders() }); if (!response.ok) { + window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname)); return null; } - return await response.json(); + const payload = await response.json(); + const user = payload && payload.data ? payload.data : payload; + + if (user && !getToken()) { + setToken("cookie-session"); + } + + return user; } catch (error) { return null; } @@ -6352,9 +6112,16 @@ MINEROPS_SHELL_AUTH_JS = r""" } } - function logoutFromHeader() { - clearToken(); - window.location.reload(); + async function logoutFromHeader() { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "same-origin" + }); + } finally { + clearToken(); + window.location.replace("/login"); + } } async function renderHeaderAuth() { @@ -6388,6 +6155,13 @@ MINEROPS_SHELL_AUTH_JS = r""" `; + if (user.role === "Admin") { + const accountsLink = document.createElement("a"); + accountsLink.href = "/accounts"; + accountsLink.textContent = "Accounts"; + authArea.prepend(accountsLink); + } + document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader; return; } @@ -23630,9 +23404,11 @@ async def insert_miner_command_event( def minerops_command_require_bearer(request: Request): auth_header = request.headers.get("authorization") or "" + cookie_token = request.cookies.get(SESSION_COOKIE) or "" + header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else "" - if not auth_header.lower().startswith("bearer "): - raise HTTPException(status_code=401, detail="Login token required.") + if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT): + raise HTTPException(status_code=401, detail="Login required.") async def minerops_command_user_from_request(request: Request): diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh new file mode 100755 index 0000000..40436e2 --- /dev/null +++ b/scripts/test-dev-baseline.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_name="${MINEROPS_SMOKE_PROJECT:-minerops-smoke-$$}" +dev_port="${MINEROPS_SMOKE_PORT:-18001}" +keep_environment="${MINEROPS_SMOKE_KEEP:-false}" +base_url="http://127.0.0.1:${dev_port}" +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) +tmp_prefix="/tmp/${project_name}" +admin_cookie="${tmp_prefix}-admin.cookies" +manager_cookie="${tmp_prefix}-manager.cookies" +technician_cookie="${tmp_prefix}-technician.cookies" +readonly_cookie="${tmp_prefix}-readonly.cookies" + +cleanup() { + status=$? + + if (( status != 0 )); then + echo + echo "Smoke test failed. Recent service logs:" + "${compose[@]}" logs --no-color --tail 160 || true + fi + + rm -f "${tmp_prefix}"-*.json "${tmp_prefix}"-*.cookies "${tmp_prefix}"-*.headers + if [[ "${keep_environment}" != "true" ]]; then + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + else + echo "Keeping Compose project ${project_name} for inspection." + fi + + exit "${status}" +} + +trap cleanup EXIT + +status_for() { + local cookie_jar="$1" + local method="$2" + local path="$3" + local body="${4:-}" + local args=(--silent --output /dev/null --write-out '%{http_code}' -X "${method}") + if [[ -n "${cookie_jar}" ]]; then + args+=(-b "${cookie_jar}") + fi + if [[ -n "${body}" ]]; then + args+=(-H 'Content-Type: application/json' --data "${body}") + fi + curl "${args[@]}" "${base_url}${path}" +} + +if [[ ! -f .env.dev ]]; then + echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2 + exit 1 +fi + +echo "Validating Compose configuration..." +MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet + +echo "Building and starting disposable development stack..." +MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build + +echo "Waiting for the gateway health endpoint..." +health_file="${tmp_prefix}-health.json" +for attempt in $(seq 1 90); do + if curl --fail --silent "${base_url}/api/health" >"${health_file}"; then + break + fi + + if (( attempt == 90 )); then + echo "Gateway did not become healthy within 90 seconds." >&2 + exit 1 + fi + + sleep 1 +done + +health_json="$(cat "${health_file}")" +[[ "${health_json}" == *'"status":"ok"'* ]] +[[ "${health_json}" == *'"environment":"development"'* ]] +[[ "${health_json}" == *'"database":"ok"'* ]] +[[ "${health_json}" == *'"redis":"ok"'* ]] + +echo "Checking migrations and required schema..." +current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" +[[ "${current_revision}" == *"0007_identity_and_roles"* ]] + +missing_tables="$( + "${compose[@]}" exec -T postgres sh -c \ + 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \ + < sql/verify-development-schema.sql +)" +if [[ -n "${missing_tables}" ]]; then + echo "Missing required tables:" >&2 + echo "${missing_tables}" >&2 + exit 1 +fi + +echo "Checking the unauthenticated login gate..." +setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")" +[[ "${setup_status}" == *'"setup_required":true'* ]] +[[ "$(status_for "" GET /login)" == "200" ]] +for path in dashboard repairs locations system-health accounts; do + [[ "$(status_for "" GET "/${path}")" == "303" ]] +done +[[ "$(status_for "" GET /api/scanner/status)" == "401" ]] + +echo "Creating the first administrator and role test accounts..." +setup_response="$(curl --fail --silent -c "${admin_cookie}" \ + -H 'Content-Type: application/json' \ + --data '{"username":"admin","display_name":"Test Administrator","email":"admin@example.com","password":"MinerOpsTest!123"}' \ + "${base_url}/api/auth/setup-admin")" +[[ "${setup_response}" == *'"role":"Admin"'* ]] +[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]] +[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]] + +for account in \ + 'manager|Operations Manager|Manager|manager@example.com' \ + 'technician|Repair Technician|Technician|technician@example.com' \ + 'readonly|Read Only User|Read Only|readonly@example.com'; do + IFS='|' read -r username display_name role email <<<"${account}" + response="$(curl --fail --silent -b "${admin_cookie}" \ + -H 'Content-Type: application/json' \ + --data "{\"username\":\"${username}\",\"display_name\":\"${display_name}\",\"email\":\"${email}\",\"role\":\"${role}\",\"password\":\"MinerOpsTest!123\"}" \ + "${base_url}/api/admin/users")" + [[ "${response}" == *"\"role\":\"${role}\""* ]] +done + +roles_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/roles")" +for role in Admin Manager Technician 'Read Only'; do + [[ "${roles_response}" == *"\"name\":\"${role}\""* ]] +done +users_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/users")" +for username in admin manager technician readonly; do + [[ "${users_response}" == *"\"username\":\"${username}\""* ]] +done + +echo "Logging in each role and checking authorization boundaries..." +for login in \ + "manager|${manager_cookie}|Manager" \ + "technician|${technician_cookie}|Technician" \ + "readonly|${readonly_cookie}|Read Only"; do + IFS='|' read -r username cookie_jar role <<<"${login}" + login_response="$(curl --fail --silent -c "${cookie_jar}" \ + -H 'Content-Type: application/json' \ + --data "{\"username\":\"${username}\",\"password\":\"MinerOpsTest!123\"}" \ + "${base_url}/api/auth/login")" + [[ "${login_response}" == *"\"role\":\"${role}\""* ]] + [[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]] +done + +[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]] +[[ "$(status_for "${manager_cookie}" GET /accounts)" == "403" ]] +[[ "$(status_for "${manager_cookie}" GET /api/admin/users)" == "403" ]] +[[ "$(status_for "${technician_cookie}" GET /repairs)" == "200" ]] +[[ "$(status_for "${technician_cookie}" GET /system-health)" == "403" ]] +[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]] +[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]] + +room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')" +[[ "${room_status}" == "200" || "${room_status}" == "201" ]] + +me_response="$(curl --fail --silent -b "${readonly_cookie}" "${base_url}/api/auth/me")" +[[ "${me_response}" == *'"role":"Read Only"'* ]] +[[ "$(status_for "${readonly_cookie}" POST /api/auth/logout)" == "200" ]] +[[ "$(status_for "${readonly_cookie}" GET /dashboard)" == "303" ]] + +echo "Checking development safety interlocks..." +"${compose[@]}" exec -T backend sh -ec ' + test "$SCHEDULED_SCAN_ENABLED" = "false" + test "$MINEROPS_HUB_MODE" = "true" + test "$MINEROPS_DISABLE_LOCAL_SCANNER" = "true" + test "$MINEROPS_ENABLE_REAL_REBOOT" = "false" + test "$MINEROPS_ENABLE_REAL_POWER_CONTROL" = "false" +' + +echo "Running retention rollups against the disposable baseline..." +"${compose[@]}" exec -T postgres sh -c \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < sql/minerops-retention-hourly.sql >/dev/null +"${compose[@]}" exec -T postgres sh -c \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < sql/minerops-retention-daily.sql >/dev/null + +echo "Development identity baseline passed." diff --git a/sql/minerops-retention-daily.sql b/sql/minerops-retention-daily.sql new file mode 100644 index 0000000..2a6f3b1 --- /dev/null +++ b/sql/minerops-retention-daily.sql @@ -0,0 +1,34 @@ +INSERT INTO miner_status_daily_rollups ( + miner_id, + bucket_date, + sample_count, + avg_mhs_av, + max_mhs_av, + avg_power_w, + max_chip_temp_c, + avg_chip_temp_c, + avg_pool_rejected_percent, + updated_at +) +SELECT + miner_id, + observed_at::date AS bucket_date, + COUNT(*) AS sample_count, + AVG(mhs_av)::double precision, + MAX(mhs_av)::double precision, + AVG(power_w)::double precision, + MAX(chip_temp_max_c)::double precision, + AVG(chip_temp_avg_c)::double precision, + AVG(pool_rejected_percent)::double precision, + now() +FROM miner_status_snapshots +GROUP BY miner_id, observed_at::date +ON CONFLICT (miner_id, bucket_date) DO UPDATE +SET sample_count = EXCLUDED.sample_count, + avg_mhs_av = EXCLUDED.avg_mhs_av, + max_mhs_av = EXCLUDED.max_mhs_av, + avg_power_w = EXCLUDED.avg_power_w, + max_chip_temp_c = EXCLUDED.max_chip_temp_c, + avg_chip_temp_c = EXCLUDED.avg_chip_temp_c, + avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent, + updated_at = now(); diff --git a/sql/minerops-retention-hourly.sql b/sql/minerops-retention-hourly.sql new file mode 100644 index 0000000..6c4c3b2 --- /dev/null +++ b/sql/minerops-retention-hourly.sql @@ -0,0 +1,34 @@ +INSERT INTO miner_status_hourly_rollups ( + miner_id, + bucket_at, + sample_count, + avg_mhs_av, + max_mhs_av, + avg_power_w, + max_chip_temp_c, + avg_chip_temp_c, + avg_pool_rejected_percent, + updated_at +) +SELECT + miner_id, + date_trunc('hour', observed_at) AS bucket_at, + COUNT(*) AS sample_count, + AVG(mhs_av)::double precision, + MAX(mhs_av)::double precision, + AVG(power_w)::double precision, + MAX(chip_temp_max_c)::double precision, + AVG(chip_temp_avg_c)::double precision, + AVG(pool_rejected_percent)::double precision, + now() +FROM miner_status_snapshots +GROUP BY miner_id, date_trunc('hour', observed_at) +ON CONFLICT (miner_id, bucket_at) DO UPDATE +SET sample_count = EXCLUDED.sample_count, + avg_mhs_av = EXCLUDED.avg_mhs_av, + max_mhs_av = EXCLUDED.max_mhs_av, + avg_power_w = EXCLUDED.avg_power_w, + max_chip_temp_c = EXCLUDED.max_chip_temp_c, + avg_chip_temp_c = EXCLUDED.avg_chip_temp_c, + avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent, + updated_at = now(); diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql new file mode 100644 index 0000000..19c95af --- /dev/null +++ b/sql/verify-development-schema.sql @@ -0,0 +1,30 @@ +WITH expected(table_name) AS ( + VALUES + ('roles'), + ('permissions'), + ('users'), + ('miners'), + ('miner_status_snapshots'), + ('miner_status_hourly_rollups'), + ('miner_status_daily_rollups'), + ('minerops_retention_runs'), + ('miner_maintenance_events'), + ('location_positions'), + ('miner_location_states'), + ('location_import_issues'), + ('miner_command_events'), + ('miner_api_credentials'), + ('miner_pool_snapshots'), + ('miner_api3_enrichment_snapshots'), + ('miner_api3_hashboard_snapshots'), + ('minerops_site_agents'), + ('minerops_agent_snapshots'), + ('minerops_agent_command_results'), + ('minerops_agent_command_queue'), + ('minerops_agent_discoveries'), + ('minerops_scheduled_bulk_actions') +) +SELECT table_name +FROM expected +WHERE to_regclass('public.' || table_name) IS NULL +ORDER BY table_name;