Skip to content

Commit

Permalink
Simplify API for querying directory services users and groups
Browse files Browse the repository at this point in the history
Cache query responses are fast enough that we should include
by default. Directory services users and groups can be reasonably
omitted if filters explicitly call for local or builtin users.

Remove currently-disabled old LDAP test and validate that DS users
and groups returned via user.query and group.query.

Add useful CallErrors if someone tries to modify DS user.
Sometimes API consumers are over-enthusiastic about what they
can do with users provided by AD / LDAP.
  • Loading branch information
anodos325 committed Jul 2, 2024
1 parent 85812d9 commit 2a7f966
Show file tree
Hide file tree
Showing 16 changed files with 229 additions and 529 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
if not ad['enable']:
ldap_enabled = render_ctx["ldap.config"]["enable"]
users = middleware.call_sync('user.query')
users = middleware.call_sync('user.query', [['local', '=', True]])
root_user = filter_list(users, [['username', '=', 'root']], {'get': True})
%>\
Expand Down
6 changes: 4 additions & 2 deletions src/middlewared/middlewared/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ async def run(self, ws, request, conndata):
try:
user = await self.middleware.call(
'user.query',
[['username', '=', token['username']]],
[['username', '=', token['username']], ['local', '=', True]],
{'get': True},
)
except MatchNotFound:
Expand All @@ -806,7 +806,9 @@ async def run(self, ws, request, conndata):
if 'ALL' in user['sudo_commands'] or 'ALL' in user['sudo_commands_nopasswd']:
as_root = True
else:
for group in await self.middleware.call('group.query', [['id', 'in', user['groups']]]):
for group in await self.middleware.call('group.query', [
['id', 'in', user['groups']], ['local', '=', True]
]):
if 'ALL' in group['sudo_commands'] or 'ALL' in group['sudo_commands_nopasswd']:
as_root = True
break
Expand Down
209 changes: 172 additions & 37 deletions src/middlewared/middlewared/plugins/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import middlewared.sqlalchemy as sa
from middlewared.utils import run, filter_list
from middlewared.utils.crypto import sha512_crypt
from middlewared.utils.directoryservices.constants import DSType, DSStatus
from middlewared.utils.nss import pwd, grp
from middlewared.utils.nss.nss_common import NssModule
from middlewared.utils.privilege import credential_has_full_admin, privileges_group_mapping
Expand All @@ -34,6 +35,7 @@
)
from middlewared.plugins.smb_.constants import SMBBuiltin
from middlewared.plugins.idmap_.idmap_constants import (
BASE_SYNTHETIC_DATASTORE_ID,
TRUENAS_IDMAP_DEFAULT_LOW,
SID_LOCAL_USER_PREFIX,
SID_LOCAL_GROUP_PREFIX
Expand Down Expand Up @@ -123,6 +125,30 @@ def validate_sudo_commands(commands):
return verrors


def filters_include_ds_accounts(filters):
""" Check for filters limiting to local accounts """
for f in filters:
if len(f) < 3:
# OR -- assume evaluation for this will result in including DS
continue

# Directory services do not provide builtin accounts
# local explicitly denotes not directory service
if f[0] in ('local', 'builtin'):
match f[1]:
case '=':
if f[2] is True:
return False
case '!=':
if f[2] is False:
return False

case _:
pass

return True


class UserModel(sa.Model):
__tablename__ = 'account_bsdusers'

Expand Down Expand Up @@ -167,7 +193,7 @@ async def user_extend_context(self, rows, extra):
[], {'prefix': 'bsdgrpmember_'}
)

group_roles = await self.middleware.call('group.query', [], {'select': ['id', 'roles']})
group_roles = await self.middleware.call('group.query', [['local', '=', True]], {'select': ['id', 'roles']})

