LinuxV · sudo and Privileged Accesssudo design
Least-privilege sudo design
What you'll learn
- Translate operational needs into sudoers rules
- Apply the principle of least privilege to service accounts
- Use Runas_Alias to scope what can be done as whom
- Audit existing rules for over-grant
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09
Every sudoers rule should answer two questions:
- What does this user (or service) actually need to do?
- What is the smallest set of commands that satisfies that need?
If a rule grants more than the answer to question 2, it is an over-grant. Over-grants accumulate, and an attacker who compromises one service account inherits the union of every grant.
Translating operational needs into rules
| Operational need | Bad grant | Good grant |
|---|---|---|
| Operator can restart sshd | ops ALL=(ALL) ALL | ops ALL=(root) /usr/bin/systemctl --no-pager restart ssh, /usr/bin/systemctl --no-pager reload ssh, /usr/bin/systemctl --no-pager restart sshd, /usr/bin/systemctl --no-pager reload sshd |
| Application can manage its own service | app ALL=(ALL) NOPASSWD: ALL | app ALL=(root) NOPASSWD: /usr/bin/systemctl --no-pager restart myapp.service, /usr/bin/systemctl --no-pager status myapp.service |
| Operator can read logs | ops ALL=(ALL) ALL | Add operator to the adm group, no sudo needed |
| Operator can run a fixed database report | ops ALL=(DB) /usr/bin/psql | ops ALL=(DB) /usr/local/bin/pg-readonly-report.sh |
| CI can deploy | ci ALL=(ALL) NOPASSWD: ALL | ci ALL=(root) NOPASSWD: /usr/local/bin/deploy.sh, /usr/bin/systemctl --no-pager restart myapp.service |
--no-pager is not cosmetic. The next section explains why it is
part of the grant.
Note that the SSH row lists both ssh and sshd. The
OpenSSH server unit is ssh.service on Debian and Ubuntu and
sshd.service on RHEL, Rocky and Alma, and sudoers matches the
command line literally — it has no idea the two names mean the
same daemon. A rule written for one family denies the operator on
the other, and the error they see is “command not allowed”, which
reads like a permissions problem rather than a naming one. If
your fleet is single-family, list only that name and say so in a
comment; if it is mixed, list both. Check with
systemctl list-unit-files | grep -E '^ssh' before you write the
rule.
Runas_Alias — what can be done as whom
The (AS_WHOM) field in a rule specifies the target user. Common
defaults assume root. For finer scoping:
Runas_Alias APP = root, myapp
Runas_Alias DB = postgres, mysql
deploy ALL=(APP) /usr/local/bin/deploy-app.sh
ops ALL=(DB) /usr/local/bin/pg-readonly-report.sh
The deploy operator can run the deploy script as the application’s service user; the operations team can run one reviewed reporting script as the database service user. Neither gets unrestricted root.
The obvious version of that second rule is
ops ALL=(DB) /usr/bin/psql, /usr/bin/mysql, and it is wrong.
Runas scoping bounds who you become; it does not bound what you
then run. psql and mysql are interactive clients that execute
shell commands, so the rule grants a shell as postgres - and
postgres owns every database on the cluster. Grant a script you
control, not an interactive client.
$ sudo -lUser alice may run the following commands on host01:
(root) /usr/bin/systemctl --no-pager status ssh, /usr/bin/systemctl --no-pager restart ssh
(postgres) /usr/local/bin/pg-readonly-report.sh
(myapp) /usr/local/bin/deploy-app.shIllustrative output
Command-scoped is not escape-proof
A command-scoped rule bounds which binary starts. It does not bound which binaries that binary starts. If the granted command can spawn a subprocess, you have granted that subprocess too, at the same privilege.
The recurring offenders:
- Pagers.
lessimplements! shell-command. Anything that pipes into a pager inherits that. - Editors.
vi,vim,nano -svariants, and anything honouring$EDITORor$VISUAL. - Database clients.
psqlhas\!; MySQL has\!andsystem. PostgreSQL additionally hasCOPY ... FROM PROGRAM, which runs a command as the server’s OS user. - Anything with an exec hook.
find -exec,tar --to-command,awk 'BEGIN{system(...)}',rsync -e,gitwithcore.pageror hook configuration.
systemctl and the pager
systemctl status, list-units and friends pipe output into a
pager unless told otherwise. Under sudo the pager runs with the
elevated privilege, and less will happily run !bash.
systemd mitigates this: $SYSTEMD_PAGERSECURE secure mode is
auto-enabled when $SUDO_UID is set, which disables the pager’s
shell escape. That mitigation is real on a current baseline, and
it is not something to rely on. It disappears if the rule carries
SETENV:, if SYSTEMD_PAGERSECURE survives in env_keep, or if
the host runs an older systemd. systemd’s own manual recommends
pinning the pager off for exactly this scenario.
So pin it in the grant, not in the operator’s habits:
Defaults!SYSTEMCTL env_keep -= "PAGER SYSTEMD_PAGER SYSTEMD_PAGERSECURE"
Cmnd_Alias SYSTEMCTL = /usr/bin/systemctl --no-pager restart ssh, \
/usr/bin/systemctl --no-pager reload ssh, \
/usr/bin/systemctl --no-pager status ssh
ops ALL=(root) SYSTEMCTL
Because --no-pager is written into the Cmnd_Alias as an
argument, sudo will only match an invocation that includes it.
An operator who types sudo systemctl status ssh without the
flag is denied, which is the point: the flag is part of the
grant, not advice.
Database clients: grant a script, not a client
ops ALL=(DB) /usr/bin/psql is not read-only database access. It
is a shell as postgres, and postgres can read and drop every
database on the cluster.
Replace the client with a wrapper you review and version:
#!/usr/bin/env bash
# /usr/local/bin/pg-readonly-report.sh (root:root 0755)
set -euo pipefail
exec psql --no-psqlrc -X -A -F$'\t' -v ON_ERROR_STOP=1 \
-d appdb -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state;"
ops ALL=(postgres) NOPASSWD: /usr/local/bin/pg-readonly-report.sh ""
The trailing "" pins the grant to no arguments, so the
operator cannot append their own SQL. The script must not be
writable by the operator, or the review is meaningless.
Service accounts
$ cat /etc/sudoers.d/20-myapp# myapp application service rules
Cmnd_Alias MYAPP_SVC = /usr/bin/systemctl start myapp.service, \
/usr/bin/systemctl stop myapp.service, \
/usr/bin/systemctl restart myapp.service, \
/usr/bin/systemctl status myapp.service, \
/usr/bin/systemctl reload myapp.service
myapp ALL=(root) NOPASSWD: MYAPP_SVC
Defaults:myapp !authenticate !log_input !log_outputIllustrative output
Note the line continuation. sudoers takes a single trailing
backslash, and it must be the last character on the line. A
doubled \\ is not a continuation: sudoers reads the first
backslash as escaping the second, leaving a stray character, and
visudo -c -f rejects the file with syntax error: stray escape sequence. A trailing space after the backslash breaks it the
same way.
The principle of least privilege, applied
For each operator, role, and service, follow this workflow:
- List the operational tasks the role actually performs. "Restart sshd", "view logs", "deploy myapp", "rotate certificates"
- For each task, identify the exact command.
systemctl restart ssh,journalctl -u ssh,/usr/local/bin/deploy.sh,certbot renew - For each command, identify the target user. Most are root; some are an application user
- Group commands into Cmnd_Alias entries by concern
- Grant the narrowest possible rule.
(target-user) /exact/path/to/command— no NOPASSWD for humans - Add the rule to /etc/sudoers.d/ with a leading number and a comment
- Validate with
visudo -c -f FILE. - **Test by running
sudo -l -U <user>** and confirming only the expected commands appear - Document in the runbook why the rule exists, who approved it, when it expires
Auditing existing rules
$ grep -r 'NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null/etc/sudoers.d/10-operators:%ops ALL=(ALL) NOPASSWD: ALLIllustrative output
$ grep -r 'ALL.*ALL' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -v '^#'/etc/sudoers.d/10-operators:%ops ALL=(ALL) ALL
/etc/sudoers.d/20-myapp:myapp ALL=(root) NOPASSWD: MYAPP_SVCIllustrative output
$ find /etc/sudoers.d -type f -not -name README -not -name '*~' | while read f; do echo === $f ===; visudo -c -f $f 2>&1; done=== /etc/sudoers.d/10-operators ===
/etc/sudoers.d/10-operators: parsed OK
=== /etc/sudoers.d/20-myapp ===
/etc/sudoers.d/20-myapp: parsed OK
=== /etc/sudoers.d/30-breakglass ===
/etc/sudoers.d/30-breakglass: parsed OKIllustrative output
Breaking-glass accounts
Every production fleet should have a break-glass account: a human user that can sudo without contacting central identity, in case central identity is unavailable. The discipline:
# /etc/sudoers.d/30-breakglass
User_Alias BREAKGLASS = bg-host01, bg-host02
BREAKGLASS ALL=(ALL) ALL
Defaults:BREAKGLASS !requiretty, env_reset
- The break-glass users exist only locally on each host (not in LDAP/SSSD). When LDAP is down, they still authenticate via /etc/shadow.
- The break-glass password is sealed in a safe or in an out-of-band secret store. It is used only when central identity is unavailable.
- Each use is alerted: a SIEM rule fires on every sudo invocation by a break-glass account.
- The break-glass credentials are tested quarterly to confirm they still work.
Knowledge check
Knowledge check · 5 questions
Q1. Which sudoers rule is the most appropriate for an application that needs to manage its own systemd service?
Q2. !authenticate is appropriate for service accounts but not for human users.
Q3. Which of the following are correct least-privilege sudoers practices? Select all that apply.
Q4. A read-only troubleshooting request is satisfied with `ops ALL=(postgres) /usr/bin/psql`. The rule names one binary, one target user, and no NOPASSWD. Why does it still fail least privilege?
Q5. Your team is granted `ops ALL=(root) /usr/bin/systemctl status ssh`. A colleague argues no hardening is needed because systemd auto-enables pager secure mode when $SUDO_UID is set. What is the correct operational response?
Passing score: 75%. Answers are checked in this browser.