feat: authenticate API tokens and enforce route ACLs

This commit is contained in:
Sergey Antropoff
2026-07-13 01:28:00 +03:00
parent c45044f901
commit 8471bcdbac
6 changed files with 267 additions and 19 deletions
+8
View File
@@ -55,6 +55,14 @@ print(proxmox.version.get())
print(proxmox.nodes("pve1").qemu.get()) print(proxmox.nodes("pve1").qemu.get())
``` ```
The deterministic seed also provides hashed development API tokens. For token
authentication use proxmoxer `token_name="automation"` and
`token_value="automation-secret"` with user `root@pam`. The readonly
`auditor@pve!readonly` token proves privilege separation: authenticated reads
work, while QEMU power operations return 403. API-token requests do not require
CSRF; ticket-authenticated mutations still do. These are disposable local test
credentials only.
Run the external-client smoke flow against the Compose network with Run the external-client smoke flow against the Compose network with
`PROXMOXER_HOST=tls-gateway`, `PROXMOXER_PORT=8443`, and pytest marker `PROXMOXER_HOST=tls-gateway`, `PROXMOXER_PORT=8443`, and pytest marker
`compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task `compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task
+87 -17
View File
@@ -13,7 +13,9 @@ from fastapi.responses import JSONResponse
from app.api.errors import ApiError, ContractValidationError from app.api.errors import ApiError, ContractValidationError
from app.config import Settings from app.config import Settings
from app.contracts.model import Method, Schema, Snapshot from app.contracts.model import Method, Schema, Snapshot
from app.security.auth import verify_csrf, verify_ticket from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, authorize, requirement_from_contract
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]] Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
FallbackMode = Literal["error", "schema-default", "fixture"] FallbackMode = Literal["error", "schema-default", "fixture"]
@@ -81,9 +83,9 @@ def _endpoint(
fallback: FallbackMode, fallback: FallbackMode,
) -> Callable[[Request], Awaitable[JSONResponse]]: ) -> Callable[[Request], Awaitable[JSONResponse]]:
async def dispatch(request: Request) -> JSONResponse: async def dispatch(request: Request) -> JSONResponse:
_authenticate(request, semantic_path)
handler = handlers.get(semantic_path, method.verb)
inputs = await _parse_inputs(request, method) inputs = await _parse_inputs(request, method)
await _authenticate(request, semantic_path, method, inputs)
handler = handlers.get(semantic_path, method.verb)
if handler is not None: if handler is not None:
data = await handler(request, inputs) data = await handler(request, inputs)
elif fallback == "schema-default": elif fallback == "schema-default":
@@ -108,22 +110,90 @@ def _endpoint(
return dispatch return dispatch
def _authenticate(request: Request, semantic_path: str) -> None: async def _authenticate(
request: Request, semantic_path: str, method: Method, inputs: dict[str, Any]
) -> None:
if semantic_path in {"/version", "/access/ticket"}: if semantic_path in {"/version", "/access/ticket"}:
return return
ticket = request.cookies.get("PVEAuthCookie") authorization = request.headers.get("Authorization", "")
if ticket is None: token_privileges: frozenset[str] | None = None
raise ApiError(401, "authentication required") principal: str
settings = cast(Settings, request.app.state.settings) if authorization.startswith("PVEAPIToken="):
key = settings.ticket_signing_key.get_secret_value().encode() database = cast(AsyncpgDatabase, request.app.state.database)
try: try:
verify_ticket(ticket, key) parsed_token = parse_api_token(authorization)
except ValueError as error: except ValueError as error:
raise ApiError(401, "authentication failure") from error raise ApiError(401, "authentication failure") from error
if request.method not in {"GET", "HEAD", "OPTIONS"}: row = await database.pool.fetchrow(
token = request.headers.get("CSRFPreventionToken", "") """SELECT p.name, t.secret_hash, t.privileges
if not verify_csrf(ticket, token, key): FROM api_tokens t JOIN principals p ON p.id=t.principal_id
raise ApiError(403, "invalid CSRF prevention token") WHERE p.name=$1 AND t.token_id=$2
AND (t.expires_at IS NULL OR t.expires_at > now())""",
parsed_token.principal,
parsed_token.token_id,
)
if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
raise ApiError(401, "authentication failure")
principal = str(row["name"])
token_privileges = frozenset(str(item) for item in row["privileges"])
else:
ticket = request.cookies.get("PVEAuthCookie")
if ticket is None:
raise ApiError(401, "authentication required")
settings = cast(Settings, request.app.state.settings)
key = settings.ticket_signing_key.get_secret_value().encode()
try:
claims = verify_ticket(ticket, key)
except ValueError as error:
raise ApiError(401, "authentication failure") from error
principal = claims.principal
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf_value = request.headers.get("CSRFPreventionToken", "")
if not verify_csrf(ticket, csrf_value, key):
raise ApiError(403, "invalid CSRF prevention token")
request.state.principal = principal
if principal == "root@pam" and token_privileges is None:
return
database = cast(AsyncpgDatabase, request.app.state.database)
await _authorize(database, principal, token_privileges, method, inputs)
async def _authorize(
database: AsyncpgDatabase,
principal: str,
token_privileges: frozenset[str] | None,
method: Method,
inputs: dict[str, Any],
) -> None:
values = cast(dict[str, Any], inputs["values"])
requirement = requirement_from_contract(
method.permissions, {name: str(value) for name, value in values.items()}
)
if requirement is None:
return
rows = await database.pool.fetch(
"""SELECT a.path, a.propagate, r.privileges
FROM acl_entries a JOIN roles r ON r.name=a.role_name
JOIN principals p ON p.id=a.principal_id WHERE p.name=$1""",
principal,
)
entries = tuple(
AclEntry(
principal,
str(row["path"]),
frozenset(str(item) for item in row["privileges"]),
bool(row["propagate"]),
)
for row in rows
)
if not authorize(
principal,
requirement.path,
requirement.privileges,
entries,
token_privileges=token_privileges,
):
raise ApiError(403, "permission check failed")
async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]: async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
+40
View File
@@ -332,6 +332,46 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
stable_id("principal:root@pam"), stable_id("principal:root@pam"),
hash_secret("secret", salt=b"pve-simulator-v1"), hash_secret("secret", salt=b"pve-simulator-v1"),
) )
await connection.execute(
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
VALUES($1, 'automation', $2, $3)
ON CONFLICT (principal_id, token_id) DO UPDATE
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges""",
stable_id("principal:root@pam"),
hash_secret("automation-secret", salt=b"pve-token-seed-v1"),
["VM.Audit", "VM.PowerMgmt", "Sys.Audit"],
)
auditor_id = stable_id("principal:auditor@pve")
await connection.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES($1, 'auditor@pve', $2, 'pve')
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
realm_name=EXCLUDED.realm_name""",
auditor_id,
hash_secret("auditor-secret", salt=b"pve-auditor-v1"),
)
await connection.execute(
"""INSERT INTO roles(name, privileges)
VALUES('PVEAuditor', $1)
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
["Sys.Audit", "VM.Audit"],
)
await connection.execute(
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
VALUES($1, 'PVEAuditor', '/', true)
ON CONFLICT (principal_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
auditor_id,
)
await connection.execute(
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
VALUES($1, 'readonly', $2, $3)
ON CONFLICT (principal_id, token_id) DO UPDATE
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges""",
auditor_id,
hash_secret("readonly-secret", salt=b"pve-readonly-v1"),
["Sys.Audit", "VM.Audit"],
)
async def seed_url( async def seed_url(
+3 -1
View File
@@ -24,7 +24,9 @@ worker tests; a single smoke run chooses the transition valid for current state.
- Core: version, ticket login, node list/status, and cluster resources. - Core: version, ticket login, node list/status, and cluster resources.
- QEMU: list, configuration, current status, start, and stop. - QEMU: list, configuration, current status, start, and stop.
- Tasks: node task list, status, and append-only log. - Tasks: node task list, status, and append-only log.
- Authentication: ticket cookie and ticket-bound CSRF validation for mutations. - Authentication: ticket cookie and ticket-bound CSRF validation for mutations,
plus hashed API-token authentication without CSRF and token privilege
separation at the contract-derived ACL boundary.
- Persistence: PostgreSQL resources, durable leased tasks, and deterministic - Persistence: PostgreSQL resources, durable leased tasks, and deterministic
`small` seed data. `small` seed data.
+24 -1
View File
@@ -4,7 +4,7 @@ import os
from threading import Event from threading import Event
import pytest import pytest
from proxmoxer import ProxmoxAPI # type: ignore[import-untyped] from proxmoxer import ProxmoxAPI, ResourceException # type: ignore[import-untyped]
pytestmark = [ pytestmark = [
pytest.mark.compatibility, pytest.mark.compatibility,
@@ -25,6 +25,29 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
assert any(node["node"] == "pve1" for node in proxmox.nodes.get()) assert any(node["node"] == "pve1" for node in proxmox.nodes.get())
assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get()) assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get())
token_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="root@pam",
token_name=os.getenv("PROXMOXER_TOKEN_NAME", "automation"),
token_value=os.getenv("PROXMOXER_TOKEN_SECRET", "automation-secret"),
verify_ssl=False,
)
assert any(node["node"] == "pve1" for node in token_api.nodes.get())
readonly_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="auditor@pve",
token_name=os.getenv("PROXMOXER_READONLY_TOKEN_NAME", "readonly"),
token_value=os.getenv("PROXMOXER_READONLY_TOKEN_SECRET", "readonly-secret"),
verify_ssl=False,
)
assert readonly_api.nodes.get()
with pytest.raises(ResourceException) as denied:
readonly_api.nodes("pve1").qemu("101").status.start.post()
assert denied.value.status_code == 403
if os.getenv("PROXMOXER_MUTATION_TEST") == "1": if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
status = proxmox.nodes("pve1").qemu("101").status.current.get() status = proxmox.nodes("pve1").qemu("101").status.current.get()
operation = "start" if status["status"] == "stopped" else "stop" operation = "start" if status["status"] == "stopped" else "stop"
+105
View File
@@ -0,0 +1,105 @@
"""HTTP-boundary API-token and contract permission tests."""
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import _authenticate
from app.config import Settings
from app.contracts.model import Method, Permissions, Schema
from app.db.pool import AsyncpgDatabase
from app.security.auth import hash_secret
class FakePool:
def __init__(self, secret: str, token_privileges: list[str]) -> None:
self.secret_hash = hash_secret(secret, salt=b"boundary-token-v1")
self.token_privileges = token_privileges
async def fetchrow(self, _query: str, principal: str, token_id: str) -> dict[str, Any] | None:
if principal != "operator@pve" or token_id != "api":
return None
return {
"name": principal,
"secret_hash": self.secret_hash,
"privileges": self.token_privileges,
}
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
return [
{
"path": "/vms",
"propagate": True,
"privileges": ["VM.Audit", "VM.PowerMgmt"],
"principal": principal,
}
]
class FakeDatabase:
def __init__(self, pool: FakePool) -> None:
self.pool = pool
def token_request(secret: str, token_privileges: list[str]) -> Request:
app = FastAPI()
app.state.settings = Settings()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(FakePool("valid", token_privileges)))
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/api2/json/nodes/pve1/qemu/101/status/start",
"headers": [(b"authorization", f"PVEAPIToken=operator@pve!api={secret}".encode())],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
def power_method() -> Method:
return Method(
verb="POST",
name="start",
returns=Schema(type="string"),
permissions=Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]}),
checksum="1" * 64,
)
async def test_api_token_skips_csrf_but_honors_separated_privileges() -> None:
allowed = token_request("valid", ["VM.PowerMgmt"])
await _authenticate(
allowed,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert allowed.state.principal == "operator@pve"
denied = token_request("valid", ["VM.Audit"])
with pytest.raises(ApiError) as error:
await _authenticate(
denied,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert error.value.status_code == 403
async def test_api_token_rejects_unknown_or_wrong_secret() -> None:
request = token_request("wrong", ["VM.PowerMgmt"])
with pytest.raises(ApiError) as error:
await _authenticate(
request,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert error.value.status_code == 401