Sign in
Use your MinerOps account to continue.
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, request: Request) -> None: response.delete_cookie( SESSION_COOKIE, path="/", secure=request_uses_https(request), 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(request: Request, response: Response): clear_session_cookie(response, request) 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 repair_timeline_request(path: str) -> bool: parts = path.split("/") if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]: return parts[3] not in {"queue", "parts-summary"} return ( len(parts) == 5 and parts[:3] == ["", "api", "repairs"] and parts[4] == "events" ) def repair_completion_request(path: str) -> bool: parts = path.split("/") return ( len(parts) == 5 and parts[:3] == ["", "api", "repairs"] and parts[4] == "complete" ) def repair_parts_request(path: str) -> bool: parts = path.split("/") return ( len(parts) == 5 and parts[:3] == ["", "api", "repairs"] and parts[4] == "parts" ) or ( len(parts) == 7 and parts[:3] == ["", "api", "repairs"] and parts[4] == "parts" and parts[6] == "void" ) def component_request(path: str) -> bool: parts = path.split("/") return path == "/api/components" or ( len(parts) == 4 and parts[:3] == ["", "api", "components"] ) def required_api_permission(method: str, path: str) -> str | None: if component_request(path): return "components.create" if method == "POST" else "components.read" if repair_parts_request(path): return "repairs.read" if method == "GET" else "repairs.parts.manage" if path.startswith("/api/admin/"): return "users.manage" if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"): return "miners.replace_control_board" if ( method == "POST" and len(path.split("/")) == 5 and path.split("/")[:3] == ["", "api", "miners"] and path.split("/")[4] == "replace-control-board" ): return "miners.replace_control_board" if method == "POST" and path == "/api/repairs": return "repairs.create" if method == "POST" and repair_completion_request(path): return "repairs.complete" if method == "GET" and repair_timeline_request(path): return "repairs.read" 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, request) return response permission = required_page_permission(path) if not user_has_permission(user, permission): return HTMLResponse( "
Your account does not have permission to view this page.
", 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"""Use your MinerOps account to continue.
Manage access to MinerOps and assign operational roles.
| Account | Role | Status | Last login | Actions |
|---|---|---|---|---|
| Loading accounts... | ||||