Proxmox VEXV · Security & HardeningAccess control
The ACL model: paths, propagation and custom roles
What you'll learn
- Read an ACL as the four-part tuple Proxmox stores: path, principal, role, propagate
- Predict which permission wins when several ACLs match the same object
- Build a least-privilege custom role with pveum role add and assign it at the narrowest path that works
- Audit effective permissions for a user, a group and a token rather than inferring them from the ACL list
Prerequisites
Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12
Every authorisation decision Proxmox makes reduces to one question: does this principal hold a role containing the required privilege, on a path that covers the object being touched?
Everything else — realms, groups, tokens, two-factor — is about establishing who the principal is. This lesson is about what happens after that, and it is the half operators get wrong, because the failure mode is silent. Over-granting produces no error, no log line and no ticket. It produces an account that works, which is exactly what the person who asked for it wanted.
The four fields
An access control entry is a tuple of four things, and Proxmox stores it that way:
| Field | What it is | Example |
|---|---|---|
| path | The object or subtree the entry applies to | /vms/100, /storage/pbs-main |
| principal | A user, a group, or an API token | alice@corp, @backup-ops, svc@pve!nightly |
| role | A named set of privileges | PVEVMUser, BackupRunner |
| propagate | Whether the entry covers sub-paths | 1 (default) or 0 |
pveum acl modify writes one. The path is positional; the rest are
options:
pveum acl modify /vms/100 --users alice@corp --roles PVEVMUserThe same command takes --groups, --tokens and --propagate. A single
invocation can name several of each, and each combination becomes its own
stored entry.
The path tree
Paths look like a filesystem and behave like one. The complete set of top-level paths in PVE 9.2:
| Path | Covers |
|---|---|
/ | Everything. The root of the tree. |
/access | The access-control subsystem itself |
/access/groups | Group administration |
/access/realms/{realmid} | One authentication realm |
/access/users/{userid} | One user object |
/nodes | All nodes |
/nodes/{node} | One node: its shell, its services, its firewall |
/vms | All guests, VMs and containers alike |
/vms/{vmid} | One guest |
/storage | All storage definitions |
/storage/{storeid} | One storage |
/pool | All resource pools |
/pool/{poolname} | One pool, and everything in it |
/sdn | The SDN subsystem |
/sdn/zones/{zone} | One SDN zone |
/sdn/vnets/{vnet} | One VNet |
Two of these are worth pausing on.
/pool/{poolname} is the only path that is indirect. A pool is a
membership list of VMs and storages, so an ACL on the pool path reaches
objects that are not underneath it in the string sense. This is the
mechanism the Proxmox documentation recommends for delegated
administration: "Pools can be used to group a set of virtual machines and datastores. You can then simply set permissions on pools (/pool/{poolid}), which are inherited by all pool members." Add a VM to
the pool and it inherits the pool ACLs; remove it and it stops.
/nodes/{node} is the path that grants shell access. Sys.Console on a
node path is root on that node, because the console it opens is a root
shell. There is no non-root node console in PVE. Any role containing
Sys.Console assigned at /nodes or / is an administrator role
regardless of what else it contains, and regardless of what the role is
called.
Which entry wins
Several entries can match one object at once: a role at /, another at
/vms, a group membership, a pool. Proxmox resolves them with four
rules, quoted from the documentation:
Permissions for individual users always replace group permissions. Permissions for groups apply when the user is member of that group. Permissions on deeper levels replace those inherited from an upper level.
NoAccesscancels all other roles on a given path.
Replace, not merge. This is the rule that surprises people, and it
cuts both ways. A user with PVEAuditor at / and PVEVMUser at
/vms/100 does not have “auditor everywhere plus VM user on 100”. On
/vms/100 they have PVEVMUser and only PVEVMUser — the deeper
entry replaced the inherited one. If PVEVMUser lacks a privilege that
PVEAuditor granted, it is gone on that object.
The practical consequence: a narrow grant on a specific object can remove access the person had a moment ago. Operators tend to reason additively, assign a scoped role to fix one gap, and create a second gap they do not notice until someone reports that a page has stopped loading.
NoAccess is the deny primitive. It is a built-in role containing no
privileges, and the rule above makes it absolute on the path where it is
set. It is how you carve an exception out of a broad grant: PVEAdmin
propagating from /, NoAccess on /vms/900 for the guest nobody
should touch.
pveum acl modify /vms --groups ops --roles PVEVMAdmin
pveum acl modify /vms/900 --groups ops --roles NoAccess--propagate 0 scopes an entry to exactly one object. The default is
1. A non-propagating entry on /vms applies to the collection endpoint
and not to any individual guest, which is almost never what someone
means when they set it. Use it deliberately or leave it alone.
Roles are just named privilege sets
There are seventeen built-in roles in PVE 9.2. The ones worth memorising:
| Role | Grants | Reach for it when |
|---|---|---|
Administrator | Every privilege | Never, on a path other than the one root already covers |
NoAccess | Nothing, and cancels everything else on the path | Carving an exception out of a broad grant |
PVEAuditor | Read-only across the tree | Monitoring, inventory, a new joiner |
PVEVMUser | Console, power, backup on a guest | A tenant who operates a guest but does not configure it |
PVEVMAdmin | Full guest configuration | A team that owns a set of guests |
PVEDatastoreUser | Allocate space, audit a storage | Anything that needs to write backups |
PVEPoolUser | Audit a pool | Rarely alone; pair it with a guest role |
PVESysAdmin | Node-level: ACLs, audit, console, syslog | Node operators — note the Sys.Console |
PVEUserAdmin | Create and modify users | An identity team that should not touch guests |
The rest — PVETemplateUser, PVEDatastoreAdmin, PVEPoolAdmin,
PVESDNAdmin, PVESDNUser, PVEMappingAdmin, PVEMappingUser,
PVEAdmin — are narrower slices of the same privilege list.
Privileges themselves are grouped by what they act on. The full set in PVE 9.2:
Node and system: Group.Allocate, Mapping.Audit,
Mapping.Modify, Mapping.Use, Permissions.Modify, Pool.Allocate,
Pool.Audit, Realm.AllocateUser, Realm.Allocate, SDN.Allocate,
SDN.Audit, Sys.Audit, Sys.Console, Sys.Incoming, Sys.Modify,
Sys.PowerMgmt, Sys.Syslog, User.Modify.
Guest: SDN.Use, VM.Allocate, VM.Audit, VM.Backup,
VM.Clone, VM.Config.CDROM, VM.Config.CPU, VM.Config.Cloudinit,
VM.Config.Disk, VM.Config.HWType, VM.Config.Memory,
VM.Config.Network, VM.Config.Options, VM.Console,
VM.GuestAgent.Audit, VM.GuestAgent.FileRead,
VM.GuestAgent.FileSystemMgmt, VM.GuestAgent.FileWrite,
VM.GuestAgent.Unrestricted, VM.Migrate, VM.PowerMgmt,
VM.Replicate, VM.Snapshot, VM.Snapshot.Rollback.
Storage: Datastore.Allocate, Datastore.AllocateSpace,
Datastore.AllocateTemplate, Datastore.Audit.
The guest-agent privileges are split five ways for a reason.
VM.GuestAgent.FileRead reads any file inside the guest as the agent
user, which on a Linux guest is root. VM.GuestAgent.Unrestricted
permits arbitrary command execution in the guest. Neither is implied by
VM.Console, and a monitoring role that only needs to ask the agent for
the guest IP wants VM.GuestAgent.Audit and nothing else.
Building a custom role
The workflow is: name the job, list the API calls it makes, map those to privileges, create the role, assign it at the narrowest path that covers the job.
Take a monitoring collector that needs to read cluster and guest status and nothing else.
pveum role add MonitoringCollector \
--privs 'Sys.Audit VM.Audit Datastore.Audit VM.GuestAgent.Audit'
pveum acl modify / --tokens 'monitor@pve!prometheus' \
--roles MonitoringCollector --propagate 1Now a backup operator who runs vzdump jobs against a pool and needs
somewhere to write them.
pveum role add BackupRunner --privs 'VM.Audit VM.Backup VM.Snapshot'
pveum role add BackupTarget --privs 'Datastore.Audit Datastore.AllocateSpace'
pveum acl modify /pool/production --tokens 'svc-backup@pve!nightly' \
--roles BackupRunner
pveum acl modify /storage/pbs-main --tokens 'svc-backup@pve!nightly' \
--roles BackupTargetSplitting into two roles is not ceremony. BackupRunner on the pool and
BackupTarget on one storage means the token cannot write to any other
storage, and cannot back up a guest that is not in the pool. One role
containing all five privileges, assigned at /, would do the same job
and also permit writing to every datastore in the cluster.
VM.Snapshot is in the list because vzdump in snapshot mode takes
one. A job configured for stop mode does not need it, and leaving it
out is a real reduction — VM.Snapshot combined with
VM.Snapshot.Rollback is a data-destroying pair, and the second is
correctly absent here.
Auditing what you actually granted
The ACL list tells you what was written. It does not tell you what a principal can do, because of inheritance, group membership, replacement and token intersection. Ask Proxmox directly.
$ pveum user permissions alice@corp --path /vms/100┌──────────────────┬──────┐
│ privilege │ prop │
╞══════════════════╪══════╡
│ VM.Audit │ 1 │
├──────────────────┼──────┤
│ VM.Backup │ 1 │
├──────────────────┼──────┤
│ VM.Console │ 1 │
├──────────────────┼──────┤
│ VM.PowerMgmt │ 1 │
└──────────────────┴──────┘Illustrative output
Omit --path and it returns the whole resolved tree, which is the right
call during an access review. For tokens there is a separate subcommand,
and it is the one that matters most:
pveum user token permissions svc-backup@pve nightlyReading the raw entries is still useful for spotting an ACL nobody remembers adding:
pveum acl list
pveum user list
pveum role listCommon mistakes
- Reasoning additively. Deeper entries replace inherited ones. A scoped grant can remove access, and the symptom appears on an unrelated page.
- Auditing the ACL list instead of effective permissions. The list is
the input.
pveum user permissionsandpveum user token permissionsare the output, and for a privilege-separated token they are the only place the intersection is visible. - Custom roles that contain
Sys.Console. The role is an administrator role. Name it accordingly or remove the privilege. - Granting at
/because the narrower path did not work. It usually did not work because the job touches two subtrees. Write two entries. - Using
Administratoras a shortcut during an incident and leaving it. Every incident-time grant needs an expiry in the same ticket that created it.
Key takeaways
- An ACL is path, principal, role, propagate. Everything else resolves into those four fields.
- Deeper entries replace inherited ones;
NoAccesscancels everything on its path. Neither behaviour is additive. Sys.Console,Permissions.Modify, andVM.Config.DiskwithDatastore.AllocateSpaceare administrator-equivalent whatever the role is called.- Build roles from the API calls a job makes, then assign at the narrowest path that covers the job — which is often two paths, not one.
- Verify with
pveum user permissionsandpveum user token permissions. The ACL list is not the answer.
Knowledge check
Knowledge check · 4 questions
Q1. A user holds PVEAuditor at / with propagation, and PVEVMUser at /vms/100. What are their effective permissions on VM 100?
Q2. Which of these make a custom role administrator-equivalent regardless of what the role is named? Select all that apply.
Q3. A privilege-separated API token can hold permissions on a path that its owning user does not have, provided the token is given an explicit ACL there.
Q4. A backup token needs to run vzdump against guests in the production pool and write to the pbs-main datastore. What is the correct scoping?
Passing score: 75%. Answers are checked in this browser.