A deploy user needs to write to one directory owned by a service account. The ticket is two lines long. The fastest fix is chmod 777, and it works right up until someone asks who else can write there.

The second-fastest fix is to add the user to the directory's group. That also works, with two side effects people forget: group membership is host-wide, so the user now has that group's access to every file the group owns, and supplementary groups are resolved at login, so the change may not apply to the session that is currently running.

POSIX ACLs exist for exactly this case: a per-object grant to a named user or group, without changing ownership or opening the file to everyone. They are old, they are supported by every mainstream Linux filesystem, and they have one sharp edge that causes most of the "the ACL is set but it does not work" reports. The edge is the mask, which ls -l displays in the group permission slot.

The three-slot model is the problem

Unix mode bits describe exactly three identities: the owner, the owning group, and everyone else. There is one slot per identity. If a fourth party needs access, you have to reuse a slot:

  • Change the owner. Now the real owner has lost their role.
  • Change the group. Now every member of that group has the same access, everywhere.
  • Open the "other" bits. Now every account on the host has it.

ACLs add a fourth kind of slot: named entries attached to the object itself. The traditional bits still exist and still mean what they meant. The ACL is a superset, not a replacement.

What an ACL actually contains

acl(5) defines two ACLs per object:

  • The access ACL, which controls access to the object.
  • The default ACL, which only directories can have, and which seeds the ACL of objects created inside that directory.

An access ACL must contain exactly one user:: entry (the owner), one group:: entry (the owning group), and one other:: entry. On top of that you can add user:name: and group:name: entries. As soon as you add one of those, the ACL must also contain exactly one mask:: entry.

The mask is a ceiling. It limits the permissions granted by user:name, group:name, and group::. It does not limit user:: (the owner) or other::.

That last sentence explains most of the confusion in this article.

Grant one user and watch the mask appear

Install the tools (they are not always in a minimal image):

sudo apt install acl # Debian/Ubuntu sudo dnf install acl # Fedora/RHEL 

Start with a directory that is closed to everyone except the owning group:

sudo groupadd --system app sudo mkdir -p /srv/app/uploads sudo chown root:app /srv/app/uploads sudo chmod 2770 /srv/app/uploads 

2770 means rwx for root, rws for the app group, nothing for others. The leading 2 is setgid, so new files created inside inherit the app group instead of the creator's primary group. That is usually what you want for a shared directory.

$ getfacl /srv/app/uploads getfacl: Removing leading '/' from absolute path names # file: srv/app/uploads # owner: root # group: app user::rwx group::r-x other::--- 

No mask yet, because there are no named entries. Now grant the deploy user read, write, and traverse:

sudo setfacl -m u:deploy:rwX /srv/app/uploads 

The capital X is not a typo. It grants execute only if the object is a directory or already has execute set for someone. That makes rX safe to apply recursively: directories stay traversable, regular files do not become executable.

$ getfacl /srv/app/uploads # file: srv/app/uploads # owner: root # group: app user::rwx user:deploy:rwx group::r-x mask::rwx other::--- 

setfacl created the mask automatically and set it to the union of the owning group's permissions and every named entry's permissions. This is documented behaviour: unless you pass a mask explicitly, setfacl recalculates it on every modify.

Now look at the same directory through ls:

$ ls -ld /srv/app/uploads drwxrws---+ 2 root app 4096 Nov 3 21:14 /srv/app/uploads 

The group bits now read rws. That is the mask being displayed, not the owning group's entry. The + at the end is the signal that an extended ACL exists. Anyone reading only ls will conclude the app group can write. getfacl shows the truth: group::r-x, capped by mask::rwx, so the group's effective access is still r-x.

This display split is the reason to stop treating ls -l as the source of truth on any path that has an ACL.

The chmod that silently revokes the grant

Six weeks later, a hardening script runs chmod 700 across /srv. Nothing in getfacl is deleted. The named entry is still there. The deploy user still gets Permission denied.

$ sudo chmod 700 /srv/app/uploads $ getfacl /srv/app/uploads # file: srv/app/uploads # owner: root # group: app user::rwx user:deploy:rwx #effective:--- group::r-x #effective:--- mask::--- other::--- 

