1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-03-13 08:58:35 +03:00

Added meta pool recursion save restiction and some more tests

This commit is contained in:
Adolfo Gómez García 2023-02-02 17:49:49 +01:00
parent 5b5beb30d6
commit f71659b5a6
No known key found for this signature in database
GPG Key ID: DD1ABF20724CDA23
13 changed files with 236 additions and 137 deletions

View File

@ -33,10 +33,10 @@ import functools
import logging
from uds import models
from uds.core import VERSION
from uds.core.managers import cryptoManager
from ...utils import rest
from ...fixtures import rest as rest_fixtures
logger = logging.getLogger(__name__)
@ -66,16 +66,7 @@ class GroupsTest(rest.test.RESTActorTestCase):
for group in groups:
# Locate the group in the auth
dbgrp = self.auth.groups.get(name=group['name'])
self.assertEqual(dbgrp.uuid, group['id'])
self.assertEqual(dbgrp.comments, group['comments'])
self.assertEqual(dbgrp.state, group['state'])
self.assertEqual(dbgrp.is_meta, group['type'] == 'meta')
self.assertEqual(dbgrp.meta_if_any, group['meta_if_any'])
if dbgrp.is_meta:
self.assertEqual(
sorted([x.uuid for x in dbgrp.groups.all()]),
sorted(group['groups'])
)
self.assertTrue(rest.assertions.assertGroupIs(dbgrp, group, compare_uuid=True))
def test_groups_tableinfo(self) -> None:
url = f'authenticators/{self.auth.uuid}/groups/tableinfo'
@ -113,15 +104,7 @@ class GroupsTest(rest.test.RESTActorTestCase):
response = self.client.rest_get(f'{url}/{i.uuid}')
self.assertEqual(response.status_code, 200)
group = response.json()
self.assertEqual(group['name'], i.name)
self.assertEqual(group['id'], i.uuid)
self.assertEqual(group['comments'], i.comments)
self.assertEqual(group['state'], i.state)
self.assertEqual(group['type'], 'meta' if i.is_meta else 'group')
self.assertEqual(group['meta_if_any'], i.meta_if_any)
if i.is_meta:
self.assertEqual(sorted(group['groups']), sorted([x.uuid for x in i.groups.all()]))
self.assertTrue(rest.assertions.assertGroupIs(i, group, compare_uuid=True))
# invalid user
response = self.client.rest_get(f'{url}/invalid')
@ -129,6 +112,30 @@ class GroupsTest(rest.test.RESTActorTestCase):
def test_group_create_edit(self) -> None:
url = f'authenticators/{self.auth.uuid}/groups'
# Normal group
group_dct = rest_fixtures.createGroup()
response = self.client.rest_put(
url,
group_dct,
)
self.assertEqual(response.status_code, 200)
group = models.Group.objects.get(name=group_dct['name'])
self.assertTrue(rest.assertions.assertGroupIs(group, group_dct))
# Now, will fail because name is already in use
response = self.client.rest_put(
url,
group_dct,
content_type='application/json',
)
self.assertEqual(response.status_code, 400)
# Now a meta group, with some groups inside
groups = [self.simple_groups[0].uuid]
return
url = f'authenticators/{self.auth.uuid}/users'
user_dct: typing.Dict[str, typing.Any] = {

View File

@ -28,13 +28,12 @@
"""
@author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import time
import typing
import functools
import logging
from uds import models
from uds.core import VERSION
from uds.core.managers import cryptoManager
from ...utils import rest
from ...fixtures import rest as rest_fixtures
@ -66,7 +65,7 @@ class UsersTest(rest.test.RESTActorTestCase):
for user in users:
# Locate the user in the auth
self.assertTrue(
rest_fixtures.assertUserIs(self.auth.users.get(name=user['name']), user)
rest.assertions.assertUserIs(self.auth.users.get(name=user['name']), user)
)
def test_users_tableinfo(self) -> None:
@ -109,7 +108,7 @@ class UsersTest(rest.test.RESTActorTestCase):
self.assertEqual(response.status_code, 200)
user = response.json()
self.assertTrue(
rest_fixtures.assertUserIs(i, user),
rest.assertions.assertUserIs(i, user),
'User {} {} is not correct'.format(
i, models.User.objects.filter(uuid=i.uuid).values()[0]
),
@ -134,18 +133,20 @@ class UsersTest(rest.test.RESTActorTestCase):
def test_user_create_edit(self) -> None:
url = f'authenticators/{self.auth.uuid}/users'
user_dct = rest_fixtures.createUser(
groups=[self.groups[0].uuid, self.groups[1].uuid]
groups=[self.simple_groups[0].uuid, self.simple_groups[1].uuid, self.meta_groups[0].uuid]
)
# Now, will work
response = self.client.rest_put(
url,
user_dct,
content_type='application/json',
)
# Get user from database and ensure values are correct
dbusr = self.auth.users.get(name=user_dct['name'])
self.assertTrue(rest_fixtures.assertUserIs(dbusr, user_dct))
# Fix user_dct to remove it for comparison. Meta groups cannot be directly "assigned" to users
user_dct['groups'] = user_dct['groups'][:-1]
self.assertTrue(rest.assertions.assertUserIs(dbusr, user_dct))
self.assertEqual(response.status_code, 200)
# Returns nothing
@ -160,7 +161,7 @@ class UsersTest(rest.test.RESTActorTestCase):
user_dct = rest_fixtures.createUser( # nosec: test password, also, "fixme" means "create a random password" in this case
id=dbusr.uuid,
groups=[self.groups[2].uuid],
groups=[self.simple_groups[2].uuid],
password='fixme',
mfa_data='mfadata',
)
@ -175,7 +176,7 @@ class UsersTest(rest.test.RESTActorTestCase):
# Get user from database and ensure values are correct
dbusr = self.auth.users.get(name=user_dct['name'])
self.assertTrue(
rest_fixtures.assertUserIs(dbusr, user_dct, compare_password=True)
rest.assertions.assertUserIs(dbusr, user_dct, compare_password=True)
)
def test_user_delete(self) -> None:

View File

@ -30,11 +30,7 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import typing
from uds import models
from uds.core.auths.user import User as aUser
from uds.core.managers import cryptoManager
from ..utils import rest, ensure_data
from ..utils import rest
# User REST structure
class UserRestStruct(rest.RestStruct):
@ -49,42 +45,19 @@ class UserRestStruct(rest.RestStruct):
mfa_data: typing.Optional[str]
password: typing.Optional[str]
# Group REST structure
class GroupRestStruct(rest.RestStruct):
id: rest.uuid_type
name: str
comments: str
state: str
type: str
is_meta: bool
meta_if_any: bool
# Provide a "random" dictionary based on a
def createUser(**kwargs) -> typing.Dict[str, typing.Any]:
return UserRestStruct.random_create(**kwargs).as_dict()
def assertUserIs(
user: models.User, compare_to: typing.Mapping[str, typing.Any], compare_uuid=False, compare_password=False
) -> bool:
ignore_fields = ['password', 'groups', 'mfa_data', 'last_access', 'role']
if not compare_uuid:
ignore_fields.append('id')
# If last_access is present, compare it here, because it's a datetime object
if 'last_access' in compare_to:
if int(user.last_access.timestamp()) != compare_to['last_access']:
return False
if ensure_data(user, compare_to, ignore_keys=ignore_fields):
# Compare groups
if 'groups' in compare_to:
if set(g.dbGroup().uuid for g in aUser(user).groups()) != set(
compare_to['groups']
):
return False
# Compare mfa_data
if 'mfa_data' in compare_to:
if user.mfa_data != compare_to['mfa_data']:
return False
# Compare password
if compare_password:
return cryptoManager().checkHash(compare_to['password'], user.password)
return True
return False
def createGroup(**kwargs) -> typing.Dict[str, typing.Any]:
return GroupRestStruct.random_create(**kwargs).as_dict()

View File

@ -74,9 +74,6 @@ def compare_dicts(
if v != actual[k]:
errors.append((k, f'Value for key "{k}" is "{actual[k]}" instead of "{v}"'))
if errors:
logger.info('Errors found: %s', errors)
return errors
@ -95,6 +92,11 @@ def ensure_data(
db_data['id'] = db_data['uuid']
del db_data['uuid']
return not compare_dicts(
errors = compare_dicts(
dct, db_data, ignore_keys=ignore_keys, ignore_values=ignore_values
)
if errors:
logger.info('Errors found: %s', errors)
return False
return True

View File

@ -31,13 +31,15 @@
import logging
import random
import uuid
import typing
from django.test import SimpleTestCase
from django.test.client import Client
# Not used, alloes "rest.test" or "rest.assertions"
from . import test
from . import assertions
from .. import generators
from uds.REST.handlers import AUTH_TOKEN_HEADER

View File

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2022 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
import typing
from uds import models
from uds.core.auths.user import User as aUser
from uds.core.managers import cryptoManager
from .. import ensure_data
logger = logging.getLogger(__name__)
def assertUserIs(
user: models.User, compare_to: typing.Mapping[str, typing.Any], compare_uuid=False, compare_password=False
) -> bool:
ignore_fields = ['password', 'groups', 'mfa_data', 'last_access', 'role']
if not compare_uuid:
ignore_fields.append('id')
# If last_access is present, compare it here, because it's a datetime object
if 'last_access' in compare_to:
if int(user.last_access.timestamp()) != compare_to['last_access']:
logger.info('User last_access do not match: %s != %s', user.last_access.timestamp(), compare_to['last_access'])
return False
if ensure_data(user, compare_to, ignore_keys=ignore_fields):
# Compare groups
if 'groups' in compare_to:
groups = set(i.uuid for i in user.groups.all() if i.is_meta is False)
compare_to_groups = set(compare_to['groups'])
# Ensure groups are PART compare_to_groups
if groups - compare_to_groups != set():
logger.info('User groups do not match: %s != %s', groups, compare_to_groups)
return False
# Compare mfa_data
if 'mfa_data' in compare_to:
if user.mfa_data != compare_to['mfa_data']:
logger.info('User mfa_data do not match: %s != %s', user.mfa_data, compare_to['mfa_data'])
return False
# Compare password
if compare_password:
if not cryptoManager().checkHash(compare_to['password'], user.password):
logger.info('User password do not match: %s != %s', user.password, compare_to['password'])
return False
return True
return False
def assertGroupIs(
group: models.Group, compare_to: typing.Mapping[str, typing.Any], compare_uuid=False
) -> bool:
ignore_fields = ['groups', 'users', 'is_meta', 'type', 'pools']
if not compare_uuid:
ignore_fields.append('id')
if ensure_data(group, compare_to, ignore_keys=ignore_fields):
if group.is_meta:
grps = set(i.uuid for i in group.groups.all())
compare_to_groups = set(compare_to['groups'])
if grps != compare_to_groups:
logger.info('Group groups do not match: %s != %s', grps, compare_to_groups)
return False
if 'type' in compare_to:
if group.is_meta != (compare_to['type'] == 'meta'):
logger.info('Group type do not match: %s != %s', group.is_meta, compare_to['type'])
return False
if 'pools' in compare_to:
pools = set(i.uuid for i in group.deployedServices.all())
compare_to_pools = set(compare_to['pools'])
if pools != compare_to_pools:
logger.info('Group pools do not match: %s != %s', pools, compare_to_pools)
return False
return True
return False

View File

@ -145,15 +145,15 @@ class UDSClient(UDSClientMixin, Client):
return typing.cast('UDSHttpResponse', super().put(*args, **kwargs))
def rest_put(self, method: str, *args, **kwargs) -> 'UDSHttpResponse':
# compose url
kwargs['content_type'] = kwargs.get('content_type', 'application/json')
return self.put(self.compose_rest_url(method), *args, **kwargs)
def delete(self, *args, **kwargs) -> 'UDSHttpResponse':
self.append_remote_addr(kwargs)
kwargs['content_type'] = kwargs.get('content_type', 'application/json')
return typing.cast('UDSHttpResponse', super().delete(*args, **kwargs))
def rest_delete(self, method: str, *args, **kwargs) -> 'UDSHttpResponse':
# compose url
kwargs['content_type'] = kwargs.get('content_type', 'application/json')
return self.delete(self.compose_rest_url(method), *args, **kwargs)
@ -192,15 +192,15 @@ class UDSAsyncClient(UDSClientMixin, AsyncClient):
return typing.cast('UDSHttpResponse', await super().post(*args, **kwargs))
async def rest_post(self, method: str, *args, **kwargs) -> 'UDSHttpResponse':
# compose url
kwargs['content_type'] = kwargs.get('content_type', 'application/json')
return await self.post(self.compose_rest_url(method), *args, **kwargs)
async def put(self, *args, **kwargs) -> 'UDSHttpResponse':
self.append_remote_addr(kwargs)
kwargs['content_type'] = kwargs.get('content_type', 'application/json')
return typing.cast('UDSHttpResponse', await super().put(*args, **kwargs))
async def rest_put(self, method: str, *args, **kwargs) -> 'UDSHttpResponse':
# compose url
kwargs['content_type'] = kwargs.get('content_type', 'application/json')
return await self.put(self.compose_rest_url(method), *args, **kwargs)
async def delete(self, *args, **kwargs) -> 'UDSHttpResponse':

View File

@ -34,7 +34,7 @@ import typing
from django.utils.translation import gettext as _
from django.forms.models import model_to_dict
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.core.exceptions import ValidationError
from uds.core.util.state import State
@ -82,7 +82,9 @@ class Users(DetailHandler):
def getItems(self, parent: Authenticator, item: typing.Optional[str]):
# processes item to change uuid key for id
def uuid_to_id(iterable: typing.Iterable[typing.MutableMapping[str, typing.Any]]):
def uuid_to_id(
iterable: typing.Iterable[typing.MutableMapping[str, typing.Any]]
):
for v in iterable:
v['id'] = v['uuid']
del v['uuid']
@ -94,18 +96,21 @@ class Users(DetailHandler):
if item is None:
values = list(
uuid_to_id(
(i for i in parent.users.all().values(
'uuid',
'name',
'real_name',
'comments',
'state',
'staff_member',
'is_admin',
'last_access',
'parent',
'mfa_data',
))
(
i
for i in parent.users.all().values(
'uuid',
'name',
'real_name',
'comments',
'state',
'staff_member',
'is_admin',
'last_access',
'parent',
'mfa_data',
)
)
)
)
for res in values:
@ -206,7 +211,7 @@ class Users(DetailHandler):
if 'password' in self._params:
valid_fields.append('password')
self._params['password'] = cryptoManager().hash(self._params['password'])
if 'mfa_data' in self._params:
valid_fields.append('mfa_data')
self._params['mfa_data'] = self._params['mfa_data'].strip()
@ -218,27 +223,29 @@ class Users(DetailHandler):
user = None
try:
auth = parent.getInstance()
if item is None: # Create new
auth.createUser(
fields
) # this throws an exception if there is an error (for example, this auth can't create users)
user = parent.users.create(**fields)
else:
auth.modifyUser(fields) # Notifies authenticator
user = parent.users.get(uuid=processUuid(item))
user.__dict__.update(fields)
logger.debug('User parent: %s', user.parent)
# If internal auth, threat it "special"
if auth.isExternalSource is False and not user.parent:
groups = self.readFieldsFromParams(['groups'])['groups']
logger.debug('Groups: %s', groups)
logger.debug('Got Groups %s', parent.groups.filter(uuid__in=groups))
user.groups.set(parent.groups.filter(uuid__in=groups))
user.save()
with transaction.atomic():
auth = parent.getInstance()
if item is None: # Create new
auth.createUser(
fields
) # this throws an exception if there is an error (for example, this auth can't create users)
user = parent.users.create(**fields)
else:
auth.modifyUser(fields) # Notifies authenticator
user = parent.users.get(uuid=processUuid(item))
user.__dict__.update(fields)
user.save()
logger.debug('User parent: %s', user.parent)
# If internal auth, and not a child user, save groups
if not auth.isExternalSource and not user.parent:
groups = self.readFieldsFromParams(['groups'])['groups']
# Save but skip meta groups, they are not real groups, but just a way to group users based on rules
user.groups.set(
g
for g in parent.groups.filter(uuid__in=groups)
if g.is_meta is False
)
except User.DoesNotExist:
raise self.invalidItemException()
except IntegrityError: # Duplicate key probably
@ -351,29 +358,18 @@ class Groups(DetailHandler):
if multi:
return res
if not i:
raise # Invalid item
raise # Invalid item
# Add pools field if 1 item only
result = res[0]
if i.is_meta:
result[
'pools'
] = (
[]
) # Meta groups do not have "assigned "pools, they get it from groups interaction
else:
result['pools'] = [v.uuid for v in i.deployedServices.all()]
result['pools'] = [v.uuid for v in getPoolsForGroups([i])]
return result
except Exception:
logger.exception('REST groups')
raise self.invalidItemException()
def getTitle(self, parent: str):
def getTitle(self, parent: Authenticator) -> str:
try:
return _('Groups of {0}').format(
Authenticator.objects.get(
uuid=processUuid(self._kwargs['parent_id'])
).name
)
return _('Groups of {0}').format(parent.name)
except Exception:
return _('Current groups')
@ -464,7 +460,8 @@ class Groups(DetailHandler):
group.__dict__.update(toSave)
if is_meta:
group.groups.set(parent.groups.filter(uuid__in=self._params['groups']))
# Do not allow to add meta groups to meta groups
group.groups.set(i for i in parent.groups.filter(uuid__in=self._params['groups']) if i.is_meta is False)
if pools:
# Update pools

View File

@ -96,10 +96,10 @@ class ServicePool(UUIDModel, TaggingMixin): # type: ignore
related_name='deployedServices',
on_delete=models.CASCADE,
)
transports = models.ManyToManyField(
transports: 'models.ManyToManyField[Transport, ServicePool]' = models.ManyToManyField(
Transport, related_name='deployedServices', db_table='uds__ds_trans'
)
assignedGroups = models.ManyToManyField(
assignedGroups: 'models.ManyToManyField[Group, ServicePool]' = models.ManyToManyField(
Group, related_name='deployedServices', db_table='uds__ds_grps'
)
state = models.CharField(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,7 @@ gettext("No entries found");
gettext(", (%i more items)");
gettext("Search");
gettext("No entries found");
gettext("Select");
gettext("Main");
gettext("Yes");
gettext("No");
@ -220,10 +221,10 @@ gettext("Successfully saved");
gettext("dismiss");
gettext("Delete image");
gettext("Generate report");
gettext("Generate report");
gettext("Generating report...");
gettext("Report finished");
gettext("dismiss");
gettext("Generate report");
gettext("Delete tunnel token - USE WITH EXTREME CAUTION!!!");
gettext("New Notifier");
gettext("Edit Notifier");
@ -249,6 +250,7 @@ gettext("Blocked");
gettext("Service pools");
gettext("Users");
gettext("Groups");
gettext("Match mode");
gettext("Any");
gettext("All");
gettext("Group");
@ -282,7 +284,6 @@ gettext("yes");
gettext("no");
// HTML
gettext("Remove all");
gettext("Add");
gettext("Cancel");
gettext("Ok");
gettext("Discard & close");
@ -474,6 +475,8 @@ gettext("Enabled");
gettext("Disabled");
gettext("Service Pools");
gettext("Match mode");
gettext("Any group");
gettext("All groups");
gettext("Selected Groups");
gettext("Cancel");
gettext("Ok");

View File

@ -99,7 +99,7 @@
</svg>
</div>
</uds-root>
<script src="/uds/res/admin/runtime.js?stamp=1673892564" type="module"></script><script src="/uds/res/admin/polyfills.js?stamp=1673892564" type="module"></script><script src="/uds/res/admin/main.js?stamp=1673892564" type="module"></script>
<script src="/uds/res/admin/runtime.js?stamp=1675356496" type="module"></script><script src="/uds/res/admin/polyfills.js?stamp=1675356496" type="module"></script><script src="/uds/res/admin/main.js?stamp=1675356496" type="module"></script>
</body></html>