From dfb1074b716386a006966cf86dde359ae179495f Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Mon, 13 Jul 2026 00:01:17 +0300 Subject: [PATCH] feat: add authentication and ACL primitives --- .../migrations/002_identity_realms_tokens.sql | 16 +++ app/security/__init__.py | 1 + app/security/acl.py | 87 +++++++++++ app/security/auth.py | 136 ++++++++++++++++++ docs/architecture.md | 6 + pyproject.toml | 2 +- tests/unit/test_acl.py | 45 ++++++ tests/unit/test_auth.py | 68 +++++++++ tests/unit/test_migrations.py | 5 +- 9 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 app/db/migrations/002_identity_realms_tokens.sql create mode 100644 app/security/__init__.py create mode 100644 app/security/acl.py create mode 100644 app/security/auth.py create mode 100644 tests/unit/test_acl.py create mode 100644 tests/unit/test_auth.py diff --git a/app/db/migrations/002_identity_realms_tokens.sql b/app/db/migrations/002_identity_realms_tokens.sql new file mode 100644 index 0000000..8e797c5 --- /dev/null +++ b/app/db/migrations/002_identity_realms_tokens.sql @@ -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; diff --git a/app/security/__init__.py b/app/security/__init__.py new file mode 100644 index 0000000..64959db --- /dev/null +++ b/app/security/__init__.py @@ -0,0 +1 @@ +"""Authentication, secrets, and authorization boundaries.""" diff --git a/app/security/acl.py b/app/security/acl.py new file mode 100644 index 0000000..6556384 --- /dev/null +++ b/app/security/acl.py @@ -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)) diff --git a/app/security/auth.py b/app/security/auth.py new file mode 100644 index 0000000..c5c9aad --- /dev/null +++ b/app/security/auth.py @@ -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 + ) diff --git a/docs/architecture.md b/docs/architecture.md index 58ed794..1d3f02a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 3953099..6c1c624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/unit/test_acl.py b/tests/unit/test_acl.py new file mode 100644 index 0000000..1ca87cc --- /dev/null +++ b/tests/unit/test_acl.py @@ -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"}) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..7a1ec43 --- /dev/null +++ b/tests/unit/test_auth.py @@ -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") diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index b2365a4..795de9d 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -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