fix(delivery): recognize privileged service groups in byte proofs

This commit is contained in:
2026-10-22 05:54:34 +08:00
parent 0cd94a20ec
commit 278337dd46
3 changed files with 91 additions and 20 deletions
+67 -2
View File
@@ -336,7 +336,9 @@ def live_byte_proof_model(ordinary_bundle, monkeypatch, tmp_path):
path = tmp_path / "byte-proof.json" path = tmp_path / "byte-proof.json"
path.write_bytes(policy.canonical(record)) path.write_bytes(policy.canonical(record))
monkeypatch.setattr(operation_proof, "RECORD", path) 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, "protected", lambda *a, **kw: None)
monkeypatch.setattr( monkeypatch.setattr(
operation_proof.preflight, operation_proof.preflight,
@@ -367,6 +369,69 @@ def test_live_parent_model_reuses_complete_proof_without_remembering_it(
assert not reads 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( @pytest.mark.parametrize(
("field", "value"), ("field", "value"),
[ [
@@ -415,7 +480,7 @@ def test_ineligible_parent_proof_model_requires_full_bytes(
): ):
bundle, expected, _ = live_byte_proof_model bundle, expected, _ = live_byte_proof_model
if reason == "ordinary_uid": 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"}: elif reason in {"foreign_group", "parent"}:
unit = "unrelated.service" if reason == "foreign_group" else operation_proof.PARENT unit = "unrelated.service" if reason == "foreign_group" else operation_proof.PARENT
monkeypatch.setattr( monkeypatch.setattr(
+4
View File
@@ -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 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 the complete bundle once. Its Root startup checks and node-health helpers can
reuse that proof while the same transient maintenance invocation is running. 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, 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, binding the full metadata digest to the selected manifest, release, configuration,
boot, MainPID and InvocationID. Each consumer checks the live owner and every boot, MainPID and InvocationID. Each consumer checks the live owner and every
+20 -18
View File
@@ -15,33 +15,32 @@ DIRECTORY = Path("/run/kf-node-verification")
RECORD = DIRECTORY / "byte-proof.json" RECORD = DIRECTORY / "byte-proof.json"
PARENT = "kf-node-maintenance.service" PARENT = "kf-node-maintenance.service"
GROUP = "/sys/fs/cgroup/kfonlinejudge.slice/" GROUP = "/sys/fs/cgroup/kfonlinejudge.slice/"
CONSUMERS = frozenset( # Privileged ExecStartPre checks retain their ordinary service's supplementary
"kf-" + name + ".service" # group. These are fixed unit identities, never groups supplied by a proof.
for name in ( CONSUMERS = {
"postgres", "kf-postgres.service": 1203,
"rabbitmq", "kf-rabbitmq.service": 1204,
"sandbox-factory", "kf-sandbox-factory.service": 0,
"coordinator", "kf-coordinator.service": 1201,
"api", "kf-api.service": 1200,
"outbox", "kf-outbox.service": 1200,
"problem-import", "kf-problem-import.service": 1200,
"worker", "kf-worker.service": 1202,
"proxy", "kf-proxy.service": 1205,
"node-health", "kf-node-health.service": 0,
) }
)
MAX_AGE_NS = 900 * 10**9 MAX_AGE_NS = 900 * 10**9
MAX_RECORD = 4096 MAX_RECORD = 4096
_REPORTED: tuple[int, str] | None = None _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()) == ( return (os.getuid(), os.geteuid(), os.getgid(), os.getegid()) == (
0, 0,
0, 0,
0, 0,
0, 0,
) and set(os.getgroups()) <= {0} ) and set(os.getgroups()) <= allowed_groups
def _now() -> int: def _now() -> int:
@@ -194,7 +193,10 @@ def _observed(value: dict) -> None:
def reuse(bundle: Path, expected: str) -> dict | None: def reuse(bundle: Path, expected: str) -> dict | None:
"""Missing/ineligible proofs use full bytes; eligible corrupt proofs refuse.""" """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 return None
try: try:
RECORD.lstat() RECORD.lstat()