Skip to content

Commit

Permalink
Allow setting filesystem ACL entries by user or group name
Browse files Browse the repository at this point in the history
This adds some logic to allow users to specify `who` in an ACL
entry and have backend resolve the name into a numeric ID prior
to ACL being written.
  • Loading branch information
anodos325 committed Jul 3, 2024
1 parent bef6b6f commit 9a7570e
Show file tree
Hide file tree
Showing 3 changed files with 215 additions and 7 deletions.
92 changes: 92 additions & 0 deletions src/middlewared/middlewared/plugins/filesystem_/acl.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ def setacl_posix1e(self, job, data):
'nfs4_ace',
Str('tag', enum=['owner@', 'group@', 'everyone@', 'USER', 'GROUP']),
Int('id', null=True, validators=[Range(min_=-1, max_=2147483647)]),
Str('who'),
Str('type', enum=['ALLOW', 'DENY']),
Dict(
'perms',
Expand Down Expand Up @@ -838,6 +839,7 @@ def setacl_posix1e(self, job, data):
Bool('default', default=False),
Str('tag', enum=['USER_OBJ', 'GROUP_OBJ', 'USER', 'GROUP', 'OTHER', 'MASK']),
Int('id', default=-1, validators=[Range(min_=-1, max_=2147483647)]),
Str('who'),
Dict(
'perms',
Bool('READ', default=False),
Expand Down Expand Up @@ -869,6 +871,69 @@ def setacl_posix1e(self, job, data):
@returns()
@job(lock="perm_change")
def setacl(self, job, data):
"""
Set ACL of a given path. Takes the following parameters:
`path` full path to directory or file.
`dacl` ACL entries. Formatting depends on the underlying `acltype`. NFS4ACL requires
NFSv4 entries. POSIX1e requires POSIX1e entries.
`uid` the desired UID of the file user. If set to None (the default), then user is not changed.
`gid` the desired GID of the file group. If set to None (the default), then group is not changed.
`recursive` apply the ACL recursively
`traverse` traverse filestem boundaries (ZFS datasets)
`strip` convert ACL to trivial. ACL is trivial if it can be expressed as a file mode without
losing any access rules.
`canonicalize` reorder ACL entries so that they are in concanical form as described
in the Microsoft documentation MS-DTYP 2.4.5 (ACL). This only applies to NFSv4 ACLs.
The following notes about ACL entries are necessarily terse. If more detail is requried
please consult relevant TrueNAS documentation.
Notes about NFSv4 ACL entry fields:
`tag` refers to the type of principal to whom the ACL entries applies. USER and GROUP have
conventional meanings. `owner@` refers to the owning user of the file, `group@` refers to the owning
group of the file, and `everyone@` refers to ALL users (including the owning user and group)..
`id` refers to the numeric user id or group id associatiated with USER or GROUP entries.
`who` a user or group name may be specified in lieu of numeric ID for USER or GROUP entries
`type` may be ALLOW or DENY. Deny entries take precedence over allow when the ACL is evaluated.
`perms` permissions allowed or denied by the entry. May be set as a simlified BASIC type or
more complex type detailing specific permissions.
`flags` inheritance flags determine how this entry will be presented (if at all) on newly-created
files or directories within the specified path. Only valid for directories.
Notes about posix1e ACL entry fields:
`default` the ACL entry is in the posix default ACL (will be copied to new files and directories)
created within the directory where it is set. These are _NOT_ evaluated when determining access for
the file on which they're set. If default is false then the entry applies to the posix access ACL,
which is used to determine access to the directory, but is not inherited on new files / directories.
`tag` the type of principal to whom the ACL entry apples. USER and GROUP have conventional meanings
USER_OBJ refers to the owning user of the file and is also denoted by "user" in conventional POSIX
UGO permissions. GROUP_OBJ refers to the owning group of the file and is denoted by "group" in the
same. OTHER refers to POSIX other, which applies to all users and groups who are not USER_OBJ or
GROUP_OBJ. MASK sets maximum permissions granted to all USER and GROUP entries. A valid POSIX1 ACL
entry contains precisely one USER_OBJ, GROUP_OBJ, OTHER, and MASK entry for the default and access
list.
`id` refers to the numeric user id or group id associatiated with USER or GROUP entries.
`who` a user or group name may be specified in lieu of numeric ID for USER or GROUP entries
`perms` - object containing posix permissions.
"""
verrors = ValidationErrors()
data['loc'] = self._common_perm_path_validate("filesystem.setacl", data, verrors)
verrors.check()
Expand All @@ -879,6 +944,33 @@ def setacl(self, job, data):
path_acltype = self.path_get_acltype(data['path'])
acltype = ACLType[path_acltype]

for idx, entry in enumerate(data['dacl']):
if entry.get('who') in (None, ''):
continue

if entry.get('id') is not None:
continue

# We're using user.query and group.query to intialize cache entries if required
match entry['tag']:
case 'USER':
method = 'user.query'
filters = [['username', '=', entry['who']]]
key = 'uid'
case 'GROUP':
method = 'group.query'
filters = [['group', '=', entry['who']]]
key = 'gid'
case _:
raise ValidationError(
f'filesystem.setacl.{idx}.who',
'Name may only be specified for USER and GROUP entries'
)
try:
entry['id'] = self.middleware.call_sync(method, filters, {'get': True})[key]
except MatchNotFound:
raise ValidationError(f'filesystem.setacl.{idx}.who', f'{entry["who"]}: account does not exist')

if acltype == ACLType.NFS4:
return self.setacl_nfs4(job, data)
elif acltype == ACLType.POSIX1E:
Expand Down
28 changes: 21 additions & 7 deletions src/middlewared/middlewared/plugins/filesystem_/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@


class ACLType(enum.Enum):
NFS4 = (['tag', 'id', 'perms', 'flags', 'type'], ["owner@", "group@", "everyone@"])
POSIX1E = (['default', 'tag', 'id', 'perms'], ["USER_OBJ", "GROUP_OBJ", "OTHER", "MASK"])
DISABLED = ([], [])
NFS4 = (('tag', 'perms', 'flags', 'type'), ("owner@", "group@", "everyone@"))
POSIX1E = (('default', 'tag', 'perms'), ("USER_OBJ", "GROUP_OBJ", "OTHER", "MASK"))
DISABLED = ((), ())

def _validate_id(self, id_, special):
if id_ is None or id_ < 0:
Expand All @@ -29,24 +29,38 @@ def _validate_entry(self, idx, entry, errors):

def validate(self, theacl):
errors = []
ace_keys = self.value[0]
ace_keys = set(self.value[0])
id_keys = set(('who', 'id'))

if self != ACLType.NFS4 and theacl.get('nfs41flags'):
errors.append(f"NFS41 ACL flags are not valid for ACLType [{self.name}]")

for idx, entry in enumerate(theacl['dacl']):
extra = set(entry.keys()) - set(ace_keys)
missing = set(ace_keys) - set(entry.keys())
entry_keys = set(entry.keys())
extra = entry_keys - ace_keys - id_keys
missing = ace_keys - entry_keys

if extra:
errors.append(
(idx, f"ACL entry contains invalid extra key(s): {extra}", None)
)
continue
if missing:
errors.append(
(idx, f"ACL entry is missing required keys(s): {missing}", None)
)
continue

if extra or missing:
if not ace_keys & id_keys:
errors.append(
(idx, 'Numeric ID "id" or account name "who" must be specified', None)
)
continue

if len(ace_keys & id_keys) == 2:
errors.append(
(idx, 'Numeric ID "id" and account name "who" may not be specified simultaneously', None)
)
continue

self._validate_entry(idx, entry, errors)
Expand Down
102 changes: 102 additions & 0 deletions tests/api2/test_acl_by_who.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from copy import deepcopy
import os
import pytest

from middlewared.service_exception import ValidationErrors
from middlewared.test.integration.assets.pool import dataset
from middlewared.test.integration.utils import call

permset_posix_full = {"READ": True, "WRITE": True, "EXECUTE": True}
permset_nfsv4_full = {"BASIC": "FULL_CONTROL"}
flagset_nfsv4_inherit = {"BASIC": "INHERIT"}


@pytest.fixture(scopy='module')
def posix_acl_dataset():
with dataset('posix') as ds:
yield ds


@pytest.fixture(scopy='module')
def nfsv4_acl_dataset():
with dataset('nfs4', data={'share_type': 'SMB'}) as ds:
yield ds


def test__posix_by_who(posix_acl_dataset):
target = os.path.join('/mnt', posix_acl_dataset)
the_acl = call('filesystem.getacl', target)['acl']
the_acl.extend([
{'tag': 'USER', 'who': 'root', 'perms': permset_posix_full, 'default': False},
{'tag': 'GROUP', 'who': 'root', 'perms': permset_posix_full, 'default': False},
])

call('filesystem.setacl', {'path': target, 'dacl': the_acl}, job=True)

new_acl = call('filesystem.getacl', target)['acl']
saw_user = False
saw_group = False
for entry in new_acl:
if entry['tag'] == 'USER':
assert entry['id'] == 0
assert entry['perms'] == permset_posix_full
saw_user = True
elif entry['tag'] == 'GROUP':
assert entry['id'] == 0
assert entry['perms'] == permset_posix_full
saw_group = True


assert saw_user, str(new_acl)
assert saw_group, str(new_acl)


def test__nfsv4_by_who(nfsv4_acl_dataset):
target = os.path.join('/mnt', nfsv4_acl_dataset)
the_acl = call('filesystem.getacl', target)['acl']
the_acl.extend([
{'tag': 'USER', 'who': 'root', 'perms': permset_nfsv4_full, 'flags': flagset_nfsv4_inherit, 'type': 'ALLOW'},
{'tag': 'GROUP', 'who': 'root', 'perms': permset_nfsv4_full, 'flags': flagset_nfsv4_inherit, 'type': 'ALLOW'},
])

call('filesystem.setacl', {'path': target, 'dacl': the_acl}, job=True)

new_acl = call('filesystem.getacl', target)['acl']
saw_user = False
saw_group = False
for entry in new_acl:
if entry['tag'] == 'USER':
assert entry['id'] == 0
assert entry['perms'] == permset_nfsv4_full
saw_user = True
elif entry['tag'] == 'GROUP' and entry['id'] == 0:
assert entry['perms'] == permset_nfsv4_full
saw_group = True

assert saw_user, str(new_acl)
assert saw_group, str(new_acl)


def test__acl_validation_errors_posix(posix_acl_dataset):
target = os.path.join('/mnt', ds)
the_acl = call('filesystem.getacl', target)['acl']

new_acl = deepcopy(the_acl)
new_acl.extend([
{'tag': 'USER', 'perms': permset_posix_full, 'default': False},
])

with pytest.raises(ValidationErrors) as ve:
call('filesystem.setacl', {'path': target, 'dacl': the_acl}, job=True)

assert ve.value.errors[0].errmsg == 'Numeric ID "id" or account name "who" must be specified'

new_acl = deepcopy(the_acl)
new_acl.extend([
{'tag': 'USER', 'perms': permset_posix_full, 'default': False, 'who': 'root', 'id': 0},
])

with pytest.raises(ValidationErrors) as ve:
call('filesystem.setacl', {'path': target, 'dacl': the_acl}, job=True)

assert ve.value.errors[0].errmsg == 'Numeric ID "id" and account name "who" may not be specified simultaneously'

0 comments on commit 9a7570e

Please sign in to comment.