Access Control Lists in Linux

A POSIX Access Control List (ACL) extends the classic nine-bit UNIX permission model — owner/group/other × read/write/execute — so that a file can grant named users and named groups their own individual permissions, beyond the single owning user and single owning group the mode bits can express. The need is concrete: with only nine bits you cannot say “user alice may write this file, group dev may read it, and everyone else gets nothing” without juggling group memberships, because there is exactly one owner slot and one group slot. POSIX ACLs add an arbitrary list of user:NAME: and group:NAME: entries, plus a critical mask:: entry that bounds them all, stored in the inode’s extended attributes and evaluated by the VFS on every access (acl(5)). They are the fine-grained layer of Linux discretionary access control — still discretionary (the owner sets the policy), still per-object, but no longer limited to three principals.

Mental Model

Think of a POSIX ACL as the nine mode bits with extra rows inserted and one governor bolted on. A minimal ACL — the “access ACL” every file conceptually has — is exactly the three mode-bit groups expressed as three entries:

  • user::rw- — the owner (tag ACL_USER_OBJ); corresponds to the owner mode bits.
  • group::r-- — the owning group (tag ACL_GROUP_OBJ); corresponds to the group mode bits.
  • other::r--everyone else (tag ACL_OTHER); corresponds to the other mode bits.

An extended ACL adds two new kinds of row and one governor:

  • user:alice:rw- — a named user entry (tag ACL_USER), repeatable for any number of users.
  • group:dev:r-- — a named group entry (tag ACL_GROUP), repeatable for any number of groups.
  • mask::rw- — the mask entry (tag ACL_MASK): “the maximum access rights that can be granted by entries of type ACL_USER, ACL_GROUP_OBJ, or ACL_GROUP” (acl(5)). It is a ceiling, ANDed against those entries at check time.
flowchart TB
  ACL["Access ACL of a file"]
  ACL --> UO["user:: (owner)<br/>ACL_USER_OBJ"]
  ACL --> UN["user:alice: , user:bob:<br/>ACL_USER (named, repeatable)"]
  ACL --> GO["group:: (owning group)<br/>ACL_GROUP_OBJ"]
  ACL --> GN["group:dev:<br/>ACL_GROUP (named, repeatable)"]
  ACL --> MASK["mask::<br/>ACL_MASK — CEILING on the boxed rows"]
  ACL --> OTH["other::<br/>ACL_OTHER"]
  MASK -. "ANDs down" .-> UN
  MASK -. "ANDs down" .-> GO
  MASK -. "ANDs down" .-> GN

The anatomy of a POSIX access ACL. What it shows: the three base entries (user::, group::, other::) mirror the mode bits, while named-user and named-group entries are the extension, and the mask:: entry sits over the dashed group of entries as an upper bound. The insight to take: owner and other are never clipped by the mask; everything in the middle — named users, the owning group, named groups — has its effective permission reduced to entry_perm AND mask_perm. This is why chmod’s “group” digit on an ACL-bearing file changes the mask, not the owning group, with surprising results.

The single most counterintuitive fact, which everything below circles back to: once a file has an extended ACL, the group digit of chmod writes to the mask entry, not to the owning-group entry. chmod 750 file on an ACL-bearing file sets mask::r-x, which then silently clamps every named user and named group down to at most r-x — even if you had granted alice write access. This is mandated behaviour, not a bug (acl(5): “Modification of the file permission bits results in the modification of the associated ACL entries”), and the kernel code below proves it.

Mechanical Walk-through — How the Kernel Stores and Evaluates ACLs

Storage: two extended attributes

A POSIX ACL is persisted in the inode’s extended-attribute store under two well-known names, defined in the kernel UAPI (xattr.h, v6.12):

