diff --git a/schemas/qualification-release.schema.json b/schemas/qualification-release.schema.json index 48a85f6..ba11124 100644 --- a/schemas/qualification-release.schema.json +++ b/schemas/qualification-release.schema.json @@ -229,11 +229,34 @@ "role": {"const": "immutability"}, "name": {"const": "OpenAdapt policy: immutable release tags"}, "bypass_actors": {"const": []}, - "rules": {"const": [ - {"type": "deletion"}, - {"type": "non_fast_forward"}, - {"type": "update", "parameters": {"update_allows_fetch_and_merge": false}} - ]} + "rules": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + {"const": {"type": "deletion"}}, + {"const": {"type": "non_fast_forward"}}, + {"oneOf": [ + {"const": {"type": "update"}}, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "parameters"], + "properties": { + "type": {"const": "update"}, + "parameters": { + "type": "object", + "additionalProperties": false, + "required": ["update_allows_fetch_and_merge"], + "properties": { + "update_allows_fetch_and_merge": {"type": "boolean", "const": false} + } + } + } + } + ]} + ] + } }} ] }, diff --git a/scripts/production_trust.py b/scripts/production_trust.py index e96c401..34e9a56 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -653,12 +653,26 @@ def validate_tag_rulesets( else [ {"type": "deletion"}, {"type": "non_fast_forward"}, - { - "type": "update", - "parameters": {"update_allows_fetch_and_merge": False}, - }, + {"type": "update"}, ] ) + if ( + index == 1 + and isinstance(rules, list) + and len(rules) == 3 + and isinstance(rules[2], dict) + and "parameters" in rules[2] + ): + # GitHub omits this branch-only option on tag update rules. Preserve + # either exact representation; never normalize the retained evidence. + parameters = closed( + rules[2]["parameters"], + {"update_allows_fetch_and_merge"}, + "tag update rule parameters", + ) + if parameters["update_allows_fetch_and_merge"] is not False: + raise TrustError("tag update rule cannot allow fetch and merge") + expected_rules[2]["parameters"] = {"update_allows_fetch_and_merge": False} if rules != expected_rules: raise TrustError("tag ruleset rules differ from immutable policy") return value diff --git a/tests/test_tag_ruleset_representation.py b/tests/test_tag_ruleset_representation.py new file mode 100644 index 0000000..28af7f3 --- /dev/null +++ b/tests/test_tag_ruleset_representation.py @@ -0,0 +1,145 @@ +"""The two exact GitHub tag update forms preserve the same release controls.""" + +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator +from test_production_trust import rulesets, trust + +ROOT = Path(__file__).resolve().parents[1] +EXPLICIT_FALSE = { + "type": "update", + "parameters": {"update_allows_fetch_and_merge": False}, +} + + +class TagRulesetRepresentationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + schema = json.loads( + (ROOT / "schemas/qualification-release.schema.json").read_text() + ) + cls.validator = Draft202012Validator( + { + "$schema": schema["$schema"], + "$defs": schema["$defs"], + **schema["$defs"]["publication_staging"]["properties"]["tag_rulesets"], + } + ) + + def validate_runtime(self, value): + return trust.validate_tag_rulesets( + value, + repository="OpenAdaptAI/openadapt-capture", + repository_id="1115283835", + ) + + def assert_refused(self, value): + with self.assertRaises(trust.TrustError): + self.validate_runtime(value) + self.assertFalse(self.validator.is_valid(value)) + + def test_both_exact_tag_update_forms_preserve_observed_bytes(self) -> None: + for update in ({"type": "update"}, EXPLICIT_FALSE): + with self.subTest(update=update): + value = rulesets() + value[1]["rules"][2] = copy.deepcopy(update) + before = json.dumps(value, sort_keys=True).encode() + digest = trust.digest_bytes(trust.TAG_RULESETS_DOMAIN, value) + self.assertIs(self.validate_runtime(value), value) + self.validator.validate(value) + self.assertEqual(json.dumps(value, sort_keys=True).encode(), before) + self.assertEqual( + trust.digest_bytes(trust.TAG_RULESETS_DOMAIN, value), digest + ) + + def test_update_rule_rejects_non_boolean_false_and_extra_fields(self) -> None: + invalid = [ + {"type": "update", "parameters": {"update_allows_fetch_and_merge": flag}} + for flag in (True, None, 0, 1, 0.0, "false", "False", "0", [], {}) + ] + invalid.extend( + [ + {"type": "update", "parameters": None}, + {"type": "update", "parameters": {}}, + {"type": "update", "parameters": []}, + {"type": "update", "extra": False}, + {**EXPLICIT_FALSE, "extra": False}, + { + "type": "update", + "parameters": { + "update_allows_fetch_and_merge": False, + "extra": False, + }, + }, + {"parameters": {"update_allows_fetch_and_merge": False}}, + ] + ) + for update in invalid: + with self.subTest(update=update): + value = rulesets() + value[1]["rules"][2] = update + self.assert_refused(value) + + def test_both_forms_keep_all_protection_boundaries(self) -> None: + for update in ({"type": "update"}, EXPLICIT_FALSE): + original = rulesets() + original[1]["rules"][2] = copy.deepcopy(update) + mutations = [ + ("missing deletion", lambda v: v[1]["rules"].pop(0)), + ("missing non-fast-forward", lambda v: v[1]["rules"].pop(1)), + ("missing update", lambda v: v[1]["rules"].pop(2)), + ("extra rule", lambda v: v[1]["rules"].append({"type": "creation"})), + ( + "duplicate update", + lambda v: v[1]["rules"].append(copy.deepcopy(v[1]["rules"][2])), + ), + ("reordered rules", lambda v: v[1]["rules"].reverse()), + ( + "extra deletion field", + lambda v: v[1]["rules"][0].update(extra=False), + ), + ( + "immutability bypass", + lambda v: v[1]["bypass_actors"].extend(v[0]["bypass_actors"]), + ), + ("missing creation authority", lambda v: v[0]["bypass_actors"].clear()), + ( + "different creation authority", + lambda v: v[0]["bypass_actors"][0].update(actor_id="123"), + ), + ("missing creation", lambda v: v[0]["rules"].clear()), + ( + "extra creation rule", + lambda v: v[0]["rules"].append({"type": "update"}), + ), + ("branch target", lambda v: v[1].update(target="branch")), + ("inactive ruleset", lambda v: v[1].update(enforcement="disabled")), + ( + "wider tag scope", + lambda v: v[1]["conditions"]["ref_name"].update( + include=["refs/tags/*"] + ), + ), + ( + "excluded tag", + lambda v: v[1]["conditions"]["ref_name"].update( + exclude=["refs/tags/v1.0.0"] + ), + ), + ("extra ruleset", lambda v: v.append(copy.deepcopy(v[1]))), + ("missing ruleset", lambda v: v.pop(1)), + ] + for label, mutate in mutations: + with self.subTest(update=update, mutation=label): + value = copy.deepcopy(original) + mutate(value) + self.assert_refused(value) + + +if __name__ == "__main__": + unittest.main()