The entry says rwx. The effective comment says ---. The mask is zero, so nothing outside the owner entry survives.

This is not a bug. When an ACL has a mask, the file's group permission bits are the mask. chmod's group digit therefore writes the mask, and chmod never edits named entries. acl(5) puts it plainly: the owner permissions correspond to user::, the group permissions correspond to mask:: when a mask exists, and the other permissions correspond to other::. Modifying the mode bits modifies those entries.

So chmod 700 did exactly what it says — owner full, group none — and the mask did the rest. Any tool that enforces a mode will do this: a configuration management file resource with mode: '0700', a Dockerfile RUN chmod, install -m 700, a backup restore that reapplies modes.

Three habits prevent it:

  1. Set the mask in the same command as the grant. Be explicit about the ceiling you want:
sudo setfacl -m u:deploy:rwX,m::rwx /srv/app/uploads 
  1. Re-read the ACL after any chmod. A one-line check catches the regression before a deploy does:
getfacl -p /srv/app/uploads | grep -E '^(user:deploy|mask::)' 
  1. Pick modes that agree with the mask. If the path must stay 0750, the mask is r-x, and a named user will never get write through that mask. Either the mode or the requirement has to change. Trying to satisfy both is how you get a ticket that reopens every month.

setfacl -n stops setfacl from recalculating the mask, which is useful when you want to add an entry without changing the ceiling. It does not protect you from chmod; nothing does. The mode bits and the mask are the same storage.

Default ACLs: inheritance for new files

An access ACL applies to the object you set it on. New files created inside a directory do not inherit it — unless the directory has a default ACL.

Default ACLs are directories-only. getfacl prints them with a default: prefix:

sudo setfacl -d -m u::rwx,g::r-x,m::r-x,o::---,u:deploy:r-x /srv/app/uploads 
$ getfacl /srv/app/uploads # file: srv/app/uploads # owner: root # group: app user::rwx group::r-x other::--- default:user::rwx default:user:deploy:r-x default:group::r-x default:mask::r-x default:other::--- 

Now create two objects:

sudo touch /srv/app/uploads/report.txt sudo mkdir /srv/app/uploads/incoming 
$ getfacl /srv/app/uploads/report.txt # file: srv/app/uploads/report.txt # owner: root # group: app user::rw- user:deploy:r-x #effective:r-- group::r-x #effective:r-- mask::r-- other::--- 

The file inherited the default entries, then the creation mode (0666 for touch) removed every permission the mode did not include. The default ACL granted r-x to deploy, but a regular file created without execute ends up with the mask at r--. Inheritance is a ceiling, not a guarantee.

A directory created with mode 0777 keeps the execute bit, and it also inherits the default ACL as its own default ACL, so the policy propagates down the tree:

$ getfacl /srv/app/uploads/incoming # file: srv/app/uploads/incoming # owner: root # group: app user::rwx user:deploy:r-x group::r-x mask::r-x other::--- default:user::rwx default:user:deploy:r-x default:group::r-x default:mask::r-x default:other::--- 

Two consequences worth internalising:

The umask is not part of the default-ACL path. acl(5) describes the umask as part of what happens when there is no default ACL. When one exists, the creation mode and the default ACL determine the result. A directory whose default ACL grants group write will keep producing group-writable files for a process running under umask 077. That is convenient for shared trees and surprising during a security review. Treat a default ACL as a permission change, because it is one.

Inheritance only applies at creation. Adding a default ACL does not fix existing files. That needs a recursive pass.

Apply the policy to an existing tree

sudo setfacl -R -m u:deploy:rX /srv/app sudo find /srv/app -type d -exec setfacl -d -m u::rwx,g::r-x,m::r-x,o::---,u:deploy:r-x {} + 

The first command modifies access ACLs for every existing object. The second walks the directories again to install the default ACL, because -R -m without -d does not touch default entries.

setfacl -R follows symlinks named on the command line and skips symlinks found inside subdirectories. If that is not the behaviour you want, -P refuses to follow symlinks at all and -L follows all of them. For a policy pass over a directory that might contain links out of the tree, be deliberate about which one you choose.

Removing ACLs without removing the wrong ones

