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
@@ -0,0 +1,16 @@
CREATE TABLE realms (
name text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('pam', 'pve', 'openid', 'ldap'))
);
INSERT INTO realms(name, kind) VALUES ('pam', 'pam'), ('pve', 'pve');
ALTER TABLE principals ADD COLUMN realm_name text REFERENCES realms(name) ON DELETE RESTRICT;
CREATE TABLE api_tokens (
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
token_id text NOT NULL,
secret_hash text NOT NULL,
privileges text[] NOT NULL DEFAULT '{}',
expires_at timestamptz,
PRIMARY KEY (principal_id, token_id),
CHECK (secret_hash LIKE 'scrypt$%')
);
CREATE INDEX api_tokens_expires_idx ON api_tokens(expires_at) WHERE expires_at IS NOT NULL;
+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
)
+6
View File
@@ -90,6 +90,12 @@ domain models and repositories that do not depend on FastAPI or source-specific
contract structures. PostgreSQL is the system of record for resources, security
state, locks, scenarios, and tasks.
Authentication secrets use salted scrypt hashes. Session tickets are signed and
expiring; mutation requests use ticket-bound CSRF tokens. API-token privileges
are intersected with their owning principal's effective propagated ACLs, so a
token cannot escalate its owner. Logs redact recognized ticket, password, and
token representations before emission.
The API layer is an adapter. It authenticates, authorizes, validates against the
selected contract, dispatches to a semantic handler, and renders a
version-compatible response. A route without a semantic handler is explicitly
+1 -1
View File
@@ -43,7 +43,7 @@ line-length = 100
select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
"tests/**/*.py" = ["S101", "S105"]
[tool.mypy]
python_version = "3.13"
+45
View File
@@ -0,0 +1,45 @@
"""ACL propagation, token separation, and contract mapping tests."""
from app.contracts.model import Permissions
from app.security.acl import AclEntry, authorize, effective_privileges, requirement_from_contract
ENTRIES = (
AclEntry("alice@pve", "/vms", frozenset({"VM.Audit", "VM.PowerMgmt"})),
AclEntry("alice@pve", "/vms/200", frozenset({"VM.Config"}), propagate=False),
)
def test_acl_propagation_matrix() -> None:
assert effective_privileges("alice@pve", "/vms/100", ENTRIES) == frozenset(
{"VM.Audit", "VM.PowerMgmt"}
)
assert "VM.Config" in effective_privileges("alice@pve", "/vms/200", ENTRIES)
assert "VM.Config" not in effective_privileges("alice@pve", "/vms/200/snapshot", ENTRIES)
assert not effective_privileges("bob@pve", "/vms/100", ENTRIES)
def test_api_token_privileges_are_intersection_not_escalation() -> None:
assert authorize(
"alice@pve",
"/vms/100",
frozenset({"VM.Audit"}),
ENTRIES,
token_privileges=frozenset({"VM.Audit"}),
)
assert not authorize(
"alice@pve",
"/vms/100",
frozenset({"VM.PowerMgmt"}),
ENTRIES,
token_privileges=frozenset({"VM.Audit"}),
)
def test_contract_permission_maps_to_capability_requirement() -> None:
permissions = Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]})
requirement = requirement_from_contract(permissions, {"vmid": "100"})
assert requirement is not None
assert requirement.path == "/vms/100"
assert requirement.privileges == frozenset({"VM.PowerMgmt"})
+68
View File
@@ -0,0 +1,68 @@
"""Authentication, CSRF, token, and redaction matrices."""
import pytest
from starlette.responses import Response
from app.security.auth import (
AuthenticationError,
csrf_token,
hash_secret,
issue_ticket,
parse_api_token,
redact_secrets,
set_ticket_cookie,
verify_csrf,
verify_secret,
verify_ticket,
)
KEY = b"test-signing-key-with-at-least-32-bytes"
def test_password_and_token_hashes_do_not_store_plaintext() -> None:
encoded = hash_secret("correct horse", salt=b"0123456789abcdef")
assert "correct horse" not in encoded
assert verify_secret("correct horse", encoded)
assert not verify_secret("wrong", encoded)
assert not verify_secret("correct horse", "unknown$format")
def test_signed_ticket_expiry_and_csrf() -> None:
ticket = issue_ticket("root@pam", KEY, now=100, ttl=60)
assert verify_ticket(ticket, KEY, now=120).principal == "root@pam"
token = csrf_token(ticket, KEY)
assert verify_csrf(ticket, token, KEY)
assert not verify_csrf(ticket, token + "x", KEY)
with pytest.raises(AuthenticationError, match="expired"):
verify_ticket(ticket, KEY, now=161)
with pytest.raises(AuthenticationError, match="invalid"):
verify_ticket(ticket + "x", KEY, now=120)
def test_ticket_cookie_is_http_only_and_secure() -> None:
response = Response()
set_ticket_cookie(response, "ticket")
header = response.headers["set-cookie"]
assert "PVEAuthCookie=ticket" in header
assert "HttpOnly" in header
assert "Secure" in header
assert "SameSite=strict" in header
def test_api_token_parsing_and_log_redaction() -> None:
token = parse_api_token("PVEAPIToken=user@pve!automation=supersecret")
assert token.principal == "user@pve"
assert token.token_id == "automation"
assert token.secret == "supersecret"
redacted = redact_secrets(
"PVEAPIToken=user@pve!automation=supersecret password=hunter2 token=abc"
)
assert "supersecret" not in redacted
assert "hunter2" not in redacted
assert "token=abc" not in redacted
with pytest.raises(AuthenticationError):
parse_api_token("Bearer secret")
+4 -1
View File
@@ -17,7 +17,8 @@ def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
def test_repository_migration_defines_required_planes() -> None:
migration = load_migrations()[0]
migrations = load_migrations()
migration = migrations[0]
for table in (
"contract_snapshots",
@@ -30,3 +31,5 @@ def test_repository_migration_defines_required_planes() -> None:
"audit_events",
):
assert f"CREATE TABLE {table}" in migration.sql
assert "CREATE TABLE realms" in migrations[1].sql
assert "CREATE TABLE api_tokens" in migrations[1].sql