#define XATTR_SYSTEM_PREFIX           "system."
#define XATTR_POSIX_ACL_ACCESS        "posix_acl_access"
#define XATTR_NAME_POSIX_ACL_ACCESS   XATTR_SYSTEM_PREFIX XATTR_POSIX_ACL_ACCESS   /* system.posix_acl_access  */
#define XATTR_POSIX_ACL_DEFAULT       "posix_acl_default"
#define XATTR_NAME_POSIX_ACL_DEFAULT  XATTR_SYSTEM_PREFIX XATTR_POSIX_ACL_DEFAULT  /* system.posix_acl_default */

So the access ACL (the one that governs access to the object itself) lives in system.posix_acl_access, and the default ACL (only meaningful on directories — the template inherited by new children) lives in system.posix_acl_default. They are in the system. namespace specifically so that the VFS, not arbitrary userspace, mediates them: writing them goes through the ACL set-path, not a raw xattr write, which is how the kernel keeps the mode bits and the ACL consistent.

In-memory representation

When loaded, an ACL is a flat array of entries (posix_acl.h, v6.12):

struct posix_acl_entry {
	short			e_tag;     /* ACL_USER_OBJ, ACL_USER, ... */
	unsigned short		e_perm;    /* ACL_READ | ACL_WRITE | ACL_EXECUTE */
	union {
		kuid_t		e_uid;     /* for ACL_USER  */
		kgid_t		e_gid;     /* for ACL_GROUP */
	};
};
 
struct posix_acl {
	refcount_t		a_refcount;
	struct rcu_head		a_rcu;
	unsigned int		a_count;
	struct posix_acl_entry	a_entries[];   /* flexible array */
};

Each entry carries a tag (e_tag), three permission bits (e_perm), and — for named entries only — the kernel user-id or group-id it applies to. The tags and permission values are numeric constants (uapi/posix_acl.h, v6.12): ACL_USER_OBJ 0x01, ACL_USER 0x02, ACL_GROUP_OBJ 0x04, ACL_GROUP 0x08, ACL_MASK 0x10, ACL_OTHER 0x20; and ACL_READ 0x04, ACL_WRITE 0x02, ACL_EXECUTE 0x01. The whole structure is reference-counted (a_refcount) and RCU-freed, because it is read on the permission-check fast path and must not require a lock to read.

The entries appear in a canonical order the kernel enforces in posix_acl_valid(): ACL_USER_OBJ, then any ACL_USER, then ACL_GROUP_OBJ, then any ACL_GROUP, then at most one ACL_MASK, then ACL_OTHER. That same validator records needs_mask = 1 the moment it sees any named entry — an ACL with named users or groups must carry a mask entry to be valid.

Loading: get_inode_acl() and the cache

When the VFS needs an inode’s ACL it calls get_inode_acl(inode, type), which dispatches into __get_acl() (posix_acl.c, v6.12):

static struct posix_acl *__get_acl(struct mnt_idmap *idmap,
				   struct dentry *dentry, struct inode *inode, int type)
{
	...
	acl = get_cached_acl(inode, type);
	if (!is_uncached_acl(acl))
		return acl;                       /* fast path: cached on the inode */
	if (!IS_POSIXACL(inode))
		return NULL;                      /* fs doesn't support ACLs        */
	...
	if (dentry && inode->i_op->get_acl)
		acl = inode->i_op->get_acl(idmap, dentry, type);
	else if (inode->i_op->get_inode_acl)
		acl = inode->i_op->get_inode_acl(inode, type, false);
	...
}

The ACL is cached on the inode (inode->i_acl for the access ACL, inode->i_default_acl for the default), so the common case is a pointer dereference with no filesystem I/O. On a cache miss the code uses a sentinel + cmpxchg dance (the uncached_acl_sentinel) to detect a racing set_cached_acl()/forget_cached_acl() and only installs the freshly read ACL if no one else has touched the slot meanwhile — the lockless caching the RCU-friendly struct is designed for. The actual bytes come from the filesystem’s ->get_acl/->get_inode_acl inode operation, which decodes the system.posix_acl_access (or _default) xattr.

The check: posix_acl_permission()

