feat: authenticate API tokens and enforce route ACLs
This commit is contained in:
@@ -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