Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/.publication/lib/policy.py

321 lines
13 KiB
Python

"""Publication policy: the authorization oracle.
A policy record authorizes one internal repository to project one ref to one
destination. No record, or any defect in a record, denies publication.
Rationale and the trust argument live in docs/DESIGN.md.
"""
import json
import os
import re
SCHEMA_VERSION = 1
PROJECTIONS = frozenset({
"source-only",
"source+release-assets",
"source+rolling-archive",
})
KNOWN_EVENTS = frozenset({
"push", "workflow_dispatch", "pull_request", "schedule", "release",
})
# A matrix gate expands one declaration into many jobs. The selector is the
# explicit list in `matrix_jobs`, never a prefix: prefix matching decides which
# jobs count, and that is the ambiguity the (workflow_path, job_name) key exists
# to remove. Adding a leg upstream therefore requires a policy commit.
SUPPORTED_CARDINALITIES = frozenset({"exactly-one", "matrix"})
DECLARED_CARDINALITIES = frozenset({"exactly-one", "at-least-one", "all-of", "matrix"})
RERUN_POLICIES = frozenset({
"latest-attempt-must-succeed",
"all-attempts-must-succeed",
})
REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
HOST_RE = re.compile(r"^[a-z0-9.-]+$")
REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
WORKFLOW_PATH_RE = re.compile(r"^\.(gitea|forgejo|github)/workflows/[A-Za-z0-9._-]+\.ya?ml$")
TOP_KEYS = {
"schema_version", "internal_repository", "destination_host",
"destination_repository", "permitted_refs", "projection",
"publisher_identity", "allowed_events", "rerun_policy",
"required_gates", "repo_assertions", "mechanism",
"history_allowlist_sha256",
}
GATE_KEYS = {"workflow_path", "job_name", "cardinality"}
MATRIX_GATE_KEYS = GATE_KEYS | {"matrix_jobs"}
ASSERT_KEYS = {"require_files", "readme_install_pattern", "max_blob_bytes"}
MECH_KEYS = {"version", "manifest_sha256"}
class PolicyError(Exception):
"""A policy record is absent, malformed, or declares something unsupported."""
def __init__(self, reasons):
self.reasons = list(reasons)
super().__init__("; ".join(self.reasons))
class Gate:
__slots__ = ("workflow_path", "job_name", "cardinality", "matrix_jobs")
def __init__(self, workflow_path, job_name, cardinality, matrix_jobs=()):
self.workflow_path = workflow_path
self.job_name = job_name
self.cardinality = cardinality
self.matrix_jobs = tuple(matrix_jobs)
def __repr__(self):
n = f", {len(self.matrix_jobs)} legs" if self.matrix_jobs else ""
return f"Gate({self.workflow_path}::{self.job_name}, {self.cardinality}{n})"
class Policy:
__slots__ = (
"schema_version", "internal_repository", "destination_host",
"destination_repository", "permitted_refs", "projection",
"publisher_identity", "allowed_events", "rerun_policy",
"required_gates", "repo_assertions", "mechanism_version",
"mechanism_manifest_sha256", "source_path", "history_allowlist_sha256",
)
def __init__(self, raw, source_path=None):
self.schema_version = raw["schema_version"]
self.internal_repository = raw["internal_repository"]
self.destination_host = raw["destination_host"]
self.destination_repository = raw["destination_repository"]
self.permitted_refs = tuple(raw["permitted_refs"])
self.projection = raw["projection"]
self.publisher_identity = raw["publisher_identity"]
self.allowed_events = frozenset(raw["allowed_events"])
self.rerun_policy = raw["rerun_policy"]
self.required_gates = tuple(
Gate(g["workflow_path"], g["job_name"], g["cardinality"],
g.get("matrix_jobs", ()))
for g in raw["required_gates"]
)
self.repo_assertions = dict(raw["repo_assertions"])
self.mechanism_version = raw["mechanism"]["version"]
self.mechanism_manifest_sha256 = raw["mechanism"]["manifest_sha256"]
self.history_allowlist_sha256 = raw["history_allowlist_sha256"]
self.source_path = source_path
def _strict_keys(obj, expected, where, reasons):
if not isinstance(obj, dict):
reasons.append(f"{where}: expected object")
return False
got = set(obj)
for k in sorted(expected - got):
reasons.append(f"{where}: missing key '{k}'")
for k in sorted(got - expected):
reasons.append(f"{where}: unknown key '{k}'")
return got == expected
def validate(raw):
"""Return a list of reasons the record is unusable. Empty list means valid."""
reasons = []
if not _strict_keys(raw, TOP_KEYS, "policy", reasons):
return reasons
if raw["schema_version"] != SCHEMA_VERSION:
reasons.append(
f"policy: schema_version {raw['schema_version']!r} is not {SCHEMA_VERSION}")
for field, pattern in (
("internal_repository", REPO_RE),
("destination_repository", REPO_RE),
("destination_host", HOST_RE),
):
v = raw[field]
if not isinstance(v, str) or not pattern.match(v):
reasons.append(f"policy: {field} {v!r} is malformed")
refs = raw["permitted_refs"]
if not isinstance(refs, list) or not refs:
reasons.append("policy: permitted_refs must be a non-empty list")
else:
for r in refs:
if not isinstance(r, str) or not REF_RE.match(r):
reasons.append(f"policy: permitted_ref {r!r} is malformed")
if len(set(refs)) != len(refs):
reasons.append("policy: permitted_refs contains duplicates")
if raw["projection"] not in PROJECTIONS:
reasons.append(f"policy: projection {raw['projection']!r} is not one of "
+ ", ".join(sorted(PROJECTIONS)))
if not isinstance(raw["publisher_identity"], str) or not raw["publisher_identity"]:
reasons.append("policy: publisher_identity must be a non-empty string")
events = raw["allowed_events"]
if not isinstance(events, list) or not events:
reasons.append("policy: allowed_events must be a non-empty list")
else:
for e in events:
if e not in KNOWN_EVENTS:
reasons.append(f"policy: allowed_event {e!r} is unknown")
if raw["rerun_policy"] not in RERUN_POLICIES:
reasons.append(f"policy: rerun_policy {raw['rerun_policy']!r} is not one of "
+ ", ".join(sorted(RERUN_POLICIES)))
gates = raw["required_gates"]
if not isinstance(gates, list) or not gates:
reasons.append("policy: required_gates must be a non-empty list")
else:
seen = set()
for i, g in enumerate(gates):
where = f"required_gates[{i}]"
expected = (MATRIX_GATE_KEYS
if isinstance(g, dict) and g.get("cardinality") == "matrix"
else GATE_KEYS)
if not _strict_keys(g, expected, where, reasons):
continue
wp, jn, card = g["workflow_path"], g["job_name"], g["cardinality"]
if not isinstance(wp, str) or not WORKFLOW_PATH_RE.match(wp):
reasons.append(f"{where}: workflow_path {wp!r} is malformed")
if not isinstance(jn, str) or not jn:
reasons.append(f"{where}: job_name must be a non-empty string")
if card not in DECLARED_CARDINALITIES:
reasons.append(f"{where}: cardinality {card!r} is not recognised")
elif card not in SUPPORTED_CARDINALITIES:
reasons.append(
f"{where}: gate {wp}::{jn} declares cardinality {card!r}, which is "
f"UNSUPPORTED in schema v{SCHEMA_VERSION}; publication is denied "
f"rather than guessed")
if card == "matrix":
legs = g["matrix_jobs"]
if not isinstance(legs, list) or not legs:
reasons.append(f"{where}: matrix_jobs must be a non-empty list "
"naming every expanded job exactly")
else:
if any(not isinstance(x, str) or not x for x in legs):
reasons.append(f"{where}: matrix_jobs must be non-empty strings")
if len(set(legs)) != len(legs):
reasons.append(f"{where}: matrix_jobs contains duplicates")
key = (wp, jn)
if key in seen:
reasons.append(f"{where}: duplicate gate {wp}::{jn}")
seen.add(key)
a = raw["repo_assertions"]
if _strict_keys(a, ASSERT_KEYS, "repo_assertions", reasons):
if not isinstance(a["require_files"], list) or any(
not isinstance(f, str) or not f for f in a["require_files"]):
reasons.append("repo_assertions: require_files must be a list of paths")
p = a["readme_install_pattern"]
if p is not None:
if not isinstance(p, str):
reasons.append("repo_assertions: readme_install_pattern must be a string or null")
else:
try:
re.compile(p)
except re.error as exc:
reasons.append(f"repo_assertions: readme_install_pattern is not a valid regex ({exc})")
n = a["max_blob_bytes"]
if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
reasons.append("repo_assertions: max_blob_bytes must be a positive integer")
m = raw["mechanism"]
if _strict_keys(m, MECH_KEYS, "mechanism", reasons):
if not isinstance(m["version"], int) or isinstance(m["version"], bool) or m["version"] < 1:
reasons.append("mechanism: version must be a positive integer")
if not isinstance(m["manifest_sha256"], str) or not SHA256_RE.match(m["manifest_sha256"]):
reasons.append("mechanism: manifest_sha256 must be 64 lowercase hex characters")
h = raw["history_allowlist_sha256"]
if h is not None and (not isinstance(h, str) or not SHA256_RE.match(h)):
reasons.append(
"policy: history_allowlist_sha256 must be 64 lowercase hex characters or "
"null; pinning it here is what stops a repository blessing its own history")
return reasons
def load_record(path):
try:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
except FileNotFoundError:
raise PolicyError([f"policy: no record at {path}"])
except json.JSONDecodeError as exc:
raise PolicyError([f"policy: {path} is not valid JSON ({exc})"])
reasons = validate(raw)
if reasons:
raise PolicyError(reasons)
return Policy(raw, source_path=os.path.abspath(path))
class PolicySet:
"""Every record in a directory, indexed by internal repository.
A record whose filename does not match its internal_repository is rejected:
the filename is how a human finds it, and a mismatch hides one record behind
another's name.
"""
def __init__(self, records):
self._by_repo = records
@classmethod
def from_directory(cls, directory):
by_repo = {}
reasons = []
if not os.path.isdir(directory):
raise PolicyError([f"policy: no policy directory at {directory}"])
for name in sorted(os.listdir(directory)):
if not name.endswith(".json"):
continue
path = os.path.join(directory, name)
try:
pol = load_record(path)
except PolicyError as exc:
reasons.extend(f"{name}: {r}" for r in exc.reasons)
continue
expected = pol.internal_repository.replace("/", "-") + ".json"
if name != expected:
reasons.append(
f"{name}: record for {pol.internal_repository} must be named {expected}")
continue
if pol.internal_repository in by_repo:
reasons.append(f"{name}: duplicate record for {pol.internal_repository}")
continue
by_repo[pol.internal_repository] = pol
if reasons:
raise PolicyError(reasons)
return cls(by_repo)
def authorize(self, internal_repository):
pol = self._by_repo.get(internal_repository)
if pol is None:
raise PolicyError(
[f"policy: no record authorizes {internal_repository}; publication denied"])
return pol
def __len__(self):
return len(self._by_repo)
def repositories(self):
return tuple(sorted(self._by_repo))
def assert_policy_is_external(policy, consuming_repo_root):
"""The publishing repo must not be able to edit its own authorization.
Filesystem containment is the check available here; the durable control is
that the policy repository denies write to every publishing identity.
"""
if policy.source_path is None:
raise PolicyError(["policy: record has no source path to verify"])
root = os.path.realpath(consuming_repo_root)
src = os.path.realpath(policy.source_path)
if src == root or src.startswith(root + os.sep):
raise PolicyError([
f"policy: record {src} lives inside the repository it authorizes ({root}); "
"a repository must not be able to write its own policy"])