Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-129780 / 24.10 / Simplify API for querying directory services users and groups #13942

Merged
merged 1 commit into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
anodos325 marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -516,7 +542,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 @@ -527,7 +556,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 @@ -539,7 +570,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 @@ -627,6 +658,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 @@ -855,6 +903,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 @@ -1191,21 +1256,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('group.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 @@ -1708,9 +1816,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 @@ -1726,16 +1831,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 @@ -1843,6 +1943,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 @@ -1920,6 +2038,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
Loading