It’s 2 AM and the on-call dev needs to bounce one service
The app is wedged. The on-call dev knows a restart fixes it. You are asleep, and the dev does not have root, because you are not insane.
So you get the message: “can you restart demo-web?” Again. For the third time this month.
The usual answer is a sudoers line. It works. But for this exact job, “let group X restart service Y”, a polkit rule scoped on action id, unit, and verb is the cleaner tool. There is no sudo in the path. The user types plain systemctl restart demo-web.service. The rule names one unit and one verb, so there is no argument list to get wrong.
sudoers still wins in a few cases, and I’ll list them. First, the working rule, tested.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/linux/polkit-restart-one-service
The rule
Here it is. Group oncall may restart demo-web.service. Nothing else changes.
polkit.addRule(function(action, subject) { if (action.id == "org.freedesktop.systemd1.manage-units" && action.lookup("unit") == "demo-web.service" && action.lookup("verb") == "restart" && subject.isInGroup("oncall")) { return polkit.Result.YES; }});And the sudoers version of the same job:
%oncall ALL=(root) NOPASSWD: /usr/bin/systemctl restart demo-web.serviceI ran both in a Debian trixie container with systemd as PID 1 (polkit 126, systemd 257, sudo 1.9.16). The test script uses alice (in oncall) and bob (not in it). With no rule installed, alice gets this:
$ systemctl --no-ask-password restart demo-web.serviceFailed to restart demo-web.service: Interactive authentication required.See system logs and 'systemctl status demo-web.service' for details.With the rule dropped into /etc/polkit-1/rules.d/, the same command succeeds for alice, the service gets a new main PID, and these all still fail:
alice: systemctl stop demo-web.service -> Interactive authentication required.alice: systemctl restart demo-other.service -> Interactive authentication required.alice: systemctl enable demo-web.service -> Interactive authentication required.bob: systemctl restart demo-web.service -> Interactive authentication required.That is the feature. Now the machinery, so you can debug it when it misbehaves.
What polkit is, in four sentences
polkitd is a daemon that answers one question: may this process do this action? Programs like systemd ask it over D-Bus. Each action has an id, such as org.freedesktop.systemd1.manage-units, declared in an XML .policy file under /usr/share/polkit-1/actions. Each action also has default answers, and your .rules files can override those defaults.
Look at the defaults for the action systemd uses for start, stop, and restart:
$ pkaction --action-id org.freedesktop.systemd1.manage-units --verboseorg.freedesktop.systemd1.manage-units: description: Manage system services or other units message: Authentication is required to manage system services or other units. vendor: The systemd Project vendor_url: https://systemd.io icon: implicit any: auth_admin implicit inactive: auth_admin implicit active: auth_admin_keepauth_admin means “an administrator must authenticate”. On a terminal, systemctl starts a text agent (pkttyagent) and asks for an admin password, which the on-call dev does not have. With --no-ask-password, or from a script with no terminal, nothing can ask, so the answer is a refusal. That is the Interactive authentication required message above. Either way, no admin password means no restart.
The rule file overrides this for one narrow case. You are not changing the policy. You are adding an exception in front of it.
How rules are found and run
Rules are JavaScript files (the interpreter only supports ECMAScript 5, so no arrow functions, no let). polkitd reads .rules files from these directories, in this order:
/etc/polkit-1/rules.d/run/polkit-1/rules.d/usr/local/share/polkit-1/rules.d/usr/share/polkit-1/rules.d
Files are processed in lexical order by basename, so 10-foo.rules runs before 50-bar.rules regardless of directory. On a tie, the earlier directory wins. Inside the files, each function you register with polkit.addRule() is called in order until one returns a value. The first function that returns a result wins. A function that returns nothing, null, or polkit.Result.NOT_HANDLED passes the question to the next one.
That is why the rule above has no else. If the conditions do not match, the function falls off the end and returns undefined, so polkit moves on to the defaults.
The possible results are the same strings the .policy defaults use:
polkit.Result.NO "no"polkit.Result.YES "yes"polkit.Result.AUTH_SELF "auth_self"polkit.Result.AUTH_SELF_KEEP "auth_self_keep"polkit.Result.AUTH_ADMIN "auth_admin"polkit.Result.AUTH_ADMIN_KEEP "auth_admin_keep"polkit.Result.NOT_HANDLED nullpolkitd watches those directories. Drop a file in, edit it, or delete it, and the rules are purged and reloaded. No restart needed. My test script waits two seconds after installing a rule, and that was enough every time.
The details systemd sends
A bare action id is too coarse. manage-units covers start, stop, restart, and reload for every unit on the box. If your rule only checked the id, you would have written “this group is root, but with extra steps”.
So systemd attaches details to the check, and you read them with action.lookup(). I logged them with a debug rule. These are the real strings, for alice running each verb against demo-web.service:
unit=demo-web.service verb=startunit=demo-web.service verb=stopunit=demo-web.service verb=restartunit=demo-web.service verb=try-restartunit=demo-web.service verb=reloadunit=demo-web.service verb=reload-or-restartunit=demo-web.service verb=killTwo things to notice. First, restart and reload-or-restart and try-restart are separate verbs. A rule that matches "restart" does not cover systemctl try-restart. That is exactly what you want from a one-verb rule. If you want both, check both:
var verb = action.lookup("verb");if (verb == "restart" || verb == "try-restart") { /* ... */ }Second, systemctl restart demo-web (no suffix) logged as unit=demo-web.service. systemctl adds the .service suffix before it asks. So match "demo-web.service", with the suffix, and a short name typed without the suffix still works for the user.
Subject details you can match on
The subject object tells you who is asking. The useful bits:
subject.isInGroup("oncall"): true if the user is in that group.subject.userandsubject.uid: the user name and numeric id.subject.local: true only if the subject is on a local seat.subject.active: true only if the session is active.subject.system_unit: the systemd system unit the process belongs to, if any.
In my container run, alice came through runuser and the debug rule printed local=false active=false. A real SSH login has no seat either, and the man page defines local as true only on a local seat. The stock defaults treat allow_any, allow_inactive, and allow_active differently, which is why a desktop user and an SSH user can see different results on stock rules. Your rule above is not affected, because it matches on group and returns YES before the defaults are consulted. Do not add subject.local or subject.active to it, or you will lock out the SSH sessions you wrote it for.
Debugging: polkit.log and the silent flag
You will want to see what polkit sees. Use polkit.log():
polkit.addRule(function(action, subject) { if (action.id == "org.freedesktop.systemd1.manage-units") { polkit.log("unit=" + action.lookup("unit") + " verb=" + action.lookup("verb") + " user=" + subject.user + " local=" + subject.local + " active=" + subject.active); }});The 00- prefix makes it run first. It returns nothing, so the real rules still decide.
Then I ran a restart and checked journalctl -u polkit. It printed zero matching lines.
This is the gotcha that eats an hour. Debian’s polkit.service starts the daemon as polkitd --no-debug --log-level=notice. The --no-debug flag sends stderr to nowhere, and in polkit 126 polkit.log() writes to stderr. (The man page on my machine says log entries go to syslog with LOG_AUTHPRIV. That is not what the 126 source does, so trust your journal over the man page.) The Arch unit on my desktop has the same --no-debug.
The fix is a drop-in that removes the flag:
[Service]ExecStart=ExecStart=/usr/lib/polkit-1/polkitd --log-level=noticeAfter systemctl daemon-reload && systemctl restart polkit, the log lines show up in journalctl -u polkit. Remove the drop-in when you are done, because you are logging every manage-units check on the box.
Testing with pkcheck
pkcheck asks polkit the same question without running the real command. It needs root to pass details, because details are only trusted from root or the action owner. As alice I got:
Only trusted callers (e.g. uid 0 or an action owner) can use CheckAuthorization() and pass detailsSo run it as root against a process owned by the user. Pass pid,start-time,uid, as the man page recommends over the racy bare pid:
pkcheck --action-id org.freedesktop.systemd1.manage-units \ --process "$apid,$astart,$(id -u alice)" \ --detail unit demo-web.service --detail verb restartecho $? # 0, authorized
# same command with --detail verb stopecho $? # 2, needs authentication and -u was not passedExit 0 means authorized. Exit 2 means polkit wants interactive authentication and pkcheck was not told to ask. Details are printed with octal escapes, so demo-web.service shows as demo\55web\56service. Do not panic when you see that.
Mistakes that bite
A blanket YES on manage-units. Drop the unit and verb checks and the group can start, stop, and restart every service on the box, including ones that run as root and do whatever they like. That is root with a longer commute. Always match unit and verb.
A syntax error. I added a broken 10-broken.rules next to the working rule. polkitd logged Error compiling script /etc/polkit-1/rules.d/10-broken.rules: SyntaxError: parse error (line 1) (the line number depends on your file), skipped that one file, and the good rule kept working. So it does not take down your other rules. It does silently stop your new rule from existing, and the only evidence is in journalctl -u polkit. Check there after every edit.
Matching the wrong unit string. The unit detail includes .service. "demo-web" never matches.
Expecting enable to work. systemctl enable and disable use a separate action, org.freedesktop.systemd1.manage-unit-files. In my run, alice could restart but enable was refused, because the rule only covers manage-units. Handle it with its own rule if you need it. You almost never do.
Subject checks that exclude SSH. Covered above. Group membership is the right filter.
AUTH_*_KEEP results. If you return AUTH_ADMIN_KEEP from a rule, the man page warns that the authorization is then granted for the same action id and subject for a few minutes, even if the details differ. A rule that depends on unit and verb should not return a _KEEP result. Return YES or nothing.
When sudoers is still the better pick
The polkit rule is not universal. Pick sudoers when:
- The box has no systemd. Alpine, Void, Devuan, or a container with a plain init. polkit’s
manage-unitsaction is a systemd thing. - You need to pin an arbitrary command. A rule can only match what a program asks polkit about. Sudoers can allow
/usr/local/bin/rotate-backups --dry-runand nothing else. If there is no polkit action for it, there is no rule for it. - You want the audit trail. sudo logs each command it runs, with user and arguments, to the journal or auth log. A polkit YES leaves no line of its own unless you add
polkit.log()yourself (and see the section above for how that goes). systemd still logs the restart itself. - You need an environment or user switch.
sudo -u deployandDefaultsoptions have no polkit equivalent. - You already manage sudoers everywhere. One consistent mechanism for a fleet is a fair reason.
The sudoers line has its own sharp edge: arguments must match as written. In my test, sudo -n systemctl restart demo-web.service worked for alice and stop and the other unit were refused with sudo: a password is required. Anything that is not that exact string misses the rule.
The polkit rule wins when the job is “one unit, one verb, systemd box”. The user runs plain systemctl, no sudo appears in their muscle memory or your logs, and there is no wrapper script to forget about.
If you use run0 instead of sudo, there is a separate story about why a polkit rule cannot scope it to a command, covered in sudo vs doas vs run0.
Rollback
Delete the rules file. polkitd notices and reloads. My test script does exactly this before the sudoers phase and checks that alice’s plain systemctl restart fails again. Nothing was installed, patched, or left behind.
The SumGuy Take
Use sudoers when you need to pin an exact command line or log who ran it. Use a polkit rule for the narrow, common case of “this group restarts this one service”. It is less code to get wrong, and the permissions live next to the daemon that enforces them.
Whichever you choose, test it as the real user with --no-ask-password so a failure fails instead of hanging at 2 AM.
Common Questions
Does a polkit rule work over SSH?
Yes. A polkit rule that matches on group membership works over SSH, because the rule runs before the defaults and never looks at the session. SSH sessions have no seat, so subject.local is false, but subject.isInGroup() still works. Do not add subject.local or subject.active conditions.
Can I let a user enable or disable a service with polkit?
Yes, with a second rule. The systemctl enable and disable commands use the action org.freedesktop.systemd1.manage-unit-files, not manage-units. A restart-only rule leaves enable refused. In the tested setup, alice was refused enable while restart worked. Add a separate rule for that action if you need it.
How do I see why polkit denied a request?
Log it with polkit.log() and read journalctl -u polkit. On Debian and Arch the stock unit starts polkitd with --no-debug, which hides polkit.log() output in polkit 126. Drop the flag with a systemd drop-in, restart polkit, and the log lines appear.
Does systemctl try-restart match a rule for restart?
No. systemd sends the verb try-restart for systemctl try-restart and reload-or-restart for systemctl reload-or-restart, so a rule matching only restart does not cover either. This keeps the rule narrow. Add each verb to the condition if you want the group to run them.
How do I remove a polkit rule?
Delete the .rules file from /etc/polkit-1/rules.d/. polkitd watches the directory and reloads without a restart. The user’s next systemctl restart is refused again with Interactive authentication required. Nothing else needs undoing, because the rule only adds an exception in front of the defaults.