diff --git a/README.md b/README.md index a1b8207..c3f92ad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/registry.py b/app/api/registry.py index a64967a..0020c58 100644 --- a/app/api/registry.py +++ b/app/api/registry.py @@ -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: diff --git a/app/db/migrations/005_token_lifecycle.sql b/app/db/migrations/005_token_lifecycle.sql new file mode 100644 index 0000000..ff3af50 --- /dev/null +++ b/app/db/migrations/005_token_lifecycle.sql @@ -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(); diff --git a/app/handlers/access.py b/app/handlers/access.py new file mode 100644 index 0000000..aada5a4 --- /dev/null +++ b/app/handlers/access.py @@ -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) diff --git a/app/handlers/core.py b/app/handlers/core.py index 77c470e..cc4778a 100644 --- a/app/handlers/core.py +++ b/app/handlers/core.py @@ -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 diff --git a/docs/compatibility-0.1.0.md b/docs/compatibility-0.1.0.md index 7cf186d..bf35bed 100644 --- a/docs/compatibility-0.1.0.md +++ b/docs/compatibility-0.1.0.md @@ -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`, diff --git a/docs/original-prompt-gap-plan.md b/docs/original-prompt-gap-plan.md index 5d5dd8d..0cb6baf 100644 --- a/docs/original-prompt-gap-plan.md +++ b/docs/original-prompt-gap-plan.md @@ -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. diff --git a/tests/compatibility/test_proxmoxer.py b/tests/compatibility/test_proxmoxer.py index 5bc789e..64f5d8d 100644 --- a/tests/compatibility/test_proxmoxer.py +++ b/tests/compatibility/test_proxmoxer.py @@ -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" diff --git a/tests/unit/test_access_handlers.py b/tests/unit/test_access_handlers.py new file mode 100644 index 0000000..d0c3d37 --- /dev/null +++ b/tests/unit/test_access_handlers.py @@ -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 diff --git a/tests/unit/test_api_auth_boundary.py b/tests/unit/test_api_auth_boundary.py index 7fe3199..d5a9f12 100644 --- a/tests/unit/test_api_auth_boundary.py +++ b/tests/unit/test_api_auth_boundary.py @@ -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]]: