Initial release of the oVirt/RHV Engine API simulator.
Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""oVirt Engine SSO OAuth2 + Basic auth + session cookies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.errors import OVirtError
|
||||
from app.ovirt.settings import (
|
||||
OPT_BASIC_SESSION_TTL_SECONDS,
|
||||
OPT_DEFAULT_API_SCOPE,
|
||||
OPT_DEFAULT_AUTH_DOMAIN,
|
||||
OPT_DEFAULT_TOKEN_TYPE,
|
||||
OPT_DEFAULT_USER_ROLE,
|
||||
OPT_OAUTH_TOKEN_TTL_SECONDS,
|
||||
option_int,
|
||||
option_value,
|
||||
)
|
||||
from app.security.auth import verify_secret
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
token_id: str
|
||||
user_id: UUID
|
||||
user_name: str
|
||||
domain: str
|
||||
roles: tuple[str, ...]
|
||||
expires_at: datetime
|
||||
is_admin: bool
|
||||
scope: str
|
||||
|
||||
|
||||
async def authenticate_password(
|
||||
conn: Connection,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve user@domain credentials."""
|
||||
|
||||
name, _, domain = username.partition("@")
|
||||
if not domain:
|
||||
domain = await option_value(conn, OPT_DEFAULT_AUTH_DOMAIN)
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT u.id, u.name, u.password_hash, u.enabled, d.name AS domain_name
|
||||
FROM ov_users u
|
||||
JOIN ov_domains d ON d.id = u.domain_id
|
||||
WHERE u.name = $1 AND d.name = $2""",
|
||||
name,
|
||||
domain,
|
||||
)
|
||||
if row is None or not row["enabled"] or not verify_secret(password, row["password_hash"]):
|
||||
raise OVirtError("Unauthorized", "Incorrect credentials", status_code=401)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def issue_oauth_token(
|
||||
conn: Connection,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
scope: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
token_type = await option_value(conn, OPT_DEFAULT_TOKEN_TYPE)
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = await option_int(conn, OPT_OAUTH_TOKEN_TTL_SECONDS)
|
||||
scope = scope or default_scope
|
||||
if scope and default_scope not in scope.split():
|
||||
raise OVirtError("Unauthorized", "Invalid scope", status_code=400)
|
||||
user = await authenticate_password(conn, username, password)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
expires = now + timedelta(seconds=ttl_seconds)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
|
||||
VALUES($1, $2, $3, $4, $5, false)""",
|
||||
token,
|
||||
user["id"],
|
||||
scope,
|
||||
expires,
|
||||
now,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
|
||||
return {
|
||||
"access_token": row["id"],
|
||||
"token_type": token_type,
|
||||
"scope": row["scope"],
|
||||
"exp": int(row["expires_at"].timestamp()),
|
||||
}
|
||||
|
||||
|
||||
async def validate_bearer(conn: Connection, token: str) -> AuthContext:
|
||||
if not token:
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT t.id, t.user_id, t.expires_at, t.revoked, t.scope,
|
||||
u.name AS user_name, d.name AS domain_name
|
||||
FROM ov_tokens t
|
||||
JOIN ov_users u ON u.id = t.user_id
|
||||
JOIN ov_domains d ON d.id = u.domain_id
|
||||
WHERE t.id = $1""",
|
||||
token,
|
||||
)
|
||||
if row is None or row["revoked"]:
|
||||
raise OVirtError("Unauthorized", "Invalid token", status_code=401)
|
||||
expires = row["expires_at"]
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
if expires <= datetime.now(UTC):
|
||||
raise OVirtError("Unauthorized", "Token has expired", status_code=401)
|
||||
roles = await _roles_for_user(conn, row["user_id"])
|
||||
return AuthContext(
|
||||
token_id=str(row["id"]),
|
||||
user_id=row["user_id"],
|
||||
user_name=str(row["user_name"]),
|
||||
domain=str(row["domain_name"]),
|
||||
roles=roles,
|
||||
expires_at=expires,
|
||||
is_admin="SuperUser" in roles or "admin" in roles,
|
||||
scope=str(row["scope"] or ""),
|
||||
)
|
||||
|
||||
|
||||
async def validate_basic(conn: Connection, header_value: str) -> AuthContext:
|
||||
try:
|
||||
encoded = header_value.split(" ", 1)[1].strip()
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, _, password = decoded.partition(":")
|
||||
except Exception as exc:
|
||||
raise OVirtError("Unauthorized", "Malformed basic auth", status_code=401) from exc
|
||||
user = await authenticate_password(conn, username, password)
|
||||
token = secrets.token_urlsafe(24)
|
||||
now = datetime.now(UTC)
|
||||
ttl = await option_int(conn, OPT_BASIC_SESSION_TTL_SECONDS)
|
||||
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
expires = now + timedelta(seconds=ttl)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
|
||||
VALUES($1, $2, $3, $4, $5, false)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
token,
|
||||
user["id"],
|
||||
default_scope,
|
||||
expires,
|
||||
now,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
|
||||
roles = await _roles_for_user(conn, user["id"])
|
||||
return AuthContext(
|
||||
token_id=str(row["id"]),
|
||||
user_id=user["id"],
|
||||
user_name=str(user["name"]),
|
||||
domain=str(user["domain_name"]),
|
||||
roles=roles,
|
||||
expires_at=row["expires_at"] if row["expires_at"].tzinfo else row["expires_at"].replace(tzinfo=UTC),
|
||||
is_admin="SuperUser" in roles or "admin" in roles,
|
||||
scope=str(row["scope"] or default_scope),
|
||||
)
|
||||
|
||||
|
||||
async def _roles_for_user(conn: Connection, user_id: UUID) -> tuple[str, ...]:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT r.name FROM ov_permissions p
|
||||
JOIN ov_roles r ON r.id = p.role_id
|
||||
WHERE p.user_id = $1""",
|
||||
user_id,
|
||||
)
|
||||
names = [str(r["name"]) for r in rows]
|
||||
if not names:
|
||||
default_role = await option_value(conn, OPT_DEFAULT_USER_ROLE)
|
||||
exists = await conn.fetchval("SELECT 1 FROM ov_roles WHERE name=$1", default_role)
|
||||
if exists:
|
||||
names = [default_role]
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def extract_auth(headers: dict[str, str]) -> tuple[str, str] | None:
|
||||
"""Return ('bearer'|'basic'|'session', credential) or None."""
|
||||
|
||||
lower = {k.lower(): v for k, v in headers.items()}
|
||||
auth = lower.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
return "bearer", auth.split(" ", 1)[1].strip()
|
||||
if auth.lower().startswith("basic "):
|
||||
return "basic", auth
|
||||
session = lower.get("prefer") or ""
|
||||
if "jsessionid" in lower:
|
||||
return "session", lower["jsessionid"]
|
||||
cookie = lower.get("cookie", "")
|
||||
for part in cookie.split(";"):
|
||||
part = part.strip()
|
||||
if part.lower().startswith("jsessionid="):
|
||||
return "session", part.split("=", 1)[1]
|
||||
if part.lower().startswith("ovirt_token="):
|
||||
return "bearer", part.split("=", 1)[1]
|
||||
if session.lower().startswith("persistent-auth"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_request_auth(conn: Connection, headers: dict[str, str]) -> AuthContext:
|
||||
kind = extract_auth(headers)
|
||||
if kind is None:
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
mode, credential = kind
|
||||
if mode == "bearer":
|
||||
return await validate_bearer(conn, credential)
|
||||
if mode == "basic":
|
||||
return await validate_basic(conn, credential)
|
||||
if mode == "session":
|
||||
return await validate_bearer(conn, credential)
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
Reference in New Issue
Block a user