for i in res:
uid = i['user']['id']
Expand Down Expand Up @@ -251,9 +277,6 @@ async def query(self, filters, options):
The following `additional_information` options are supported:
`SMB` - include Windows SID and NT Name for user. If this option is not specified, then these
keys will have `null` value.
`DS` - include users from Directory Service (LDAP or Active Directory) in results
`"extra": {"search_dscache": true}` is a legacy method of querying for directory services users.
"""
ds_users = []
options = options or {}
Expand All @@ -269,13 +292,8 @@ async def query(self, filters, options):
datastore_options.pop('select', None)

extra = options.get('extra', {})
dssearch = extra.pop('search_dscache', False)
additional_information = extra.get('additional_information', [])

if 'DS' in additional_information:
dssearch = True
additional_information.remove('DS')

username_sid = {}
if 'SMB' in additional_information:
try:
Expand All @@ -289,16 +307,24 @@ async def query(self, filters, options):
# broken
self.logger.error('Failed to retrieve passdb information', exc_info=True)

if dssearch:
ds_state = await self.middleware.call('directoryservices.get_state')
if ds_state['activedirectory'] == 'HEALTHY' or ds_state['ldap'] == 'HEALTHY':
ds_users = await self.middleware.call('directoryservices.cache.query', 'USER', filters, options.copy())
# For AD users, we will not have 2FA attribute normalized so let's do that
ad_users_2fa_mapping = await self.middleware.call('auth.twofactor.get_ad_users')
for index, user in enumerate(filter(
lambda u: not u['local'] and 'twofactor_auth_configured' not in u, ds_users)
):
ds_users[index]['twofactor_auth_configured'] = bool(ad_users_2fa_mapping.get(user['sid']))
if filters_include_ds_accounts(filters):
ds = await self.middleware.call('directoryservices.status')
if ds['type'] is not None and ds['status'] == DSStatus.HEALTHY.name:
ds_users = await self.middleware.call(
'directoryservices.cache.query', 'USER', filters, options.copy()
)

match DSType(ds['type']):
case DSType.AD:
# For AD users, we will not have 2FA attribute normalized so let's do that
ad_users_2fa_mapping = await self.middleware.call('auth.twofactor.get_ad_users')
for index, user in enumerate(filter(
lambda u: not u['local'] and 'twofactor_auth_configured' not in u, ds_users)
):
ds_users[index]['twofactor_auth_configured'] = bool(ad_users_2fa_mapping.get(user['sid']))
case _:
# FIXME - map twofactor_auth_configured hint for LDAP users
pass

result = await self.middleware.call(
'datastore.query', self._config.datastore, [], datastore_options
Expand Down Expand Up @@ -521,7 +547,10 @@ def do_create(self, data):
group_created = False

if create:
group = self.middleware.call_sync('group.query', [('group', '=', data['username'])])
group = self.middleware.call_sync('group.query', [
('group', '=', data['username']),
('local', '=', True)
])
if group:
group = group[0]
else:
Expand All @@ -532,7 +561,9 @@ def do_create(self, data):
'sudo_commands_nopasswd': [],
'allow_duplicate_gid': False
}, False)
group = self.middleware.call_sync('group.query', [('id', '=', group)])[0]
group = self.middleware.call_sync('group.query', [
('id', '=', group), ('local', '=', True)
])[0]
group_created = True

data['group'] = group['id']
Expand All @@ -544,7 +575,7 @@ def do_create(self, data):

if data['smb']:
groups.append((self.middleware.call_sync(
'group.query', [('group', '=', 'builtin_users')], {'get': True},
'group.query', [('group', '=', 'builtin_users'), ('local', '=', True)], {'get': True},
))['id'])

if data.get('uid') is None:
Expand Down Expand Up @@ -632,6 +663,23 @@ def do_update(self, app, audit_callback, pk, data):
Update attributes of an existing user.
"""

if pk > BASE_SYNTHETIC_DATASTORE_ID:
# datastore ids for directory services are created by adding the
# posix ID to a base value so that we can use getpwuid / getgrgid to
# convert back to a username / group name
try:
username = self.middleware.call_sync(
'user.get_user_obj', {'uid': pk - BASE_SYNTHETIC_DATASTORE_ID}
)['pw_name']
except KeyError:
username = 'UNKNOWN'

audit_callback(username)
raise CallError(
'Users provided by a directory service must be modified through the identity provider '
'(LDAP server or domain controller).', errno.EPERM
)

user = self.middleware.call_sync('user.get_instance', pk)
audit_callback(user['username'])