sudo setfacl -x u:deploy /srv/app/uploads # remove one named entry sudo setfacl -b /srv/app/uploads # remove all extended access entries sudo setfacl -k /srv/app/uploads # remove the default ACL 

-b keeps the three base entries and drops everything else, including the mask. -k leaves the access ACL alone and removes only the default ACL. Neither is recursive unless you add -R, and -R -b will strip every extended entry in the tree, including ones another team added.

Inventory before you delete:

getfacl -R -s /srv/app 

-s skips files that only have the base entries, so the output is the list of objects that actually have extended ACLs. On a large tree this is the difference between a review and a wall of text.

Backups, copies, and moves

ACLs live in the inode's extended attributes, so whether they survive a copy depends on the tool.

# Back up every ACL under a tree, keeping absolute paths sudo getfacl -R -p /srv/app > /root/srv-app.acl # Dry run, then restore sudo setfacl --test --restore=/root/srv-app.acl sudo setfacl --restore=/root/srv-app.acl 

-p matters. Without it, getfacl strips the leading slash and the restore is relative to your current directory. That is how a restore lands in the wrong place and appears to work.

--restore also reads the # owner: and # group: comments and attempts to restore ownership, plus the flags comment for setuid, setgid, and sticky bits. A permission backup is therefore privileged input. Do not restore one from a directory you do not trust.

For archives and sync:

  • tar --acls -cpf backup.tar /srv/app — GNU tar stores POSIX ACLs only with --acls.
  • rsync -aAX /srv/app/ /backup/app/ — -a alone does not include ACLs; -A does.
  • cp -p preserves ACLs, and mv always preserves them on the same filesystem. A plain cp does not.
  • Restoring a getfacl dump into a different root requires rewriting the # file: paths first. --restore uses the paths recorded in the file.

If a copy cannot preserve an ACL, cp, mv, and setfacl are documented to warn or fail rather than silently dropping it. Check exit status in scripts; set -e is cheap insurance.

Filesystem support

ext4, XFS, Btrfs, and tmpfs all support POSIX ACLs on current kernels. ext4 enables them by default. Verify what you are actually on:

findmnt -T /srv/app -o TARGET,FSTYPE,OPTIONS 

If the filesystem does not support ACLs, setfacl falls back to the mode bits, reports an error if the ACL does not fit, and exits non-zero. That failure mode is easy to miss in a script that ignores exit codes.

NFSv4 is a separate case. It uses NFSv4 ACLs, which are richer than POSIX ACLs and are managed with nfs4_getfacl and nfs4_setfacl, not getfacl/setfacl. Do not assume a POSIX ACL workflow applies to an NFSv4 mount.

Finally, modifying an ACL requires being the file owner or holding CAP_FOWNER. On a normal Linux system that means root or the owner. Delegating "manage ACLs on this tree" is delegating a privileged operation.

A verification recipe

Do not trust the ACL listing alone; test the access as the identity that needs it.

sudo -u deploy test -r /srv/app/uploads && echo "read: ok" sudo -u deploy test -w /srv/app/uploads && echo "write: ok" sudo -u deploy test -x /srv/app/uploads && echo "traverse: ok" 

For a quick audit of where the mask is quietly nullifying an entry, use the tabular view. It capitalises permissions that are ineffective because of the mask:

getfacl -t -R /srv/app 

And for one path, a compact assertion:

test "$(getfacl -p /srv/app/uploads | awk -F: '/^mask::/{print $3}')" = "rwx" \ && echo "mask ok" || echo "mask changed - investigate" 

Why not just use 777

Because 777 is not a grant to one user. It is a grant to every account that can reach the path: service accounts, container UIDs sharing the host, a compromised web process, a backup user with a wide mount. On a single-user laptop the difference is academic. On anything with more than one trust boundary it is the whole ballgame.

A named ACL is also auditable in a way that a mode change is not. getfacl -R -s /srv lists exactly the objects where the default policy has been overridden, and getfacl output is valid input to setfacl, so the exceptions can be reviewed, diffed, and restored.

The rule I use: pick the strictest mode that expresses the base policy, then add the smallest named entry that satisfies the exception, set the mask explicitly, and verify as the target user. If a later chmod breaks it, the effective comments in getfacl will tell you in one line.

Sources