The heart of ACL enforcement is posix_acl_permission() (posix_acl.c, v6.12). Its job: “Return 0 if current is granted want access to the inode by the acl. Returns -E… otherwise.” The algorithm walks the entries in order and stops at the first matching class, mirroring exactly the five-step procedure in acl(5):

int posix_acl_permission(struct mnt_idmap *idmap, struct inode *inode,
			 const struct posix_acl *acl, int want)
{
	const struct posix_acl_entry *pa, *pe, *mask_obj;
	int found = 0;
	...
	want &= MAY_READ | MAY_WRITE | MAY_EXEC;
 
	FOREACH_ACL_ENTRY(pa, acl, pe) {
		switch(pa->e_tag) {
		case ACL_USER_OBJ:
			if (vfsuid_eq_kuid(i_uid_into_vfsuid(idmap, inode), current_fsuid()))
				goto check_perm;          /* owner: NO mask          */
			break;
		case ACL_USER:
			if (vfsuid_eq_kuid(make_vfsuid(idmap, fs_userns, pa->e_uid), current_fsuid()))
				goto mask;                /* named user: APPLY mask  */
			break;
		case ACL_GROUP_OBJ:
		case ACL_GROUP:
			if (vfsgid_in_group_p(...)) {
				found = 1;
				if ((pa->e_perm & want) == want)
					goto mask;        /* group(s): APPLY mask    */
			}
			break;
		case ACL_MASK:
			break;
		case ACL_OTHER:
			if (found)
				return -EACCES;           /* in a group, but none granted */
			else
				goto check_perm;          /* other: NO mask          */
		}
	}
	return -EIO;
 
mask:
	for (mask_obj = pa+1; mask_obj != pe; mask_obj++) {
		if (mask_obj->e_tag == ACL_MASK) {
			if ((pa->e_perm & mask_obj->e_perm & want) == want)
				return 0;
			return -EACCES;
		}
	}
check_perm:
	if ((pa->e_perm & want) == want)
		return 0;
	return -EACCES;
}

Reading the control flow carefully reveals the mask’s exact role. want is reduced to the requested read/write/execute bits. The loop checks each class:

  • Owner (ACL_USER_OBJ): if the caller’s filesystem UID is the owner, jump straight to check_perm — the owner entry is checked raw, the mask never touches it.
  • Named user (ACL_USER): on a UID match, jump to mask. There the code finds the ACL_MASK entry and grants access only if (pa->e_perm & mask_obj->e_perm & want) == want — the entry’s permission ANDed with the mask must cover everything wanted. This is the mask as ceiling, in one line.
  • Owning group / named group (ACL_GROUP_OBJ, ACL_GROUP): if the caller is in the group and the entry’s permission satisfies want, jump to mask (same AND-with-mask check). The found = 1 flag records “the caller matched a group entry,” which matters for the ACL_OTHER fallthrough: if you were in some group but no group entry granted enough, you get -EACCES rather than falling through to the (possibly more permissive) other entry. You cannot “escape downward” to other once a group entry matched you.
  • Other (ACL_OTHER): the final fallback, checked raw — the mask does not apply.

So the mask clamps named users, the owning group, and named groups, and leaves owner and other untouched — precisely what acl(5) states: the mask is “the maximum access which can be granted by any ACL entry except the user entry for the file owner and the other entry.”

Note this function is only one half of the access check. The VFS first tries the fast capability/owner shortcuts in generic_permission()/acl_permission_check(); posix_acl_permission() is consulted for the inode’s access ACL when the fast path does not already decide the question. The result is AND-combined with the rest of Discretionary Access Control and any LSM hook.

Why chmod writes the mask: __posix_acl_chmod_masq()

This is the mechanism behind the most surprising ACL behaviour. When chmod runs on a file that has an extended ACL, the kernel does not just rewrite the mode bits — it rewrites the ACL to match, via posix_acl_chmod()__posix_acl_chmod_masq() (posix_acl.c, v6.12):

