Skip to content

Repository files navigation

facl — a terminal "Advanced Security Settings" dialog for NFSv4 ACLs

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.

main screen

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

Main screen

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

Entry editor

entry editor

  • 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 fd
    This folder and subfolders d
    This folder and files f
    Subfolders and files only fdi
    Subfolders only di
    Files only fi

    "Only apply within this container" = n (no_propagate).

  • Basic permissions map to the setfacl permission 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).

Effective access (E)

effective access

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 and aclmode

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.

Ownership

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.

Yank, paste and templates

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.

Drift audit

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.

Several paths

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.

Apply scopes (directories)

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

Audit and alarm entries

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.

Non-interactive use

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.

Configuration and shell completions

~/.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.

NFS client mounts

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 ufs replaces the assumption.
  • The inherited flag (I) does not cross NFS — FreeBSD's NFS code has no ACE4_INHERITED_ACE, in either direction. Entries the server inherited show up as plain entries, and an I you set is dropped. Consequences: -audit is 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 no I.
  • Root is not root: unless the export says -maproot=root, the server maps uid 0 to nobody. The pre-flight check, which normally says nothing for root, warns on NFS about every path where nobody would not be allowed to setfacl or chown.
  • Wire details: delete_child is 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 ACLs getfacl itself is refused through NFS (its pathconf probe gets EACCES — the server checks read_attributes on GETATTR and the client caches the verdict), in which case facl reports that error and a chmod from the owner (under aclmode=discard) or a fix on the server gets you back in.

Tests

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 ./acl

TestMatrixUFS 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 expectations
  • acl package: 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.
  • acl integration tests (skipped unless the temp dir is on an NFSv4 file system and setfacl exists): trivial ACL == setfacl -b for 21 modes; inheritance == what the kernel actually gives new children for 6 parent shapes × 6 creation modes × file/dir (including grandchildren and aclinherit=restricted stripping), 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 with access(2) for a set of allow/deny shapes; multi-target plans touch each path once with explicit targets winning; DerivedMode matches stat(2) after setfacl and Chmod matches a real chmod(2) under the dataset's aclmode; the drift audit finds exactly the tampered descendants and its fix restores them; ownership plans do a real recursive chgrp; saved templates apply with a real setfacl -M; the 124-entry limit is exactly where the kernel says ENOSPC.
  • acl matrix tests: TestMatrixZFS (needs FACL_TEST_POOL) runs the inheritance and chmod comparisons above on all 20 aclinherit × aclmode combinations, each on its own throwaway dataset; TestMatrixUFS (needs FACL_TEST_UFS) runs the inheritance, chmod and derived-mode comparisons on a UFS file system with nfsv4acls; TestMatrixNFS (needs FACL_TEST_NFS) the inheritance and chmod ones through an NFSv4 client mount.
  • main package: -json output for the three reports against real files (kernel-backed), the configuration file parser.
  • ui package: drives the bubbletea model with synthetic key presses (navigation, reorder, undo, editor, picker, apply flow, rendering at 60–140 columns).

License

BSD 2-Clause, see LICENSE.

About

Terminal UI and CLI for FreeBSD NFSv4 ACLs, modelled after the Windows Advanced Security Settings dialog — inheritance scopes, effective access, ZFS aclinherit/aclmode awareness, chmod preview; verified against the kernel.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages