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:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""vSphere session authentication."""
+179
View File
@@ -0,0 +1,179 @@
"""Role / privilege authorization for vSphere REST (and SOAP gates)."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any
from fastapi import Depends
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.errors import unauthorized
from app.vsphere.security.session import SessionInfo, require_session
# Privilege catalog (subset of vSphere privilege ids).
PRIVILEGES: dict[str, str] = {
"System.Anonymous": "Anonymous access",
"System.Read": "Read inventory",
"System.View": "View inventory",
"Global.ManageCustomFields": "Manage custom fields",
"Authorization.ModifyPermissions": "Modify permissions",
"VirtualMachine.Inventory.Create": "Create VM",
"VirtualMachine.Inventory.Delete": "Delete VM",
"VirtualMachine.Inventory.Move": "Move VM",
"VirtualMachine.Interact.PowerOn": "Power on VM",
"VirtualMachine.Interact.PowerOff": "Power off VM",
"VirtualMachine.Interact.Suspend": "Suspend VM",
"VirtualMachine.Interact.Reset": "Reset VM",
"VirtualMachine.Interact.DeviceConnection": "Connect devices",
"VirtualMachine.Interact.ConsoleInteract": "Console",
"VirtualMachine.Config.AddNewDisk": "Add disk",
"VirtualMachine.Config.AddExistingDisk": "Add existing disk",
"VirtualMachine.Config.RemoveDisk": "Remove disk",
"VirtualMachine.Config.CPUCount": "Change CPU",
"VirtualMachine.Config.Memory": "Change memory",
"VirtualMachine.Config.AddRemoveDevice": "Add/remove device",
"VirtualMachine.Config.Rename": "Rename VM",
"VirtualMachine.Provisioning.Clone": "Clone VM",
"VirtualMachine.Provisioning.DeployTemplate": "Deploy template",
"VirtualMachine.Provisioning.MarkAsTemplate": "Mark as template",
"VirtualMachine.State.CreateSnapshot": "Create snapshot",
"VirtualMachine.State.RemoveSnapshot": "Remove snapshot",
"VirtualMachine.State.RevertToSnapshot": "Revert snapshot",
"Datastore.Browse": "Browse datastore",
"Datastore.FileManagement": "Manage datastore files",
"Host.Config.Maintenance": "Host maintenance",
"Folder.Create": "Create folder",
"Folder.Delete": "Delete folder",
"Folder.Rename": "Rename folder",
"Folder.Move": "Move folder",
"Datacenter.Create": "Create datacenter",
"Datacenter.Delete": "Delete datacenter",
"Cluster.Create": "Create cluster",
"Cluster.Delete": "Delete cluster",
"Resource.CreatePool": "Create resource pool",
"Resource.DeletePool": "Delete resource pool",
"Network.Assign": "Assign network",
"ContentLibrary.CreateLocalLibrary": "Create content library",
"ContentLibrary.AddLibraryItem": "Add library item",
"InventoryService.Tagging.CreateCategory": "Create tag category",
"InventoryService.Tagging.CreateTag": "Create tag",
"InventoryService.Tagging.AttachTag": "Attach tag",
}
_ALL = frozenset(PRIVILEGES)
_READ = frozenset({"System.Anonymous", "System.Read", "System.View", "Datastore.Browse"})
_POWER = frozenset(
{
*_READ,
"VirtualMachine.Interact.PowerOn",
"VirtualMachine.Interact.PowerOff",
"VirtualMachine.Interact.Suspend",
"VirtualMachine.Interact.Reset",
"VirtualMachine.Interact.ConsoleInteract",
"VirtualMachine.State.CreateSnapshot",
"VirtualMachine.State.RemoveSnapshot",
"VirtualMachine.State.RevertToSnapshot",
"VirtualMachine.Provisioning.Clone",
}
)
_VM_ADMIN = frozenset(
{
*_POWER,
"VirtualMachine.Inventory.Create",
"VirtualMachine.Inventory.Delete",
"VirtualMachine.Inventory.Move",
"VirtualMachine.Config.AddNewDisk",
"VirtualMachine.Config.AddExistingDisk",
"VirtualMachine.Config.RemoveDisk",
"VirtualMachine.Config.CPUCount",
"VirtualMachine.Config.Memory",
"VirtualMachine.Config.AddRemoveDevice",
"VirtualMachine.Config.Rename",
"VirtualMachine.Provisioning.DeployTemplate",
"VirtualMachine.Provisioning.MarkAsTemplate",
"VirtualMachine.Interact.DeviceConnection",
"Datastore.FileManagement",
"Network.Assign",
"InventoryService.Tagging.CreateCategory",
"InventoryService.Tagging.CreateTag",
"InventoryService.Tagging.AttachTag",
"ContentLibrary.CreateLocalLibrary",
"ContentLibrary.AddLibraryItem",
}
)
ROLE_PRIVILEGES: dict[str, frozenset[str]] = {
"Administrator": _ALL,
"ReadOnly": _READ,
"VirtualMachinePowerUser": _POWER,
"VirtualMachineAdministrator": _VM_ADMIN,
}
def privileges_for_roles(roles: list[str] | tuple[str, ...]) -> frozenset[str]:
granted: set[str] = set()
for role in roles:
granted.update(ROLE_PRIVILEGES.get(role, ()))
return frozenset(granted)
def has_privilege(roles: list[str] | tuple[str, ...], privilege: str) -> bool:
granted = privileges_for_roles(roles)
if privilege in granted:
return True
# Wildcard Administrator already has exact set; keep prefix convenience.
return any(p.endswith(".*") and privilege.startswith(p[:-1]) for p in granted)
async def load_roles(database: Database, username: str) -> list[str]:
pool = database.pool # type: ignore[attr-defined]
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.endswith("@vsphere.local") else ["ReadOnly"]
roles = list(row["roles"] or [])
return roles or ["ReadOnly"]
def require_privilege(*needed: str) -> Callable[..., Any]:
"""FastAPI dependency factory: session must hold every listed privilege."""
async def _dependency(
session: SessionInfo = Depends(require_session),
database: Database = Depends(get_database),
) -> SessionInfo:
roles = list(session.roles)
if not roles:
roles = await load_roles(database, session.username)
for privilege in needed:
if not has_privilege(roles, privilege):
raise unauthorized(f"Missing privilege: {privilege}")
return session
return _dependency
require_read = require_privilege("System.Read")
require_power = require_privilege("VirtualMachine.Interact.PowerOn")
require_vm_mutate = require_privilege("VirtualMachine.Inventory.Create")
require_admin = require_privilege("Authorization.ModifyPermissions")
def guard(*needed: str) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
"""Decorator-style helper for non-FastAPI call sites (SOAP)."""
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
@wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return await fn(*args, **kwargs)
wrapper.__vsphere_privileges__ = needed # type: ignore[attr-defined]
return wrapper
return decorator
+154
View File
@@ -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