static int __posix_acl_chmod_masq(struct posix_acl *acl, umode_t mode)
{
	struct posix_acl_entry *group_obj = NULL, *mask_obj = NULL;
	...
	FOREACH_ACL_ENTRY(pa, acl, pe) {
		switch(pa->e_tag) {
		case ACL_USER_OBJ:  pa->e_perm = (mode & S_IRWXU) >> 6; break;
		case ACL_USER:
		case ACL_GROUP:     break;                       /* named entries untouched */
		case ACL_GROUP_OBJ: group_obj = pa; break;
		case ACL_MASK:      mask_obj = pa; break;
		case ACL_OTHER:     pa->e_perm = (mode & S_IRWXO); break;
		}
	}
	if (mask_obj) {
		mask_obj->e_perm = (mode & S_IRWXG) >> 3;         /* <-- group digit -> MASK */
	} else {
		group_obj->e_perm = (mode & S_IRWXG) >> 3;        /* no mask: group digit -> group_obj */
	}
	return 0;
}

The owner digit of the mode goes to ACL_USER_OBJ, the other digit goes to ACL_OTHER — as you would expect. But the group digit ((mode & S_IRWXG) >> 3) goes to mask_obj if a mask entry exists, and only falls back to group_obj (the owning-group entry) when there is no mask. Since any extended ACL has a mask, chmod 640 file on an ACL-bearing file sets mask::r--, instantly capping every named user and named group at read-only, regardless of what you granted them. The named entries themselves (ACL_USER, ACL_GROUP) are explicitly left untouched — their stored permission is preserved, but their effective permission is now throttled by the new mask. getfacl makes this visible by printing an #effective: comment next to any entry the mask is clipping.

Equivalence: posix_acl_equiv_mode()

The dual question — “can this ACL be collapsed back into plain mode bits?” — is answered by posix_acl_equiv_mode(), which returns 0 if the ACL is exactly representable by the nine mode bits and 1 if it is “extended.” The function sets not_equiv = 1 the instant it sees any ACL_USER, ACL_GROUP, or ACL_MASK entry. When an ACL becomes equivalent to plain mode bits (e.g. you remove the last named entry), the kernel can drop the system.posix_acl_access xattr entirely and revert to pure mode bits — which is why a file’s ACL and its ls -l mode never disagree.

Default ACLs and inheritance — the umask override

A default ACL exists only on directories and is the inheritance template. When a new file or directory is created inside a directory that has a system.posix_acl_default xattr, posix_acl_create() runs (posix_acl.c, v6.12):

int posix_acl_create(struct inode *dir, umode_t *mode,
		struct posix_acl **default_acl, struct posix_acl **acl)
{
	...
	if (S_ISLNK(*mode) || !IS_POSIXACL(dir))
		return 0;
	p = get_inode_acl(dir, ACL_TYPE_DEFAULT);
	if (!p || p == ERR_PTR(-EOPNOTSUPP)) {
		*mode &= ~current_umask();         /* NO default ACL: umask applies */
		return 0;
	}
	...
	clone = posix_acl_clone(p, GFP_NOFS);
	ret = posix_acl_create_masq(clone, mode);   /* default ACL applies; umask bypassed */
	...
	if (!S_ISDIR(*mode))
		posix_acl_release(p);
	else
		*default_acl = p;                  /* a new SUBDIR inherits the default ACL too */
}

This single function is the formal statement of the umask/ACL relationship described in The umask and Default Permissions. If the parent has no default ACL, the kernel falls back to *mode &= ~current_umask() and the umask applies normally. If the parent has a default ACL, that branch is never reached: the default ACL is cloned into the new file’s access ACL, posix_acl_create_masq() intersects it with the requested mode, and the umask is ignored — exactly as umask(2) says (“If the parent directory has a default ACL, the umask is ignored, the default ACL is inherited”). Furthermore, if the newly created object is itself a directory, the parent’s default ACL is copied to it as its own default ACL (*default_acl = p), so the policy propagates recursively down the tree as new subdirectories are made — the foundation of “everything created under this tree gets these permissions.”

