feat: add authentication and ACL primitives
This commit is contained in:
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Authentication, secrets, and authorization boundaries."""
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
contract structures. PostgreSQL is the system of record for resources, security
|
||||||
state, locks, scenarios, and tasks.
|
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
|
The API layer is an adapter. It authenticates, authorizes, validates against the
|
||||||
selected contract, dispatches to a semantic handler, and renders a
|
selected contract, dispatches to a semantic handler, and renders a
|
||||||
version-compatible response. A route without a semantic handler is explicitly
|
version-compatible response. A route without a semantic handler is explicitly
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ line-length = 100
|
|||||||
select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
|
select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/**/*.py" = ["S101"]
|
"tests/**/*.py" = ["S101", "S105"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.13"
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -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")
|
||||||
@@ -17,7 +17,8 @@ def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_repository_migration_defines_required_planes() -> None:
|
def test_repository_migration_defines_required_planes() -> None:
|
||||||
migration = load_migrations()[0]
|
migrations = load_migrations()
|
||||||
|
migration = migrations[0]
|
||||||
|
|
||||||
for table in (
|
for table in (
|
||||||
"contract_snapshots",
|
"contract_snapshots",
|
||||||
@@ -30,3 +31,5 @@ def test_repository_migration_defines_required_planes() -> None:
|
|||||||
"audit_events",
|
"audit_events",
|
||||||
):
|
):
|
||||||
assert f"CREATE TABLE {table}" in migration.sql
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user