feat: add authentication and ACL primitives
This commit is contained in:
@@ -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:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user