"""Session IDs compatible with vmware-api-session-id header.""" from __future__ import annotations import secrets from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from typing import Any from fastapi import Depends, Request from fastapi.security import HTTPBasic, HTTPBasicCredentials from app.db.pool import AsyncpgDatabase, Database from app.dependencies import get_database from app.security.auth import hash_secret, verify_secret from app.vsphere.errors import unauthenticated SESSION_TTL = timedelta(hours=2) SESSION_HEADER = "vmware-api-session-id" DEFAULT_USER = "administrator@vsphere.local" DEFAULT_PASSWORD = "VMware1!" _basic = HTTPBasic(auto_error=False) @dataclass(frozen=True, slots=True) class SessionInfo: id: str username: str roles: tuple[str, ...] = field(default_factory=tuple) def _pool(database: Database) -> Any: return database.pool # type: ignore[attr-defined] async def ensure_default_credentials(database: Database) -> None: """Idempotently insert lab SSO credentials (full set via seed preferred).""" from app.vsphere.profiles import lab_credentials pool = _pool(database) async with pool.acquire() as conn: for cred in lab_credentials(): await conn.execute( """ INSERT INTO vsphere_credentials (username, password_hash, roles) VALUES ($1, $2, $3) ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash, roles = EXCLUDED.roles """, cred.username, hash_secret(cred.password), list(cred.roles), ) async def verify_password(database: Database, username: str, password: str) -> bool: pool = _pool(database) async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT password_hash FROM vsphere_credentials WHERE username = $1", username, ) if row is None: return username == DEFAULT_USER and password == DEFAULT_PASSWORD return verify_secret(password, str(row["password_hash"])) async def create_session(database: Database, username: str) -> str: session_id = secrets.token_hex(16) expires = datetime.now(UTC) + SESSION_TTL pool = _pool(database) async with pool.acquire() as conn: await conn.execute( """ INSERT INTO vsphere_sessions (id, username, expires_at) VALUES ($1, $2, $3) """, session_id, username, expires, ) return session_id async def delete_session(database: Database, session_id: str) -> None: pool = _pool(database) async with pool.acquire() as conn: await conn.execute("DELETE FROM vsphere_sessions WHERE id = $1", session_id) async def _roles_for(database: Database, username: str) -> tuple[str, ...]: pool = _pool(database) async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT roles FROM vsphere_credentials WHERE username = $1", username, ) if row is None: return ("Administrator",) if username == DEFAULT_USER else ("ReadOnly",) roles = tuple(str(r) for r in (row["roles"] or [])) return roles or ("ReadOnly",) async def lookup_session(database: Database, session_id: str) -> SessionInfo | None: pool = _pool(database) async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT id, username, expires_at FROM vsphere_sessions WHERE id = $1 """, session_id, ) if row is None: return None if row["expires_at"] <= datetime.now(UTC): await conn.execute("DELETE FROM vsphere_sessions WHERE id = $1", session_id) return None await conn.execute( "UPDATE vsphere_sessions SET expires_at = $2 WHERE id = $1", session_id, datetime.now(UTC) + SESSION_TTL, ) username = str(row["username"]) roles = await _roles_for(database, username) return SessionInfo(id=str(row["id"]), username=username, roles=roles) async def require_session( request: Request, database: Database = Depends(get_database), ) -> SessionInfo: session_id = request.headers.get(SESSION_HEADER) or request.cookies.get(SESSION_HEADER) if not session_id: raise unauthenticated() info = await lookup_session(database, session_id) if info is None: raise unauthenticated("Invalid or expired session") return info async def optional_basic( credentials: HTTPBasicCredentials | None = Depends(_basic), ) -> HTTPBasicCredentials | None: return credentials def as_asyncpg(database: Database) -> AsyncpgDatabase: if not isinstance(database, AsyncpgDatabase): raise TypeError("vsphere routes require AsyncpgDatabase") return database