diff --git a/decnet/cli/init.py b/decnet/cli/init.py index 38891bb0..88a1de47 100644 --- a/decnet/cli/init.py +++ b/decnet/cli/init.py @@ -379,13 +379,23 @@ def _install_units( def _install_polkit( - deploy: Path, rules_dir: Path, *, force: bool, dry_run: bool + deploy: Path, rules_dir: Path, *, group: str, force: bool, dry_run: bool ) -> str: - src = deploy / "polkit" / "50-decnet-workers.rules" + """Render the group-scoped polkit rule to /etc/polkit-1/rules.d/. + + The rule has to reference the same POSIX group passed via --group — + otherwise the API (running as that user) can't + systemctl start/stop decnet-*.service without an interactive auth + prompt that never gets answered in a daemon context. + """ + src = deploy / "polkit" / "50-decnet-workers.rules.j2" if not src.is_file(): - raise RuntimeError(f"missing polkit rule at {src}") - return _copy_if_changed( - src, rules_dir / src.name, + raise RuntimeError(f"missing polkit rule template at {src}") + rendered = _render_template(src, {"group": group}) + # 50-decnet-workers.rules.j2 → 50-decnet-workers.rules + dst_name = src.name[: -len(".j2")] + return _write_rendered_if_changed( + src, rules_dir / dst_name, rendered, mode=0o644, force=force, dry_run=dry_run, ) @@ -755,7 +765,8 @@ def register(app: typer.Typer) -> None: _step( "install polkit rule", lambda: _install_polkit( - deploy, polkit_dir, force=force, dry_run=dry_run, + deploy, polkit_dir, group=group, + force=force, dry_run=dry_run, ), ) _step( diff --git a/deploy/polkit/50-decnet-workers.rules b/deploy/polkit/50-decnet-workers.rules.j2 similarity index 66% rename from deploy/polkit/50-decnet-workers.rules rename to deploy/polkit/50-decnet-workers.rules.j2 index f9f2afaf..c366ad60 100644 --- a/deploy/polkit/50-decnet-workers.rules +++ b/deploy/polkit/50-decnet-workers.rules.j2 @@ -1,10 +1,14 @@ -// Allow members of the 'decnet' group to manage DECNET systemd units +// Allow members of the '{{ group }}' group to manage DECNET systemd units // (start / stop / restart / reload) without a password prompt. // // Scope is locked to units matching `decnet-.service` or the // `decnet.target` grouping unit. Any other unit is unaffected by this // rule and still goes through the default polkit policy. // +// The group name is rendered at `decnet init` time from --group; the +// default is `decnet`, but dev boxes that pass --group $USER get a +// rule that matches the operator's own login group. +// // Install: /etc/polkit-1/rules.d/50-decnet-workers.rules polkit.addRule(function(action, subject) { @@ -12,7 +16,7 @@ polkit.addRule(function(action, subject) { var unit = action.lookup("unit"); if (unit && /^decnet-[a-z]+\.service$|^decnet\.target$/.test(unit) && - subject.isInGroup("decnet")) { + subject.isInGroup("{{ group }}")) { return polkit.Result.YES; } } diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 8df2e086..2e910bd0 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -77,7 +77,9 @@ def _seed_deploy(monkeypatch: Any, tmp_path: Path) -> Path: "ExecStart={{ install_dir }}/venv/bin/decnet api\n" ) (deploy / "decnet.target").write_text("# target\n") - (deploy / "polkit" / "50-decnet-workers.rules").write_text("// rule\n") + (deploy / "polkit" / "50-decnet-workers.rules.j2").write_text( + '// rule for {{ group }}\n' + ) (deploy / "tmpfiles.d" / "decnet.conf").write_text("d /run/decnet\n") monkeypatch.setattr(_init, "_deploy_root", lambda: deploy) return deploy