feat: authenticate API tokens and enforce route ACLs
This commit is contained in:
+87
-17
@@ -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
|
||||
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)
|
||||
except ValueError as error:
|
||||
raise ApiError(401, "authentication failure") from error
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
token = request.headers.get("CSRFPreventionToken", "")
|
||||
if not verify_csrf(ticket, token, key):
|
||||
raise ApiError(403, "invalid CSRF prevention token")
|
||||
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:
|
||||
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"}:
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user