From c57891ef9d8bb32bed3582188061bcd4ec21cdee Mon Sep 17 00:00:00 2001 From: Swagnik Dutta Date: Thu, 29 Jun 2023 14:11:17 +0530 Subject: [PATCH] feat(usergroup): adds usergroup command Rapyuta.io supports creating user groups for granting users access to project and perform access management. The CLI lacked support for it and this commit introduces the command for usergroup management. Usergroup can be created using the rio apply manifest. You can run the following to see how to build a manifest rio explain usergroup Here's how the command usage looks like: Usage: rio usergroup [OPTIONS] COMMAND [ARGS]... Create and manage usergroups in organization Options: --help Show this message and exit. Commands: delete Delete usergroup from organization inspect Inspect the usergroup resource list List all user groups in selected organization --- docs/source/index.rst | 1 + docs/source/usergroup.rst | 10 ++ riocli/apply/manifests/usergroup.yaml | 18 +++ riocli/apply/resolver.py | 14 +- riocli/bootstrap.py | 2 + .../jsonschema/schemas/usergroup-schema.yaml | 113 ++++++++++++++++ riocli/organization/utils.py | 55 ++++++++ riocli/usergroup/__init__.py | 39 ++++++ riocli/usergroup/delete.py | 60 +++++++++ riocli/usergroup/inspect.py | 84 ++++++++++++ riocli/usergroup/list.py | 65 +++++++++ riocli/usergroup/model.py | 127 ++++++++++++++++++ riocli/usergroup/util.py | 84 ++++++++++++ 13 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 docs/source/usergroup.rst create mode 100644 riocli/apply/manifests/usergroup.yaml create mode 100644 riocli/jsonschema/schemas/usergroup-schema.yaml create mode 100644 riocli/organization/utils.py create mode 100644 riocli/usergroup/delete.py create mode 100644 riocli/usergroup/inspect.py create mode 100644 riocli/usergroup/model.py create mode 100644 riocli/usergroup/util.py diff --git a/docs/source/index.rst b/docs/source/index.rst index 716e56cb..baacb80a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -74,6 +74,7 @@ Rapyuta CLI has commands for all rapyuta.io resources. You can read more about t Rosbag Secret Static Route + User Group VPN diff --git a/docs/source/usergroup.rst b/docs/source/usergroup.rst new file mode 100644 index 00000000..b0f1e5e6 --- /dev/null +++ b/docs/source/usergroup.rst @@ -0,0 +1,10 @@ +User Group +============ + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + +.. click:: riocli.usergroup:usergroup + :prog: rio usergroup + :nested: full diff --git a/riocli/apply/manifests/usergroup.yaml b/riocli/apply/manifests/usergroup.yaml new file mode 100644 index 00000000..c63734f9 --- /dev/null +++ b/riocli/apply/manifests/usergroup.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: api.rapyuta.io/v2 +kind: UserGroup +metadata: + name: usergroup_name + organization: org-bqgpmsafgnvnawlkuvxtxohs + labels: + key: value +spec: + description: This is a sample user group description + members: + - emailID: qa.rapyuta+e2e@gmail.com + - emailID: random.user@rapyuta-robotics.com + admins: + - emailID: admin.user@rapyuta-robotics.com + projects: + - name: project01 + - name: project02 diff --git a/riocli/apply/resolver.py b/riocli/apply/resolver.py index ec6929c6..2371ef2f 100644 --- a/riocli/apply/resolver.py +++ b/riocli/apply/resolver.py @@ -32,6 +32,7 @@ from riocli.project.model import Project from riocli.secret.model import Secret from riocli.static_route.model import StaticRoute +from riocli.usergroup.model import UserGroup class _Singleton(type): @@ -55,7 +56,8 @@ class ResolverCache(object, metaclass=_Singleton): 'Package': Package, 'Disk': Disk, 'Deployment': Deployment, - "ManagedService": ManagedService + "ManagedService": ManagedService, + 'UserGroup': UserGroup, } KIND_REGEX = { @@ -99,7 +101,12 @@ def find_depends(self, depends, *args): elif 'guid' in depends and depends['kind'] not in ('network', 'managedservice'): return depends['guid'], None elif 'nameOrGUID' in depends: - obj_list = self._list_functors(depends['kind'])() + if depends['kind'] == 'usergroup': + org_guid = depends['organization'] + obj_list = self._list_functors(depends['kind'])(org_guid) + else: + obj_list = self._list_functors(depends['kind'])() + obj_match = list(self._find_functors(depends['kind'])( depends['nameOrGUID'], obj_list, *args)) if not obj_list or (isinstance(obj_list, list) and len(obj_list) == 0): @@ -123,6 +130,7 @@ def _guid_functor(self, kind): "disk": lambda x: munchify(x)['internalDeploymentGUID'], "device": lambda x: munchify(x)['uuid'], "managedservice": lambda x: munchify(x)['metadata']['name'], + "usergroup": lambda x: munchify(x).guid } return mapping[kind] @@ -140,6 +148,7 @@ def _list_functors(self, kind): "disk": self._list_disks, "device": self.client.get_all_devices, "managedservice": self._list_managedservices, + "usergroup": self.client.list_usergroups } return mapping[kind] @@ -158,6 +167,7 @@ def _find_functors(self, kind): "disk": self._generate_find_guid_functor(), "device": self._generate_find_guid_functor(), "managedservice": lambda name, instances: filter(lambda i: i.metadata.name == name, instances), + "usergroup": lambda name, groups: filter(lambda i: i.name == name, groups), } return mapping[kind] diff --git a/riocli/bootstrap.py b/riocli/bootstrap.py index 34471800..f08e80ce 100644 --- a/riocli/bootstrap.py +++ b/riocli/bootstrap.py @@ -41,6 +41,7 @@ from riocli.secret import secret from riocli.shell import shell, deprecated_repl from riocli.static_route import static_route +from riocli.usergroup import usergroup from riocli.vpn import vpn @@ -97,3 +98,4 @@ def version(): cli.add_command(template) cli.add_command(organization) cli.add_command(vpn) +cli.add_command(usergroup) diff --git a/riocli/jsonschema/schemas/usergroup-schema.yaml b/riocli/jsonschema/schemas/usergroup-schema.yaml new file mode 100644 index 00000000..8844c05b --- /dev/null +++ b/riocli/jsonschema/schemas/usergroup-schema.yaml @@ -0,0 +1,113 @@ +--- +$schema: http://json-schema.org/draft-07/schema# +title: UserGroup +description: A construct in rapyuta.io that allows one to grant access to projects to multiple users at once +$ref: "#/definitions/usergroup" +definitions: + usergroup: + type: object + properties: + apiVersion: + const: api.rapyuta.io/v2 + default: api.rapyuta.io/v2 + kind: + const: UserGroup + metadata: + "$ref": "#/definitions/metadata" + spec: + "$ref": "#/definitions/usergroupSpec" + + required: + - apiVersion + - kind + - metadata + - spec + + metadata: + type: object + properties: + name: + type: string + guid: + $ref: "#/definitions/uuid" + creator: + $ref: "#/definitions/uuid" + project: + $ref: "#/definitions/projectGUID" + organization: + $ref: "#/definitions/organizationGUID" + labels: + $ref: "#/definitions/stringMap" + uniqueItems: true + + required: + - name + - organization + + usergroupSpec: + type: object + properties: + description: + type: string + members: + type: array + items: + "$ref": "#/definitions/member" + admins: + type: array + items: + "$ref": "#/definitions/member" + projects: + type: array + items: + "$ref": "#/definitions/project" + additionalProperties: false + + member: + type: object + properties: + guid: + $ref: "#/definitions/uuid" + emailID: + $ref: "#/definitions/email" + + oneOf: + - required: + - guid + - required: + - emailID + + project: + type: object + properties: + guid: + type: string + pattern: "^project-([a-z0-9]{20}|[a-z]{24})$" + name: + type: string + oneOf: + - required: + - guid + - required: + - name + + stringMap: + type: object + additionalProperties: + type: string + + projectGUID: + type: string + pattern: "^project-([a-z0-9]{20}|[a-z]{24})$" + + organizationGUID: + type: string + pattern: "^org-([a-z0-9]{20}|[a-z]{24})$" + + uuid: + type: string + pattern: "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + + email: + type: string + pattern: "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9]{2,}$" diff --git a/riocli/organization/utils.py b/riocli/organization/utils.py new file mode 100644 index 00000000..6c435a4a --- /dev/null +++ b/riocli/organization/utils.py @@ -0,0 +1,55 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +from rapyuta_io.utils import RestClient +from rapyuta_io.utils.rest_client import HttpMethod + +from riocli.config import Configuration + + +def _api_call( + method: str, + path: typing.Union[str, None] = None, + payload: typing.Union[typing.Dict, None] = None, + load_response: bool = True, +) -> typing.Any: + config = Configuration() + coreapi_host = config.data.get( + 'core_api_host', + 'https://gaapiserver.apps.rapyuta.io' + ) + + url = '{}/api/organization'.format(coreapi_host) + if path: + url = '{}/{}'.format(url, path) + + headers = config.get_auth_header() + response = RestClient(url).method(method).headers(headers).execute( + payload=payload) + + data = None + + if load_response: + data = response.json() + + if not response.ok: + err_msg = data.get('error') + raise Exception(err_msg) + + return data + + +def get_organization_details(organization_guid): + return _api_call(HttpMethod.GET, '{}/get'.format(organization_guid)) \ No newline at end of file diff --git a/riocli/usergroup/__init__.py b/riocli/usergroup/__init__.py index e69de29b..69839067 100644 --- a/riocli/usergroup/__init__.py +++ b/riocli/usergroup/__init__.py @@ -0,0 +1,39 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import click +from click_help_colors import HelpColorsGroup + +from riocli.constants import Colors +from riocli.usergroup.delete import delete_usergroup +from riocli.usergroup.inspect import inspect_usergroup +from riocli.usergroup.list import list_usergroup + + +@click.group( + invoke_without_command=False, + cls=HelpColorsGroup, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +def usergroup() -> None: + """ + Manage usergroups on rapyuta.io + """ + pass + + +usergroup.add_command(list_usergroup) +usergroup.add_command(inspect_usergroup) +usergroup.add_command(delete_usergroup) diff --git a/riocli/usergroup/delete.py b/riocli/usergroup/delete.py new file mode 100644 index 00000000..ad7eeccb --- /dev/null +++ b/riocli/usergroup/delete.py @@ -0,0 +1,60 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import click +from click_help_colors import HelpColorsCommand +from yaspin.api import Yaspin + +from riocli.config import new_client +from riocli.constants import Colors, Symbols +from riocli.usergroup.util import name_to_guid +from riocli.utils.spinner import with_spinner + + +@click.command( + 'delete', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option('--force', '-f', '--silent', 'force', is_flag=True, + default=False, help='Skip confirmation') +@click.argument('group-name') +@click.pass_context +@with_spinner(text="Deleting user group...") +@name_to_guid +def delete_usergroup( + ctx: click.Context, + group_name: str, + group_guid: str, + force: bool, + spinner: Yaspin = None, +) -> None: + """ + Delete usergroup from organization + """ + if not force: + with spinner.hidden(): + click.confirm('Deleting usergroup {} ({})'.format(group_name, group_guid), abort=True) + + try: + client = new_client() + org_guid = ctx.obj.data.get('organization_id') + client.delete_usergroup(org_guid, group_guid) + spinner.text = click.style('User group deleted successfully.', fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style('Failed to delete usergroup: {}'.format(e), Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) from e diff --git a/riocli/usergroup/inspect.py b/riocli/usergroup/inspect.py new file mode 100644 index 00000000..8ad01fd1 --- /dev/null +++ b/riocli/usergroup/inspect.py @@ -0,0 +1,84 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +import click +from click_help_colors import HelpColorsCommand +from rapyuta_io.clients.project import User, Project + +from riocli.config import new_client +from riocli.constants import Colors +from riocli.usergroup.util import name_to_guid +from riocli.utils import inspect_with_format + + +@click.command( + 'inspect', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option('--format', '-f', 'format_type', default='yaml', + type=click.Choice(['json', 'yaml'], case_sensitive=False)) +@click.argument('group-name') +@click.pass_context +@name_to_guid +def inspect_usergroup(ctx: click.Context, format_type: str, group_name: str, group_guid: str, spinner=None) -> None: + """ + Inspect the usergroup resource + """ + try: + client = new_client() + org_guid = ctx.obj.data.get('organization_id') + usergroup = client.get_usergroup(org_guid, group_guid) + data = make_usergroup_inspectable(usergroup) + inspect_with_format(data, format_type) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + +def make_usergroup_inspectable(usergroup: typing.Any): + return { + 'name': usergroup.name, + 'description': usergroup.description, + 'guid': usergroup.guid, + 'creator': usergroup.creator, + 'members': [make_user_inspectable(member) for member in usergroup.members], + 'admins': [make_user_inspectable(admin) for admin in usergroup.admins], + 'projects': [make_project_inspectable(project) for project in usergroup.projects] + } + + +def make_user_inspectable(u: User): + return { + 'guid': u.guid, + 'firstName': u.first_name, + 'lastName': u.last_name, + 'emailID': u.email_id, + 'state': u.state, + 'organizations': u.organizations + } + + +def make_project_inspectable(p: Project): + return { + 'ID': p.id, + 'CreatedAt': p.created_at, + 'UpdatedAt': p.updated_at, + 'DeletedAt': p.deleted_at, + 'name': p.name, + 'guid': p.guid, + 'creator': p.creator + } diff --git a/riocli/usergroup/list.py b/riocli/usergroup/list.py index e69de29b..551c237b 100644 --- a/riocli/usergroup/list.py +++ b/riocli/usergroup/list.py @@ -0,0 +1,65 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import click +from click_help_colors import HelpColorsCommand +from riocli.config import new_client +from riocli.constants import Colors +from riocli.utils import tabulate_data + + +@click.command( + 'list', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.pass_context +def list_usergroup(ctx: click.Context) -> None: + """ + List all user groups in selected organization + """ + + try: + client = new_client() + org_guid = ctx.obj.data.get('organization_id') + user_groups = client.list_usergroups(org_guid) + _display_usergroup_list(user_groups) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + +def _display_usergroup_list(usergroups: typing.Any, show_header: bool = True): + headers = [] + if show_header: + headers = ( + 'ID', 'Name', 'Creator', 'Members', 'Projects', 'Description' + ) + + data = [ + [ + group.guid, + group.name, + group.creator, + len(group.members) if group.members else 0, + len(group.projects) if group.projects else 0, + group.description + ] + for group in usergroups + ] + + tabulate_data(data, headers) diff --git a/riocli/usergroup/model.py b/riocli/usergroup/model.py new file mode 100644 index 00000000..1e417f00 --- /dev/null +++ b/riocli/usergroup/model.py @@ -0,0 +1,127 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +from munch import unmunchify +from rapyuta_io import Client + +from riocli.config import new_v2_client +from riocli.jsonschema.validate import load_schema +from riocli.model import Model +from riocli.organization.utils import get_organization_details + +USER_GUID = 'guid' +USER_EMAIL = 'emailID' + + +class UserGroup(Model): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + v2client = new_v2_client() + organization_details = get_organization_details(self.metadata.organization) + user_projects = v2client.list_projects(self.metadata.organization) + + self.project_name_to_guid_map = {p['metadata']['name']: p['metadata']['guid'] for p in user_projects} + self.user_email_to_guid_map = {user[USER_EMAIL]: user[USER_GUID] for user in organization_details['users']} + + def unmunchify(self) -> typing.Dict: + """Unmuchify self""" + usergroup = unmunchify(self) + usergroup.pop('rc', None) + usergroup.pop('project_name_to_guid_map', None) + usergroup.pop('user_email_to_guid_map', None) + + return usergroup + + def find_object(self, client: Client) -> typing.Any: + group_guid, usergroup = self.rc.find_depends({ + 'kind': self.kind.lower(), + 'nameOrGUID': self.metadata.name, + 'organization': self.metadata.organization + }) + + if not usergroup: + return False + + return usergroup + + def create_object(self, client: Client) -> typing.Any: + usergroup = self.unmunchify() + payload = self._modify_payload(usergroup) + # Inject the user group name in the payload + payload['spec']['name'] = usergroup['metadata']['name'] + return client.create_usergroup(self.metadata.organization, payload['spec']) + + def update_object(self, client: Client, obj: typing.Any) -> typing.Any: + payload = self._modify_payload(self.unmunchify()) + payload = self._create_update_payload(obj, payload) + return client.update_usergroup(self.metadata.organization, obj.guid, payload) + + def delete_object(self, client: Client, obj: typing.Any) -> typing.Any: + return client.delete_usergroup(self.metadata.organization, obj.guid) + + def _modify_payload(self, group: typing.Dict) -> typing.Dict: + for entity in ('members', 'admins'): + for u in group['spec'].get(entity, []): + if USER_GUID in u: + continue + u[USER_GUID] = self.user_email_to_guid_map.get(u[USER_EMAIL]) + u.pop(USER_EMAIL) + + for p in group['spec'].get('projects', []): + if 'guid' in p: + continue + p['guid'] = self.project_name_to_guid_map.get(p['name']) + p.pop('name') + + return group + + @classmethod + def pre_process(cls, client: Client, d: typing.Dict) -> None: + pass + + @staticmethod + def validate(data): + schema = load_schema('usergroup') + schema.validate(data) + + @staticmethod + def _create_update_payload(old: typing.Any, new: typing.Dict) -> typing.Dict: + payload = { + 'name': old.name, + 'guid': old.guid, + 'description': new['spec']['description'], + 'update': { + 'members': {'add': [], 'remove': []}, + 'projects': {'add': [], 'remove': []}, + 'admins': {'add': [], 'remove': []} + } + } + + for entity in ('members', 'projects', 'admins'): + # Assure that the group creator is not removed + old_set = {i.guid for i in (getattr(old, entity) or []) if i.guid != old.creator} + new_set = {i['guid'] for i in new['spec'].get(entity, [])} + + added = new_set - old_set + removed = old_set - new_set + + payload['update'][entity]['add'] = [{'guid': guid} for guid in added] + payload['update'][entity]['remove'] = [{'guid': guid} for guid in removed] + + # This is a special case where admins are not added to the membership list + # And as a consequence they don't show up in the group. This will fix that. + payload['update']['members']['add'].extend(payload['update']['admins']['add']) + + return payload diff --git a/riocli/usergroup/util.py b/riocli/usergroup/util.py new file mode 100644 index 00000000..861eb66f --- /dev/null +++ b/riocli/usergroup/util.py @@ -0,0 +1,84 @@ +# Copyright 2023 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import typing + +import click +from rapyuta_io import Client + +from riocli.config import new_client +from riocli.constants import Colors + + +def name_to_guid(f: typing.Callable) -> typing.Callable: + @functools.wraps(f) + def decorated(*args: typing.Any, **kwargs: typing.Any) -> None: + try: + client = new_client() + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + group_name = kwargs.pop('group_name') + group_guid = None + + ctx = args[0] + org_guid = ctx.obj.data.get('organization_id') + + if group_name.startswith('group-'): + group_guid = group_name + group_name = None + + if group_name is None: + group_name = get_usergroup_name(client, org_guid, group_guid) + + if group_guid is None: + try: + group_guid = find_usergroup_guid(client, org_guid, group_name) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + kwargs['group_name'] = group_name + kwargs['group_guid'] = group_guid + f(*args, **kwargs) + + return decorated + + +def get_usergroup_name(client: Client, org_guid: str, group_guid: str) -> str: + try: + usergroup = client.get_usergroup(org_guid, group_guid) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + return usergroup.name + + +def find_usergroup_guid(client: Client, org_guid, group_name: str) -> str: + user_groups = client.list_usergroups(org_guid=org_guid) + + for g in user_groups: + if g.name == group_name: + return g.guid + + raise UserGroupNotFound() + + +class UserGroupNotFound(Exception): + def __init__(self, message='usergroup not found!'): + self.message = message + super().__init__(self.message)