feat: authenticate API tokens and enforce route ACLs
This commit is contained in:
@@ -55,6 +55,14 @@ print(proxmox.version.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
|
||||
`PROXMOXER_HOST=tls-gateway`, `PROXMOXER_PORT=8443`, and pytest marker
|
||||
`compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task
|
||||
|
||||
+77
-7
@@ -13,7 +13,9 @@ from fastapi.responses import JSONResponse
|
||||
from app.api.errors import ApiError, ContractValidationError
|
||||
from app.config import Settings
|
||||
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]]
|
||||
FallbackMode = Literal["error", "schema-default", "fixture"]
|
||||
@@ -81,9 +83,9 @@ def _endpoint(
|
||||
fallback: FallbackMode,
|
||||
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
||||
async def dispatch(request: Request) -> JSONResponse:
|
||||
_authenticate(request, semantic_path)
|
||||
handler = handlers.get(semantic_path, method.verb)
|
||||
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:
|
||||
data = await handler(request, inputs)
|
||||
elif fallback == "schema-default":
|
||||
@@ -108,22 +110,90 @@ def _endpoint(
|
||||
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"}:
|
||||
return
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
token_privileges: frozenset[str] | None = None
|
||||
principal: str
|
||||
if authorization.startswith("PVEAPIToken="):
|
||||
database = cast(AsyncpgDatabase, request.app.state.database)
|
||||
try:
|
||||
parsed_token = parse_api_token(authorization)
|
||||
except ValueError as error:
|
||||
raise ApiError(401, "authentication failure") from error
|
||||
row = await database.pool.fetchrow(
|
||||
"""SELECT p.name, t.secret_hash, t.privileges
|
||||
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
|
||||
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:
|
||||
verify_ticket(ticket, key)
|
||||
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"}:
|
||||
token = request.headers.get("CSRFPreventionToken", "")
|
||||
if not verify_csrf(ticket, token, key):
|
||||
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]:
|
||||
|
||||
@@ -332,6 +332,46 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
|
||||
stable_id("principal:root@pam"),
|
||||
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(
|
||||
|
||||
@@ -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.
|
||||
- QEMU: list, configuration, current status, start, and stop.
|
||||
- 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
|
||||
`small` seed data.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
from proxmoxer import ProxmoxAPI # type: ignore[import-untyped]
|
||||
from proxmoxer import ProxmoxAPI, ResourceException # type: ignore[import-untyped]
|
||||
|
||||
pytestmark = [
|
||||
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(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":
|
||||
status = proxmox.nodes("pve1").qemu("101").status.current.get()
|
||||
operation = "start" if status["status"] == "stopped" else "stop"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user