Initial commit: VMware vSphere API simulator scaffold.
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user