feat: add persistent API token lifecycle
This commit is contained in:
@@ -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.
|
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
|
||||||
Implemented semantics currently include version, ticket login, node listing and
|
Implemented semantics currently include version, ticket login, node listing and
|
||||||
status, cluster resources, basic QEMU list/config/status/start/stop, and task
|
status, cluster resources, basic QEMU list/config/status/start/stop, and task
|
||||||
list/status/log. Mutations require the ticket-bound CSRF header and execute
|
list/status/log, plus API-token list/create/read/update/delete. Mutations require
|
||||||
through PostgreSQL-leased workers; all other declared methods return an explicit
|
the ticket-bound CSRF header and execute through PostgreSQL-leased workers; all
|
||||||
unsupported error.
|
other declared methods return an explicit unsupported error.
|
||||||
|
|
||||||
## Development
|
## 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
|
CSRF; ticket-authenticated mutations still do. These are disposable local test
|
||||||
credentials only.
|
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
|
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
|
||||||
|
|||||||
+6
-2
@@ -125,7 +125,7 @@ async def _authenticate(
|
|||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise ApiError(401, "authentication failure") from error
|
raise ApiError(401, "authentication failure") from error
|
||||||
row = await database.pool.fetchrow(
|
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
|
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
|
||||||
WHERE p.name=$1 AND t.token_id=$2
|
WHERE p.name=$1 AND t.token_id=$2
|
||||||
AND (t.expires_at IS NULL OR t.expires_at > now())""",
|
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"])):
|
if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
|
||||||
raise ApiError(401, "authentication failure")
|
raise ApiError(401, "authentication failure")
|
||||||
principal = str(row["name"])
|
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:
|
else:
|
||||||
ticket = request.cookies.get("PVEAuthCookie")
|
ticket = request.cookies.get("PVEAuthCookie")
|
||||||
if ticket is None:
|
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();
|
||||||
@@ -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)
|
||||||
@@ -11,6 +11,7 @@ from app.api.errors import ApiError
|
|||||||
from app.api.registry import HandlerRegistry
|
from app.api.registry import HandlerRegistry
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db.pool import AsyncpgDatabase
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.handlers.access import register_access_handlers
|
||||||
from app.handlers.qemu import register_qemu_handlers
|
from app.handlers.qemu import register_qemu_handlers
|
||||||
from app.security.auth import csrf_token, issue_ticket, verify_secret
|
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", "GET", nodes)
|
||||||
registry.register("/nodes/{node}/status", "GET", node_status)
|
registry.register("/nodes/{node}/status", "GET", node_status)
|
||||||
registry.register("/cluster/resources", "GET", resources)
|
registry.register("/cluster/resources", "GET", resources)
|
||||||
|
register_access_handlers(registry)
|
||||||
register_qemu_handlers(registry)
|
register_qemu_handlers(registry)
|
||||||
return registry
|
return registry
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ Proxmox compatibility.
|
|||||||
| Level | Methods | Contract share | Evidence |
|
| Level | Methods | Contract share | Evidence |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
|
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
|
||||||
| Stateful semantics implemented | 13 | 1.93% | Handler registry and unit/integration tests |
|
| Stateful semantics implemented on current main | 18 | 2.67% | Handler registry and unit/integration tests |
|
||||||
| Schema-only or explicitly unsupported | 662 | 98.07% | Default 501 fallback |
|
| Schema-only or explicitly unsupported | 657 | 97.33% | Default 501 fallback |
|
||||||
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
|
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
|
||||||
|
|
||||||
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
|
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ large seeding proves bounded batch operations rather than row-at-a-time inserts.
|
|||||||
|
|
||||||
## G3 — authentication and authorization surface
|
## 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.
|
`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.
|
credential redaction.
|
||||||
- [ ] Wire users, groups, roles, ACL propagation, route-derived permissions and
|
- [ ] Wire users, groups, roles, ACL propagation, route-derived permissions and
|
||||||
token privilege separation into every semantic handler.
|
token privilege separation into every semantic handler.
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
|||||||
readonly_api.nodes("pve1").qemu("101").status.start.post()
|
readonly_api.nodes("pve1").qemu("101").status.start.post()
|
||||||
assert denied.value.status_code == 403
|
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":
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,6 +25,7 @@ class FakePool:
|
|||||||
"name": principal,
|
"name": principal,
|
||||||
"secret_hash": self.secret_hash,
|
"secret_hash": self.secret_hash,
|
||||||
"privileges": self.token_privileges,
|
"privileges": self.token_privileges,
|
||||||
|
"privilege_separation": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
|
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
|
||||||
|
|||||||
Reference in New Issue
Block a user