Configuration / Code — getfacl and setfacl

The userspace tools getfacl and setfacl (from the acl package) read and write these xattrs through the ACL syscalls. A worked session:

$ getfacl report.txt
# file: report.txt
# owner: erfan
# group: staff
user::rw-
group::r--
other::r--

That is a minimal ACL — it is just the mode bits. Now grant a named user and a named group:

$ setfacl -m u:alice:rw,g:dev:r-- report.txt   # -m = modify/add entries
$ getfacl report.txt
# file: report.txt
# owner: erfan
# group: staff
user::rw-
user:alice:rw-
group::r--
group:dev:r--
mask::rw-                                       # <-- auto-created mask, union of granted perms
other::r--

setfacl automatically created the mask::rw- entry. Per setfacl(1): “The default behavior of setfacl is to recalculate the ACL mask entry, unless a mask entry was explicitly given. The mask entry is set to the union of all permissions of the owning group, and all named user and group entries.” Now watch chmod collide with the mask:

$ chmod 640 report.txt
$ getfacl report.txt
# file: report.txt
user::rw-
user:alice:rw-                  #effective:r--   # alice's WRITE is now masked off!
group::r--
group:dev:r--
mask::r--                                         # chmod's group digit (4 = r) became the mask
other::---

The #effective:r-- comment is getfacl telling you the mask has clipped alice down — she still has rw- stored, but her effective access is r--. To restore it you raise the mask: setfacl -m m::rw report.txt (or just re-grant her, which auto-recomputes the mask back up).

Default ACLs use the -d flag (or a default: prefix):

$ setfacl -d -m u:ci:rwx,g:dev:rx /srv/build      # default ACL on the directory
$ getfacl /srv/build
# ...
default:user::rwx
default:user:ci:rwx
default:group::r-x
default:mask::rwx
default:other::r-x
$ touch /srv/build/artifact                        # umask IGNORED here
$ getfacl /srv/build/artifact
user::rw-
user:ci:rwx                     #effective:rw-      # inherited; mask from default clips x
group::r-x
mask::rw-
other::r-x

Other useful options (setfacl(1)): -x u:alice removes a named entry; -b strips all extended entries (back to plain mode bits); -k removes the default ACL; -R recurses; -n (--no-mask) suppresses the automatic mask recalculation when you want to set the mask yourself; and getfacl file1 | setfacl --set-file=- file2 copies one file’s ACL onto another, since setfacl accepts getfacl’s output format.

Failure Modes and Common Misunderstandings

“I gave alice rw, why can’t she write?” Almost always the mask. A later chmod (often by a deployment script doing chmod -R 755) reset mask::r-x, clipping alice to read-only. getfacl shows the #effective: comment; setfacl -m m::rwx or re-granting fixes it. The deeper lesson: never chmod recursively over a tree that relies on ACLs — you will silently flatten every named grant to the mask.

ls -l shows a + you cannot explain. A trailing + in ls -l output (-rw-r--r--+) means the file has an extended ACL. The mode digits you see for “group” are actually the mask, not the owning group — another reason chmod math on ACL files surprises people.

Copying files loses ACLs. cp does not preserve ACLs unless told to (cp -p or cp --preserve=mode, and even then it is xattr-dependent); tar needs --acls; rsync needs -A. Backups and deploys routinely strip ACLs and leave files relying on plain mode bits. Verify with getfacl after any copy.

ACLs require filesystem and mount support. The filesystem must store the xattrs and the superblock must advertise SB_POSIXACL (IS_POSIXACL() in the kernel). ext4/xfs/btrfs support ACLs and modern kernels enable them by default; some setups still need acl in mount options, and many network/overlay filesystems support them only partially. If setfacl returns Operation not supported, the mount does not support ACLs.

