Reported symptoms
- At 08:00 every member of the ops team found that
sudono longer worked, on all 200 hosts, simultaneously. - The DBA team is unaffected. Their rules live directly in
/etc/sudoers. - The first hypothesis was the directory: a group sync had run overnight
and everyone assumed
opsmembership had been dropped. It had not. - The second hypothesis was SSSD or PAM. Both are clean and unchanged.
sudo visudo -cwas run on three hosts. All three reportedparsed OK, so sudoers was ruled out.- The rule file is right there in
/etc/sudoers.d/, with the correct content, owner and mode.
Evidence provided
$ sudo -l
[sudo] password for alice:
Sorry, user alice may not run sudo on host01.
$ id alice
uid=1841(alice) gid=1841(alice) groups=1841(alice),4001(ops),4009(oncall)
$ getent group ops
ops:*:4001:alice,bob,carla,dan,erin
$ ls -l /etc/sudoers.d/
total 12
-r--r----- 1 root root 118 Aug 10 18:41 10-ops-team.conf
-r--r----- 1 root root 87 Mar 02 09:15 20-dba-readonly
-r--r----- 1 root root 44 Jan 18 11:03 90-cloud-init
$ sudo grep -r ops /etc/sudoers.d/
/etc/sudoers.d/10-ops-team.conf:%ops ALL=(ALL:ALL) ALL
$ sudo visudo -c
/etc/sudoers: parsed OK
/etc/sudoers.d/20-dba-readonly: parsed OK
/etc/sudoers.d/90-cloud-init: parsed OK
$ sudo -l -U alice
User alice is not allowed to run sudo on host01.
$ sudo -ll -U bob
User bob is not allowed to run sudo on host01.
$ git -C /srv/cfg log --oneline -1 -- roles/base/files/sudoers.d/
c41f8a2 style: standardise drop-in filenames on the .conf suffix
Work the evidence before reading on
The answer is visible in the transcript, in a place that reads as formatting rather than as content. Three things to line up:
visudo -cprints one line per file it validated. Count the files it names. Now count the files inls -l /etc/sudoers.d/.- The one that is missing from
visudo -cis the one whose rule is missing from the policy. - Look at what is different about that filename compared to the other two.
Before continuing: visudo -c said parsed OK. What exact question
was it answering?
Root cause
1. sudo ignores drop-in filenames containing a dot
/etc/sudoers ends with an include directive:
@includedir /etc/sudoers.d
(Older files write this as #includedir /etc/sudoers.d. Despite
appearances that is a directive, not a comment — sudo predates the
convention, and @ was added later as the unambiguous spelling. Both
work; neither is disabled by the leading #.)
When sudo reads that directory it deliberately skips any file whose name
contains a . anywhere, or ends in ~. From sudoers(5): files in
the included directory that end in ~ or that contain a . character
are ignored.
The reason is sound. Package upgrades leave behind
10-ops-team.dpkg-dist, 10-ops-team.rpmnew, 10-ops-team.rpmsave;
editors leave 10-ops-team~ and .10-ops-team.swp. Loading any of those
as live authorisation policy would be a serious security bug. So sudo
refuses to read them.
The rule makes no exception for a dot you meant. 10-ops-team.conf
contains a dot. It is skipped.
2. visudo -c validated exactly what sudo reads
This is the part that cost the team two hours, and it is not a bug.
visudo -c walks the same include directory using the same skip rules
and validates each file it decides to read. It reported parsed OK for
three files. It said nothing about the fourth, because as far as sudo is
concerned there is no fourth.
Read carefully, the output is the answer: the file you are worried
about is absent from a list of everything that was read. Read quickly,
parsed OK is a green light and the investigation moves on.
The distinction generalises:
| Command | Question it answers |
|---|---|
visudo -c | Does everything sudo reads parse? |
sudo -l -U alice | What is alice actually allowed to do? |
Only the second one can detect a rule that was never loaded.
3. Why it hit every host at once, and only one team
The rename came from configuration management, so it converged across the
fleet overnight. The DBA rules live in /etc/sudoers itself and were
never subject to the include rules, which is why exactly one team lost
access — and why the incident looked like a directory-group problem
specific to ops.
Resolution
- Get a root shell. Nobody has sudo, so work down this list: an already-open root session on any host; the console or out-of-band management; a documented break-glass account; the cloud provider serial console or run-command service; single-user mode via the boot loader as a last resort
- For 200 hosts, the configuration-management agent is the answer. Puppet, Salt, Chef and an Ansible pull-mode agent all already run as root and do not use sudo. Fixing the template and letting the fleet converge is faster and safer than 200 console sessions
- Confirm the diagnosis on one host before changing anything else. Compare the file list from
visudo -cagainstls /etc/sudoers.d/; the missing name is the fault - Rename the file. This is the whole fix:
- ``
sudo mv /etc/sudoers.d/10-ops-team.conf /etc/sudoers.d/10-ops-team`` - Verify with the rendered policy, not the file:
- ``
sudo -l -U alice sudo visudo -c`` - Fix the template that produced the name, in the same change. Otherwise the next converge renames it straight back and the incident recurs at a time nobody will connect to this one
- Canary before the fleet. Deploy to one host, have a real ops-team member run
sudo -n truethere, and only then roll out - Roll out and re-verify a sample, rather than trusting that convergence implies correctness
- Add the naming assertion to CI while the incident is fresh, so the class is closed rather than the instance
Verification
- The rendered policy contains the rule.
sudo -l -U alice, run as root, lists the expected commands. This is the check that can fail and the one that was missing from the change process - A real user can actually use it. Have an ops-team member run
sudo -n trueand confirm exit status 0. A policy that renders correctly but fails in practice points at PAM or the directory, which is a different incident - visudo now reads the file.
sudo visudo -creportsparsed OKand names10-ops-teamin its output. Its presence in that list is the point, not theparsed OK - No filename in the directory can be skipped.
ls /etc/sudoers.d/ | grep -E "\.|~$"returns nothing. Any output is a file sudo is ignoring, whether or not you meant it to - Permissions are still right.
ls -l /etc/sudoers.d/10-ops-teamshowsroot:rootand mode 0440. sudo refuses a world-writable or wrongly-owned drop-in — a different silent-ish failure with a different fix - Convergence does not undo it. Re-run the configuration-management agent, then repeat the first two checks. This is where a hand-fix that skipped the template is exposed
- The fleet is sampled, not assumed. Run
sudo -l -U aliceon a random handful of hosts across different roles and regions - The break-glass path was exercised and still works. If you used it during this incident, note whether it behaved as documented; if you did not need it, test it separately
Prevention
- Verify with
sudo -l -U user.visudo -canswers a narrower question than most people think, and in this failure it answers it correctly and unhelpfully. - Name drop-ins with letters, digits, dashes and underscores only. No
dots, no
.conf, no.disabled, no trailing tilde. Enforce it in CI, because nothing at runtime will. - Never disable a rule by renaming it. Remove the file, or comment out
its contents with
visudo -f. A rename can silently disable a rule you wanted, and equally silently enable one you did not. - Edit with
visudo -fso a syntax error is refused rather than written. - Canary authorisation changes. Any change that can remove administrative access from a fleet should reach one host before it reaches all of them.
- Maintain and test a break-glass path. This incident was survivable because the configuration-management agent runs as root; without it, 200 console sessions is a very long morning.
- When a change lands via configuration management, fix the source, not the host. A hand-fixed host is a host waiting for the next converge.