321 lines
13 KiB
Python
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"])
|