Expand Down Expand Up @@ -860,6 +908,23 @@ def do_delete(self, audit_callback, pk, options):
The `delete_group` option deletes the user primary group if it is not being used by
any other user.
"""
if pk > BASE_SYNTHETIC_DATASTORE_ID:
# datastore ids for directory services are created by adding the
# posix ID to a base value so that we can use getpwuid / getgrgid to
# convert back to a username / group name
try:
username = self.middleware.call_sync(
'user.get_user_obj', {'uid': pk - BASE_SYNTHETIC_DATASTORE_ID}
)['pw_name']
except KeyError:
username = 'UNKNOWN'

audit_callback(username)
raise CallError(
'Users provided by a directory service must be deleted from the identity provider '
'(LDAP server or domain controller).', errno.EPERM
)


user = self.middleware.call_sync('user.get_instance', pk)
audit_callback(user['username'])
Expand Down Expand Up @@ -1196,21 +1261,64 @@ async def setup_local_administrator(self, app, username, password, options):
raise CallError('Local administrator is already set up', errno.EEXIST)

if username == 'admin':
if await self.middleware.call('user.query', [['uid', '=', ADMIN_UID]]):
# first check based on NSS to catch collisions with AD / LDAP users
try:
pwd_obj = await self.middleware.call('user.get_user_obj', {'uid': ADMIN_UID})
raise CallError(
f'A {pwd_obj["source"].lower()} user with uid={ADMIN_UID} already exists, '
'setting up local administrator is not possible',
errno.EEXIST,
)
except KeyError:
pass

try:
pwd_obj = await self.middleware.call('user.get_user_obj', {'username': 'admin'})
raise CallError(f'"admin" {pwd_obj["source"].lower()} user already exists, '
'setting up local administrator is not possible',
errno.EEXIST)
except KeyError:
pass

try:
grp_obj = await self.middleware.call('group.get_group_obj', {'gid': ADMIN_GID})
raise CallError(
f'A {grp_obj["source"].lower()} group with gid={ADMIN_GID} already exists, '
'setting up local administrator is not possible',
errno.EEXIST,
)
except KeyError:
pass

try:
grp_obj = await self.middleware.call('get.get_group_obj', {'groupname': 'admin'})
raise CallError(f'"admin" {grp_obj["source"].lower()} group already exists, '
'setting up local administrator is not possible',
errno.EEXIST)
except KeyError:
pass

# double-check our database in case we have for some reason failed to write to passwd
local_users = await self.middleware.call('user.query', [['local', '=', True]])
local_groups = await self.middleware.call('group.query', [['local', '=', True]])

if filter_list(local_users, [['uid', '=', ADMIN_UID]]):
raise CallError(
f'A user with uid={ADMIN_UID} already exists, setting up local administrator is not possible',
errno.EEXIST,
)
if await self.middleware.call('user.query', [['username', '=', 'admin']]):

if filter_list(local_users, [['username', '=', 'admin']]):
raise CallError('"admin" user already exists, setting up local administrator is not possible',
errno.EEXIST)

if await self.middleware.call('group.query', [['gid', '=', ADMIN_GID]]):
if filter_list(local_groups, [['gid', '=', ADMIN_GID]]):
raise CallError(
f'A group with gid={ADMIN_GID} already exists, setting up local administrator is not possible',
errno.EEXIST,
)
if await self.middleware.call('group.query', [['group', '=', 'admin']]):

if filter_list(local_groups, [['group', '=', 'admin']]):
raise CallError('"admin" group already exists, setting up local administrator is not possible',
errno.EEXIST)

Expand Down Expand Up @@ -1713,9 +1821,6 @@ async def query(self, filters, options):
The following `additional_information` options are supported:
`SMB` - include Windows SID and NT Name for group. If this option is not specified, then these
keys will have `null` value.
`DS` - include groups from Directory Service (LDAP or Active Directory) in results
`"extra": {"search_dscache": true}` is a legacy method of querying for directory services groups.
"""
ds_groups = []
options = options or {}
Expand All @@ -1731,16 +1836,11 @@ async def query(self, filters, options):
datastore_options.pop('select', None)

extra = options.get('extra', {})
dssearch = extra.pop('search_dscache', False)
additional_information = extra.get('additional_information', [])

if 'DS' in additional_information:
dssearch = True
additional_information.remove('DS')

if dssearch:
ds_state = await self.middleware.call('directoryservices.get_state')
if ds_state['activedirectory'] == 'HEALTHY' or ds_state['ldap'] == 'HEALTHY':
if filters_include_ds_accounts(filters):
ds = await self.middleware.call('directoryservices.status')
if ds['type'] is not None and ds['status'] == DSStatus.HEALTHY.name:
ds_groups = await self.middleware.call('directoryservices.cache.query', 'GROUP', filters, options)

if 'SMB' in additional_information:
Expand Down Expand Up @@ -1848,6 +1948,24 @@ async def do_update(self, audit_callback, pk, data):
Update attributes of an existing group.
"""

if pk > BASE_SYNTHETIC_DATASTORE_ID:
# datastore ids for directory services are created by adding the
# posix ID to a base value so that we can use getpwuid / getgrgid to
# convert back to a username / group name
try:
groupname = (await self.middleware.call(
'group.get_group_obj', {'gid': pk - BASE_SYNTHETIC_DATASTORE_ID}
))['gr_name']
except KeyError:
groupname = 'UNKNOWN'


audit_callback(groupname)
raise CallError(
'Groups provided by a directory service must be modified through the identity provider '
'(LDAP server or domain controller).', errno.EPERM
)

group = await self.get_instance(pk)
audit_callback(group['name'])

Expand Down Expand Up @@ -1925,6 +2043,23 @@ async def do_delete(self, audit_callback, pk, options):
The `delete_users` option deletes all users that have this group as their primary group.
"""

if pk > BASE_SYNTHETIC_DATASTORE_ID:
# datastore ids for directory services are created by adding the
# posix ID to a base value so that we can use getpwuid / getgrgid to
# convert back to a username / group name
try:
groupname = (await self.middleware.call(
'group.get_group_obj', {'gid': pk - BASE_SYNTHETIC_DATASTORE_ID}
))['gr_name']
except KeyError:
groupname = 'UNKNOWN'

audit_callback(groupname)
raise CallError(
'Groups provided by a directory service must be deleted from the identity provider '
'(LDAP server or domain controller).', errno.EPERM
)

group = await self.get_instance(pk)
audit_callback(group['name'] + (' and all its users' if options['delete_users'] else ''))

Expand Down
2 changes: 1 addition & 1 deletion src/middlewared/middlewared/plugins/account_/2fa.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def translate_username(self, username):
return await self.middleware.call(
'user.query', [['username', '=', user['pw_name']]], {
'get': True,
'extra': {'additional_information': ['DS', 'SMB']},
'extra': {'additional_information': ['SMB']},
}
)

Expand Down
Loading

0 comments on commit 2a7f966

Please sign in to comment.