Skip to main content
RunBook Academy

LinuxV · sudo and Privileged Accesssudo design

Least-privilege sudo design

Intermediate⏱ ~12 minbashvisudosudogit

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

Not yet marked complete on this device.

Every sudoers rule should answer two questions:

  1. What does this user (or service) actually need to do?
  2. 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 needBad grantGood grant
Operator can restart sshdops ALL=(ALL) ALLops 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 serviceapp ALL=(ALL) NOPASSWD: ALLapp ALL=(root) NOPASSWD: /usr/bin/systemctl --no-pager restart myapp.service, /usr/bin/systemctl --no-pager status myapp.service
Operator can read logsops ALL=(ALL) ALLAdd operator to the adm group, no sudo needed
Operator can run a fixed database reportops ALL=(DB) /usr/bin/psqlops ALL=(DB) /usr/local/bin/pg-readonly-report.sh
CI can deployci ALL=(ALL) NOPASSWD: ALLci 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.

Read-only / Safe
$ sudo -l
User 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.sh

Illustrative 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. less implements ! shell-command. Anything that pipes into a pager inherits that.
  • Editors. vi, vim, nano -s variants, and anything honouring $EDITOR or $VISUAL.
  • Database clients. psql has \!; MySQL has \! and system. PostgreSQL additionally has COPY ... 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, git with core.pager or 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

Configuration changeservice-account rule
$ 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_output

Illustrative 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:

  1. List the operational tasks the role actually performs. "Restart sshd", "view logs", "deploy myapp", "rotate certificates"
  2. For each task, identify the exact command. systemctl restart ssh, journalctl -u ssh, /usr/local/bin/deploy.sh, certbot renew
  3. For each command, identify the target user. Most are root; some are an application user
  4. Group commands into Cmnd_Alias entries by concern
  5. Grant the narrowest possible rule. (target-user) /exact/path/to/command — no NOPASSWD for humans
  6. Add the rule to /etc/sudoers.d/ with a leading number and a comment
  7. Validate with visudo -c -f FILE.
  8. **Test by running sudo -l -U <user>** and confirming only the expected commands appear
  9. Document in the runbook why the rule exists, who approved it, when it expires

Auditing existing rules

Read-only / Safeaudit NOPASSWD
$ grep -r 'NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null
/etc/sudoers.d/10-operators:%ops ALL=(ALL) NOPASSWD: ALL

Illustrative output

Read-only / Safeaudit broad grants
$ 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_SVC

Illustrative output

Read-only / Safevalidate every drop-in
$ 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 OK

Illustrative 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

  1. Q1. Which sudoers rule is the most appropriate for an application that needs to manage its own systemd service?

  2. Q2. !authenticate is appropriate for service accounts but not for human users.

  3. Q3. Which of the following are correct least-privilege sudoers practices? Select all that apply.

  4. 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?

  5. 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.