feat: add persistent API token lifecycle

This commit is contained in:
Sergey Antropoff
2026-07-13 01:35:01 +03:00
parent 8471bcdbac
commit 5934c567b5
10 changed files with 311 additions and 9 deletions
+8 -3
View File
@@ -10,9 +10,9 @@ limits are recorded in [the 0.1.0 compatibility report](docs/compatibility-0.1.0
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
Implemented semantics currently include version, ticket login, node listing and
status, cluster resources, basic QEMU list/config/status/start/stop, and task
list/status/log. Mutations require the ticket-bound CSRF header and execute
through PostgreSQL-leased workers; all other declared methods return an explicit
unsupported error.
list/status/log, plus API-token list/create/read/update/delete. Mutations require
the ticket-bound CSRF header and execute through PostgreSQL-leased workers; all
other declared methods return an explicit unsupported error.
## Development
@@ -63,6 +63,11 @@ 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.
Token lifecycle is available at
`/access/users/{userid}/token[/{tokenid}]`. A generated secret is returned only
by create or explicit regenerate; only its scrypt hash is stored. List/read never
return token values, and deletion immediately invalidates authentication.
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
+6 -2
View File
@@ -125,7 +125,7 @@ async def _authenticate(
except ValueError as error:
raise ApiError(401, "authentication failure") from error
row = await database.pool.fetchrow(
"""SELECT p.name, t.secret_hash, t.privileges
"""SELECT p.name, t.secret_hash, t.privileges, t.privilege_separation
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())""",
@@ -135,7 +135,11 @@ async def _authenticate(
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"])
token_privileges = (
frozenset(str(item) for item in row["privileges"])
if bool(row["privilege_separation"])
else None
)
else:
ticket = request.cookies.get("PVEAuthCookie")
if ticket is None:
@@ -0,0 +1,6 @@
INSERT INTO realms(name, kind) VALUES ('test', 'pve') ON CONFLICT (name) DO NOTHING;
ALTER TABLE api_tokens
ADD COLUMN comment text,
ADD COLUMN privilege_separation boolean NOT NULL DEFAULT true,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
+149
View File
@@ -0,0 +1,149 @@
"""Persistent Proxmox API-token lifecycle handlers."""
from __future__ import annotations
import secrets
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.security.auth import hash_secret
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
def _require_owner(request: Request, userid: str) -> None:
principal = str(request.state.principal)
if principal != "root@pam" and principal != userid:
raise ApiError(403, "permission check failed")
def _token_info(row: Any) -> dict[str, Any]:
result: dict[str, Any] = {"privsep": bool(row["privilege_separation"])}
if row["comment"] is not None:
result["comment"] = str(row["comment"])
if row["expire"] is not None:
result["expire"] = int(row["expire"])
return result
def _expire_value(values: dict[str, Any]) -> int | None:
value = values.get("expire")
return value if isinstance(value, int) and not isinstance(value, bool) else None
def register_access_handlers(registry: HandlerRegistry) -> None:
async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
userid = str(_values(inputs)["userid"])
_require_owner(request, userid)
rows = await _database(request).pool.fetch(
"""SELECT t.token_id, t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
WHERE p.name=$1 ORDER BY t.token_id""",
userid,
)
return [{"tokenid": str(row["token_id"]), **_token_info(row)} for row in rows]
async def token_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
_require_owner(request, userid)
row = await _database(request).pool.fetchrow(
"""SELECT t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
WHERE p.name=$1 AND t.token_id=$2""",
userid,
tokenid,
)
if row is None:
raise ApiError(404, "API token does not exist")
return _token_info(row)
async def token_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
_require_owner(request, userid)
secret = secrets.token_urlsafe(32)
row = await _database(request).pool.fetchrow(
"""INSERT INTO api_tokens(
principal_id, token_id, secret_hash, comment, expires_at,
privilege_separation
) SELECT id, $2, $3, $4,
CASE WHEN $5::bigint IS NULL OR $5=0 THEN NULL ELSE to_timestamp($5) END,
$6 FROM principals WHERE name=$1
ON CONFLICT (principal_id, token_id) DO NOTHING
RETURNING comment, privilege_separation,
extract(epoch from expires_at)::bigint AS expire""",
userid,
tokenid,
hash_secret(secret),
values.get("comment"),
_expire_value(values),
bool(values.get("privsep", True)),
)
if row is None:
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)", userid
)
raise ApiError(409 if exists else 404, "user or API token conflict")
return {"full-tokenid": f"{userid}!{tokenid}", "info": _token_info(row), "value": secret}
async def token_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
_require_owner(request, userid)
regenerate = bool(values.get("regenerate", False))
secret = secrets.token_urlsafe(32) if regenerate else None
row = await _database(request).pool.fetchrow(
"""UPDATE api_tokens t SET
comment=COALESCE($3::text, comment),
expires_at=CASE WHEN $4::bigint IS NULL THEN expires_at
WHEN $4=0 THEN NULL ELSE to_timestamp($4) END,
privilege_separation=COALESCE($5::boolean, privilege_separation),
secret_hash=COALESCE($6::text, secret_hash), updated_at=now()
FROM principals p WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2
RETURNING t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire""",
userid,
tokenid,
values.get("comment"),
_expire_value(values),
values.get("privsep"),
hash_secret(secret) if secret is not None else None,
)
if row is None:
raise ApiError(404, "API token does not exist")
result = _token_info(row)
if secret is not None:
result.update({"full-tokenid": f"{userid}!{tokenid}", "value": secret})
return result
async def token_delete(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
userid, tokenid = str(values["userid"]), str(values["tokenid"])
_require_owner(request, userid)
status = await _database(request).pool.execute(
"""DELETE FROM api_tokens t USING principals p
WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2""",
userid,
tokenid,
)
if status != "DELETE 1":
raise ApiError(404, "API token does not exist")
registry.register("/access/users/{userid}/token", "GET", token_list)
registry.register("/access/users/{userid}/token/{tokenid}", "GET", token_get)
registry.register("/access/users/{userid}/token/{tokenid}", "POST", token_create)
registry.register("/access/users/{userid}/token/{tokenid}", "PUT", token_update)
registry.register("/access/users/{userid}/token/{tokenid}", "DELETE", token_delete)
+2
View File
@@ -11,6 +11,7 @@ from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.db.pool import AsyncpgDatabase
from app.handlers.access import register_access_handlers
from app.handlers.qemu import register_qemu_handlers
from app.security.auth import csrf_token, issue_ticket, verify_secret
@@ -93,5 +94,6 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
registry.register("/nodes", "GET", nodes)
registry.register("/nodes/{node}/status", "GET", node_status)
registry.register("/cluster/resources", "GET", resources)
register_access_handlers(registry)
register_qemu_handlers(registry)
return registry
+2 -2
View File
@@ -9,8 +9,8 @@ Proxmox compatibility.
| Level | Methods | Contract share | Evidence |
|---|---:|---:|---|
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
| Stateful semantics implemented | 13 | 1.93% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 662 | 98.07% | Default 501 fallback |
| Stateful semantics implemented on current main | 18 | 2.67% | Handler registry and unit/integration tests |
| Schema-only or explicitly unsupported | 657 | 97.33% | Default 501 fallback |
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
+2 -2
View File
@@ -41,9 +41,9 @@ large seeding proves bounded batch operations rather than row-at-a-time inserts.
## G3 — authentication and authorization surface
- [ ] Expose API-token lifecycle and authenticate
- [x] Expose API-token lifecycle and authenticate
`PVEAPIToken=USER@REALM!TOKENID=SECRET` without CSRF.
- [ ] Complete pam, pve, and test realm behavior, ticket skew/expiry and
- [x] Complete pam, pve, and test realm behavior, ticket skew/expiry and
credential redaction.
- [ ] Wire users, groups, roles, ACL propagation, route-derived permissions and
token privilege separation into every semantic handler.
+20
View File
@@ -48,6 +48,26 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
readonly_api.nodes("pve1").qemu("101").status.start.post()
assert denied.value.status_code == 403
token_endpoint = proxmox.access.users("root@pam").token("ephemeral")
created = token_endpoint.post(comment="compatibility lifecycle", privsep=0)
assert created["full-tokenid"] == "root@pam!ephemeral"
ephemeral = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="root@pam",
token_name=os.getenv("PROXMOXER_EPHEMERAL_TOKEN_NAME", "ephemeral"),
token_value=created["value"],
verify_ssl=False,
)
assert ephemeral.nodes.get()
updated = token_endpoint.put(comment="updated", privsep=0)
assert updated["comment"] == "updated"
assert token_endpoint.get()["comment"] == "updated"
token_endpoint.delete()
with pytest.raises(ResourceException) as removed:
ephemeral.nodes.get()
assert removed.value.status_code == 401
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
status = proxmox.nodes("pve1").qemu("101").status.current.get()
operation = "start" if status["status"] == "stopped" else "stop"
+115
View File
@@ -0,0 +1,115 @@
"""API-token lifecycle handler tests without external services."""
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.access import register_access_handlers
class TokenPool:
def __init__(self) -> None:
self.token: dict[str, Any] | None = None
async def fetch(self, _query: str, _userid: str) -> list[dict[str, Any]]:
return [] if self.token is None else [{"token_id": "test", **self.token}]
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "INSERT INTO" in query:
self.token = {
"comment": arguments[3],
"privilege_separation": arguments[5],
"expire": arguments[4],
}
return self.token
if "UPDATE api_tokens" in query:
if self.token is None:
return None
self.token["comment"] = arguments[2]
self.token["privilege_separation"] = arguments[4]
return self.token
return self.token
async def fetchval(self, _query: str, _userid: str) -> bool:
return True
async def execute(self, _query: str, _userid: str, _tokenid: str) -> str:
if self.token is None:
return "DELETE 0"
self.token = None
return "DELETE 1"
class FakeDatabase:
def __init__(self, pool: TokenPool) -> None:
self.pool = pool
def request(pool: TokenPool, principal: str = "root@pam") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = principal
return result
def values(**items: object) -> dict[str, Any]:
return {"values": items}
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = TokenPool()
http_request = request(pool)
create = registry.get("/access/users/{userid}/token/{tokenid}", "POST")
get = registry.get("/access/users/{userid}/token/{tokenid}", "GET")
update = registry.get("/access/users/{userid}/token/{tokenid}", "PUT")
delete = registry.get("/access/users/{userid}/token/{tokenid}", "DELETE")
list_tokens = registry.get("/access/users/{userid}/token", "GET")
assert create and get and update and delete and list_tokens
created = await create(
http_request,
values(userid="root@pam", tokenid="test", comment="first", privsep=True),
)
assert created["full-tokenid"] == "root@pam!test"
assert created["value"]
assert "value" not in await get(http_request, values(userid="root@pam", tokenid="test"))
assert await list_tokens(http_request, values(userid="root@pam"))
updated = await update(
http_request,
values(userid="root@pam", tokenid="test", comment="second", privsep=False),
)
assert updated["comment"] == "second"
await delete(http_request, values(userid="root@pam", tokenid="test"))
with pytest.raises(ApiError) as missing:
await get(http_request, values(userid="root@pam", tokenid="test"))
assert missing.value.status_code == 404
async def test_token_lifecycle_rejects_non_owner() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
handler = registry.get("/access/users/{userid}/token", "GET")
assert handler
with pytest.raises(ApiError) as denied:
await handler(request(TokenPool(), "auditor@pve"), values(userid="other@pve"))
assert denied.value.status_code == 403
+1
View File
@@ -25,6 +25,7 @@ class FakePool:
"name": principal,
"secret_hash": self.secret_hash,
"privileges": self.token_privileges,
"privilege_separation": True,
}
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]: