diff --git a/lib/args/basic_login.py b/lib/args/basic_login.py new file mode 100644 index 0000000..717a9a6 --- /dev/null +++ b/lib/args/basic_login.py @@ -0,0 +1,3 @@ +from collections import namedtuple + +BasicLogin = namedtuple('BasicLogin', ['user', 'password']) diff --git a/lib/input_output/logger.py b/lib/input_output/logger.py new file mode 100644 index 0000000..6f53a64 --- /dev/null +++ b/lib/input_output/logger.py @@ -0,0 +1,16 @@ + +class Logger: + """ + minimal logger to either be verbose or don't print messages at all. + """ + verbose = True + + def __init__(self, verbose: bool): + """ + Constructor + """ + self.verbose = verbose + + def log(self, *args): + if self.verbose: + print(*args) diff --git a/lib/input_output/yaml_utils.py b/lib/input_output/yaml_utils.py new file mode 100644 index 0000000..d8d1726 --- /dev/null +++ b/lib/input_output/yaml_utils.py @@ -0,0 +1,31 @@ +import yaml +import json + + +def read_yaml_file(path): + """ + reads a .yaml file and returns a dictionary + :param path: path to the yaml file + :return: returns a dictionary + """ + with open(path, 'r') as f: + content = yaml.load(f, Loader=yaml.FullLoader) + + return content + + +def create_yaml_file_from_json_file(json_file_path, yaml_file_path='test.yaml'): + """ + This function can be used to transform a json file to a yaml file. + requires import json and import yaml + :param json_file_path: path to json file + :param yaml_file_path: path to yaml file (will be created if it does not exist) + :return: + """ + + with open(json_file_path, 'r') as json_file: + json_data = json.load(json_file) + with open(yaml_file_path, 'w') as file: + yaml.dump(json_data, file, sort_keys=False) + + return True diff --git a/lib/rest_requests/request.py b/lib/rest_requests/request.py index b9518a0..f845960 100644 --- a/lib/rest_requests/request.py +++ b/lib/rest_requests/request.py @@ -4,22 +4,23 @@ import os import requests -from requests.auth import HTTPDigestAuth +from requests.auth import HTTPDigestAuth, HTTPBasicAuth from requests_toolbelt import MultipartEncoder from rest_requests.request_error import RequestError -def get_request(url, digest_login, element_description, asset_type_description=None, asset_description=None, - stream=False): +def get_request(url, login, element_description, asset_type_description=None, asset_description=None, + stream=False, headers=None, use_digest=True): """ - Make a get request to the given url with the given digest login. If the request fails with an error or a status - code != 200, a Request Error with the error message /status code and the given descriptions is thrown. + Make a get request to the given url with the given login credentials (Either Basic Auth or Digest Login). + If the request fails with an error or a status code != 200, a Request Error with the error message /status code + and the given descriptions is thrown. :param url: URL to make get request to :type url: str - :param digest_login: The login credentials for digest authentication - :type digest_login: DigestLogin + :param login: The login credentials (either HTTP Basic or digest authentication) + :type login: Login :param element_description: Element description in case of errors, e.g. 'event', 'series', 'tenants' :type element_description: str :param asset_type_description: Asset type description in case of errors, e.g. 'series', 'episode' @@ -28,13 +29,23 @@ def get_request(url, digest_login, element_description, asset_type_description=N :type asset_description: str :param stream: Whether to stream response :type stream: bool + :param headers: The headers to include in the request + :type headers: dict + :param use_digest: Whether to use digest login + :type use_digest: bool :return: response :raise RequestError: """ + headers = headers if headers else {} + if use_digest: + auth = HTTPDigestAuth(login.user, login.password) + headers["X-Requested-Auth"] = "Digest" + else: + auth = HTTPBasicAuth(login.user, login.password) + try: - response = requests.get(url, auth=HTTPDigestAuth(digest_login.user, digest_login.password), - headers={"X-Requested-Auth": "Digest"}, stream=stream) + response = requests.get(url, auth=auth, headers=headers, stream=stream) except Exception as e: raise RequestError.with_error(url, str(e), element_description, asset_type_description, asset_description) @@ -129,3 +140,41 @@ def big_post_request(url, digest_login, element_description, asset_type_descript raise RequestError.with_status_code(url, str(response.status_code), element_description, asset_type_description, asset_description) return response + + +def put_request(url, digest_login, element_description, asset_type_description=None, asset_description=None, + data=None, files=None): + """ + Make a put request to the given url with the given digest login. If the request fails with an error or a status + code != 200, a Request Error with the error message /status code and the given descriptions is thrown. + + :param url: URL to make put request to + :type url: str + :param digest_login: The login credentials for digest authentication + :type digest_login: DigestLogin + :param element_description: Element description in case of errors, e.g. 'event', 'series', 'tenants' + :type element_description: str + :param asset_type_description: Asset type type description in case of errors, e.g. 'series', 'episode' + :type asset_type_description: str + :param asset_description: Asset description in case of errors, e.g. 'Dublin Core catalogs', 'ACL' + :type asset_description: str + :param data: Any data to attach to the request + :type data: dict + :param files: Any files to attach to the request + :type files: dict + :return: response + :raise RequestError: + """ + + auth = HTTPDigestAuth(digest_login.user, digest_login.password) + headers = {"X-Requested-Auth": "Digest"} + + try: + response = requests.put(url, auth=auth, headers=headers, data=data, files=files) + except Exception as e: + raise RequestError.with_error(url, str(e), element_description, asset_type_description, asset_description) + + if response.status_code < 200 or response.status_code > 299: + raise RequestError.with_status_code(url, str(response.status_code), element_description, asset_type_description, + asset_description) + return response diff --git a/multi-tenant-configuration/README.md b/multi-tenant-configuration/README.md new file mode 100644 index 0000000..3b066b5 --- /dev/null +++ b/multi-tenant-configuration/README.md @@ -0,0 +1,71 @@ +# Multi-tenants User configuration scripts for Opencast + +This script simplifies the process of multi-tenant configuration. +It allows to read in configurations of tenants and opencast organizations and checks if these configurations match the ones found on the respective opencast system. + +The *configuration* file `opencast-organizations.yml` in the environment folder contains specifications for: + +- Opencast Organizations +- Switchcast System Accounts +- Capture Agent Accounts +- Tenants +- Users (and their Roles) + +the *configuration* file `scripts/config/group_configuration.json` contains specifications for the Groups: +- Group name +- Group description +- Tenant (on which this group should exist) +- Group members + +## How to Use + +### Configuration + +The script is configured by editing the values in `config.py`: + +| Configuration Key | Description | Default/Example | +| :-------------------- | :-------------------------------------------- | :--------------------------- | +| `server_url` | The URL of the global admin node | `"http://localhost:8080"` | +| `tenant_url_pattern` | The URL pattern of the target tenants | `"http://{}:8080"` | +| `tenant_urls` | Optional dictionary of server URLs per tenant | `{'tenant1': 'http://tenant1:8080', 'tenant2': 'http://tenant2:8080'}` | +| `ignored_tenants` | Optional list of tenants which are ignored | `['mh_default_org']` | +| `digest_user` | The user name of the digest user | `opencast_system_account` | +| `digest_pw` | The password of the digest user | `CHANGE_ME` | +| `org_config_path` | The path to the organization config file | `"environment/{}/opencast-organizations.yml"` | +| `group_config_path` | The path to the group config file | `"configurations/group_configuration.yaml"` | + +The configured digest user needs to exist on all tenants and has to have the same password. + +The optional dictionary `tenant_urls` can be used if the tenant-id is not an exact part of the tenant URL or the URLs don't follow a common pattern. + +#### group config: +The group names in the group config file must be unique per Tenant! + +In the group description, python placeholder can be used (i.e. `{tenant_id}`) to include the current tenant-id in the description. + +### Usage + +The script can be called with the following command (all parameters in brackets are optional): + +`python main.py -e ENVIRONMENT [-t TENANT_ID] [-c CHECK] [-v True]` + +| Param | Description | +| :---: | :---------- | +| `-e` / `--environment` | The environment where to find the configuration file (either `staging` or `production`) | +| `-t` / `--tenant-id` | The id of the target tenant to be configured | +| `-c` / `--check` | checks to be performed (`users`, `groups`, `cast` or `capture`) (default: `all`) | +| `-v` / `--verbose` | enables logging to be prompted if set to `True` | + +#### example: + +`python main.py -e staging -t tenant1 -c groups -v True` + +## Requirements + +This script was written for Python 3.8. You can install the necessary packages with + +**ToDo check the requirements file** + +`pip install -r requirements.txt` + +Additionally, this script uses modules contained in the _lib_ directory. diff --git a/multi-tenant-configuration/config.py b/multi-tenant-configuration/config.py new file mode 100644 index 0000000..d794ff7 --- /dev/null +++ b/multi-tenant-configuration/config.py @@ -0,0 +1,31 @@ +# Configuration + +# Set this to your admin node +server_url = "http://localhost:8080" + +# If you have multiple tenants, use an URL pattern. The blank {} will be filled with the tenant-id. +# example: +# tenant_url_pattern = "https://{}.example.org" +tenant_url_pattern = "http://{}:8080" + +# You can also define a dictionary of tenant URLs, which will be prioritized over the URL pattern: +# example: +# tenant_urls = { +# 'tenant1': 'http://tenant1:8080', +# 'tenant2': 'http://tenant2:8080' +# } + +# List of tenants which should be ignored +ignored_tenants = [ + 'mh_default_org' +] + +# Digest User login +digest_user = "opencast_system_account" +digest_pw = "CHANGE_ME" + +# path to environment configuration file. +# The {} are a placeholder which will be filled with the environment passed as an argument (e.g. staging or production). +org_config_path = "configurations/environment/{}/opencast-organizations.yml" +# path to group configuration file +group_config_path = "configurations/group_configuration.yaml" diff --git a/multi-tenant-configuration/configurations/environment/staging/opencast-organizations.yml b/multi-tenant-configuration/configurations/environment/staging/opencast-organizations.yml new file mode 100644 index 0000000..d4a35a6 --- /dev/null +++ b/multi-tenant-configuration/configurations/environment/staging/opencast-organizations.yml @@ -0,0 +1,60 @@ +--- + +opencast_organizations: + - id: All Tenants + name: Dummy Tenant + switchcast_system_accounts: + - username: player + name: Player System User + email: test@test.de + password: 34dchG6nbhmhnG + roles: [ROLE_ADMIN, ROLE_SUDO] + - username: annotate + name: Annotate System User + email: test@test.de + password: jhvhuJH7utghfgfgJH + roles: [ROLE_ADMIN, ROLE_SUDO] + - username: cast + name: Cast System User + email: test@test.de + password: jhvhuJH7utghfgfgJH + roles: [ROLE_ADMIN, ROLE_SUDO] + capture_agent_accounts: [] + + - id: tenant1 + name: Tenant1 + capture_agent_accounts: + - username: ca-tenant1-ch + password: jvblkajklvjhaklehr + external_api_accounts: + - username: moodle-tenant1-ch + password: hghghjghdghdjd76 + name: Moodle System User + email: test@test.de + roles: [ROLE_EXTERNAL_APPLICATION] + - username: guy1 + password: abc + name: Guy 1 + email: test@test.de + roles: [ROLE_ADMIN] + - username: guy2 + password: abc + name: Guy 2 + email: test@test.de + roles: [ROLE_ADMIN, ROLE_SUDO] + - id: tenant2 + name: Tenant2 + capture_agent_accounts: + - username: ca-tenant2-ch + password: hjfkhfzuruzf76 + external_api_accounts: + - username: moodle-tenant2-ch + password: 67rdghn + name: Moodle System User + email: test@test.de + roles: [ROLE_EXTERNAL_APPLICATION] + - username: guyx + password: abc + name: Guy X + email: test@test.de + roles: [ROLE_ADMIN, ROLE_SUDO] diff --git a/multi-tenant-configuration/configurations/group_configuration.json b/multi-tenant-configuration/configurations/group_configuration.json new file mode 100644 index 0000000..47510fe --- /dev/null +++ b/multi-tenant-configuration/configurations/group_configuration.json @@ -0,0 +1,113 @@ +{ + "groups" : [ + { + "name": "System Administrators", + "description": "System Administrators", + "tenants": "all", + "type": "closed", + "members": [ + { + "name": "Guy 1", + "email": "test@test.de", + "reason": "Operations partner", + "uid": "guy-1", + "tenants": "all" + }, + { + "name": "Guy 2", + "email": "test@test.de", + "reason": "Operations partner", + "uid": "guy-2", + "tenants": "tenant1" + } + ], + "inactive_members": [ ], + "permissions": [ + { + "tenants": "all", + "roles": ["ROLE_ADMIN", "ROLE_SUDO"] + } + ] + }, + { + "name": "Organization Administrators", + "description": "Organization administrators have full access to all content of ${name}", + "tenants": "all", + "type": "open", + "members": [], + "inactive_members": [], + "permissions": [ + { + "tenants": "all", + "roles": [ + "ROLE_ADMIN_UI", + "ROLE_ORG_ADMIN" + ] + }, + { + "tenants" : "tenant2", + "roles": { + "add": [ + "ROLE_UI_EVENTS_DETAILS_ACL_VIEW", + "ROLE_UI_EVENTS_DETAILS_ACL_EDIT" + ], + "remove": [] + } + } + ] + }, + { + "name": "Producers", + "description": "Producers have limited access to content and functionality", + "tenants": "all", + "type": "open", + "members": [], + "inactive_members": [], + "permissions": [ + { + "tenants": "all", + "roles": [ + "ROLE_ADMIN_UI", + "ROLE_UI_EVENTS_CREATE" + ] + }, + { + "tenants" : "tenant1", + "roles": { + "add": [ + "ROLE_UI_EVENTS_COUNTERS_VIEW" + ], + "remove": [ + "ROLE_UI_EVENTS_CREATE" + ] + } + }, + { + "tenants" : "tenant2", + "roles": { + "add": [ + "ROLE_ORG_ADMIN" + ], + "remove": [] + } + } + ] + }, + { + "name": "Tenant2 Producers", + "description": "Tenant2 Producers have limited access to content and functionality", + "tenants": "tenant2", + "type": "open", + "members": [], + "inactive_members": [], + "permissions": [ + { + "tenants": "all", + "roles": [ + "ROLE_ADMIN_UI" + ] + } + ] + } + ] +} diff --git a/multi-tenant-configuration/configurations/group_configuration.yaml b/multi-tenant-configuration/configurations/group_configuration.yaml new file mode 100644 index 0000000..c3e4b35 --- /dev/null +++ b/multi-tenant-configuration/configurations/group_configuration.yaml @@ -0,0 +1,79 @@ +--- + +groups: + - name: System Administrators + description: System Administrators + tenants: all + members: + - name: Guy 1 + email: test@test.de + reason: Operations partner + username: guy1 + tenants: all + - name: Guy 2 + email: test@test.de + reason: Operations partner + username: guy2 + tenants: tenant1 + inactive_members: [] + permissions: + - tenants: all + roles: + - ROLE_ADMIN + - ROLE_SUDO + - name: Organization Administrators + description: Organization administrators have full access to all content of {tenant_id} + tenants: all + members: [] + inactive_members: [] + permissions: + - tenants: all + roles: + - ROLE_ADMIN_UI + - ROLE_ORG_ADMIN + - tenants: tenant2 + roles: + add: + - ROLE_UI_EVENTS_DETAILS_ACL_VIEW + - ROLE_UI_EVENTS_DETAILS_ACL_EDIT + remove: [] + - name: Producers + description: Producers have limited access to content and functionality + tenants: all + members: [] + inactive_members: [] + permissions: + - tenants: all + roles: + - ROLE_ADMIN_UI + - ROLE_UI_EVENTS_CREATE + - tenants: tenant1 + roles: + add: + - ROLE_UI_EVENTS_COUNTERS_VIEW + remove: + - ROLE_UI_EVENTS_CREATE + - tenants: tenant2 + roles: + add: + - ROLE_ORG_ADMIN + remove: [] + - name: Tenant1 Producers + description: Tenant1 Producers have limited access to content and functionality + tenants: tenant1 + members: + - name: Guy X + email: test@test.de + reason: Operations partner + username: guyx + tenants: all + - name: Guy 2 + email: test@test.de + reason: Operations partner + username: guy2 + tenants: tenant1 + inactive_members: [] + permissions: + - tenants: all + roles: + - ROLE_ADMIN_UI diff --git a/multi-tenant-configuration/configure_capture_accounts.py b/multi-tenant-configuration/configure_capture_accounts.py new file mode 100644 index 0000000..dbb3f8f --- /dev/null +++ b/multi-tenant-configuration/configure_capture_accounts.py @@ -0,0 +1,85 @@ +from rest_requests.request import get_request +from rest_requests.request_error import RequestError +from args.basic_login import BasicLogin +from input_output.logger import Logger + + +CONFIG = None +ENV_CONFIG = None + + +def set_config_capture_accounts(env_conf: dict, config: dict, logger: Logger): + """ + Sets/imports the global config variables. + must be called before any checks can be performed. + :param env_conf: The environment configuration which specifies the user and system accounts + :type env_conf: dict + :param config: The script configuration + :type config: dict + :param logger: A Logger instance + :type logger: Logger + """ + + global ENV_CONFIG + global CONFIG + global log + ENV_CONFIG = env_conf + CONFIG = config + log = logger.log + + +def check_capture_accounts(tenant): + """ + Performs the checks for each capture agent on the specified tenant + :param tenant: The target tenant + :type tenant: dict + """ + log('\nStart checking Capture Agent Accounts for tenant: ', tenant['id']) + + # Check and configure Capture Agent Accounts: + for capture_agent_account in tenant['capture_agent_accounts']: + __check_capture_agent_account(capture_agent_account, tenant['id']) + + +def __check_capture_agent_account(account: dict, tenant_id: str) -> bool: + """ + Performs checks for the specified Capture Agent Account: + - checks if username and password exists + - checks if account has API access (and if password matches) + Checks if the capture agent defined in the config has access to the service registry + with the username and password defined in the config, and sends a get request to '/services/available.json' + to find the ingest service. If check fails, prints a warning. + + :param account: The Capture Agent Account to be checked + :type account: dict + :param tenant_id: The target tenant + :type tenant_id: str + :return: bool + """ + log(f"Checking Capture Agent Account {account['username']} on tenant {tenant_id}.") + + # check username and password + if not account['username']: + print('ERROR: No Capture Agent Account has been configured') + return False + if not account['password']: + print(f"ERROR: No password configured for Capture Agent User {account['username']}") + return False + + # Check if account has api access + url = f'{CONFIG.tenant_urls[tenant_id]}/services/available.json?serviceType=org.opencastproject.ingest' + login = BasicLogin(user=account['username'], password=account['password']) + try: + response = get_request(url, login, '/services/available.json', use_digest=False) + except RequestError: + print(f"WARNING: Capture Agent {account['username']} has no access.") + return False + except Exception as e: + print('ERROR: Failed to check for API access.') + print(str(e)) + return False + + if 'services' in response.json().keys(): + return True + else: + return False diff --git a/multi-tenant-configuration/configure_groups.py b/multi-tenant-configuration/configure_groups.py new file mode 100644 index 0000000..b41f17e --- /dev/null +++ b/multi-tenant-configuration/configure_groups.py @@ -0,0 +1,458 @@ +from rest_requests.request import get_request, post_request, put_request +from rest_requests.request_error import RequestError +from args.digest_login import DigestLogin +from configure_users import get_user +from input_output.logger import Logger +from input_output.input import get_yes_no_answer +from user_interaction import check_or_ask_for_permission + + +CONFIG = None +GROUP_CONFIG = None +DIGEST_LOGIN = None + + +def set_config_groups(digest_login: DigestLogin, group_config: dict, config: dict, logger: Logger): + """ + Sets/imports the global config variables. + Must be called before any checks can be performed. + :param digest_login: The digest login to be used + :type digest_login: DigestLogin + :param group_config: The group configuration which specifies the groups on each tenant + :type group_config: dict + :param config: The script configuration + :type config: dict + :param logger: A Logger instance + :type logger: Logger + """ + + global DIGEST_LOGIN + global GROUP_CONFIG + global CONFIG + global log + DIGEST_LOGIN = digest_login + GROUP_CONFIG = group_config + CONFIG = config + log = logger.log + + +def check_groups(tenant_id: str): + """ + Performs the checks for each group on the specified tenant + :param tenant_id: The target tenant + :type tenant_id: str + """ + log('\nStart checking groups for tenant: ', tenant_id) + + # For all Groups: + for group in GROUP_CONFIG['groups']: + # Check group + if group['tenants'] == 'all' or group['tenants'] == tenant_id: + group['identifier'] = __generate_group_identifier(group, tenant_id) + __check_group(group=group, tenant_id=tenant_id) + + +def __check_group(group: dict, tenant_id: str): + """ + Performs all checks for the specified group: + - checks if group exists + - checks if group description matches + - checks if group members match + - checks if group roles match + :param group: The group to be checked + :type group: dict + :param tenant_id: The target tenant + :type tenant_id: str + """ + log(f"\nCheck group {group['name']} with id {group['identifier']}") + + # Check if group exists. + existing_group = get_group(group, tenant_id) + if not existing_group: + # Create group if it does not exist. + # Ask for permission + action_allowed = check_or_ask_for_permission( + target_type='group', + action='create', + target_name=group['name'], + tenant_id=tenant_id + ) + if action_allowed: + create_group(group=group, tenant_id=tenant_id) + else: + # Check if group name and description match the name and description provided in the configuration. + # Update them if they do not match. (Asks for permission) + __check_group_description(group=group, existing_group=existing_group, tenant_id=tenant_id) + # Check if group members exist. + # Check if group members match the group members provided in the configuration. + # Add or remove members accordingly. + __check_group_members(group=group, existing_group=existing_group, tenant_id=tenant_id) + # Check if group roles match the group roles provided in the configuration. + # Update group roles if they do not match. (Asks for permission) + __check_group_roles(group=group, existing_group=existing_group, tenant_id=tenant_id) + + +def __check_group_description(group: dict, existing_group: dict, tenant_id: str): + """ + Checks if the group description matches. + Ask for permission to update the group if necessary. + :param group: The group as specified in the config file + :type group: dict + :param existing_group: The existing group as specified on the tenant system + :type existing_group: dict + :param tenant_id: The target tenant + :type tenant_id: str + """ + log(f"check names and description for group {group['name']}.") + + if __group_description_template(group['description'], tenant_id) == existing_group['description']: + log('Group descriptions match.') + else: + action_allowed = check_or_ask_for_permission( + target_type='group', + action='update the description', + target_name=group['name'], + tenant_id=tenant_id + ) + if action_allowed: + update_group(tenant_id, group) + + +def __check_group_members(group: dict, existing_group: dict, tenant_id: str): + """ + Checks if the group member match. + Asks for permission to either add or remove members accordingly. + :param group: The group as specified in the configuration file + :type group: dict + :param existing_group: The existing group as specified on the tenant system + :type existing_group: dict + :param tenant_id: The target tenant + :type tenant_id: str + """ + log(f"Check members for group {group['name']}.") + + group_roles = __extract_roles_from_group(group=group, tenant_id=tenant_id) + if "ROLE_ADMIN" or "ROLE_SUDO" in group_roles: + print("ATTENTION: This group contains admin or sudo rights!") + + group_members = __extract_members_from_group(group=group, tenant_id=tenant_id) + existing_group_members = sorted(filter(None, existing_group['members'].split(","))) + log("Config group members: ", group_members) + + members = existing_group_members.copy() + missing_members = [member for member in group_members if member not in existing_group_members] + for member in missing_members: + if not get_user(username=member, tenant_id=tenant_id): + log(f"Member {member} of group {group['name']} not found on tenant {tenant_id}.") + missing_members.remove(member) + additional_members = [member for member in existing_group_members if member not in group_members] + + if not missing_members and not additional_members: + log('Group members match.') + else: + print("Existing group members: ", existing_group_members) + if missing_members: + print("Missing members: ", missing_members) + action_allowed = check_or_ask_for_permission( + target_type='group', + action='add missing members', + target_name=group['name'], + tenant_id=tenant_id, + option_i=True + ) + if action_allowed == 'i': + for member in missing_members: + action_allowed = get_yes_no_answer(f"Add member {member} to group {group['name']}?") + if action_allowed: + members.append(member) + elif action_allowed: + for member in missing_members: + members.append(member) + + if additional_members: + print("Additional members: ", additional_members) + action_allowed = check_or_ask_for_permission( + target_type='group', + action='remove additional members', + target_name=group['name'], + tenant_id=tenant_id, + option_i=True + ) + if action_allowed == 'i': + for member in additional_members: + action_allowed = get_yes_no_answer(f"remove member {member} from group {group['name']}?") + if action_allowed: + members.remove(member) + elif action_allowed: + for member in additional_members: + members.remove(member) + + # Update Group if there are any changes + if members != existing_group_members: + # members = ",".join(list(dict.fromkeys(members))) + members = ",".join(members) + update_group(tenant_id, group, overwrite_members=members) + + +def __check_group_roles(group: dict, existing_group: dict, tenant_id: str): + """ + Checks if the group roles match. + Asks for permission to either add or remove roles accordingly. + :param group: The group as specified in the configuration file + :type group: dict + :param existing_group: The existing group as specified on the tenant system + :type existing_group: dict + :param tenant_id: The target tenant + :type tenant_id: str + """ + log(f"Check roles for group {group['name']}.") + + group_roles = __extract_roles_from_group(group=group, tenant_id=tenant_id) + existing_group_roles = sorted(existing_group['roles'].split(",")) + log("Config group roles: ", group_roles) + + roles = existing_group_roles.copy() + missing_roles = [role for role in group_roles if role not in existing_group_roles] + additional_roles = [role for role in existing_group_roles if role not in group_roles] + + if group_roles == existing_group_roles: + log('Group roles match.') + else: + print("Existing group roles: ", existing_group_roles) + if missing_roles: + print("Missing roles: ", missing_roles) + action_allowed = check_or_ask_for_permission( + target_type='group', + action='add missing group roles', + target_name=group['name'], + tenant_id=tenant_id, + option_i=True + ) + if action_allowed == 'i': + for role in missing_roles: + action_allowed = get_yes_no_answer(f"Add role {role} to group {group['name']}?") + if action_allowed: + roles.append(role) + elif action_allowed: + for role in missing_roles: + roles.append(role) + + if additional_roles: + print("Additional roles: ", additional_roles) + action_allowed = check_or_ask_for_permission( + target_type='group', + action='remove additional group roles', + target_name=group['name'], + tenant_id=tenant_id, + option_i=True + ) + if action_allowed == 'i': + for role in additional_roles: + action_allowed = get_yes_no_answer(f"remove role {role} from group {group['name']}?") + if action_allowed: + roles.remove(role) + elif action_allowed: + for role in additional_roles: + roles.remove(role) + + if roles != existing_group_roles: + # roles = ",".join(list(dict.fromkeys(roles))) + roles = ",".join(roles) + update_group(tenant_id, group, overwrite_roles=roles) + + +def get_group(group: dict, tenant_id: str): + """ + Checks if the group exists on the specified tenant + :param group: The group as defined in the configuration file + :type group: dict + :param tenant_id: The target tenant + :type tenant_id: str + :return: The group as specified on the tenant if it exists or False + """ + log(f"check if group {group['name']} exists.") + + url = f"{CONFIG.tenant_urls[tenant_id]}/api/groups/{group['identifier']}" + try: + response = get_request(url, DIGEST_LOGIN, '/api/groups/') + return response.json() + except RequestError as err: + if err.get_status_code() == "404": + pass + else: + raise Exception + except Exception as e: + print("ERROR: ", str(e)) + return False + + +def create_group(group: dict, tenant_id: str): + """ + Sends a POST request to /api/groups/ to create a new group with the given parameter. + :param group: The group to be created (usually the one specified in the configuration file) + :type group: dict + :param tenant_id: The target tenant + :type tenant_id: str + :return: Returns the response if successful or False if the request failed + """ + log(f"trying to create group {group['name']}. ") + + url = f'{CONFIG.tenant_urls[tenant_id]}/api/groups/' + + # extract members and roles + members = __extract_members_from_group(group, tenant_id) + # check if member exist on tenant + for member in members: + if not get_user(username=member, tenant_id=tenant_id): + print(f"WARNING: Member {member} does not exist.") + members.remove(member) + members = ",".join(members) + roles = __extract_roles_from_group(group, tenant_id, as_string=True) + + data = { + 'name': group['name'], + 'description': __group_description_template(group['description'], tenant_id), + 'roles': roles, + 'members': members, + } + print(data) + try: + response = post_request(url, DIGEST_LOGIN, '/api/groups/', data=data) + except RequestError as err: + if err.get_status_code() == "400": + print(f"Bad Request: Group with name {group['name']} could not be created.") + elif err.get_status_code() == "409": + print(f"Conflict: Group with name {group['name']} could not be created.\n" + f"Potentially, Group with name {group['name']} already exists.") + print("RequestError: ", err) + return False + except Exception as e: + print(f"Group with name {group['name']} could not be created. \n", "Exception: ", str(e)) + return False + + log(f"created group {group['name']}.\nmembers: {members} \nroles: {roles} ") + return response + + +def update_group(tenant_id: str, group: dict, + overwrite_name=None, overwrite_description=None, overwrite_roles=None, overwrite_members=None): + """ + Updates the group on the tenant. + Either with the parameters defined in the group or with the specific parameters to individually overwrite them. + :param tenant_id: The target tenant + :type tenant_id: str + :param group: The group as specified in the configuration file + :type group: dict + :param overwrite_name: Optional name + :type overwrite_name: str or None + :param overwrite_description: Optional description + :type overwrite_description: str or None + :param overwrite_roles: Optional roles + :type overwrite_roles: str or None + :param overwrite_members: Optional members + :type overwrite_members: str or None + :return: Returns the response if successful or False if the request failed + """ + log(f"Trying to update group ... ") + + name = overwrite_name if overwrite_name else group['name'] + description = overwrite_description if overwrite_description else \ + __group_description_template(group['description'], tenant_id) + roles = overwrite_roles if overwrite_roles else \ + __extract_roles_from_group(group, tenant_id, as_string=True) + members = overwrite_members if overwrite_members else \ + __extract_members_from_group(group, tenant_id, as_string=True) + + url = f"{CONFIG.tenant_urls[tenant_id]}/api/groups/{group['identifier']}" + data = { + 'name': name, + 'description': description, + 'roles': roles, + 'members': members, + } + try: + response = put_request(url, DIGEST_LOGIN, '/api/groups/{groupId}', data=data) + except RequestError as err: + if err.get_status_code() == "404": + print(f"Bad Request: Group with name {name} does not exist.") + print("RequestError: ", err) + return False + except Exception as e: + print(f"Group with name {name} could not be updated. \n", "Exception: ", str(e)) + return False + + log(f"Updated group {name}.") + return response + + +def __generate_group_identifier(group: dict, tenant_id: str): + """ + generates the group identifier based on the group name + :param group: The group as specified in the configuration file + :type group: dict + :param tenant_id: The target tenant + :type tenant_id: str + :return: The group id: str + """ + identifier = group['name'].replace(' ', '_').lower() + return identifier + + +def __group_description_template(description: str, tenant_id: str): + """ + replaces placeholders for names in the group description + :param description: The group description with placeholders + :type description: str + :param tenant_id: The tenant id to be inserted into the description + :type tenant_id: str + :return: group description with the inserted name, str + """ + description = description.format(tenant_id=tenant_id) + return description + + +def __extract_members_from_group(group: dict, tenant_id: str, as_string=False): + """ + Parses the group configuration and extracts the tenant specific group members. + Does not check if a member exists on the tenant. + :param group: The group as specified in the configuration file + :type group: dict + :param tenant_id: The target tenant + :type tenant_id: str + :param as_string: Whether the roles should be returned as a string or a list + :type as_string: bool + :return: Comma separated string of members (e.g. "guy1,guy2") or list of members. + """ + members = [member['username'] for member in group['members'] if member['tenants'] in ['all', tenant_id]] + if as_string: + members = ",".join(sorted(members)) + return members + + +def __extract_roles_from_group(group: dict, tenant_id: str, as_string=False): + """ + Parses the group configuration and extracts the tenant specific group roles for a specific group. + :param group: The group as specified in the configuration file + :type group: dict + :param tenant_id: The target tenant + :type tenant_id: str + :param as_string: Whether the roles should be returned as a string or a list + :type as_string: bool + :return: Sorted comma separated list of roles (e.g. "ROLE_ADMIN,ROLE_SUDO" or ['ROLE_ADMIN', 'ROLE_SUDO'] ) + """ + roles = [] + for permission in group['permissions']: + # add all default roles + if permission['tenants'] == 'all': + for role in permission['roles']: + roles.append(role) + # add/remove tenant specific roles + elif permission['tenants'] == tenant_id: + for role in permission['roles']['add']: + roles.append(role) + for role in permission['roles']['remove']: + if role in roles: + roles.remove(role) + if as_string: + roles = ','.join(sorted(roles)) + return roles diff --git a/multi-tenant-configuration/configure_users.py b/multi-tenant-configuration/configure_users.py new file mode 100644 index 0000000..ff76917 --- /dev/null +++ b/multi-tenant-configuration/configure_users.py @@ -0,0 +1,387 @@ +from rest_requests.request import get_request, post_request, put_request +from rest_requests.request_error import RequestError +from args.basic_login import BasicLogin +from args.digest_login import DigestLogin +from input_output.logger import Logger +from input_output.input import get_yes_no_answer +from user_interaction import check_or_ask_for_permission + + +CONFIG = None +ENV_CONFIG = None +DIGEST_LOGIN = None + +UNEXPECTED_ROLES = ["ROLE_ADMIN", "ROLE_ADMIN_UI", "ROLE_UI_", "ROLE_CAPTURE_"] + + +def set_config_users(digest_login: DigestLogin, env_conf: dict, config: dict, logger: Logger): + """ + Sets/imports the global config variables. + must be called before any checks can be performed. + :param digest_login: The digest login to be used + :type digest_login: DigestLogin + :param env_conf: The environment configuration which specifies the user and system accounts + :type env_conf: dict + :param config: The script configuration + :type config: dict + :param logger: A Logger instance + :type logger: Logger + """ + + global DIGEST_LOGIN + global ENV_CONFIG + global CONFIG + global log + DIGEST_LOGIN = digest_login + ENV_CONFIG = env_conf + CONFIG = config + log = logger.log + + +def check_system_accounts(system_accounts, tenant_id): + """ + Performs checks on the system accounts (e.g. player, annotate, cast). + + :param system_accounts: The switchcast system accounts to be checked + :type system_accounts: dict + :param tenant_id: The target tenant + :type tenant_id: str + """ + # check switchcast system accounts + log(f"Start checking system accounts for tenant {tenant_id} ...") + for system_account in system_accounts: + __check_user(system_account, tenant_id) + + +def check_external_api_accounts(tenant): + """ + Performs checks on the external api accounts for the given tenant. + + :param tenant: The target tenant + :type tenant: dict + """ + # check and configure external api accounts + log(f"Start checking External API accounts for tenant {tenant['id']} ...") + for user in tenant['external_api_accounts']: + __check_user(user, tenant['id']) + + +def __check_user(user: dict, tenant_id: str): + """ + Performs all checks for the specified user: + - checks if user exists + - checks if user has API access (and if password matches) + - checks if the user roles match the roles in the config file + - checks if the user has unexpected roles (effective roles) + :param user: The user to be checked + :type user: dict + :param tenant_id: The target tenant + :type tenant_id: str + """ + log(f"Checking user {user['name']} on tenant {tenant_id}.") + + # Check if user exists + existing_user = get_user(username=user['username'], tenant_id=tenant_id) + if not existing_user: + # create user if it does not exist on tenant (Ask for permission) + action_allowed = check_or_ask_for_permission( + target_type='user', + action='create', + target_name=user['username'], + tenant_id=tenant_id + ) + if action_allowed: + create_user(account=user, tenant_id=tenant_id) + else: + # Check if password is correct and if the account has External API access. + __check_api_access(user=user, tenant_id=tenant_id) + # Check if the user roles match the roles in the configuration file. + __check_user_roles(user, existing_user, tenant_id) + # check for unexpected roles in the effective roles. + __check_effective_user_roles(user, tenant_id) + + +def __check_api_access(user: dict, tenant_id: str): + """ + Checks if the user defined in the config has access to the API. + The check tries to login with the username and password defined in the config, + and sends a get request to '/api/info/me' . + If check fails, asks for user permission to update user. + :param user: The user defined in the config + :type user: Dict + :param tenant_id: The target tenant + :type tenant_id: String + """ + log(f"Checking API access for user {user['username']}") + + url = f'{CONFIG.tenant_urls[tenant_id]}/api/info/me' + headers = {} + login = BasicLogin(user=user['username'], password=user['password']) + + try: + get_request(url, login, '/api/info/me', headers=headers, use_digest=False) + except RequestError: + print(f"User {user['username']} has no API Access") + action_allowed = check_or_ask_for_permission( + target_type='user', + action='configure user', + target_name=user['username'], + tenant_id=tenant_id + ) + if action_allowed: + update_user(tenant_id, user=user) + except Exception as e: + print('ERROR: Failed to check for API access.') + print(str(e)) + + +def __check_effective_user_roles(user: dict, tenant_id: str): + """ + Checks if the effective user roles of the user contain unexpected roles. + prints a warning for each unexpected role. + :param user: User containing the username for whom to retrieve the roles + :type user: Dict + :param tenant_id: The ID of the target tenant + :type tenant_id: String + """ + log(f"Check effective user roles of user {user['username']}") + + effective_user_roles = get_user_roles(user['username'], tenant_id) + for role in effective_user_roles: + for unexpected_role in UNEXPECTED_ROLES: + if unexpected_role in role: + print(f"WARNING: Unexpected role found for User {user['username']}: {role}") + + +def __check_user_roles(user: dict, existing_user: dict, tenant_id: str): + """ + Checks if the INTERNAL user roles match the user roles in the config file. + If check fails, asks for user permission to update user. + :param user: The user as defined in the config file + :type user: Dict + :param existing_user: The user as defined on the tenant + :type: Dict + :param tenant_id: The target tenant + :type: String + """ + log(f"Check user roles of user {user['username']}") + + existing_user_roles = extract_internal_user_roles(existing_user) + user_roles = user['roles'] + log('config roles: ', user_roles) + + roles = existing_user_roles.copy() + missing_roles = [role for role in user_roles if role not in existing_user_roles] + additional_roles = [role for role in existing_user_roles if role not in user_roles] + + if user_roles == existing_user_roles: + log('User roles match.') + else: + print("existing user roles: ", existing_user_roles) + if missing_roles: + print("Missing roles: ", missing_roles) + action_allowed = check_or_ask_for_permission( + target_type='user', + action='add missing user roles', + target_name=user['username'], + tenant_id=tenant_id, + option_i=True + ) + if action_allowed == 'i': + for role in missing_roles: + action_allowed = get_yes_no_answer(f"Add role {role} to user {user['name']}?") + if action_allowed: + roles.append(role) + elif action_allowed: + for role in missing_roles: + roles.append(role) + + if additional_roles: + print("Additional roles: ", additional_roles) + action_allowed = check_or_ask_for_permission( + target_type='user', + action='remove additional user roles', + target_name=user['username'], + tenant_id=tenant_id, + option_i=True + ) + if action_allowed == 'i': + for role in additional_roles: + action_allowed = get_yes_no_answer(f"Remove role {role} from user {user['name']}?") + if action_allowed: + roles.remove(role) + elif action_allowed: + for role in additional_roles: + roles.remove(role) + + if roles != existing_user_roles: + # roles = ",".join(roles) + update_user(tenant_id, user, overwrite_roles=roles) + + +def get_user(username: str, tenant_id: str): + """ + Sends a GET request to the admin UI to get a user + :param username: The username of the user on the tenant + :type username: String + :param tenant_id: The target tenant + :type tenant_id: String + :return: user as JSON + """ + url = f'{CONFIG.tenant_urls[tenant_id]}/admin-ng/users/{username}.json' + try: + response = get_request(url, DIGEST_LOGIN, '/admin-ng/users/{username}.json') + except RequestError as err: + if not err.get_status_code() == "404": + print(err) + return False + except Exception as e: + print(e) + return False + + return response.json() + + +def create_user(account: dict, tenant_id: str): + """ + sends a POST request to the admin UI to create a User + uses the /admin-ng/users/ endpoint + :param account: The user account to be created (e.g. {'username': 'Peter', 'password': '123'} + :type account: dict + :param tenant_id: The target tenant + :type tenant_id: String + :return: response + """ + log(f"Create user {account['username']}") + + url = f'{CONFIG.tenant_urls[tenant_id]}/admin-ng/users/' + data = { + 'username': account['username'], + 'password': account['password'], + 'name': account['name'], + 'email': account['email'], + 'roles': __get_roles_as_json_array(account, as_string=True) + } + + try: + response = post_request(url, DIGEST_LOGIN, '/admin-ng/users/', data=data) + except RequestError as err: + if err.get_status_code() == "409": + print(f"Conflict, a user with username {account['username']} already exist.") + elif err.get_status_code() == "403": + print("Forbidden, not enough permissions to create a user with a admin role.") + return False + except Exception as e: + print("User could not be created: ", str(e)) + return False + + return response + + +def update_user(tenant_id: str, user: dict, + overwrite_name=None, overwrite_email=None, overwrite_roles=None, overwrite_pw=None): + """ + Updates a user with the parameters provided in the user argument + if they are not overwritten by the optional parameters. + :param tenant_id: The target tenant + :type tenant_id: String + :param user: The user as defined in the config, including the username used to identify the user on the system + :param overwrite_name: Optional name to use instead + :type overwrite_name: String + :param overwrite_email: Optional email to use instead + :type overwrite_email: String + :param overwrite_roles: Optional roles to use instead + :type overwrite_roles: List + :param overwrite_pw: Optional password to use instead + :type overwrite_pw: String + :return: response + """ + log(f"Trying to update user ... ") + + name = overwrite_name if overwrite_name else user['name'] + email = overwrite_email if overwrite_email else user['email'] + roles = overwrite_roles if overwrite_roles else user['roles'] + pw = overwrite_pw if overwrite_pw else user['password'] + # if not isinstance(roles, list): # in case only one role is given, make sure roles is a list + # roles = [roles] + # in case only one role is given, make sure roles is a list + roles = roles if isinstance(roles, list) else [roles] + roles = __get_roles_as_json_array(account={'roles': roles}, as_string=True) + + url = f"{CONFIG.tenant_urls[tenant_id]}/admin-ng/users/{user['username']}.json" + data = { + 'password': pw, + 'name': name, + 'email': email, + 'roles': roles + } + try: + response = put_request(url, DIGEST_LOGIN, '/admin-ng/users/{username}.json', data=data) + except RequestError as err: + print("RequestError: ", err) + if err.get_status_code() == "400": + print(f"Bad Request: Invalid data provided.") + return False + except Exception as e: + print(f"User with name {name} could not be updated. \n", "Exception: ", str(e)) + return False + + log(f"Updated user {name}.") + return response + + +def get_user_roles(user_name: str, tenant_id: str): + """ + returns the effective roles of a user (user roles + group roles). + Uses DigestLogin. + :param user_name: The username of the user on the tenant + :type user_name: String + :param tenant_id: The traget tenant + :type tenant_id: String + :return: The roles as dict + """ + url = f'{CONFIG.tenant_urls[tenant_id]}/api/info/me/roles' + headers = {'X-RUN-AS-USER': user_name} + try: + response = get_request(url, DIGEST_LOGIN, '/api/info/me/roles', headers=headers) + except Exception as e: + print(e) + return False + + return response.json() + + +def extract_internal_user_roles(user: dict, as_string=False): + """ + Extracts the INTERNAL user roles from a user on the tenant. + :param user: The user as defined on the tenant + :type user: dict + :param as_string: Whether the roles should be returned as a string + :type as_string: bool + :return: roles, as list or string + """ + roles = [] + for role in user['roles']: + if role['type'] == 'INTERNAL': + roles.append(role['name']) + if as_string: + roles = ",".join(sorted(roles)) + + return roles + + +def __get_roles_as_json_array(account: dict, as_string=False): + """ + Returns the roles of a user account in json format either as a dict or as a string + :param account: User account as defined in the config file + :type account: dict + :param as_string: If the roles should be returned as json string or json object + :type as_string: bool + :return: The roles in json format + """ + roles = [{'name': role, 'type': 'INTERNAL'} for role in account['roles']] + if as_string: + roles = [str(role) for role in roles] + roles = '[' + ','.join(roles) + ']' + + return roles diff --git a/multi-tenant-configuration/main.py b/multi-tenant-configuration/main.py new file mode 100644 index 0000000..744a06c --- /dev/null +++ b/multi-tenant-configuration/main.py @@ -0,0 +1,62 @@ +import os +import sys +sys.path.append(os.path.join(os.path.abspath('..'), "lib")) + +from args.digest_login import DigestLogin +from input_output.logger import Logger +from input_output.yaml_utils import read_yaml_file +from rest_requests.basic_requests import get_tenants +from parse_arguments import parse_args +from configure_users import check_external_api_accounts, check_system_accounts, set_config_users +from configure_groups import check_groups, set_config_groups +from configure_capture_accounts import check_capture_accounts, set_config_capture_accounts +import config + + +def main(): + + ### Parse args and config ### + digest_login = DigestLogin(user=config.digest_user, password=config.digest_pw) + environment, tenant_to_check, check, verbose = parse_args() + logger = Logger(verbose) + # parse script config + config.tenant_ids = get_tenants(config.server_url, digest_login) + for ignored_tenant in config.ignored_tenants: + config.tenant_ids.remove(ignored_tenant) + if not hasattr(config, 'tenant_urls'): + config.tenant_urls = {} + for tenant_id in config.tenant_ids: + if not tenant_id in config.tenant_urls: + config.tenant_urls[tenant_id] = config.tenant_url_pattern.format(tenant_id) + # read and parse organization config + org_conf = read_yaml_file(config.org_config_path.format(environment)) + opencast_organizations = {} + for organization in org_conf['opencast_organizations']: + opencast_organizations[organization['id']] = organization + # read group config + group_config = read_yaml_file(config.group_config_path) + # import config to scripts + set_config_users(digest_login, org_conf, config, logger) + set_config_groups(digest_login, group_config, config, logger) + set_config_capture_accounts(org_conf, config, logger) + + # if tenant is not given, we perform the checks for all tenants + tenants_to_check = [tenant_to_check] if tenant_to_check else config.tenant_ids + + ### Start checks ### + for tenant_id in tenants_to_check: + if check == 'users' or check == 'all': + check_system_accounts(opencast_organizations['All Tenants']['switchcast_system_accounts'], tenant_id) + check_external_api_accounts(opencast_organizations[tenant_id]) + if check == 'groups' or check == 'all': + check_groups(tenant_id) + if check == 'capture' or check == 'all': + check_capture_accounts(opencast_organizations[tenant_id]) + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print("\nAborting process.") + sys.exit(0) diff --git a/multi-tenant-configuration/parse_arguments.py b/multi-tenant-configuration/parse_arguments.py new file mode 100644 index 0000000..ac2caa5 --- /dev/null +++ b/multi-tenant-configuration/parse_arguments.py @@ -0,0 +1,39 @@ +from args.args_parser import get_args_parser +from args.args_error import args_error + + +def parse_args(): + """ + Parses the arguments and check them for correctness + :return: the environment, the tenant_id, the check + :rtype: triple + """ + parser, optional_args, required_args = get_args_parser() + + required_args.add_argument("-e", "--environment", type=str, nargs='+', + help="the environment (either 'staging' or 'production')") + optional_args.add_argument("-t", "--tenant-id", type=str, nargs='+', help="target tenant id") + optional_args.add_argument("-c", "--check", type=str, nargs='+', + help="checks to be performed ('users', 'groups', 'cast' or 'capture') (default: all)") + optional_args.add_argument("-v", "--verbose", type=str, nargs='+', help="enables more logging") + + args = parser.parse_args() + + if not args.environment: + args_error(parser, "You have to provide an environment. Either 'staging' or 'production'") + if not args.environment[0] in ('staging', 'production'): + args_error(parser, "The environment has to be either 'staging' or 'production'") + if len(args.environment) > 1: + args_error(parser, "You can only provide one environment. Either 'staging' or 'production'") + + if not args.tenant_id: + args.tenant_id = [''] + + if not args.check: + args.check = ['all'] + elif args.check[0] not in ['users', 'groups', 'capture']: + args_error(parser, "The check should be 'users', 'groups' or 'capture'") + + verbose = True if args.verbose else False + + return args.environment[0], args.tenant_id[0], args.check[0], verbose diff --git a/multi-tenant-configuration/requirements.txt b/multi-tenant-configuration/requirements.txt new file mode 100644 index 0000000..a4e02d8 --- /dev/null +++ b/multi-tenant-configuration/requirements.txt @@ -0,0 +1,7 @@ +certifi==2020.11.8 +chardet==3.0.4 +idna==2.10 +requests==2.25.0 +requests-toolbelt==0.9.1 +urllib3==1.26.2 +pyyaml==5.3.1 diff --git a/multi-tenant-configuration/user_interaction.py b/multi-tenant-configuration/user_interaction.py new file mode 100644 index 0000000..0165324 --- /dev/null +++ b/multi-tenant-configuration/user_interaction.py @@ -0,0 +1,176 @@ +import re + + +permissions = { + 'user': {}, + 'group': {} +} +ANSWER_PATTERN = r"^[yn]$|^[yn][ta][ta]$" +HELP_OPTION = 'h' + + +def check_or_ask_for_permission(target_type, action, target_name, tenant_id, option_i=False) -> bool: + """ + Check if a permission for the action was already given or asks for permission. + + :param target_type: The target for the action (either 'user' or 'group') + :type target_type: str + :param action: The action to be performed + :type action: str + :param target_name: The group or user name + :type target_name: str + :param tenant_id: The target tenant + :type tenant_id: str + :param option_i: Whether the user has the option to perform the action iteratively. + :type option_i: bool + :return: bool, whether the action should be performed. + """ + + # check if permission is already defined + permission = get_permission(target_type, action, target_name, tenant_id) + if permission is None: + # otherwise ask for user input ... + answer = ask_user(target_type, action, target_name, tenant_id, option_i) + # ... and process answer and update permissions + permission = process_answer(answer, target_type, action, target_name, tenant_id, option_i) + + return permission + + +def get_permission(target_type, action, target_name, tenant_id) -> bool: + """ + Returns the permission for the given action. + If no permission value is found, None is returned + + :param target_type: The target for the action (either 'user' or 'group') + :type target_type: str + :param action: The action to be performed + :type action: str + :param target_name: The group or user name + :type target_name: str + :param tenant_id: The target tenant + :type tenant_id: str + :return: bool or None, the permission value + """ + + key = __build_key(action, tenant_id, target_name) + if key in permissions[target_type].keys(): + return permissions[target_type][key] + key = __build_key(action, tenant='all', target=target_name) + if key in permissions[target_type].keys(): + return permissions[target_type][key] + key = __build_key(action, tenant=tenant_id, target='all') + if key in permissions[target_type].keys(): + return permissions[target_type][key] + key = __build_key(action, tenant='all', target='all') + if key in permissions[target_type].keys(): + return permissions[target_type][key] + + return None + + +def ask_user(target_type, action, target_name, tenant_id, option_i=False) -> str: + """ + Asks the user for permission to perform a certain action. + Returns the answer. + The answer can be stored if the user specifies if the answer holds for: + - all tenants, or + - all targets (i.e. all groups or all ), or + - both. + This can done by adding 'a' or 't' to the answer. + For example: + 'yat' corresponds to 'Yes, always do this action on all tenants for this target'. + + :param target_type: The target for the action (either 'user' or 'group') + :type target_type: str + :param action: The action in question + :type action: str + :param target_name: The group or user name + :type target_name: str + :param tenant_id: The target tenant + :type tenant_id: str + :param option_i: Whether the user has the option to answer with i. + :type option_i: bool + :return: str, the answer of the user + """ + + individual_option = "\nWrite 'i' to perform the action individually for each case. " if option_i else "" + + help_description = f"""Write 'y' to perform the action. Write 'n' to skipp this action. {individual_option} +Add 't' for 'tenant' or 'a' for 'all' to store your decision for this or all tenants. +Add 't' for 'target' or 'a' for 'all' to store your decision for this or all targets. +EXAMPLE: Write 'yat' to store your decision for the action on ALL tenants and for THIS target. +""" + + question = f"Do you want to {action} ({target_type} {target_name} on {tenant_id})? Write '{HELP_OPTION}' for help.\n" + + # ask the question + answer = input(question).lower() + while True: + # catch the help option: give a more detailed description of the options + if answer == HELP_OPTION: + answer = input(help_description) + # return all valid answers + elif __parsable(answer, option_i): + return answer + # catch all invalid answers + else: + answer = input(f"Invalid answer. Write '{HELP_OPTION}' for help.\n").lower() + + +def process_answer(answer, target_type, action, target_name, tenant_id, option_i) -> bool: + """ + Processes an answer and, if specified, stores it as a permission. + Returns a boolean, whether the action should be performed. + + :param target_type: The target for the action (either 'user' or 'group') + :type target_type: str + :param action: The action to be performed + :type action: str + :param target_name: The group or user name + :type target_name: str + :param tenant_id: The target tenant + :type tenant_id: str + :param option_i: Whether the user has the option to perform the action iteratively. + :type option_i: bool + :return: bool, whether the action should be performed. + """ + + # simple yes or no case (not stored) + if answer == 'y': + return True + if answer == 'n': + return False + # individual case + if option_i and answer == 'i': + return 'i' + + # store answer if user specified this + permission_value = True if answer.startswith('y') else False + tenant = 'all' if answer[1] == 'a' else tenant_id + target = 'all' if answer[2] == 'a' else target_name + key = __build_key(action, tenant, target) + permissions[target_type][key] = permission_value + + return permission_value + + +def __parsable(answer, option_i=False) -> bool: + """ + Checks if an answer is parsable, i.e. matches the answer pattern. + + :param answer: The answer given by the user + :type answer: str + :param option_i: Whether 'i' is an acceptable answer + :type option_i: str + :return: bool, whether the answer is parsable + """ + + return re.match(ANSWER_PATTERN, answer) or answer == HELP_OPTION or (option_i and answer == 'i') + + +def __build_key(action, tenant, target): + """ + Builds the key to store the permission in the dictionary. + """ + return action + ':' + tenant + ':' + target