facl is a Go + bubbletea /
huh front-end to FreeBSD's
getfacl(1) / setfacl(1), modelled after the Windows Advanced Security
Settings dialog: one row per ACL entry, an entry editor with Applies to
("This folder, subfolders and files", …), basic/advanced permission
checkboxes, and an apply step that previews the exact setfacl commands
before running them.
pkg install facl # FreeBSD port: sysutils/facl
go install github.com/olgeni/facl@latest # or: go build -o facl .
facl /some/zfs/path
./facl /srv/a /srv/b /srv/c.txt # edit one ACL and apply it to all of them
go test ./... # unit tests + kernel-backed integration tests
man ./facl.1 # manual page
| Key | Action |
|---|---|
↑/↓ j/k pgup/pgdn home/end |
move |
enter / e |
edit entry |
a |
add entry (principal picker first; inserted after the cursor; the kernel takes at most 124 entries per ACL) |
d / x / del |
remove entry (the last entry cannot be removed — the kernel rejects empty NFSv4 ACLs) |
K / J (shift+↑/↓) |
move entry up / down — order matters, first match wins |
u |
undo |
b |
reset to the trivial ACL implied by the mode (setfacl -b) |
i |
inheritance: convert inherited (I) entries to explicit ones, or drop them |
A / ctrl+s |
apply: choose scope → preview setfacl commands → confirm |
y / p |
yank the ACL as edited / paste it — works across facl runs (see Templates) |
Y / P |
save the ACL as a named template / pick a template to paste |
D |
drift audit: which descendants no longer carry the inherited entries the tree would propagate; enter fixes |
o |
change owner / group (optionally for all descendants); applied after the ACL changes |
E |
effective access: what a user or group can do here (see below) |
f |
with several paths: list the selection and which ACLs differ |
c |
chmod preview: what chmod to a mode would leave of the ACL (enter takes it as the edit) |
m |
toggle mouse support (or start with facl -mouse): click selects, click again edits, wheel scrolls, editor checkboxes/buttons clickable; hold shift to select text |
r |
reload from disk |
h |
key-map popup (the footer is abbreviated below 96 columns) |
? |
help, q quit |
-
Principal:
owner@,group@,everyone@,user:NAME,group:NAME(picker with filtering, or typed manually; numeric ids accepted). -
Type: Allow / Deny.
-
Applies to (directories only) ↔ NFSv4 inheritance flags:
Applies to flags This folder only – This folder, subfolders and files fdThis folder and subfolders dThis folder and files fSubfolders and files only fdiSubfolders only diFiles only fi"Only apply within this container" =
n(no_propagate). -
Basic permissions map to the
setfaclpermission sets: Full control =full_set, Modify =modify_set, Read & execute =read_set+x+s, Read =read_set+s, Write =write_set. Advanced toggles the 14 individual bits (rwxpDdaARWcCos).
Pick a user (evaluated with all of its groups) or a group, and see, bit by
bit, what the ACL as currently edited grants: entries are scanned top to
bottom, inherit-only entries are skipped, and the first entry that matches the
identity and mentions a bit decides (allowed / denied), otherwise the bit
is not granted (NFSv4 implicit deny). The owner's kernel-implied bits
(ZFS: read_acl, read_attributes, write_acl; UFS: also write_attributes) and the
superuser's privileges are shown as such. This mirrors zfs_zaccess() /
vaccess_acl_nfs4(), and is checked against access(2) in the integration
tests.
chmod(2) and the ACL are not independent on ZFS: what chmod does to a
non-trivial ACL is governed by the dataset's aclmode property — discard
(the default) throws the ACL away and leaves the trivial ACL for the new
mode (every user:/group: entry is lost); groupmask keeps them but limits
allow entries to the group bits of the new mode; passthrough keeps them and
only regenerates owner@/group@/everyone@; restricted makes chmod
fail with EPERM on non-trivial ACLs. UFS behaves like discard. facl warns in
the header when that applies, c previews the result for a given mode (octal or
chmod(1) symbolic: u+rwX, go-w, a=rx, g=u) and can take it as the
current edit, and the apply preview shows the mode ZFS will derive from the new ACL (zfs_mode_compute: ZFS recomputes
the mode bits from the ACL on every setfacl). Chmod/ModeFromACL are
checked against real chmod(2)/stat(2) in the integration tests.
o changes the owner and/or group like the Windows dialog's "Owner: Change"
(with "Replace owner on subcontainers and objects" for directories). The
changes are planned like everything else, shown in the apply preview as
chown -h lines and executed with lchown(2) after the ACL changes, so
write_acl is never given away before setfacl has run. Changing the owner
to another user needs root; without it you can still switch the group to one
of your own groups. On ZFS the owner@/group@ entries follow the new
owner automatically; explicit user:/group: entries are left alone.
y copies the ACL as currently edited to the yank register, p pastes it
(replacing the edit, undoable; files get it without inheritance flags). The
register is a plain file — $XDG_STATE_HOME/facl/yank.acl, i.e. usually
~/.local/state/facl/yank.acl, mode 0600 — so it survives quitting: facl a,
y, q, facl b, p. Y saves the ACL under a name instead, P picks a
saved template; -save NAME path and -set @NAME do the same from the CLI,
-templates lists them. The files are in getfacl/setfacl -M format with a
comment header (where it came from, when), so setfacl -b -M ~/.local/state/facl/NAME.acl PATH works without facl at all (setfacl -M
reads at most 1023 bytes; facl itself does not go through -M).
FACL_STATE_DIR overrides the directory.
Inheritance only happens at creation time, so a tree drifts: someone runs
setfacl/chmod on a file deep down (with aclmode=discard that wipes its
inherited entries), files get copied or restored in, a parent's ACL is changed
without propagating. D (or facl -audit DIR) walks the tree and lists every
descendant whose inherited (I) entries are not what propagating the current
ACLs down the tree would give — showing "has" vs "should have", and flagging
pure ordering differences separately. enter (or -audit -fix) repairs it
with the same propagate plan the apply flow uses: inherited entries rewritten,
explicit entries kept. The CLI exits 3 when drift is found, so it can run from
cron. Because it reuses the planning engine, the "should have" side follows the
dataset's aclinherit/aclmode exactly.
facl a b c… loads the ACL of the first path and applies the edited ACL to
all of them (the header shows how many of the others currently differ; f
lists them). Regular files get the ACL without inheritance flags (inherit-only
entries are dropped for them). A path inside another selected directory is set
exactly as edited — its own plan takes precedence over the outer directory's
propagation — and every path is changed exactly once.
- This folder only — change just this ACL.
- Propagate — additionally rewrite the inherited (
I) entries of every descendant to what ZFS would have inherited from the new ACL; the descendants' explicit entries are kept. - Replace — every descendant gets only the inherited entries plus the trivial entries for its own mode (like Windows "Replace all child object permission entries").
What descendants inherit follows the file system's rules, not an idealised
NFSv4: on ZFS the dataset's aclinherit (restricted — the default — strips
write_acl/write_owner from inherited allow entries; passthrough,
passthrough-x, noallow, discard) and aclmode (groupmask trimming,
restricted) properties are read with zfs get and shown in the header and in
the apply preview; on UFS with nfsv4acls the generic kernel rules apply
(acl_nfs4_inherit_entries: owner@/group@/everyone@ are not inherited, write
bits are stripped and masked by the group mode); on an NFSv4 client mount the
server does the inheritance and facl cannot see its settings, so it assumes
ZFS with default properties and says so — start it with -fs zfs:ACLINHERIT,ACLMODE
or -fs ufs to tell it what the server really is (see "NFS client mounts"
below). Freshly created children are
modelled exactly like zfs_acl_chmod() lays them out: mode deny entries,
then inherited entries, then the mode allow entries. The entry editor warns
when an entry will not be inherited as written (e.g. write_acl under
restricted).
Before anything runs, the plan is pre-flighted for the current user: paths
where setfacl will probably be refused (not the owner, no write_acl) or
where a chown needs root / group membership are flagged in the preview (the
kernel still has the last word).
Each file is changed with a single setfacl -x … -a … - invocation (paths
on stdin), computed as a minimal diff against the current ACL, so the
replacement is atomic per file and the preview shows exactly what will run.
NFSv4 also defines audit and alarm ACE types (with the S/F "successful
/ failed access" flags that getfacl prints as empty columns). The FreeBSD
kernel refuses to store them (acl_nfs4_check(): "Disallow
ACL_ENTRY_TYPE_AUDIT and ACL_ENTRY_TYPE_ALARM for now", acl_set_file fails
with EINVAL), so facl parses them only to report that clearly; ZFS's access
check would otherwise even treat them like allow entries.
facl -n path... # print ACLs and file-system properties
facl -n -set ACL [-scope S] path... # dry run: print the setfacl commands, change nothing
facl -set ACL [-scope S] [-y] path... # print the plan and apply (asks unless -y)
facl -from /srv/model [-scope S] path... # apply the ACL of another path
facl -add ENTRIES [-at N] [-scope S] path... # edit each path's own ACL: insert entries
facl -remove user:bob,3 [-scope S] path... # … remove by principal, index or full entry
facl -modify user:bob:rwxp:fd:allow path... # … setfacl -m style: replace or add (idempotent)
facl -check -set|-from|-add|… path... # dry run, exit 3 if anything would change, 0 if not
facl -effective user:bob,group:staff path... # effective access table(s)
facl -chmod 750 path... # what chmod would leave behind (or u+rwX, go-w, …)
facl [-n] -chown bob:staff [-R] path... # change ownership (alone or together with -set)
facl -where user:bob dir... # where bob has entries (his own, his groups', owner@/group@ where he owns) + his access there
facl -nontrivial dir... # every object with a non-trivial ACL
facl -dump dir... > acls.json # JSON snapshot of every object's ACL (and type/mode/owner)
facl -restore acls.json [-n|-y|-check] # bring ACLs and ownership back to the snapshot (or just compare: -check)
facl -save web-project path # save path's ACL as a template; -set @web-project applies it
facl -show web-project | -delete-template web-project | -templates
facl -audit [-fix [-n|-y]] dir... # inheritance drift report (exit 3 if any) / repair
facl -json [-n|-effective ID|-audit|-where P|-nontrivial] path... # the reports as one JSON document
ACL is in setfacl(1) syntax (user:bob:rwxp:fd:allow,owner@:full_set::allow,…),
@NAME for a saved template, @file:PATH (or @/…, @./…) for a file in
template/getfacl format — keep ACLs in your configuration repo — and @-
for the same on stdin. -scope is this (default), propagate or replace.
The dry run prints a shell-pasteable script (setfacl … - <<'EOF' … EOF).
For scripting: -add/-remove/-modify edit each target's own ACL (so
facl -modify user:bob:rwxp:fd:allow -scope propagate /srv/www grants bob on
the tree without having to restate the whole ACL, and files in a mixed
selection get the entries without inheritance flags); -modify and -remove
are idempotent; -check turns any apply mode — and -restore — into an
"is anything to do?" probe with exit status 3/0 for Ansible-style changed
reporting; exit statuses are 0 ok, 1 error, 2 declined, 3 changes pending /
drift.
-json emits one document: filesystem (zfs, aclinherit, aclmode, on
NFS nfs and assumed) plus
files (path, type, mode, owner, group, uid, gid, acl), each
entry as tag, name, id, perms, flags, type, text (the setfacl
form) and perm_names/flag_names; -effective adds identity and a
permissions list (perm, short, verdict, granted, entry,
entry_text); -audit gives reports (root, examined, clean, drifts
with has/should_have) and still exits 3 on drift; -where/-nontrivial
give query, examined, files (path, type, entries), skipped,
errors, and for a user/group granted/summary (the effective access
there); -dump is the listing for whole trees (plus skipped, errors) and
is what -restore reads. A path that is a symlink
is followed (like getfacl/setfacl without -h); symlinks inside walked
trees are skipped, never followed.
~/.config/facl/config ($XDG_CONFIG_HOME/facl/config, or FACL_CONFIG)
holds defaults for the few options that make sense as defaults, one
option = value per line, # comments:
mouse = on # -mouse by default (m still toggles it)
scope = propagate # default -scope for -set
fs /mnt/nfs = zfs:passthrough,groupmask # -fs for paths under /mnt/nfs (the NFS server's semantics)
fs /mnt/nas = ufs
fs = zfs:restricted,discard # optional: everywhere else
fs [PATH] = SEMANTICS is the one that takes a path: it applies to the
(first) path given and everything below it, the longest matching prefix wins,
a bare fs = … applies everywhere, and -fs on the command line beats them
all. Command-line flags override the file; unknown keys and bad values are
warned about, never fatal.
completions/ has _facl (zsh, drop it into a $fpath directory),
facl.bash (source it, or install as
share/bash-completion/completions/facl) and facl.fish
(~/.config/fish/completions/facl.fish). They complete the options,
-scope values, user:/group: principals for -effective, users and
groups for -chown and saved templates (@NAME) for -set.
On an NFSv4 mount (mount -t nfs -o nfsv4 …) facl works, with four things
worth knowing — all of them verified against a loopback export of a ZFS
dataset (see the test recipe below):
- Inheritance and chmod happen on the server, under its file system's
rules. facl cannot ask the server, so it assumes ZFS
aclinherit=restricted aclmode=discard(most servers) and flags the assumption in the header, the plan preview and-json("nfs": true, "assumed": true).-fs zfs[:ACLINHERIT[,ACLMODE]]or-fs ufsreplaces the assumption. - The inherited flag (
I) does not cross NFS — FreeBSD's NFS code has noACE4_INHERITED_ACE, in either direction. Entries the server inherited show up as plain entries, and anIyou set is dropped. Consequences:-auditis refused on NFS (it cannot tell inherited from explicit entries — run it on the server); propagate treats entries equal to what the parent would propagate as the inherited part, so it stays idempotent, but stale inherited entries that no longer match look explicit and are kept (replace has no such problem); predicted child ACLs carry noI. - Root is not root: unless the export says
-maproot=root, the server maps uid 0 tonobody. The pre-flight check, which normally says nothing for root, warns on NFS about every path wherenobodywould not be allowed tosetfaclorchown. - Wire details:
delete_childis dropped from the entries of regular files (the protocol only carries it for directories — predictions account for it); the kernel's 124-entry limit comes back as EPERM instead of ENOSPC; the client's attribute cache can show a stale mode right after an apply; and for some ACLsgetfaclitself is refused through NFS (itspathconfprobe gets EACCES — the server checksread_attributeson GETATTR and the client caches the verdict), in which case facl reports that error and achmodfrom the owner (underaclmode=discard) or a fix on the server gets you back in.
go test ./... # everything (unit + kernel-backed), a few seconds
go test -v -run Integration ./acl # only the kernel-backed tests, verbose
go vet ./..."Kernel-backed" means the test does not trust the model: it creates real
files under t.TempDir(), drives the real getfacl/setfacl/chmod(2)/
access(2)/stat(2) on them, and compares what the kernel did with what
the acl package predicted (TestIntegration* in acl/). These tests skip
themselves — they do not fail — when the temp dir is not on an NFSv4-ACL
file system or setfacl is missing, so run with -v and look for SKIP
if you want to be sure they ran; point TMPDIR at a ZFS dataset to run
them against its aclinherit/aclmode settings (the expectations adapt).
The access(2) comparison skips when running as root.
TestMatrixZFS goes further and runs the inheritance and chmod comparisons
on every aclinherit × aclmode combination (20 throwaway child datasets,
created, mounted and destroyed by the test). It needs a dataset it may create
children of, named in FACL_TEST_POOL; run it as root, or delegate with
zfs allow -u USER create,mount,destroy,aclinherit,aclmode,mountpoint DS
and sysctl vfs.usermount=1:
FACL_TEST_POOL=rpool/test go test -v -run Matrix ./aclTestMatrixUFS does the same for the UFS rules (acl_nfs4_inherit_entries,
acl_nfs4_sync_mode_from_acl) on a UFS file system mounted with nfsv4acls,
named in FACL_TEST_UFS; a throwaway image is enough:
sudo mdconfig -a -t swap -s 256m # prints md0
sudo newfs -U /dev/md0 && sudo tunefs -N enable /dev/md0
sudo mkdir -p /mnt/facl-ufs && sudo mount /dev/md0 /mnt/facl-ufs
sudo chown $USER /mnt/facl-ufs
FACL_TEST_UFS=/mnt/facl-ufs go test -v -run Matrix ./acl(TMPDIR=/mnt/facl-ufs go test -run Integration ./acl runs every
kernel-backed test there, with UFS expectations.)
TestMatrixNFS runs the inheritance and chmod comparisons through an NFSv4
client mount named in FACL_TEST_NFS (FACL_TEST_NFS_FS, a -fs value,
says what the server is; default the assumed ZFS defaults). A loopback export
of a ZFS dataset, nothing persisted (onestart), is enough:
sudo zfs create -o mountpoint=/export/facl rpool/test/nfs && sudo chown $USER /export/facl
printf 'V4: /export -sec=sys\n/export/facl localhost\n' | sudo tee /etc/exports
sudo service rpcbind onestart; sudo service nfsuserd onestart
sudo service mountd onestart; sudo service nfsd onestart
sudo sysctl vfs.nfsd.server_max_nfsvers=4 # the rc script caps it at 3 without nfsv4_server_enable
sudo mkdir -p /mnt/facl-nfs && sudo mount -t nfs -o nfsv4 localhost:/facl /mnt/facl-nfs
FACL_TEST_NFS=/mnt/facl-nfs go test -v -run Matrix ./acl
TMPDIR=/mnt/facl-nfs go test -v -run Integration ./acl # every kernel-backed test, NFS expectationsaclpackage: parsing round-trips/goldens, trivial-ACL-from-mode goldens, inheritance goldens for every aclinherit/aclmode combination and UFS, child ACL layout, diff scripts (incl. 2000 randomized apply checks), validation, planning.aclintegration tests (skipped unless the temp dir is on an NFSv4 file system andsetfaclexists): trivial ACL ==setfacl -bfor 21 modes; inheritance == what the kernel actually gives new children for 6 parent shapes × 6 creation modes × file/dir (including grandchildren andaclinherit=restrictedstripping), using the file system's real rules (ZFS properties or UFS); diff scripts apply exactly; propagate/replace plans are correct and idempotent; symlinks are skipped; effective access for the current user agrees withaccess(2)for a set of allow/deny shapes; multi-target plans touch each path once with explicit targets winning;DerivedModematchesstat(2)aftersetfaclandChmodmatches a realchmod(2)under the dataset'saclmode; the drift audit finds exactly the tampered descendants and its fix restores them; ownership plans do a real recursivechgrp; saved templates apply with a realsetfacl -M; the 124-entry limit is exactly where the kernel says ENOSPC.aclmatrix tests:TestMatrixZFS(needsFACL_TEST_POOL) runs the inheritance andchmodcomparisons above on all 20aclinherit×aclmodecombinations, each on its own throwaway dataset;TestMatrixUFS(needsFACL_TEST_UFS) runs the inheritance,chmodand derived-mode comparisons on a UFS file system withnfsv4acls;TestMatrixNFS(needsFACL_TEST_NFS) the inheritance andchmodones through an NFSv4 client mount.mainpackage:-jsonoutput for the three reports against real files (kernel-backed), the configuration file parser.uipackage: drives the bubbletea model with synthetic key presses (navigation, reorder, undo, editor, picker, apply flow, rendering at 60–140 columns).
BSD 2-Clause, see LICENSE.