Performance and entry limits. ACLs are stored inline or in a shared xattr block; very large ACLs (hundreds of named entries) bloat inodes and are a sign you should be using a group instead of dozens of user: entries. The canonical-order requirement also means tools must keep entries sorted; hand-editing the raw xattr is unsupported.

Alternatives and When to Choose Them — and the richacl Story

POSIX ACLs are not the only ACL model, and the road not taken is instructive. NFSv4 ACLs (also called rich ACLs) are a fundamentally more expressive model derived from Windows/NTFS: ordered lists of allow and deny entries, ~14 distinct permission bits (separate “delete,” “delete child,” “write attributes,” “take ownership,” etc.), and per-entry inheritance flags — far richer than POSIX’s read/write/execute triple and single mask. They are what NFSv4 and Samba/SMB clients actually speak on the wire.

Linux’s attempt to support them natively was richacls, a multi-year effort by Andreas Gruenbacher (the same author as the POSIX ACL implementation) to add a unified rich-ACL model to the VFS and ext4. Despite eighteen-plus patch revisions from 2015 onward, it was never merged into mainline (LWN “Rich access control lists”, 2015; LWN “Richacls”, 2015). The objections were both technical and philosophical: maintainers were uneasy about a model that mixes allow and deny entries (making “what does this ACL actually grant?” hard to reason about), about the impedance mismatch with Windows identifiers (GUIDs vs. UNIX uid/gid), and about the migration/compat story when a richacl-bearing filesystem is mounted by a kernel that does not understand them.

Uncertain

Verify: the precise, current upstream status of NFSv4/rich-ACL support in the mainline VFS as of the 6.12/6.18 LTS kernels. Reason: my primary kernel-source reads covered fs/posix_acl.c (POSIX ACLs only) and did not include a fetch of any in-tree NFSv4-ACL or richacl mapping code; the “never merged” claim rests on LWN coverage from 2015 (dated) plus a web summary of the patch-series history, not a current kernel-tree grep. The Samba/NFS-ganesha userspace path (translating NFSv4 ACLs to/from POSIX ACLs, or storing them as opaque xattrs) is a separate question I did not verify against a primary source. To resolve: grep the v6.12/v6.18 source tree for any richacl/nfs4_acl VFS interface and check the NFS client’s ACL handling, and confirm against current kernel docs whether anything beyond POSIX ACLs is exposed generically. uncertain

So in practice, on a local Linux filesystem in 2026, POSIX ACLs are the only generic ACL model the kernel offers; NFSv4 ACLs are handled at the protocol/userspace layer (the NFS server and Samba translate to and from POSIX ACLs or store the richer form as opaque metadata), not by a native in-VFS rich-ACL type (Samba NFS4 ACL overview). Choose POSIX ACLs when you need per-user/per-group grants beyond owner/group/other on a Linux box; reach for the protocol-level NFSv4/SMB ACLs only when interoperating with a Windows or NFSv4 world, and accept that the translation is lossy in both directions.

When even POSIX ACLs are not enough — when policy must be system-wide and mandatory rather than at the file owner’s discretion — the answer is not ACLs at all but mandatory access control: SELinux type enforcement or AppArmor path profiles, layered on top via the LSM framework.

Production Notes

The canonical production use of POSIX ACLs is shared project directories: set a default ACL on /srv/project granting the relevant team and CI account access, and every file created beneath it inherits it without anyone touching umasks or group memberships. This is more robust than the older “setgid directory + permissive umask” trick because it can grant multiple named principals and survives processes that set a tight umask. The trade-off is the chmod/mask footgun: any tooling that does chmod -R over such a tree will flatten the named grants to the mask, so deployment scripts must be audited for recursive chmod and prefer setfacl -R (or leave permissions alone). Many distributions also use default ACLs under /var/log (systemd-journald sets ACLs so adm/wheel users can read logs) and on systemd’s /run user directories — getfacl on those paths is a good way to see real-world ACLs in the wild.

See Also