feat: add authentication and ACL primitives

This commit is contained in:
Sergey Antropoff
2026-07-13 00:01:17 +03:00
parent 7a668127a3
commit dfb1074b71
9 changed files with 364 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
"""Authentication, secrets, and authorization boundaries."""
+87
View File
@@ -0,0 +1,87 @@
"""Capability-driven ACL evaluation with token privilege separation."""
from __future__ import annotations
from dataclasses import dataclass
from app.contracts.model import Permissions
@dataclass(frozen=True, slots=True)
class Realm:
name: str
kind: str
@dataclass(frozen=True, slots=True)
class Principal:
name: str
realm: str
@dataclass(frozen=True, slots=True)
class Role:
name: str
privileges: frozenset[str]
@dataclass(frozen=True, slots=True)
class AclEntry:
principal: str
path: str
privileges: frozenset[str]
propagate: bool = True
def _ancestors(path: str) -> tuple[str, ...]:
parts = [part for part in path.split("/") if part]
return tuple(["/"] + ["/" + "/".join(parts[:index]) for index in range(1, len(parts) + 1)])
def effective_privileges(
principal: str, path: str, entries: tuple[AclEntry, ...]
) -> frozenset[str]:
privileges: set[str] = set()
for entry in entries:
if entry.principal != principal or entry.path not in _ancestors(path):
continue
if entry.path == path or entry.propagate:
privileges.update(entry.privileges)
return frozenset(privileges)
def authorize(
principal: str,
path: str,
required: frozenset[str],
entries: tuple[AclEntry, ...],
*,
token_privileges: frozenset[str] | None = None,
) -> bool:
privileges = effective_privileges(principal, path, entries)
if token_privileges is not None:
privileges &= token_privileges
return required <= privileges
@dataclass(frozen=True, slots=True)
class CapabilityRequirement:
path: str
privileges: frozenset[str]
def requirement_from_contract(
permissions: Permissions | None, parameters: dict[str, str]
) -> CapabilityRequirement | None:
if permissions is None or not permissions.expression:
return None
check = permissions.expression.get("check")
if not isinstance(check, list) or len(check) < 3 or check[0] != "perm":
return None
raw_path = str(check[1])
for name, value in parameters.items():
raw_path = raw_path.replace(f"{{{name}}}", value).replace(f"<{name}>", value)
raw_privileges = check[2]
if not isinstance(raw_privileges, list):
return None
return CapabilityRequirement(raw_path, frozenset(str(item) for item in raw_privileges))
+136
View File
@@ -0,0 +1,136 @@
"""Password, ticket, CSRF, and API-token primitives."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import re
import secrets
import time
from dataclasses import dataclass
from starlette.responses import Response
class AuthenticationError(ValueError):
pass
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def _unb64(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def hash_secret(secret: str, *, salt: bytes | None = None) -> str:
actual_salt = salt or secrets.token_bytes(16)
digest = hashlib.scrypt(secret.encode(), salt=actual_salt, n=2**14, r=8, p=1, dklen=32)
return f"scrypt$16384$8$1${_b64(actual_salt)}${_b64(digest)}"
def verify_secret(secret: str, encoded: str) -> bool:
try:
algorithm, n, r, p, salt, expected = encoded.split("$")
if algorithm != "scrypt":
return False
actual = hashlib.scrypt(
secret.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p), dklen=32
)
return hmac.compare_digest(actual, _unb64(expected))
except (ValueError, TypeError):
return False
@dataclass(frozen=True, slots=True)
class TicketClaims:
principal: str
issued_at: int
expires_at: int
nonce: str
def issue_ticket(principal: str, key: bytes, *, now: int | None = None, ttl: int = 7200) -> str:
issued = int(time.time() if now is None else now)
claims = {
"exp": issued + ttl,
"iat": issued,
"nonce": _b64(secrets.token_bytes(12)),
"principal": principal,
}
payload = _b64(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
signature = _b64(hmac.digest(key, payload.encode(), "sha256"))
return f"PVE:{payload}.{signature}"
def verify_ticket(ticket: str, key: bytes, *, now: int | None = None) -> TicketClaims:
try:
prefix, signed = ticket.split(":", 1)
payload, signature = signed.split(".", 1)
if prefix != "PVE" or not hmac.compare_digest(
_unb64(signature), hmac.digest(key, payload.encode(), "sha256")
):
raise AuthenticationError("invalid ticket")
data = json.loads(_unb64(payload))
claims = TicketClaims(
principal=str(data["principal"]),
issued_at=int(data["iat"]),
expires_at=int(data["exp"]),
nonce=str(data["nonce"]),
)
except (ValueError, KeyError, json.JSONDecodeError) as error:
raise AuthenticationError("invalid ticket") from error
current = int(time.time() if now is None else now)
if claims.expires_at < current or claims.issued_at > current + 60:
raise AuthenticationError("ticket expired or not yet valid")
return claims
def csrf_token(ticket: str, key: bytes) -> str:
return _b64(hmac.digest(key, b"csrf:" + ticket.encode(), "sha256"))
def verify_csrf(ticket: str, token: str, key: bytes) -> bool:
return hmac.compare_digest(csrf_token(ticket, key), token)
def set_ticket_cookie(response: Response, ticket: str, *, secure: bool = True) -> None:
response.set_cookie(
"PVEAuthCookie",
ticket,
httponly=True,
secure=secure,
samesite="strict",
path="/",
)
@dataclass(frozen=True, slots=True)
class ApiToken:
principal: str
token_id: str
secret: str
TOKEN_PATTERN = re.compile(r"^PVEAPIToken=([^!=\s]+![^=\s]+)=([^\s]+)$")
def parse_api_token(header: str) -> ApiToken:
match = TOKEN_PATTERN.fullmatch(header)
if match is None:
raise AuthenticationError("invalid API token")
identity, secret = match.groups()
principal, token_id = identity.rsplit("!", 1)
return ApiToken(principal, token_id, secret)
SECRET_RE = re.compile(r"(PVEAPIToken=[^=\s]+=)[^\s]+|(password|secret|token)=([^&\s]+)", re.I)
def redact_secrets(value: str) -> str:
return SECRET_RE.sub(
lambda match: (match.group(1) or f"{match.group(2)}=") + "[REDACTED]", value
)