diff --git a/backend/tests/test_delivery_io.py b/backend/tests/test_delivery_io.py index 4554731..d16d316 100644 --- a/backend/tests/test_delivery_io.py +++ b/backend/tests/test_delivery_io.py @@ -336,7 +336,9 @@ def live_byte_proof_model(ordinary_bundle, monkeypatch, tmp_path): path = tmp_path / "byte-proof.json" path.write_bytes(policy.canonical(record)) monkeypatch.setattr(operation_proof, "RECORD", path) - monkeypatch.setattr(operation_proof, "_root", lambda: True) + for name in ("getuid", "geteuid", "getgid", "getegid"): + monkeypatch.setattr(operation_proof.os, name, lambda: 0) + monkeypatch.setattr(operation_proof.os, "getgroups", lambda: [0]) monkeypatch.setattr(operation_proof.preflight, "protected", lambda *a, **kw: None) monkeypatch.setattr( operation_proof.preflight, @@ -367,6 +369,69 @@ def test_live_parent_model_reuses_complete_proof_without_remembering_it( assert not reads +@pytest.mark.parametrize( + ("unit", "gid"), + [ + ("postgres", 1203), + ("rabbitmq", 1204), + ("sandbox-factory", 0), + ("coordinator", 1201), + ("api", 1200), + ("outbox", 1200), + ("problem-import", 1200), + ("worker", 1202), + ("proxy", 1205), + ("node-health", 0), + ], +) +def test_privileged_service_group_model_can_consume_but_not_publish( + live_byte_proof_model, monkeypatch, unit, gid +): + bundle, expected, record = live_byte_proof_model + monkeypatch.setattr(operation_proof.os, "getgroups", lambda: [gid]) + monkeypatch.setattr( + operation_proof.preflight, + "current_cgroup", + lambda: Path(operation_proof.GROUP + "kf-" + unit + ".service"), + ) + reads = observed_digests(monkeypatch) + assert manifest.verify(bundle, expected)["release_id"] == record["release_id"] + assert not reads + # Publication still requires Root's own groups, even in a consumer model. + assert operation_proof._root() is (gid == 0) + + +@pytest.mark.parametrize( + ("unit", "groups", "ordinary_id"), + [ + ("api", [1201], None), + ("worker", [1200], None), + ("node-health", [1200], None), + ("api", [0, 1200, 1201], None), + ("api/child", [1200], None), + ("api", [1200], "getuid"), + ("api", [1200], "geteuid"), + ("api", [1200], "getgid"), + ("api", [1200], "getegid"), + ], +) +def test_foreign_groups_and_each_ordinary_identity_model_require_full_bytes( + live_byte_proof_model, monkeypatch, unit, groups, ordinary_id +): + bundle, expected, _ = live_byte_proof_model + monkeypatch.setattr(operation_proof.os, "getgroups", lambda: groups) + if ordinary_id: + monkeypatch.setattr(operation_proof.os, ordinary_id, lambda: 1200) + monkeypatch.setattr( + operation_proof.preflight, + "current_cgroup", + lambda: Path(operation_proof.GROUP + "kf-" + unit + ".service"), + ) + reads = observed_digests(monkeypatch) + manifest.verify(bundle, expected) + assert reads + + @pytest.mark.parametrize( ("field", "value"), [ @@ -415,7 +480,7 @@ def test_ineligible_parent_proof_model_requires_full_bytes( ): bundle, expected, _ = live_byte_proof_model if reason == "ordinary_uid": - monkeypatch.setattr(operation_proof, "_root", lambda: False) + monkeypatch.setattr(operation_proof, "_root", lambda *args: False) elif reason in {"foreign_group", "parent"}: unit = "unrelated.service" if reason == "foreign_group" else operation_proof.PARENT monkeypatch.setattr( diff --git a/scripts/release/README.md b/scripts/release/README.md index 2ccfca4..9047b3e 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -434,6 +434,10 @@ identities again before returning. All readers are joined on success or failure. During one `start`, `resume` or `restart`, the fixed Root maintenance helper reads the complete bundle once. Its Root startup checks and node-health helpers can reuse that proof while the same transient maintenance invocation is running. +Consumers require real and effective UID/GID 0 and the exact fixed service +cgroup. A privileged service precheck may retain only Root's group and that +service's fixed group; ordinary service processes still read the complete bytes. +The publishing maintenance helper accepts only Root's own group. A Root-only systemd runtime directory holds a record of at most 4096 bytes, binding the full metadata digest to the selected manifest, release, configuration, boot, MainPID and InvocationID. Each consumer checks the live owner and every diff --git a/scripts/release/operation_proof.py b/scripts/release/operation_proof.py index 4f15d03..1b61a13 100644 --- a/scripts/release/operation_proof.py +++ b/scripts/release/operation_proof.py @@ -15,33 +15,32 @@ DIRECTORY = Path("/run/kf-node-verification") RECORD = DIRECTORY / "byte-proof.json" PARENT = "kf-node-maintenance.service" GROUP = "/sys/fs/cgroup/kfonlinejudge.slice/" -CONSUMERS = frozenset( - "kf-" + name + ".service" - for name in ( - "postgres", - "rabbitmq", - "sandbox-factory", - "coordinator", - "api", - "outbox", - "problem-import", - "worker", - "proxy", - "node-health", - ) -) +# Privileged ExecStartPre checks retain their ordinary service's supplementary +# group. These are fixed unit identities, never groups supplied by a proof. +CONSUMERS = { + "kf-postgres.service": 1203, + "kf-rabbitmq.service": 1204, + "kf-sandbox-factory.service": 0, + "kf-coordinator.service": 1201, + "kf-api.service": 1200, + "kf-outbox.service": 1200, + "kf-problem-import.service": 1200, + "kf-worker.service": 1202, + "kf-proxy.service": 1205, + "kf-node-health.service": 0, +} MAX_AGE_NS = 900 * 10**9 MAX_RECORD = 4096 _REPORTED: tuple[int, str] | None = None -def _root() -> bool: +def _root(allowed_groups: frozenset[int] = frozenset({0})) -> bool: return (os.getuid(), os.geteuid(), os.getgid(), os.getegid()) == ( 0, 0, 0, 0, - ) and set(os.getgroups()) <= {0} + ) and set(os.getgroups()) <= allowed_groups def _now() -> int: @@ -194,7 +193,10 @@ def _observed(value: dict) -> None: def reuse(bundle: Path, expected: str) -> dict | None: """Missing/ineligible proofs use full bytes; eligible corrupt proofs refuse.""" - if not _root() or str(preflight.current_cgroup()) not in {GROUP + unit for unit in CONSUMERS}: + allowed_groups = {GROUP + unit: frozenset({0, gid}) for unit, gid in CONSUMERS.items()}.get( + str(preflight.current_cgroup()) + ) + if allowed_groups is None or not _root(allowed_groups): return None try: RECORD.lstat()