135 lines
5.6 KiB
Python
135 lines
5.6 KiB
Python
"""Fail-closed authentication and policy checks for HUX-12 evidence.
|
|
|
|
The evidence producer is a loopback-only companion process. It receives a
|
|
dedicated Vault key and a non-secret policy file; neither the browser relay nor
|
|
the agent worker can use its trust class. This module deliberately performs no
|
|
network discovery: the producer supplies observations and this boundary binds
|
|
them to the configured Jenkins, Harbor, Flux, workload, pod and health facts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from hux.errors import Forbidden, Invalid
|
|
|
|
KEY_FILE_ENV = "HUX_RELEASE_EVIDENCE_KEY_FILE"
|
|
POLICY_FILE_ENV = "HUX_RELEASE_EVIDENCE_POLICY_FILE"
|
|
MAX_KEY_BYTES = 4096
|
|
MAX_POLICY_BYTES = 64 * 1024
|
|
POLICY_FIELDS = {"schema", "max_evidence_age_seconds", "workloads"}
|
|
WORKLOAD_FIELDS = {
|
|
"review_url_prefix", "jenkins_job_url", "image_repository",
|
|
"flux_kustomization", "health_url",
|
|
}
|
|
|
|
|
|
def _regular_file(path: str, modes: set[int], limit: int) -> bytes:
|
|
"""Read one bounded regular file without accepting a symlink or loose mode."""
|
|
if not path:
|
|
raise Invalid("HUX-12 evidence configuration is missing")
|
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
descriptor = -1
|
|
try:
|
|
descriptor = os.open(Path(path), flags)
|
|
info = os.fstat(descriptor)
|
|
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) not in modes:
|
|
raise Invalid("HUX-12 evidence file permissions are invalid")
|
|
data = os.read(descriptor, limit + 1)
|
|
except OSError as error:
|
|
raise Invalid("HUX-12 evidence configuration is unavailable") from error
|
|
finally:
|
|
if descriptor >= 0:
|
|
os.close(descriptor)
|
|
if not data or len(data) > limit:
|
|
raise Invalid("HUX-12 evidence file is empty or too large")
|
|
return data
|
|
|
|
|
|
def evidence_key(environ: Mapping[str, str]) -> str:
|
|
"""Return the file-only producer key; inline environment secrets are ignored."""
|
|
data = _regular_file(environ.get(KEY_FILE_ENV, ""), {0o400}, MAX_KEY_BYTES)
|
|
try:
|
|
key = data.decode("utf-8", errors="strict").strip()
|
|
except UnicodeDecodeError as error:
|
|
raise Invalid("HUX-12 evidence key is invalid") from error
|
|
if len(key) < 32:
|
|
raise Invalid("HUX-12 evidence key is too short")
|
|
return key
|
|
|
|
|
|
def _https_url(value: Any, field: str, *, prefix: bool = False) -> str:
|
|
if not isinstance(value, str) or not value or len(value) > 500:
|
|
raise Invalid(f"{field} is missing or too long")
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise Invalid(f"{field} must be a credential-free HTTPS URL")
|
|
if prefix and not value.endswith("/"):
|
|
raise Invalid(f"{field} must end with /")
|
|
return value
|
|
|
|
|
|
def load_policy(environ: Mapping[str, str]) -> dict[str, Any]:
|
|
"""Load and strictly validate the non-secret producer policy."""
|
|
raw = _regular_file(environ.get(POLICY_FILE_ENV, ""), {0o400, 0o440, 0o444}, MAX_POLICY_BYTES)
|
|
try:
|
|
policy = json.loads(raw)
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise Invalid("HUX-12 evidence policy is not valid JSON") from error
|
|
if not isinstance(policy, dict) or set(policy) != POLICY_FIELDS or policy.get("schema") != "hux.release_evidence_policy.v1":
|
|
raise Invalid("HUX-12 evidence policy shape is invalid")
|
|
age = policy.get("max_evidence_age_seconds")
|
|
workloads = policy.get("workloads")
|
|
if not isinstance(age, int) or isinstance(age, bool) or not 60 <= age <= 86400:
|
|
raise Invalid("HUX-12 evidence age bound is invalid")
|
|
if not isinstance(workloads, dict) or not workloads or len(workloads) > 16:
|
|
raise Invalid("HUX-12 workload policy is invalid")
|
|
for name, item in workloads.items():
|
|
if not isinstance(name, str) or not isinstance(item, dict) or set(item) != WORKLOAD_FIELDS:
|
|
raise Invalid("HUX-12 workload policy shape is invalid")
|
|
_https_url(item["review_url_prefix"], "review_url_prefix", prefix=True)
|
|
_https_url(item["jenkins_job_url"], "jenkins_job_url")
|
|
_https_url(item["health_url"], "health_url")
|
|
if not re.fullmatch(r"[a-z0-9./_-]+", str(item["image_repository"])):
|
|
raise Invalid("HUX-12 image repository is invalid")
|
|
if not re.fullmatch(r"[a-z0-9-]{1,63}", str(item["flux_kustomization"])):
|
|
raise Invalid("HUX-12 Flux kustomization is invalid")
|
|
return policy
|
|
|
|
|
|
def configured(environ: Mapping[str, str]) -> bool:
|
|
"""Whether producer authentication and policy are both healthy."""
|
|
try:
|
|
evidence_key(environ)
|
|
load_policy(environ)
|
|
except Invalid:
|
|
return False
|
|
return True
|
|
|
|
|
|
def workload_policy(environ: Mapping[str, str], workload: str) -> dict[str, Any]:
|
|
"""Return the exact allowlist for a workload or fail closed."""
|
|
item = load_policy(environ)["workloads"].get(workload)
|
|
if item is None:
|
|
raise Invalid("workload is not enabled for release evidence")
|
|
return item
|
|
|
|
|
|
def require_review_creator(trust: str) -> None:
|
|
"""Only the authenticated browser path may record the human review decision."""
|
|
if trust != "router":
|
|
raise Forbidden("reviewed releases require router trust")
|
|
|
|
|
|
def require_evidence_producer(trust: str) -> None:
|
|
"""Only the dedicated producer may advance a reviewed release."""
|
|
if trust != "evidence":
|
|
raise Forbidden("release transitions require evidence trust")
|