fix(delivery): recognize privileged service groups in byte proofs
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user