diff --git a/.gitignore b/.gitignore index ad5e9ac..c5ab16f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /venv/ -/bastion_safepost.egg-info/ +/*.egg-info/ /build/ /dist/ /.pypirc diff --git a/CHANGELOG.md b/CHANGELOG.md index 313f3cb..6cc1666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,81 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.8] - 2024-10-01 +### Changes +- Added a function to get user's safes +- Added list members linked function +- Added the connection_component_by_platform function in utilities +- Changed the return of connection_component_usage from string to list of dict +- Adding PrivilegedAccount object directly in epv class to allow user to import it + +### Bugfixes +- Added a semaphore in the move function to avoid mass creation before mass deletion (we ensure that max 50 accounts are created before the old accounts are deleted) + +## [0.1.7] - 2024-08-01 +### Changes +Configutation file changes: + - Existing section + - "AIM" section + - Added "passphrase" field in "AIM" section to handle AIM PEM encrypted certificate. + + - New section: + - "accounts" section defined the EPV.account option fields: + handle "LOGON_ACCOUNT_INDEX" and "RECONCILE_ACCOUNT_INDEX" definition. + Those definition has been moved from the 'custom' section. + + - "safe" section defined the EPV.safe option fields: + handle "cpm" and "retention" definition. + Those definition has been moved from the global section. + + - "api_options" section defined the new global API options. + - "deprecated_warning" field to enable/disable deprecated warning (default is true = enable) + +Serialization changes: + - Existing keys: + - "AIM" (dictionary) + - Added "passphrase" field in "AIM" section to handle AIM encrypted certificate. + - Default value for "verify" field is now "True" (ROOT certificate authority (CA) will be validated by default). + + - New keys dictionary in serialization: + - "account" key (dictionary): + - "LOGON_ACCOUNT_INDEX" and "RECONCILE_ACCOUNT_INDEX" fields. + - "safe" key (dictionary): + - "cpm" and "retention" fields. + - "api_options" key (dictionary): + - "deprecated_warning" field. + + - keys moved: + - from the global section to "account" + - "LOGON_ACCOUNT_INDEX" and "RECONCILE_ACCOUNT_INDEX" fields. + - from the "custom" section to "safe" + - "cpm" and "retention" fields. + + +Functions changes: + - Safe class: + - Added an "update" function + + - EPV class: + - "login_with_aim" function has been modified to force some parameters to be specified in the form key=value. + For example: epv_env.login_with_aim(passphrase="Hello123Word!") + - "root_ca" parameter is now deprecated, you should use the "verify" parameter. + + - "to_json" now return a dictionary for keys: "safe", "account" and "api_options". + +raise exception changes: + - new exception "CyberarkNotFoundException" will be raise for a http error 404 (page not found) instead of "CyberarkException". + +Internal modification for a better management for file configuration and serialization mainly in config.py and cyberark.py. + +### Bugfixes +- Fixed a bug where multiple file category where not all updated in some conditions + ## [0.1.6] - 2024-03-14 ### Bugfixes - add keep_cookies to serialized aim fields + ## [0.1.5] - 2024-03-08 ### Bugfixes - Tests were not all functional @@ -34,7 +105,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.1] - 2024-02-03 ### Changes - Add "get_safe_details" method -- Add support for "custom" configs to override the default logon and reconcile account index +- Add support for "custom" configs to override the default logon and reconcile account index. + **DO NOT USE, deprecated in 0.1.7**. - Add support to retain cookies during login, and use for subsequent API calls for load-balanced PVWAs. ## [0.1.0] - 2024-01-26 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 252ce7d..9ac6011 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ If you wish to contribute with code the workflow is : ## Test - In order to test you need a working Vault and a PVWA. -- Then, generate some accounts with mockaroo and the following schemas : https://www.mockaroo.com/b41fedb0. See "Troubleshoot" +- Then, generate some accounts with mockaroo and the following schemas : https://www.mockaroo.com/1e429890. See "Troubleshoot" section of some cleanup to avoid issues. - Create the associated safes : sample-it-dept,sample-iaadmins,sample-coolteam - Create safe "RENAME_ME", and grant user "admin_bot" (see below) to the "Safe Management" permissions (for safe @@ -48,6 +48,9 @@ If you wish to contribute with code the workflow is : You need an API user, such as **"admin_bot"**, to run the testing. This account is similar to Administrator for permissions, but you can't use "Administrator" itself (you will get "PASWS291E You cannot perform this task with an Administrator user. Log on with a different user and try again" error) + +The username of the API user you chose must be set in tests/__init__.py (and then just don't commit this file please.) + * In Private Ark Client: * Add to "Vault Admins" and "PVWAUsers" groups * Give "Add Safes, Audit Users, Add/Update Users, Reset Users' Passwords, Activate Users" authorizations rules. @@ -95,7 +98,6 @@ backend my_backend logs off. * `PASWS032E Platform [Oracle] is not active`: The Oracle platform is not activated. - ## Update documentation If your commit has an impact on documentation, please don't forget to update it accordingly. \ No newline at end of file diff --git a/aiobastion/accountgroup.py b/aiobastion/accountgroup.py index 8bdfffe..7e8a035 100644 --- a/aiobastion/accountgroup.py +++ b/aiobastion/accountgroup.py @@ -1,9 +1,8 @@ -import logging import re -import warnings +from typing import Union, List from .accounts import PrivilegedAccount -from .exceptions import AiobastionException, CyberarkAPIException +from aiobastion.exceptions import AiobastionException, CyberarkAPIException, AiobastionConfigurationException, CyberarkNotFoundException class PrivilegedAccountGroup: @@ -14,7 +13,7 @@ def __init__(self, GroupName: str, GroupPlatformID: str, Safe: str, GroupID: str self.safe = Safe # ready to add json representation - def to_json(self): + def to_json(self) -> dict: json_object = { "GroupName": self.name, "GroupPlatformID": self.group_platform, @@ -22,46 +21,60 @@ def to_json(self): } return json_object - def __str__(self): + def __str__(self) -> str: return f"id : {self.id}, name: {self.name}, group_platform: {self.group_platform}, safe: {self.safe}" class AccountGroup: - def __init__(self, epv): + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): + """ + AccountGroup Account group management + """ self.epv = epv + _section = "accountgroup" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException( + f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}" + ) + + def to_json(self) -> dict: + return {attr_name: getattr(self, attr_name) for attr_name in AccountGroup._SERIALIZED_FIELDS if getattr(self, attr_name, None) is not None} + # Account groups - async def list_by_safe(self, safe_name: str): + async def list_by_safe(self, safe_name: str) -> List[PrivilegedAccountGroup]: """ List all groups for a given safe :param safe_name: name of the safe :return: a list of PrivilegedAccountGroups """ - params = { - "Safe": safe_name - } + params = {"Safe": safe_name} groups = await self.epv.handle_request("get", "api/AccountGroups", params=params) return [PrivilegedAccountGroup(**g) for g in groups] - async def get_privileged_account_group_id(self, account_group: PrivilegedAccountGroup): + async def get_privileged_account_group_id(self, account_group: PrivilegedAccountGroup) -> str: """ Internal function to get the group ID in functions :param account_group: PrivilegedAccountGroup object :return: group ID """ - if account_group.id == "": + if not account_group.id: acc = await self.list_by_safe(account_group.safe) for a in acc: if a.name == account_group.name: return a.id raise AiobastionException(f"No ID found for group {account_group.name}") - else: - return account_group.id + return account_group.id - async def get_account_group_id(self, group_name: str, safe: str): + async def get_account_group_id(self, group_name: str, safe: str) -> str: """ Get account_group_id with the group_name and the safe @@ -73,25 +86,22 @@ async def get_account_group_id(self, group_name: str, safe: str): for _a in ais: if _a.name.lower() == group_name.lower(): return _a.id - raise AiobastionException(f"Group {group_name} not found in {safe}") - async def get_group_id(self, account_group): + async def get_group_id(self, account_group) -> str: """ Internal function to get group_id from object or from group_id :param account_group: PrivilegedAccountGroup object or group_id :return: group_id """ - if type(account_group) is str: - if re.match(r'\d+_\d+', account_group) is not None: + if isinstance(account_group, str): + if re.match(r'\d+_\d+', account_group): return account_group - else: - raise AiobastionException("The account_group_id provided is not correct") + raise AiobastionException("The account_group_id provided is not correct") if isinstance(account_group, PrivilegedAccountGroup): return await self.get_privileged_account_group_id(account_group) - else: - raise AiobastionException("You must provide a valid PrivilegedAccount to function get_account_id") + raise AiobastionException("You must provide a valid PrivilegedAccount to function get_account_id") async def members(self, group): """ @@ -132,10 +142,13 @@ async def add_privileged_account_group(self, account_group: PrivilegedAccountGro """ if not await self.epv.safe.exists(account_group.safe): raise AiobastionException(f"Safe {account_group.safe} does not exists") - return await self.epv.handle_request("post", "api/AccountGroups", data=account_group.to_json(), + try: + await self.epv.handle_request("post", "api/AccountGroups", data=account_group.to_json(), filter_func=lambda x: x['GroupID']) + except CyberarkNotFoundException as err: + raise CyberarkNotFoundException(f"Privileged Account group's platform \"{account_group.group_platform}\" not found") - async def add_member(self, account: (PrivilegedAccount, str), group: (PrivilegedAccountGroup, str)): + async def add_member(self, account: Union[PrivilegedAccount, str], group: Union[PrivilegedAccountGroup, str]): """ Add accounts to a group (specified by PrivilegedAccountGroup object or group_id) @@ -151,7 +164,7 @@ async def add_member(self, account: (PrivilegedAccount, str), group: (Privileged } return await self.epv.handle_request("post", f"api/AccountGroups/{group_id}/Members", data=data) - async def delete_member(self, account: (PrivilegedAccount, str), group: (PrivilegedAccountGroup, str)): + async def delete_member(self, account: Union[PrivilegedAccount, str], group: Union[PrivilegedAccountGroup, str]): """ Delete the member of an account group @@ -189,9 +202,9 @@ async def move_account_group(self, account_group_name: str, src_safe: str, dst_s if account_group.name.lower() == account_group_name.lower(): try: - logging.debug(f"Creating {account_group} to {dst_safe}") + self.epv.logger.debug(f"Creating {account_group} to {dst_safe}") new_group_id = await self.add(account_group.name, account_group.group_platform, dst_safe) - logging.debug(f"Newly created group ID : {new_group_id}") + self.epv.logger.debug(f"Newly created group ID : {new_group_id}") except CyberarkAPIException as err: if "EPVPA012E" in err.err_message: @@ -200,23 +213,20 @@ async def move_account_group(self, account_group_name: str, src_safe: str, dst_s self.epv.logger.debug(f"Warning : AG already exists and detected with ID : {new_group_id}") else: raise - except Exception as err: - # Unhandled exception - raise ag_members = await self.epv.accountgroup.members(account_group) - # Moving accounts try: moved_accounts = await self.epv.account.move(ag_members, dst_safe) except CyberarkAPIException as err: raise - logging.debug("Accounts moved !") + + self.epv.logger.debug("Accounts moved !") for agm in moved_accounts: try: await self.add_member(agm, new_group_id) - logging.debug(f"Moved {agm} into {new_group_id}") + self.epv.logger.debug(f"Moved {agm} into {new_group_id}") except: # Account are moved with their account group pass @@ -241,9 +251,9 @@ def _case_insensitive_getattr(obj, attr): account_groups = await self.list_by_safe(src_safe) for ag in account_groups: - logging.debug(f"Current AG is {ag}") + self.epv.logger.debug(f"Current AG is {ag}") ag_members = (await self.members(ag)) - logging.debug(ag_members) + self.epv.logger.debug(ag_members) if account_filter is not None: filtered = False for a in ag_members: @@ -256,7 +266,7 @@ def _case_insensitive_getattr(obj, attr): raise AiobastionException(f"Your filter doesn't exist on account {a} " f"(bad file category ? {filter_file_category})") if filtered: - logging.debug("Account group skipped ....") + self.epv.logger.debug("Account group skipped ....") continue try: diff --git a/aiobastion/accounts.py b/aiobastion/accounts.py index d7b472b..9218f7f 100644 --- a/aiobastion/accounts.py +++ b/aiobastion/accounts.py @@ -5,9 +5,9 @@ import aiohttp -from .config import validate_ip, flatten +from .config import validate_ip, flatten, validate_integer from .exceptions import ( - CyberarkAPIException, CyberarkException, AiobastionException, CyberarkAIMnotFound + CyberarkAPIException, CyberarkException, AiobastionException, CyberarkAIMnotFound, AiobastionConfigurationException ) BASE_FILECATEGORY = ("platformId", "userName", "address", "name") @@ -197,8 +197,47 @@ class Account: """ Utility class to handle account manipulation """ - def __init__(self, epv): + _ACCOUNT_DEFAULT_LOGON_ACCOUNT_INDEX = 2 + _ACCOUNT_DEFAULT_RECONCILE_ACCOUNT_INDEX = 3 + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = ["logon_account_index", + "reconcile_account_index"] + + def __init__(self, epv, logon_account_index: int = None, reconcile_account_index: int = None, **kwargs): self.epv = epv + _section = "account" + _config_source = self.epv.config.config_source + + # string conversion to int or assign default value + self.logon_account_index = validate_integer(_config_source, f"{_section}/{logon_account_index}", + logon_account_index, Account._ACCOUNT_DEFAULT_LOGON_ACCOUNT_INDEX) + self.reconcile_account_index = validate_integer(_config_source, f"{_section}/{reconcile_account_index}", + reconcile_account_index, Account._ACCOUNT_DEFAULT_RECONCILE_ACCOUNT_INDEX) + + # Validation + if not (1 <= self.logon_account_index <= 3): + raise AiobastionConfigurationException(f"Invalid value for '{_section}/logon_account_index' in " + f"{_config_source} (expected 1 to 3): {self.logon_account_index!r}") + if not (1 <= self.reconcile_account_index <= 3): + raise AiobastionConfigurationException(f"Invalid value for '{_section}/reconcile_account_index' in " + f"{_config_source} (expected 1 to 3): {self.reconcile_account_index!r}") + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + + def to_json(self): + serialized = {} + + for attr_name in Account._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized async def _handle_acc_list(self, api_call, account, *args, **kwargs): """ @@ -361,7 +400,7 @@ async def link_logon_account(self, account: Union[PrivilegedAccount, List[Privil :return: A boolean that indicates if the operation was successful. :raises CyberarkException: If link failed """ - return await self.link_account(account, logon_account, self.epv.LOGON_ACCOUNT_INDEX) + return await self.link_account(account, logon_account, self.logon_account_index) async def link_reconcile_account_by_address(self, acc_username, rec_acc_username, address): """ This function links the account with the given username and address to the reconciliation account with @@ -397,7 +436,7 @@ async def remove_reconcile_account(self, account: Union[PrivilegedAccount, List[ """ | This function unlinks the reconciliation account of the given account (or the list of accounts) | ⚠️ The "reconcile" Account is supposed to have an index of 3 - | You can change it by setting custom:RECONCILE_ACCOUNT_INDEX in your config file + | You can change it by setting "account" section field "reconcile_account_index" in your config file :param account: a PrivilegedAccount object or a list of PrivilegedAccount objects @@ -405,20 +444,20 @@ async def remove_reconcile_account(self, account: Union[PrivilegedAccount, List[ :return: A boolean that indicates if the operation was successful. :raises CyberarkException: If link failed: """ - return await self.unlink_account(account, self.epv.RECONCILE_ACCOUNT_INDEX) + return await self.unlink_account(account, self.reconcile_account_index) async def remove_logon_account(self, account: Union[PrivilegedAccount, List[PrivilegedAccount]]): """ | This function unlinks the logon account of the given account (or the list of accounts) | ⚠️ The "logon" Account index is default to 2 but can be set differently on the platform - | You can change it by setting custom:LOGON_ACCOUNT_INDEX in your config file + | You can change it by setting "account" section field "logon_account_index" in your config file :param account: a PrivilegedAccount object or a list of PrivilegedAccount objects :type account: PrivilegedAccount, list :return: A boolean that indicates if the operation was successful. :raises CyberarkException: If link failed: """ - return await self.unlink_account(account, self.epv.LOGON_ACCOUNT_INDEX) + return await self.unlink_account(account, self.logon_account_index) async def unlink_account(self, account: Union[PrivilegedAccount, List[PrivilegedAccount]], extra_password_index: int): @@ -805,6 +844,8 @@ async def update_using_list(self, account, data) -> Union[PrivilegedAccount, Lis :return: The updated PrivilegedAccount or the list of updated PrivilegedAccount """ + self.epv.logger.debug(f"Going to patch {await self.get_account_id(account)} with {data}") + updated_accounts = await self._handle_acc_id_list( "patch", lambda account_id: f"API/Accounts/{account_id}", @@ -830,7 +871,25 @@ def detect_fc_path(self, fc: str): elif fc in SECRET_MANAGEMENT_FILECATEGORY: return "/secretmanagement/" else: - return "/platformaccountproperties/" + return "/platformAccountProperties/" + + async def delete_fc(self, account, file_category): + """ + Delete the File Category + The path of the file_category is (hopefully) automatically detected + You can't delete a mandatory File Category + + :param account: address, list of accounts, account_id, list of accounts id + :param file_category: the File Category to delete + :return: The updated PrivilegedAccount or the list of updated PrivilegedAccount + :raises AiobastionException: if the FC was not found in the Vault + :raises CyberarkAPIException: if another error occured + """ + data = [{"path": f"{self.detect_fc_path(file_category)}{file_category}", "op": "remove"}] + try: + return await self.update_using_list(account, data) + except CyberarkAPIException as err: + raise async def update_single_fc(self, account, file_category, new_value, operation="replace"): """ @@ -839,15 +898,20 @@ async def update_single_fc(self, account, file_category, new_value, operation=" :param account: address, list of accounts, account_id, list of accounts id :param file_category: the File Category to update - :param new_value: The new value of the FC + :param new_value: The new value of the FC, or None if you want to delete the FC :param operation: Replace, Remove or Add :return: The updated PrivilegedAccount or the list of updated PrivilegedAccount :raises AiobastionException: if the FC was not found in the Vault :raises CyberarkAPIException: if another error occured """ + + if new_value is None: + operation = "remove" + # if we "add" and FC exists it will replace it data = [{"path": f"{self.detect_fc_path(file_category)}{file_category}", "op": operation, "value": new_value}] try: + # self.epv.logger.debug(f"Data : {data}") return await self.update_using_list(account, data) except CyberarkAPIException as err: if err.err_code == "PASWS164E" and operation == "replace": @@ -878,15 +942,26 @@ async def update_file_category(self, account, file_category, new_value): assert len(file_category) == len(new_value) except AssertionError: raise AiobastionException("You must provide the same list size for file_category and values") - for f,n in zip(file_category, new_value): - # we trust user and don't check if FC is defined at platform level - data.append({"path": f"{self.detect_fc_path(f)}{f}", "op": "add", "value": n}) + + for f, n in zip(file_category, new_value): + # self.epv.logger.debug(f"Detected path for {f}: {self.detect_fc_path(f)}") + if self.detect_fc_path(f) != "/": + found = False + for _u in data: + if _u["path"] == self.detect_fc_path(f): + _u["value"][f] = n + found = True + if not found: + data.append({"path": self.detect_fc_path(f), "op": "replace", "value": {f: n}}) + else: + # we trust user and don't check if FC is defined at platform level + data.append({"path": f"{self.detect_fc_path(f)}{f}", "op": "add", "value": n}) else: data.append({"path": f"{self.detect_fc_path(file_category)}{file_category}", "op": "add", "value": new_value}) + # self.epv.logger.debug(f"Updating {account.id} with {data}") return await self.update_using_list(account, data) - async def restore_last_cpm_version(self, account: PrivilegedAccount, cpm): """ Find in the history of passwords the last password set by the CPM and updates the password accordingly @@ -962,7 +1037,7 @@ async def get_password(self, account: Union[PrivilegedAccount, str, List[Privile "post", lambda account_id: f"API/Accounts/{account_id}/Password/Retrieve", await self.get_account_id(account), - data = data + data=data ) @@ -1245,25 +1320,29 @@ async def move(self, account: Union[PrivilegedAccount, List[PrivilegedAccount]], :param new_safe: New safe to move the account(s) into :return: Boolean that indicates if the operation was successful """ - async def _move(acc): - self.epv.logger.debug(f"Now trying to move {acc} to {new_safe}") - old_id = acc.id - acc.safeName = new_safe - try: - acc.secret = await self.get_password(acc) - except CyberarkAPIException as err: - raise CyberarkException(f"Unable to recover {acc.name} password : {str(err)}") - try: - new_account_id = await self.add_account_to_safe(acc) - except CyberarkAPIException as err: - raise CyberarkException(f"Unable to create {acc.name} new address : {str(err)}") - try: - await self.delete(old_id) - except CyberarkAPIException as err: - raise CyberarkException(f"Unable to delete {acc.name} old address : {str(err)}") - return new_account_id + async def _move(acc, _new_safe, semaphore): + async with semaphore: + self.epv.logger.debug(f"Now trying to move {acc} to {_new_safe}") + old_id = acc.id + acc.safeName = _new_safe + try: + acc.secret = await self.get_password(acc) + except CyberarkAPIException as err: + raise CyberarkException(f"Unable to recover {acc.name} password : {str(err)}") + try: + new_account_id = await self.add_account_to_safe(acc) + except CyberarkAPIException as err: + raise CyberarkException(f"Unable to create {acc.name} new address : {str(err)}") + try: + await self.delete(old_id) + except CyberarkAPIException as err: + raise CyberarkException(f"Unable to delete {acc.name} old address : {str(err)}") + return new_account_id - return await self._handle_acc_list(_move, account) + # Packets of 50 to avoid many duplicates + # Without Semaphore we create all accounts before deleting all accounts + sem = asyncio.Semaphore(50) + return await self._handle_acc_list(_move, account, new_safe, sem) # AIM get secret function async def get_secret_aim(self, account: Union[PrivilegedAccount, List[PrivilegedAccount]], reason: str = None): @@ -1317,8 +1396,8 @@ async def get_password_aim(self, **kwargs): | Retrieve secret password from the Central Credential Provider (**AIM**) GetPassword Web Service information. - ℹ️ The following parameters are optional searchable keys, see CyberArk documentation in - Developer/Central Credential Provider/Call the Central Credential Provider Web Service .../REST + | ℹ️ The following parameters are optional searchable keys. Refer to + `CyberArk Central Credential Provider - REST web service`. :param username: User account name :param safe: Safe where the account is stored. diff --git a/aiobastion/aim.py b/aiobastion/aim.py index 15f5a6c..022eb17 100644 --- a/aiobastion/aim.py +++ b/aiobastion/aim.py @@ -5,13 +5,14 @@ import ssl from collections import namedtuple from http import HTTPStatus -from typing import Union, Tuple +from typing import Union, Tuple, Optional import aiohttp from aiohttp import ContentTypeError -from .exceptions import AiobastionException, CyberarkException, CyberarkAPIException, CyberarkAIMnotFound -from .config import Config +from .exceptions import AiobastionException, CyberarkException, CyberarkAPIException, CyberarkAIMnotFound, AiobastionConfigurationException +from .config import Config, validate_integer +# from .cyberark import EPV # AIM section AIM_secret_resp = namedtuple('AIM_secret_resp', ['secret', 'detail']) @@ -21,28 +22,67 @@ class EPV_AIM: """ Class managing communication with the Central Credential Provider (AIM) GetPassword Web Service """ - _serialized_fields = ["host", "appid", "cert", "key", "verify", "timeout", "max_concurrent_tasks", - "keep_cookies"] - _getPassword_request_parm = ["safe", "folder", "object", "username", "address", "database", - "policyid", "reason", "connectiontimeout", "query", "queryformat", - "failrequestonpasswordchange"] - - def __init__(self, host: str = None, appid: str = None, cert: str = None, key: str = None, - passphrase: str = None, verify: Union[str, bool] = None, - timeout: int = Config.CYBERARK_DEFAULT_TIMEOUT, + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS_IN = [ + "appid", + "cert", + "host", + "key", + "max_concurrent_tasks", + "passphrase", + "timeout", + "verify", + ] + + # List of attributes for serialization (to_json) + _SERIALIZED_FIELDS_OUT = [ + "appid", + "cert", + "host", + "key", + "max_concurrent_tasks", + # "passphrase", # Exclude + "timeout", + "verify", + ] + + # List of attributes for user_search available in AIM + _GETPASSWORD_REQUEST_PARM = [ + "address", + "connectiontimeout", + "database", + "failrequestonpasswordchange", + "folder", + "object", + "policyid", + "query", + "queryformat", + "reason", + "safe", + "username", + ] + + # You must specify the following parameters by key=value (no positional parameters allowed): + def __init__(self, *, + appid: Optional[str] = None, + cert: Optional[str] = None, + host: Optional[str] = None, + key: Optional[str] = None, max_concurrent_tasks: int = Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS, - keep_cookies: bool = False, - serialized: dict = None): + passphrase: Optional[str] = None, + serialized: Optional[dict] = None, + timeout: int = Config.CYBERARK_DEFAULT_TIMEOUT, + verify: Optional[Union[str, bool]] = None, + ): - self.host = host self.appid = appid self.cert = cert + self.host = host self.key = key + self.max_concurrent_tasks = max_concurrent_tasks self.passphrase = passphrase - self.verify = verify self.timeout = timeout - self.max_concurrent_tasks = max_concurrent_tasks - self.keep_cookies = keep_cookies # Whether to keep cookies between AIM calls + self.verify = verify if verify is not None else Config.CYBERARK_DEFAULT_VERIFY # Session management self.__sema = None @@ -52,7 +92,7 @@ def __init__(self, host: str = None, appid: str = None, cert: str = None, key: s if serialized: for k, v in serialized.items(): keyname = k.lower() - if keyname in EPV_AIM._serialized_fields: + if keyname in EPV_AIM._SERIALIZED_FIELDS_IN: setattr(self, keyname, v) else: raise AiobastionException(f"Unknown serialized AIM field: {k} = {v!r}") @@ -64,35 +104,140 @@ def __init__(self, host: str = None, appid: str = None, cert: str = None, key: s if self.max_concurrent_tasks is None: self.max_concurrent_tasks = Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS - if self.verify is not False and not (isinstance(self.verify, str) and not isinstance(self.verify, bool)): + if self.verify is not None and not (isinstance(self.verify, str) or isinstance(self.verify, bool)): raise AiobastionException( f"Invalid type for parameter 'verify' in AIM: {type(self.verify)} value: {self.verify!r}") + if isinstance(self.verify, str): + if not os.path.exists(self.verify): + raise AiobastionConfigurationException( + f"CA certificat File not found {self.verify!r} (Parameter 'verify' in AIM).") + + + @classmethod + def validate_class_attributes(cls, serialized: dict, section: str, epv, configfile: Optional[str] = None) -> dict: + """validate_class_attributes Initialize and validate the EPV_AIM definition (file configuration and serialized) + + Arguments: + serialized {dict} Definition from configuration file or serialization + section {str} verified section name + + Keyword Arguments: + epv {EPV} EPV class definition + configfile {str} Name of the configuration file + + Raises: + AiobastionConfigurationException + + Information: + "appid": # Default = Connection (appid) + "cert": + "host": # Default = PVWA (host) + "key": + "max_concurrent_tasks": # Default = PVWA (max_concurrent_tasks) or Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS + "passphrase": + "timeout": # Default = PVWA (timeout) or Config.CYBERARK_DEFAULT_TIMEOUT + "verify": # Default = PVWA (PVWA_CA) or Config.CYBERARK_DEFAULT_VERIFY + + Returns: + new_serialized {dict} AIM defintion + """ + if configfile: + _config_source = configfile + else: + _config_source = "serialized" + + new_serialized = {} + + for k in serialized.keys(): + keyname = k.lower() + + # Special validation: integer, boolean + if keyname in ["max_concurrent_tasks", "timeout"]: + if serialized[k] is not None: + new_serialized[keyname] = validate_integer(_config_source, f"{section}/{keyname}", serialized[k]) + elif keyname in ["verify"]: + if serialized[k] is not None: + if isinstance(serialized[k], str) or isinstance(serialized[k], bool): + new_serialized["verify"] = serialized[k] + else: + raise AiobastionConfigurationException( + f"Parameter type invalid '{section}/{k}' " + f"in {_config_source}: {serialized[k]!r}") + + elif keyname in EPV_AIM._SERIALIZED_FIELDS_IN: + # String definition + if serialized[k] is not None: + new_serialized[keyname] = serialized[k] + else: + # Unknown attribute + raise AiobastionConfigurationException(f"Unknown attribute in section '{section}' from {_config_source}: {k} is unknown.") + + + + # Complete initialization with epv section (file configuration and serialized) + if epv: + if "host" not in new_serialized and epv.api_host: + new_serialized["host"] = epv.api_host + + # Should not be None or the default value + if "timeout" not in new_serialized and epv.timeout and \ + epv.timeout != Config.CYBERARK_DEFAULT_TIMEOUT: + new_serialized["timeout"] = epv.timeout + + # Should not be None or the default value + if "max_concurrent_tasks" not in new_serialized and \ + epv.max_concurrent_tasks is not None and \ + epv.max_concurrent_tasks != Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS: + new_serialized["max_concurrent_tasks"] = epv.max_concurrent_tasks + + # Should not be None or the default value + if "verify" not in new_serialized and \ + epv.verify is not None and \ + epv.verify != Config.CYBERARK_DEFAULT_VERIFY: + new_serialized["verify"] = epv.verify + + # If no value has been set, return a empty dictionary. AIM should not be set. + if not any(new_serialized.values()): + return {} + + # Default values if not set + new_serialized.setdefault("max_concurrent_tasks", Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS) + new_serialized.setdefault("timeout", Config.CYBERARK_DEFAULT_TIMEOUT) + new_serialized.setdefault("verify", Config.CYBERARK_DEFAULT_VERIFY) + + # Validation + if isinstance(new_serialized["verify"], str): + if not os.path.exists(new_serialized["verify"]): + raise AiobastionConfigurationException( + f"CA certificat File not found {new_serialized['verify']!r} (Parameter 'verify' in AIM).") + + return new_serialized + def validate_and_setup_aim_ssl(self): if self.session: return # Check mandatory attributes - for attr_name in ["host", "appid", "cert", "key"]: - v = getattr(self, attr_name, None) - - if v is None: - raise AiobastionException(f"Missing AIM mandatory parameter '{attr_name}'." - " Required parameters are: host, appid, cert, key.") + if self.host is None or \ + self.appid is None or \ + self.cert is None: + raise AiobastionException(f"Missing AIM mandatory parameters. " + "Required parameters are: host, appid, cert.") if not os.path.exists(self.cert): raise AiobastionException(f"Parameter 'cert' in AIM: Public certificate file not found: {self.cert!r}") - if not os.path.exists(self.key): + if self.key and not os.path.exists(self.key): raise AiobastionException(f"Parameter 'key' in AIM: Private key certificat file not found: {self.key!r}") - # if verify is not set, default to no ssl - if self.verify is False: + # Set verify if it is not set + if self.verify is None: self.verify = Config.CYBERARK_DEFAULT_VERIFY if not (isinstance(self.verify, str) or isinstance(self.verify, bool)): raise AiobastionException( - f"Invalid type for parameter 'verify' (or 'CA') in AIM: {type(self.verify)} value: {self.verify!r}") + f"Invalid type for parameter 'verify' in AIM: {type(self.verify)} value: {self.verify!r}") if (isinstance(self.verify, str) and not os.path.exists(self.verify)): raise AiobastionException(f"Parameter 'verify' in AIM: file not found {self.verify!r}") @@ -110,10 +255,11 @@ def validate_and_setup_aim_ssl(self): if not self.verify: # False ssl_context.check_hostname = False - if self.passphrase is not None: - ssl_context.load_cert_chain(self.cert, self.key, password=self.passphrase) - else: - ssl_context.load_cert_chain(self.cert, self.key) + + # if self.key is None: + # ssl_context.load_cert_chain(self.cert, keyfile=self.key, password=self.passphrase) + # else: + ssl_context.load_cert_chain(self.cert, keyfile=self.key, password=self.passphrase) self.request_params = \ {"timeout": self.timeout, @@ -130,7 +276,7 @@ def valid_secret_params(params: dict = None) -> str: for k in list(params.keys()): key_lower = k.lower() - if key_lower not in EPV_AIM._getPassword_request_parm: + if key_lower not in EPV_AIM._GETPASSWORD_REQUEST_PARM: error_str = f"unknown parameter: {k}={params[k]}" break @@ -155,7 +301,7 @@ def set_semaphore(self, sema, session): def to_json(self): serialized = {} - for attr_name in EPV_AIM._serialized_fields: + for attr_name in EPV_AIM._SERIALIZED_FIELDS_OUT: serialized[attr_name] = getattr(self, attr_name, None) return serialized diff --git a/aiobastion/api_options.py b/aiobastion/api_options.py new file mode 100644 index 0000000..218e623 --- /dev/null +++ b/aiobastion/api_options.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- + +import warnings +from .exceptions import AiobastionConfigurationException +from typing import Optional + + +class Api_options: + """ Api_options - global API options """ + _SERIALIZED_FIELDS = ["deprecated_warning"] + API_OPTIONS_DEFAULT_DEPRECATED_WARNING = True + + deprecated_warning_enabled_ind = None # is deprecated_warning already setup? + + def __init__(self, epv, *, deprecated_warning: Optional[bool] = None, **kwargs): + # TODO: add option to disable err.http_status 403 and 409 (for safe.py) + # TODO: add logger full setup (see logging.config) + # TODO: Add full trace Debugging by level and modules + # level Ex.: 1 = errors only, 2 = High level funtion trace, + # 3 = CyberArk call & returned values, 4 = CyberArk Communication (ssl, cockies, ...), 5 = Full trace) + + self.epv = epv + + _section = "api_options" + _config_source = self.epv.config.config_source + + self.deprecated_warning = Api_options.set_deprecated_warning(deprecated_warning, _config_source, f"{_section}/deprecated_warning") + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + + def to_json(self): + serialized = {} + + for attr_name in Api_options._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + + @classmethod + def set_deprecated_warning(cls, value: Optional[bool] = None, _config_source: Optional[str] = None, _section: Optional[str]=None) -> bool: + """ Set/reset deprecated_warning option + + :param bool value: Activate (True) or deactivate (False) aiobastion deprecated warning option + :raise AiobastionConfigurationException(f"Invalid value 'value' in 'set_deprecated_warning' funtion (expected bool): ...") + """ + if _config_source is None and _section is None: + _config_source = "'set_deprecated_warning' function" + _section = "value" + elif _config_source is None: + _config_source = "'set_deprecated_warning' function" + elif _section is None: + _section = "api_options/deprecated_warning" + + value = Api_options.validate_bool(_config_source, _section, value, Api_options.API_OPTIONS_DEFAULT_DEPRECATED_WARNING) + + if cls.deprecated_warning_enabled_ind is not None and value == cls.deprecated_warning_enabled_ind: + return cls.deprecated_warning_enabled_ind # It is already setup + + if value: + cls.deprecated_warning_enabled_ind = True + warnings.filterwarnings("module", category=DeprecationWarning, module='^aiobastion\.') + else: + cls.deprecated_warning_enabled_ind = False + warnings.filterwarnings("ignore", category=DeprecationWarning, module='^aiobastion\.') + + return cls.deprecated_warning_enabled_ind + + + # This is a copy of config.validate_bool. + # It is here to avoid circular imports. + @staticmethod + def validate_bool(config_source: str, section_name: str, val, default_value = None) -> bool: + if default_value and (val is None or (isinstance(val, str) and len(val.strip()) == 0)): + return default_value + + if isinstance(val, bool): + rt = val + else: + raise AiobastionConfigurationException(f"Invalid value '{section_name}' " + f"in {config_source} (expected bool): {val!r}") + + return rt diff --git a/aiobastion/applications.py b/aiobastion/applications.py index 1cb101a..5893fed 100644 --- a/aiobastion/applications.py +++ b/aiobastion/applications.py @@ -1,6 +1,6 @@ # import aiobastion.exceptions -from .exceptions import AiobastionException - +from .exceptions import AiobastionException, AiobastionConfigurationException +from typing import Union # class AamObject: # def __init__(self, appid: str, params: dict, cert_file: str = None, cert_key: str = None): @@ -13,9 +13,21 @@ class Applications: - def __init__(self, epv): + # _APPLICATIONS_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv + _section = "applications" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + async def add(self, app_name: str, description: str = "", location: str = "\\", access_from: int = None, access_to: int = None, expiration:str = None, disabled:bool = None, owner_first_name: str = "", owner_last_name: str = "", owner_email: str = None, owner_phone: str = "" @@ -68,12 +80,25 @@ async def add(self, app_name: str, description: str = "", location: str = "\\", if owner_email is not None: import re - if not re.fullmatch("[^@]+@[^@]+\.[^@]+", owner_email): + if not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", owner_email): raise AiobastionException(f"owner_email argument must be valid mail, given : {owner_email}") data["application"]["BusinessOwnerEmail"] = owner_email return await self.epv.handle_request("post", url, data=data) + + def to_json(self): + serialized = {} + + for attr_name in Applications._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + + async def delete(self, app_name:str): """ Delete an application @@ -226,7 +251,7 @@ async def add_authentication(self, app_name: str, path: str = None, hash_string: else: return False - async def get_authentication(self, app_name: str) -> list or bool: + async def get_authentication(self, app_name: str) -> Union[list, bool]: """ Get authenticated methods for an application @@ -238,7 +263,7 @@ async def get_authentication(self, app_name: str) -> list or bool: f'WebServices/PIMServices.svc/Applications/{app_name}/Authentications', filter_func=lambda x: x['authentication']) - async def del_authentication(self, app_name: str, auth_id: str) -> list or bool: + async def del_authentication(self, app_name: str, auth_id: str) -> Union[list, bool]: """ Delete authentication method identified by auth_id for the application diff --git a/aiobastion/config.py b/aiobastion/config.py index 0995a79..f0c5444 100644 --- a/aiobastion/config.py +++ b/aiobastion/config.py @@ -1,217 +1,488 @@ # -*- coding: utf-8 -*- +import sys # Debug import yaml import warnings from .exceptions import AiobastionConfigurationException - +from typing import Optional, Union +from .api_options import Api_options class Config: - """Parse a config file into an object""" - # Default value - CYBERARK_DEFAULT_TIMEOUT = 30 - CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS = 10 - CYBERARK_DEFAULT_RETENTION = 10 - CYBERARK_DEFAULT_VERIFY = False + """ Config Transform input information from configuration file or serialization to the different modules. - def __init__(self, configfile): + account, accountgroup, aim, applications, cyberark, group, platform, safe, sessionmanagement, systemhealth, user, utilities. + """ + # Because of a conflict (circular import) with EPV definition at intialization for AIOBASTION + # The following default values are defined here (instead of EPV class) + CYBERARK_DEFAULT_KEEP_COOKIES = False + CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS = 10 + CYBERARK_DEFAULT_TIMEOUT = 30 + CYBERARK_DEFAULT_VERIFY = True + CYBERARK_OPTIONS_MODULES_LIST = [ + "account", + "accountgroup", + "aim", + "api_options", + "applications", + "cyberark", + "group", + "platform", + "safe", + "sessionmanagement", + "systemhealth", + "user", + "utilities", + ] + + # List of EPV attributes from serialization + _EPV_SERIALIZED_FIELDS_IN = [ + "api_host", # synomym of api_host: host + "authtype", + "keep_cookies", + "max_concurrent_tasks", # synomym of max_concurrent_tasks: masktasks + "password", + "timeout", + "token", # in serializattion only (changed to __token) + "user_search", + "username", + "verify", + ] + + + def __init__(self, configfile: str = None, label: str=None, custom: dict=None, serialized=None, token=None): + """Parse the config file or the serialization and populate the options_modules (account, aim, cyberark, safe, ...). + + The EPV class will call the appropriate class (module) for initialization and validation + using options_modules. Serialization will use the same functionality. + ex: self.account = Account(self, **self.config.options_modules["account"]) + """ + + # Class definition self.configfile = configfile + self.custom = custom + self.label = label - # Global section Initialisation - self.AIM = None - self.Connection = None - self.CPM = "" - self.custom = None - self.customIPField = None - self.Label = None - self.retention = None - - # Connection section Initialisation - self.appid = None - self.authtype = "Cyberark" - self.password = None - self.user_search = None - self.username = None - - # PVWA section Initialisation - self.PVWA = None - self.max_concurrent_tasks = Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS - self.timeout = Config.CYBERARK_DEFAULT_TIMEOUT - self.PVWA_CA = Config.CYBERARK_DEFAULT_VERIFY - self.keep_cookies = False - - with open(configfile, 'r') as config: - configuration = yaml.safe_load(config) + # Temporary attributes + self.deprecated_warning = Api_options.API_OPTIONS_DEFAULT_DEPRECATED_WARNING - # Change global section name in lowercase - for k in list(configuration.keys()): - keyname = k.lower() + if label is None and serialized: + self.label = "serialized" - if keyname not in ["aim", "connection", "cpm", "custom", - "customipfield", "label", "pvwa", "retention"]: - warnings.warn(f"aiobastion - Unknown section '{k}' in {self.configfile}") - continue + if not configfile and not serialized: + raise AiobastionConfigurationException("Internal error: no configfile and no serialized") - if k != keyname: - # change the keyname in lowercase - configuration[keyname] = configuration.pop(k) + self.options_modules = {} - # Read global sections in the right order - if "connection" in configuration and configuration["connection"]: - self._read_section_connection(configuration["connection"]) + # Define a dictionary for every options module (aim, account, cyberark, safe ...) + for keyname in Config.CYBERARK_OPTIONS_MODULES_LIST: + self.options_modules[keyname] = {} + + if token is not None: + self.options_modules["cyberark"]["token"] = token + + if configfile: + self.config_source = configfile + self._mngt_configfile() + elif serialized: + self.config_source = "serialized" + self._mngt_serialized(serialized) + + # remove temporary attributes + del self.deprecated_warning + + + def _mngt_configfile(self): + """ _mngt_configfile management of the configuration file + + Cross-reference between the configuration file and the Config class attributes (for information) + + Yaml Configuration file Config class + -------------------------------------- ------------------------------------------------ + Section Attribute Attribute Dictonnary key + -------------- ------------------------ --------------- -------------- + label label + custom custom {dict} + configfile + + + connection(1) appid options_modules["aim"] appid + + connection authtype options_modules["cyberark"] authtype + connection password options_modules["cyberark"] password + connection user_search options_modules["cyberark"] user_search {dict} + connection username options_modules["cyberark"] username + + pvwa host options_modules["cyberark"] api_host + pvwa timeout options_modules["cyberark"] timeout {int} + pvwa(1) max_concurrent_tasks options_modules["cyberark"] max_concurrent_tasks {int} + pvwa(1) maxtasks options_modules["cyberark"] max_concurrent_tasks {int} + pvwa keep_cookies options_modules["cyberark"] keep_cookies {bool} + pvwa(1) ca options_modules["cyberark"] verify Union[{bool}, {str}] + pvwa(1) verify options_modules["cyberark"] verify Union[{bool}, {str}] + + + + cpm(2) options_modules["account"] cpm + retention(2) options_modules["account"] retention {int} + + + *** global api_options *** + api_options deprecated_warning options_modules["api_options"] deprecated_warning {bool} *** Temporary attribute *** + + + *** aim module *** + aim(1) appid options_modules["aim"] appid + aim cert options_modules["aim"] cert + aim host options_modules["aim"] host + aim key options_modules["aim"] key + + aim passphrase options_modules["aim"] passphrase + aim timeout options_modules["aim"] timeout {int} + + aim(1) max_concurrent_tasks options_modules["aim"] max_concurrent_tasks {int} + + aim(1) verify options_modules["aim"] verify Union[{bool}, {str}] + + custom(3) LOGON_ACCOUNT_INDEX options_modules["safe"] logon_account_index {int} + custom(3) RECONCILE_ACCOUNT_INDEX options_modules["safe"] reconcile_account_index {int} + + + *** modules (4) *** + account(3) logon_account_index options_modules["account"] logon_account_index {int} + account(3) reconcile_account_index options_modules["account"] reconcile_account_index {int} + + safe cpm options_modules["safe"] cpm + safe retention options_modules["safe"] retention {int} - if "pvwa" in configuration and configuration["pvwa"]: - self._read_section_pvwa(configuration["pvwa"]) - if "aim" in configuration and configuration["aim"]: - self._read_section_aim(configuration["aim"]) - if "cpm" in configuration: - self.CPM = configuration["cpm"] + account options_modules["account"] (Account class) + accountgroup options_modules["accountgroup"] (AccountGroup class) + aim options_modules["aim"] (EPV_AIM class) + applications options_modules["applications"] (Applications class) + group options_modules["group"] (Group class) + api_options options_modules["api_options"] (Api_options class) + platform options_modules["platform"] (Platform class) + safe options_modules["safe"] (Safe class) + sessionmanagement options_modules["sessionmanagement"] (SessionManagement class) + systemhealth options_modules["systemhealth"] (SystemHealth class) + user options_modules["user"] (User class) + utilities options_modules["utilities"] (Utilities class) + ... + + (1) Synonyms + (2) Move from Global section to save modules + (3) Move from custom to safe section (issue a warning) + (4) All modules will be initialized and validated their own attributes. + This will be done in the EPV class. + + """ + with open(self.configfile, 'r') as config: + configuration = yaml.safe_load(config) + + # Translate keys of dictionary and subdirectories in lowercase + # Do not modified the sub-key dictionary of the 'custom' section. + configuration = self._serialized_dict_lowercase_key(configuration, "", self.configfile) + + global_sections = [ + "connection", # options modules: aim and cyberark + "cpm", # deprecated, move to safe section + "label", # Config.label + "api_options", # Global API options: for all modules + "pvwa", # options modules: cyberark + "retention", # deprecated, move to safe section + "custom", # Config.custom: Customer use only (not aiobastion) + ] + Config.CYBERARK_OPTIONS_MODULES_LIST + + + # Check the global section defined in the configuration file + for k in configuration.keys(): + if k not in global_sections: + raise AiobastionConfigurationException(f"Unknown attribute in global section in {self.configfile}: {k} unknown.") + + # -------------------------------------------- + # Config attribute class + # -------------------------------------------- + # Extraction deprecated_warning global API option (for internal use only) + if "api_options" in configuration and "deprecated_warning" in configuration["api_options"]: + self.deprecated_warning = Api_options.set_deprecated_warning(configuration["api_options"]["deprecated_warning"], + _config_source=self.config_source, + _section="api_options/deprecated_warning") + else: + self.deprecated_warning = Api_options.set_deprecated_warning(None, + _config_source=self.config_source, + _section="api_options/deprecated_warning") + if "label" in configuration: self.label = configuration["label"] + + # Initialize custom information if "custom" in configuration: self.custom = configuration["custom"] - if "customipfield" in configuration: - self.customIPField = configuration["customipfield"] - if "retention" in configuration: - self.retention = self._to_integer("retention", configuration["retention"]) - def _read_section_connection(self, configuration): - for k in list(configuration.keys()): - keyname = k.lower() + # -------------------------------------------- + # cyberark: connection and pvwa + # -------------------------------------------- + if "connection" in configuration and configuration["connection"]: + for k, v in configuration["connection"].items(): + if k == "appid": + self._add_key_to_options_modules("aim", k, v) + else: + self._add_key_to_options_modules("cyberark", k, v) - if keyname == "appid": - self.appid = configuration[k] - elif keyname == "authtype": - self.authtype = configuration[k] - elif keyname == "password": - self.password = configuration[k] - elif keyname == "user_search": - self.user_search = configuration[k] - elif keyname == "username": - self.username = configuration[k] + + if "pvwa" in configuration and configuration["pvwa"]: + self._add_dict_to_options_modules("cyberark", configuration["pvwa"]) + + # -------------------------------------------- + # options modules (account, accountgroup, aim, api_options ...) + # -------------------------------------------- + for keyname in Config.CYBERARK_OPTIONS_MODULES_LIST: + if keyname in configuration and configuration[keyname]: + self._add_dict_to_options_modules(keyname, configuration[keyname]) + + # if keyname in ["safe", "aim"]: + # self._add_dict_to_options_modules(keyname, configuration[keyname]) + # else: + # self.options_modules[keyname] = configuration[keyname] + + # -------------------------------------------- + # Compatibility and exceptions + # -------------------------------------------- + # Don't allow 'safe' and ('cpm' or 'retention'). + if "cpm" in configuration or "retention" in configuration: + if "safe" in configuration: + raise AiobastionConfigurationException(f"Duplicate definition: Move 'cpm' and 'retention' to the 'safe' definition in {self.configfile}.") + else: + if self.deprecated_warning: + warnings.warn( + f"aiobastion - Deprecated parameter 'cpm' and 'retention' in 'global' section from {self.configfile}: " + "move definitions from global to 'safe' section.", DeprecationWarning, stacklevel=4) + + + # Move 'cpm' and 'retention' to 'safe' module + for keyname in ["cpm", "retention"]: + if keyname in configuration: + self._add_key_to_options_modules("safe", keyname, configuration[keyname]) + + # Don't allow 'account' and (custom['logon_account_index'] or custom['reconcile_account_index']). + self._mng_account_custom_definition() + + def _mngt_serialized(self, serialized): + """_mngt_serialized management of the serialized defintion + + Cross reference between the configuration file and the Config class attributes (for information) + + Serialized Config class + -------------------------------------- ------------------------------------------------ + Section Attribute Attribute Dictonnary key + -------------- ------------------------ --------------- -------------- + label label + custom custom {dict} + configfile = None + + api_host options_modules["cyberark"] api_host + authtype options_modules["cyberark"] authtype + keep_cookies options_modules["cyberark"] keep_cookies {bool} + max_concurrent_tasks options_modules["cyberark"] max_concurrent_tasks {int} + password options_modules["cyberark"] password + timeout options_modules["cyberark"] timeout {int} + token options_modules["cyberark"] token + user_search options_modules["cyberark"] user_search {dict} + username options_modules["cyberark"] username + verify options_modules["cyberark"] verify Union [{bool}, {str}] + + cpm(2) options_modules["account"] cpm + retention(2) options_modules["account"] retention {int} + + custom(3) LOGON_ACCOUNT_INDEX options_modules["account"] logon_account_index {int} + custom(3) RECONCILE_ACCOUNT_INDEX options_modules["account"] reconcile_account_index {int} + + *** modules (4) *** + account options_modules["account"] (Account class) + accountgroup options_modules["accountgroup"] (AccountGroup class) + aim options_modules["aim"] (EPV_AIM class) + applications options_modules["applications"] (Applications class) + group options_modules["group"] (Group class) + api_options options_modules["api_options"] (Api_options class) + platform options_modules["platform"] (Platform class) + safe options_modules["safe"] (Safe class) + sessionmanagement options_modules["sessionmanagement"] (SessionManagement class) + systemhealth options_modules["systemhealth"] (SystemHealth class) + user options_modules["user"] (User class) + utilities options_modules["utilities"] (Utilities class) + + + ... + + (1) Synonyms + (2) Move to save modules + (3) Move from custom to account module (issue a warning) + (4) All modules will be initialized and validated their own attributes. + This will be done in the EPV class. + + Raises: + AiobastionConfigurationException: + Type error: Parameter 'serialized' must be a dictionary. + Move 'cpm' and 'retention' to the 'safe' definition in serialization. + Duplicate 'aim' definition in seralized. Specify only 'aim' and remove 'AIM'. + Unknown attribute '{k}' in serialization: + """ + + if not isinstance(serialized, dict): + raise AiobastionConfigurationException("Type error: Parameter 'serialized' must be a dictionary.") + + # Translate keys of dictionary and sub-directories in lowercase + # Do not modified the sub-key dictionary of the 'custom' section. + serialized = self._serialized_dict_lowercase_key(serialized, "", self.config_source) + + # Extraction deprecated_warning global API option (for internal use only) + if "api_options" in serialized and "deprecated_warning" in serialized["api_options"]: + self.deprecated_warning = Api_options.set_deprecated_warning(serialized["api_options"]["deprecated_warning"], + _config_source=self.config_source, + _section="api_options/deprecated_warning") + else: + self.deprecated_warning = Api_options.set_deprecated_warning(None, + _config_source=self.config_source, + _section="api_options/deprecated_warning") + + # Don't allow 'safe' and ('cpm' or 'retention'). + if ("cpm" in serialized or "retention" in serialized): + if "safe" in serialized: + raise AiobastionConfigurationException("Duplicate definition: Move 'cpm' and 'retention' to the 'safe' definition in serialization.") else: - raise AiobastionConfigurationException(f"Unknown attribute '{k}' within section 'connection' " - f"in {self.configfile}") + if self.deprecated_warning: + warnings.warn( + f"aiobastion - Deprecated parameter 'cpm' and 'retention' in 'global' section from {self.config_source}: " + "move definitions from global to 'safe'.", DeprecationWarning, stacklevel=4) + # Validate dictionary keys + for k, v in serialized.items(): + # cyberark definition + if k in Config._EPV_SERIALIZED_FIELDS_IN: + # Initialize cyberark attribut + self._add_key_to_options_modules("cyberark", k, v) + + elif k in Config.CYBERARK_OPTIONS_MODULES_LIST: + # Keep options modules definition for later (account, aim, safe, api_options, ...) + self.options_modules[k] = v + + elif k == "cpm" or k == "retention": + # Initialize Safe attribut for comptibility + self._add_key_to_options_modules("safe", k, v) + + elif k == "custom": + self.custom = v + else: + raise AiobastionConfigurationException( + f"Unknown attribute '{k}' in serialization: {serialized[k]!r}") - # user_search dictionary Validation - if self.user_search: - if not isinstance(self.user_search, dict): - raise AiobastionConfigurationException(f"Malformed attribute 'user_search' within section " - f"'connection' in {self.configfile}: {self.user_search!r}") + # Don't allow 'account' and (custom['logon_account_index'] or custom['reconcile_account_index']). + self._mng_account_custom_definition() - # Check user_search parameter name - _getPassword_request_parm = ["safe", "folder", "object", "username", "address", "database", "policyid", - "reason", "connectiontimeout", "query", "queryformat", - "failrequestonpasswordchange" ] - for k in list(self.user_search.keys()): - keyname = k.lower() - if keyname not in _getPassword_request_parm: - raise AiobastionConfigurationException(f"Unknown attribute '{k}' within section " - f"'connection/user_search' in {self.configfile}") + def _add_dict_to_options_modules(self, module: str, configuration: dict): + if configuration is None: + return - if k != keyname: - self.user_search[keyname] = self.user_search.pop(k) + for k, v in configuration.items(): + if k in self.options_modules[module] and \ + v != self.options_modules[module][k]: + # Raise an error only want values are different. + raise AiobastionConfigurationException(f"Duplicate key '{module}/{k}'" + f" in {self.config_source}.") - def _read_section_pvwa(self, configuration): - synonyme_PVWA_CA = 0 - synonyme_max_concurrent_tasks = 0 + self.options_modules[module][k] = v - for k in list(configuration.keys()): - keyname = k.lower() + def _add_key_to_options_modules(self, module: str, keyname: str, value): + if value is None: + return - if keyname == "host": - self.PVWA = configuration[k] - elif keyname == "timeout": - self.timeout = self._to_integer("PVWA/" + k, configuration[k]) - elif keyname == "maxtasks" or keyname == "max_concurrent_tasks": - self.max_concurrent_tasks = self._to_integer("PVWA/" + k, configuration[k]) - synonyme_max_concurrent_tasks += 1 - elif keyname == "keep_cookies": - self.keep_cookies = bool(configuration[k]) - elif keyname == "verify" or keyname == "ca": - self.PVWA_CA = configuration[k] - synonyme_PVWA_CA += 1 - else: - raise AiobastionConfigurationException(f"Unknown attribute '{k}' within section 'PVWA' in {self.configfile}") - - if synonyme_PVWA_CA > 1: - raise AiobastionConfigurationException(f"Duplicate synonyme parameter: 'ca', 'verify' within section 'PVWA' " - f"in {self.configfile}. Specify only one of them.") - - if synonyme_max_concurrent_tasks > 1: - raise AiobastionConfigurationException(f"Duplicate synonyme parameter: 'maxtasks', 'max_concurrent_tasks' " - f"within section 'PVWA' in {self.configfile}. " - f"Specify only one of them.") - - def _read_section_aim(self, configuration): - configuration_aim = { - "appid": None, # Default = Connection (appid) - "cert": None, - "host": None, # Default = PVWA (host) - "key": None, - "passphrase": None, - "max_concurrent_tasks": None, # Default = PVWA (max_concurrent_tasks) - "verify": False, # Default = PVWA (PVWA_CA) - "keep_cookies": False, # Default = False - "timeout": None, # Default = PVWA (timeout) - } - - synonyme_verify = 0 - synonyme_max_concurrent_tasks = 0 - - for k in list(configuration.keys()): - keyname = k.lower() + if keyname in self.options_modules[module] and \ + value != self.options_modules[module][keyname]: + # Raise an error only when values are different. + raise AiobastionConfigurationException(f"Duplicate key '{module}/{keyname}'" + f" in {self.config_source}.") - if keyname in ["appid", "cert", "host", "key", "passphrase"]: - configuration_aim[keyname] = configuration[k] - elif keyname == "timeout": - configuration_aim[keyname] = self._to_integer("AIM/" + k, configuration[k]) - elif keyname == "keep_cookies": - configuration_aim[keyname] = bool(configuration[k]) - elif keyname in ["maxtasks", "max_concurrent_tasks"]: - configuration_aim["max_concurrent_tasks"] = self._to_integer("AIM/" + k, configuration[k]) - synonyme_max_concurrent_tasks += 1 - elif keyname in ["ca", "verify"]: - configuration_aim["verify"] = configuration[k] - synonyme_verify += 1 - else: - raise AiobastionConfigurationException(f"Unknown attribute '{k}' within section 'AIM' in {self.configfile}") + self.options_modules[module][keyname] = value - if synonyme_verify > 1: - raise AiobastionConfigurationException(f"Duplicate synonyme parameter: 'ca', 'verify' within section 'AIM'." - f"Specify only one of them.") - if synonyme_max_concurrent_tasks > 1: - raise AiobastionConfigurationException(f"Duplicate synonyme parameter: 'maxtasks', 'max_concurrent_tasks' " - f"within section 'AIM' in {self.configfile}." - f"Specify only one of them.") - self.AIM = configuration_aim + def _mng_account_custom_definition(self): + # Don't allow 'account' and (custom['logon_account_index'] or custom['reconcile_account_index']). + if self.custom and isinstance(self.custom, dict): + keyname_list = [] - # If not defined used Connection definitions to complete initialization. - if self.AIM["appid"] is None: - self.AIM["appid"] = self.appid + # Are logon_account_index or reconcile_account_index keys exist ? + for k in self.custom.keys(): + keyname = k.lower() - # If not defined used PVWA definitions to complete initialization. - if self.AIM["host"] is None: - self.AIM["host"] = self.PVWA - if self.AIM["timeout"] is None: - self.AIM["timeout"] = self.timeout - if self.AIM["max_concurrent_tasks"] is None: - self.AIM["max_concurrent_tasks"] = self.max_concurrent_tasks - if self.AIM["verify"] is None: - self.AIM["verify"] = self.PVWA_CA + if keyname in ["logon_account_index", "reconcile_account_index"]: + keyname_list.append(k) + + if self.deprecated_warning: + warnings.warn( + f"aiobastion - Deprecated parameter 'custom/logon_account_index' and 'custom/reconcile_account_index' from {self.config_source}: " + "move definitions from 'custom' to 'account' section.", DeprecationWarning, stacklevel=5) + + + if keyname_list: + # Don't allow 'account' and 'custom'. + if self.options_modules["account"]: + raise AiobastionConfigurationException( + "Duplicate definition: move 'logon_account_index' and " + "'reconcile_account_index' from 'custom' to 'account' section in {configfile}.") + else: + # Move 'logon_account_index' and 'reconcile_account_index' to 'account' options modules + # and remove it from custom + new_custom = {} + + for k, v in self.custom.items(): + if k in keyname_list: + self.options_modules["account"][k.lower()] = v + else: + new_custom[k] = v + + if new_custom: + self.custom = new_custom + else: + self.custom = None + + def _serialized_dict_lowercase_key(self, src: Union[dict, str], section_name: str, first_level: bool = True): + """_serialized_dict_lowercase_key - Translate keys of dictionary and sub-dictionaries in lowercase + + Do not modified the sub-key dictionary of the 'custom' section. + + Arguments: + src {dict} Source dictionary + section_name {str} Error message section name + first_level {str} Is this the primary dictionary (not a sub-dictionary) ? + + Raises: + AiobastionConfigurationException: + Invalid dictionary type '{section_name}' in {self.config_source} + Duplicate key '{section_name}/{keyname}' in {self.config_source} + + Returns: + rt New dictionary/sub-dictionary with lowercase keys + """ + if not isinstance(src, dict): + raise AiobastionConfigurationException( + f"Invalid dictionary type '{section_name}' in {self.config_source}") + + rt = {} + for k, v in src.items(): + keyname = k.lower() - def _to_integer(self, section_key, val): - try: - v = int(val) - except ValueError: - raise AiobastionConfigurationException(f"Invalid integer within '{section_key}'" - f" in {self.configfile}: {val!r}") + if keyname in rt: + raise AiobastionConfigurationException(f"Duplicate key '{section_name}/{keyname}'" + f" in {self.config_source}") - return v + if isinstance(v, dict) and not (first_level and keyname == "custom"): + rt[keyname] = self._serialized_dict_lowercase_key(v, f"{section_name}/{keyname}", first_level=False) + else: + rt[keyname] = v + + return rt # No rights at all @@ -355,6 +626,33 @@ def _to_integer(self, section_key, val): # V2_POWER.update({k: v for k, v in V2_AUDIT.items() if v}) + + +def validate_integer(config_source: str, section_name: str, val, default_value = None) -> int: + if default_value and (val is None or (isinstance(val, str) and len(val.strip()) == 0)): + return default_value + + try: + v = int(val) + except (ValueError, TypeError): + raise AiobastionConfigurationException(f"Invalid value '{section_name}' " + f"in {config_source} (expected int): {val!r}") + + return v + +def validate_bool(config_source: str, section_name: str, val, default_value = None) -> bool: + if default_value and (val is None or (isinstance(val, str) and len(val.strip()) == 0)): + return default_value + + if isinstance(val, bool): + rt = val + else: + raise AiobastionConfigurationException(f"Invalid value '{section_name}' " + f"in {config_source} (expected bool): {val!r}") + + return rt + + def validate_ip(s): a = s.split('.') if len(a) != 4: diff --git a/aiobastion/cyberark.py b/aiobastion/cyberark.py index 74f78b6..2bb33f1 100644 --- a/aiobastion/cyberark.py +++ b/aiobastion/cyberark.py @@ -4,19 +4,20 @@ import asyncio import json import ssl -from typing import Tuple -import copy +from typing import Tuple, Optional, Union from aiohttp import ContentTypeError import aiohttp +import warnings from .accountgroup import AccountGroup -from .accounts import Account +from .accounts import Account, PrivilegedAccount from .aim import EPV_AIM from .applications import Applications -from .config import Config +from .api_options import Api_options +from .config import Config, validate_integer, validate_bool from .exceptions import CyberarkException, GetTokenException, AiobastionException, CyberarkAPIException, \ - ChallengeResponseException, CyberarkAIMnotFound + ChallengeResponseException, CyberarkAIMnotFound, AiobastionConfigurationException, CyberarkNotFoundException from .platforms import Platform from .safe import Safe from .system_health import SystemHealth @@ -24,168 +25,245 @@ from .utilities import Utilities from .session_management import SessionManagement - class EPV: + """ Class that represent the connection, or future connection, to the Vault. """ - Class that represent the connection, or future connection, to the Vault. - """ - - def __init__(self, configfile: str = None, serialized: dict = None, token: str = None): + # List of EPV attributes for serialization (to_json) + _SERIALIZED_FIELDS_OUT = [ + "api_host", + "authtype", + # "cpm", # Now in save + "keep_cookies", + "max_concurrent_tasks", + # "password", # Hidden + # "retention", # Now in save + "timeout", + "token", # use self.__token + # "user_search", # Hidden + # "username", # Hidden + "verify", + ] + + def __init__(self, configfile: Optional[str] = None, token: Optional[str] = None, serialized: Optional[dict] = None): # Logging stuff logger: logging.Logger = logging.getLogger("aiobastion") self.logger = logger - # PVWA initialization - self.api_host = None # CyberArk host - self.authtype = "cyberark" # CyberArk authentification type - - # Number of parallel task for PVWA and AIM - self.max_concurrent_tasks = Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS - # Communication timeout in seconds - self.timeout = Config.CYBERARK_DEFAULT_TIMEOUT - self.keep_cookies = False # Whether to keep cookies between API calls - self.verify = Config.CYBERARK_DEFAULT_VERIFY # root certificate authority (CA)self.keep_cookies = False # Whether to keep cookies between API calls - - self.request_params = {"timeout": self.timeout, "ssl": False} # timeout & ssl setupn default value - self.__token = token # CyberArk authorization token - - # AIM Communication initialization - self.AIM = None # EPV_AIM definition - - # Linked accounts index defaults, can be overriden by configs - self.LOGON_ACCOUNT_INDEX = 2 # This really should be 1, but keep as 2 for backward compatibility - self.RECONCILE_ACCOUNT_INDEX = 3 # You SHOULD NOT change this, except for backward compatibility - - # Other section initialization - self.configfile = configfile # Name of the configuration file - self.config = None # Definition from the configuration file - self.cpm = "" # CPM to assign to safes - self.retention = Config.CYBERARK_DEFAULT_RETENTION # days of retention for objects in safe - - if configfile is None and serialized is None: - raise AiobastionException("You must provide either configfile or serialized to init EPV") - elif configfile is not None and serialized is None: - self._epv_config(configfile) - elif serialized is not None and configfile is None: - self._epv_serialize(serialized) - else: - raise AiobastionException("You must provide either configfile or serialized to init EPV, not both") + # PVWA initialization (this initialization is only for pylint) + self.api_host = None # CyberArk host + self.authtype = None # CyberArk authentication type + self.keep_cookies = None # Whether to keep cookies between API calls + self.max_concurrent_tasks = None # Maximum number of parallel task + self.password = None # Cyberar user password + self.timeout = None # Communication timeout in seconds + self.user_search = None # Search parameters to uniquely identify the PVWA user + self.username = None # CyberArk username + self.verify = None # root certificate authority (CA) + self.__token = token # CyberArk authorization token + + # read configuration file or serialization + self.config = Config(configfile=configfile, serialized=serialized, token=token) - self.user_list = None + # global API options initialization + self.api_options = Api_options(self, **self.config.options_modules["api_options"]) + + # Validate and define EPV Class attributes + self.validate_class_attributes(self.config.options_modules["cyberark"]) + + # Execution parameters + self.request_params = None # timeout & ssl setup default value # Session management self.session = None self.cookies = None self.__sema = None - # utilities - self.account = Account(self) - self.platform = Platform(self) - self.session_management = SessionManagement(self) - self.safe = Safe(self) - self.user = User(self) - self.group = Group(self) - self.application = Applications(self) - self.accountgroup = AccountGroup(self) - self.system_health = SystemHealth(self) - self.utils = Utilities(self) - - def _epv_set_linked_account_index(self, custom): - if custom is not None: - if custom['LOGON_ACCOUNT_INDEX']: self.LOGON_ACCOUNT_INDEX = int(custom['LOGON_ACCOUNT_INDEX']) # noqa: - if custom['RECONCILE_ACCOUNT_INDEX']: self.RECONCILE_ACCOUNT_INDEX = int(custom['RECONCILE_ACCOUNT_INDEX']) # noqa: - - def _epv_config(self, configfile): - self.config = Config(configfile) - - # PVWA definition - self.api_host = self.config.PVWA - self.authtype = self.config.authtype - self.max_concurrent_tasks = self.config.max_concurrent_tasks - self.timeout = self.config.timeout - self.keep_cookies = self.config.keep_cookies - self.verify = self.config.PVWA_CA - - # AIM Communication - if self.config.AIM is not None: - self.AIM = EPV_AIM(**self.config.AIM) - - # Other definition - self.cpm = self.config.CPM - self.retention = self.config.retention - - self._epv_set_linked_account_index(self.config.custom) - - def _epv_serialize(self, serialized): - if not isinstance(serialized, dict): - raise AiobastionException("Type error: Parameter 'serialized' must be a dictionary.") - - # Validate dictionary key - for k in serialized.keys(): - if k not in [ - "AIM", - "api_host", - "authtype", - "cpm", - "max_concurrent_tasks", - "retention", - "timeout", - "token", - "keep_cookies", - "verify", - "custom", - ]: - raise AiobastionException(f"Unknown serialized field: {k} = {serialized[k]!r}") - - # PVWA definition - if "api_host" in serialized: - self.api_host = serialized['api_host'] - if "authtype" in serialized: - self.authtype = serialized["authtype"] - if "max_concurrent_tasks" in serialized: - self.max_concurrent_tasks = serialized['max_concurrent_tasks'] - if "timeout" in serialized: - self.timeout = serialized["timeout"] - if "keep_cookies" in serialized: - self.keep_cookies = bool(serialized["keep_cookies"]) - if "verify" in serialized: - self.verify = serialized["verify"] - if "token" in serialized: - self.__token = serialized['token'] - if "custom" in serialized: - self.custom = serialized['custom'] - self._epv_set_linked_account_index(self.custom) - - # AIM Communication - if "AIM" in serialized: - serialized_aim = copy.copy(serialized["AIM"]) - - serialized_aim.setdefault("host", getattr(self, "api_host", None)) - serialized_aim.setdefault( - "max_concurrent_tasks", - getattr(self, "max_concurrent_tasks", Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS)) - serialized_aim.setdefault("timeout", getattr(self, "timeout", Config.CYBERARK_DEFAULT_TIMEOUT)) - serialized_aim.setdefault("verify", getattr(self, "verify", False)) - serialized_aim.setdefault("keep_cookies", getattr(self, "keep_cookies", False)) - self.AIM = EPV_AIM(serialized=serialized_aim) - - # Other definition - if "cpm" in serialized: - self.cpm = serialized['cpm'] - if "retention" in serialized: - self.retention = serialized['retention'] + # modules initialization + self.AIM = None # AIM interface + + if self.config.options_modules["aim"]: + AIM_definition = EPV_AIM.validate_class_attributes(self.config.options_modules["aim"], "aim", self, + configfile=self.config.configfile) + # Do not define AIM if not necessary. + if AIM_definition: + self.AIM = EPV_AIM(**AIM_definition) + + self.account = Account(self, **self.config.options_modules["account"]) + self.accountgroup = AccountGroup(self, **self.config.options_modules["accountgroup"]) + self.application = Applications(self, **self.config.options_modules["applications"]) + self.group = Group(self, **self.config.options_modules["group"]) + self.platform = Platform(self, **self.config.options_modules["platform"]) + self.safe = Safe(self, **self.config.options_modules["safe"]) + self.session_management = SessionManagement(self, **self.config.options_modules["sessionmanagement"]) + self.system_health = SystemHealth(self, **self.config.options_modules["systemhealth"]) + self.user = User(self, **self.config.options_modules["user"]) + self.utils = Utilities(self, **self.config.options_modules["utilities"]) + + self.PrivilegedAccount = PrivilegedAccount + + del self.config.options_modules + + + + def validate_class_attributes(self, serialized: dict): + """validate_class_attributes Initialize, validate and define the EPV attributes + from configuration file or serialization + + :param serialized: Dictionary of the serialized attributes + :raise AiobastionConfigurationException: Invalid string or boolean value + :return: Dictionary of the EPV attributes class to define + + + Synomyms for configuration file: + api_host, host + max_concurrent_tasks, masktasks + verify, ca + + All keys are already in lowercase. + """ + + def section_name(keyname: str) -> str: + """section_name Identify the section name of the keyname in the configuration file + return: + {str} "
/" or "" + """ + section = epv_section.get(keyname, None) + + if section: + return f"{section}/{keyname}" + + return keyname + + if self.config.config_source == "serialized": + epv_section = {} + else: + # Identify the section name of the keyname in the configuration file + epv_section = { + "api_host": "pvwa", + "authtype": "connection", + "ca": "pvwa", # Synonym + "host": "pvwa", # Synonym + "keep_cookies": "pvwa", + "masktasks": "pvwa", # Synonym + "max_concurrent_tasks": "pvwa", + "password": "connection", + "timeout": "pvwa", + "token": "pvwa", # changed to __token + "user_search": "connection", + "verify": "pvwa", + } + + self.api_host = None + self.authtype = None + self.keep_cookies = None + self.max_concurrent_tasks = None + self.password = None + self.timeout = None + self.user_search = None + self.username = None + self.verify = None + + # self.__token = None + + synonym_max_concurrent_tasks = 0 + synonym_verify = 0 + + for k, v in serialized.items(): + synonym_max_concurrent_tasks = 0 + + if k in ["api_host", "host"]: + if self.api_host: + raise AiobastionConfigurationException( + f"Duplicate parameter '{section_name(k)}' in {self.config.config_source}. Specify only one.") + + self.api_host = v + elif k == "authtype": + self.authtype = v + elif k == "keep_cookies": + self.keep_cookies = validate_bool(self.config.config_source, section_name(k), v) + elif k == "maxtasks" or k == "max_concurrent_tasks": + if k == "maxtasks" and self.api_options.deprecated_warning: + warnings.warn(f"aiobastion - Deprecated parameter '{section_name(k)}' use 'max_concurrent_tasks' parameter instead.", DeprecationWarning, stacklevel=3) + + synonym_max_concurrent_tasks += 1 + self.max_concurrent_tasks = validate_integer(self.config.config_source, section_name(k), v) + + if synonym_max_concurrent_tasks > 1: + raise AiobastionConfigurationException( + f"Duplicate synonym parameter '{section_name(k)}': " + f"in {self.config.config_source}. Specify only 'max_concurrent_tasks' and remove 'maxtasks'.") + + + elif k == "password": + self.password = v + elif k == "timeout": + self.timeout = validate_integer(self.config.config_source, section_name(k), v) + elif k == "token": # For serialiszation only + self.__token = serialized['token'] + elif k == "user_search": + self.user_search = v + + err = EPV_AIM.valid_secret_params(v) + + if err: + raise AiobastionConfigurationException(f"invalid parameter in '{section_name(k)}': {err}") + + elif k == "username": + self.username = v + elif k in ["verify", "ca"]: + synonym_verify += 1 + + if isinstance(v, str) or isinstance(v, bool): + self.verify = v + else: + raise AiobastionConfigurationException( + f"Parameter type invalid '{section_name(k)}' " + f"in {self.config.config_source}: {v!r}") + + + if k == "ca" and self.api_options.deprecated_warning: + warnings.warn(f"aiobastion - Deprecated parameter '{section_name(k)}' use 'verify' parameter instead.", DeprecationWarning, stacklevel=3) + + if synonym_verify > 1: + raise AiobastionConfigurationException( + f"Duplicate synonym parameter '{section_name(k)}': " + f"in {self.config.config_source}. Specify only 'verifiy' and remove 'ca'.") + + + else: + raise AiobastionConfigurationException( + f"Unknown attribute '{k}' in {self.config.config_source}: {v!r}") + + # Default value if not initialized + if self.authtype is None: + self.authtype = "cyberark" + + if self.keep_cookies is None: + self.keep_cookies = Config.CYBERARK_DEFAULT_KEEP_COOKIES + if self.max_concurrent_tasks is None: + self.max_concurrent_tasks = Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS + if self.timeout is None: + self.timeout = Config.CYBERARK_DEFAULT_TIMEOUT + if self.verify is None: + self.verify = Config.CYBERARK_DEFAULT_VERIFY + + if isinstance(self.verify, str): + if not os.path.exists(self.verify): + raise AiobastionConfigurationException( + f"CA certificat File not found {self.verify!r} (Parameter 'verify' in PVWA).") def validate_and_setup_ssl(self): if self.verify is None: self.verify = Config.CYBERARK_DEFAULT_VERIFY if not (isinstance(self.verify, str) or isinstance(self.verify, bool)): - raise AiobastionException(f"Invalid type for parameter 'verify' (or 'CA') in PVWA: {type(self.verify)} value: {self.verify!r}") + raise AiobastionException( + f"Invalid type for parameter 'verify' (or 'CA') in PVWA: {type(self.verify)} value: {self.verify!r}") if isinstance(self.verify, str): if not os.path.exists(self.verify): raise AiobastionException( - f"Parameter 'verify' (or 'CA') in PVWA: file not found {self.verify!r}") + f"CA certificat File not found {self.verify!r} (Parameter 'verify' in PVWA).") if os.path.isdir(self.verify): self.request_params = {"timeout": self.timeout, @@ -219,7 +297,7 @@ async def __login_cyberark(self, username: str, password: str, auth_type: str) - if req.status != 200: try: error = await req.text() - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # pylint: disable=broad-exception-caught error = f"Unable to get error message {err}" raise CyberarkException(error) from err @@ -265,7 +343,7 @@ async def logoff(self): return True - async def check_token(self) -> bool or None: + async def check_token(self) -> Optional[bool]: if self.__token is None: return None @@ -283,30 +361,55 @@ async def check_token(self) -> bool or None: # return False # return True - async def login_with_aim(self, aim_host: str = None, appid: str = None, username: str = None, cert_file: str = None, - cert_key: str = None, root_ca=None, timeout: int = None, max_concurrent_tasks: int = None, - user_search: dict = None, auth_type=None): - """ Authenticate the PVWA user using AIM interface to get the secret (password) in CyberArk. + async def login_with_aim( + self, + aim_host: str = None, + appid: str = None, + username: str = None, + cert_file: str = None, + cert_key: str = None, + root_ca: Optional[Union[bool, str]] = None, # Deprecated use 'verify' instead + *, # From this point all parameters are keyword only + auth_type=None, + cert_passphrase=None, + max_concurrent_tasks: int = None, + timeout: int = None, + user_search: dict = None, + verify: Optional[Union[bool, str]] = None): + """ Authenticate the PVWA user using AIM interface to get password (secret) in CyberArk. + + We only support client certificate authentication to the AIM. - We only support client certificate authentication to the AIM | ℹ️ The following parameters are optional. If a parameter is not set, it will be obtained - from *EPV* initialization (configuration file or serialization). + from *EPV* initialization (configuration file or serialization). For future compatibility, + it is recommended to set all parameters in the form key=value. | ⚠️ Any specified parameter from the *login_with_aim* function will override the *EPV_AIM* definition. + + * The function parameter behavoir toward login initialization (configuration: file or serialization): + * If a parameter is set: + * an AIM session is not open: + * the AIM configuration (EPV.AIM) is modifed + * an AIM session is open and the value is different from AIM configuration (EPV.AIM) + * a error will be raise + * If a parameter is not set: + * it will be obtained from AIM configuration (EPV.AIM) + :param aim_host: *AIM* CyberArk host :param appid: *AIM* Application ID + :param auth_type: *PVWA* logon authenticafication method: CyberArk, Windows, LDAP or Radius :param cert_file: *AIM* Filename public certificat :param cert_key: *AIM* Filename private key certificat - :param root_ca: *AIM* Directory or filename of the ROOT certificate authority (CA) - :param timeout: *AIM* Maximum wait time in seconds before generating a timeout (default 30 seconds) + :param cert_passphrase: *AIM* Certificat password :param max_concurrent_tasks: *AIM* Maximum number of parallel task (default 10) + :param timeout: *AIM* Maximum wait time in seconds before generating a timeout (default 30 seconds) :param username: *PVWA* Name of the user who is logging in to the Vault (PVWA username) - :param auth_type: *PVWA* logon authenticafication method: CyberArk, Windows, LDAP or Radius + :param verify: *AIM* ROOT certificate authority (CA) verification: True, False, Directory or filename + :type user_search: Dictionary :param user_search: *PVWA* Search parameters to uniquely identify the PVWA user (optional). - :type user_search: *PVWA* Dictionary | **user_search** dictionary may define any of the following keys: | safe, object, folder, address, database, policyid, failrequestonpasswordchange. @@ -317,9 +420,21 @@ async def login_with_aim(self, aim_host: str = None, appid: str = None, username :raise AiobastionException: AIM configuration setup error :raise CyberarkException: Runtime error """ + # For compatibility with older versions + if verify is not None and root_ca is not None and verify != root_ca: + raise AiobastionException("You can't specify both parameters: 'verify' and 'root_ca'.") + + if root_ca is not None: + if self.api_options.deprecated_warning: + warnings.warn( + "aiobastion - Deprecated parameter 'root_ca' in login_with_aim function use 'verify' parameter instead.", DeprecationWarning, stacklevel=2) + + verify = root_ca + root_ca = None + # Is AIM attribute defined ? if self.AIM: - # IF AIM is active, it is not too late to change the default configuration + # If AIM session is not active, it is not too late to change the default configuration if self.AIM.session is None: # Override AIM attributes with the function parameters if aim_host: @@ -330,48 +445,54 @@ async def login_with_aim(self, aim_host: str = None, appid: str = None, username self.AIM.cert = cert_file if cert_key: self.AIM.key = cert_key - if root_ca is not None: - self.AIM.verify = root_ca + if verify is not None: + self.AIM.verify = verify if timeout: self.AIM.timeout = timeout if max_concurrent_tasks: self.AIM.max_concurrent_tasks = max_concurrent_tasks + if cert_passphrase: + self.AIM.passphrase = cert_passphrase # Valide AIM setup self.AIM.validate_and_setup_aim_ssl() - # Complete undefined parameters with AIM and PWVA attributes + # Complete undefined parameters with AIM and PVWA attributes aim_host = (aim_host or self.AIM.host) appid = (appid or self.AIM.appid) cert_file = (cert_file or self.AIM.cert) cert_key = (cert_key or self.AIM.key) - timeout = (timeout or self.AIM.timeout or self.timeout) + cert_passphrase = (cert_passphrase or self.AIM.passphrase) max_concurrent_tasks = (max_concurrent_tasks or self.AIM.max_concurrent_tasks or self.max_concurrent_tasks) + timeout = (timeout or self.AIM.timeout or self.timeout) - if root_ca is None: # May be false + if verify is None: # May be false if self.AIM.verify is not None: - root_ca = self.AIM.verify + verify = self.AIM.verify else: if self.verify is not None: - root_ca = self.verify # PVWA + verify = self.verify # PVWA else: - root_ca = Config.CYBERARK_DEFAULT_VERIFY + verify = Config.CYBERARK_DEFAULT_VERIFY if (aim_host and aim_host != self.AIM.host) or \ (appid and appid != self.AIM.appid) or \ (cert_file and cert_file != self.AIM.cert) or \ (cert_key and cert_key != self.AIM.key) or \ - (root_ca is not None and root_ca != self.AIM.verify): + (verify is not None and verify != self.AIM.verify) or \ + (cert_passphrase and cert_passphrase != self.AIM.passphrase): raise CyberarkException("AIM is already initialized ! Please close EPV before reopen it.") else: - if root_ca is None: + # AIM is not defined + if verify is None: if self.verify is not None: - root_ca = self.verify # PVWA + verify = self.verify # PVWA else: - root_ca = Config.CYBERARK_DEFAULT_VERIFY + verify = Config.CYBERARK_DEFAULT_VERIFY - self.AIM = EPV_AIM(host=aim_host, appid=appid, cert=cert_file, key=cert_key, verify=root_ca, - timeout=timeout, max_concurrent_tasks=max_concurrent_tasks) + self.AIM = EPV_AIM(appid=appid, cert=cert_file, host=aim_host, key=cert_key, + max_concurrent_tasks=max_concurrent_tasks, + passphrase=cert_passphrase, timeout=timeout, verify=verify) # Valid AIM setup self.AIM.validate_and_setup_aim_ssl() @@ -379,21 +500,20 @@ async def login_with_aim(self, aim_host: str = None, appid: str = None, username # Check mandatory attributs if self.AIM.host is None or \ self.AIM.appid is None or \ - self.AIM.cert is None or \ - self.AIM.key is None: + self.AIM.cert is None: raise AiobastionException( - "Missing AIM mandatory parameters: host, appid, cert, key (and a optional verify).") + "Missing AIM mandatory parameters: host, appid, cert.") # Complete undefined parameters with PVWA attributes - if username is None and self.config and self.config.username: - username = self.config.username + if username is None and self.username: + username = self.username if username is None: raise AiobastionException( "Username must be provided on login_with_aim call or in configuration file.") - if user_search is None and self.config and self.config.user_search: - user_search = self.config.user_search + if user_search is None and self.user_search: + user_search = self.user_search try: await self.login(username=username, password=None, auth_type=auth_type, user_search=user_search) @@ -432,11 +552,11 @@ async def login(self, username=None, password=None, auth_type="", user_search=No "Host must be provided in configuration file or in EPV(serialized={'api_host: 'CyberArk-host'}).") if username is None: - if self.config is None or self.config.username is None: + if self.username is None: raise AiobastionException( "Username must be provided on login call or in configuration file." " You may also configure the AIM section.") - username = self.config.username + username = self.username if not auth_type: if self.authtype: @@ -450,9 +570,9 @@ async def login(self, username=None, password=None, auth_type="", user_search=No self.AIM.validate_and_setup_aim_ssl() if password is None: - if self.config and self.config.password: - password = self.config.password - self.config.password = None + if self.password is not None: + password = self.password + self.password = None else: if not self.AIM: raise AiobastionException( @@ -465,8 +585,8 @@ async def login(self, username=None, password=None, auth_type="", user_search=No params = {"UserName": username} if user_search is None: - if self.config and self.config.user_search: - user_search = self.config.user_search + if self.user_search: + user_search = self.user_search if user_search: err = EPV_AIM.valid_secret_params(user_search) @@ -491,9 +611,9 @@ async def login(self, username=None, password=None, auth_type="", user_search=No # raise except CyberarkException as err: raise GetTokenException(str(err)) from err - finally: - # update or clean the session - await self.close_session() + # finally: + # # update or clean the session + # await self.close_session() def get_session(self): self.logger.debug(f"Getting aiobastion session ({self.session})") @@ -507,7 +627,7 @@ def get_session(self): elif self.session is None: head = {'Content-type': 'application/json', 'Authorization': self.__token} - self.session = aiohttp.ClientSession(headers=head, cookies = self.cookies) + self.session = aiohttp.ClientSession(headers=head, cookies=self.cookies) self.logger.debug(f"Building session ID (token is known) : {self.session}") elif self.session.closed: @@ -515,8 +635,7 @@ def get_session(self): self.logger.debug("Never happens scenario happened (Session closed but not None)") head = {'Content-type': 'application/json', 'Authorization': self.__token} - self.session = aiohttp.ClientSession(headers=head, cookies = self.cookies) - + self.session = aiohttp.ClientSession(headers=head, cookies=self.cookies) if self.__sema is None: self.__sema = asyncio.Semaphore(self.max_concurrent_tasks) @@ -529,7 +648,7 @@ def get_session(self): async def close_session(self): self.logger.debug("Closing session") try: - if self.AIM: # This is used, at least, when login is perform + if self.AIM: # This is used, at least, when login is perform await self.AIM.close_aim_session() if self.session: @@ -550,21 +669,64 @@ def get_url(self, url) -> Tuple[str, dict]: return addr, head def to_json(self): - serialized = { - "api_host": self.api_host, - "authtype": self.authtype, - "timeout": self.timeout, - "verify": self.verify, - "cpm": self.cpm, - "retention": self.retention, - "max_concurrent_tasks": self.max_concurrent_tasks, - "token": self.__token, - } - - # AIM Communication + serialized = {} + + # EPV attributes + for attr_name in EPV._SERIALIZED_FIELDS_OUT: + if attr_name == "token": + serialized[attr_name] = self.__token + else: + serialized[attr_name] = getattr(self, attr_name, None) + + # Global API options + d = self.api_options.to_json() + if d: + serialized["api_options"] = d + + # options modules if self.AIM: serialized["AIM"] = self.AIM.to_json() + d = self.account.to_json() + if d: + serialized["account"] = d + + d = self.accountgroup.to_json() + if d: + serialized["accountgroup"] = d + + d = self.application.to_json() + if d: + serialized["application"] = d + + d = self.group.to_json() + if d: + serialized["group"] = d + + d = self.platform.to_json() + if d: + serialized["platform"] = d + + d = self.safe.to_json() + if d: + serialized["safe"] = d + + d = self.session_management.to_json() + if d: + serialized["session_management"] = d + + d = self.system_health.to_json() + if d: + serialized["system_health"] = d + + d = self.user.to_json() + if d: + serialized["user"] = d + + d = self.utils.to_json() + if d: + serialized["utils"] = d + return serialized async def get_version(self): @@ -613,7 +775,7 @@ async def handle_request(self, method: str, short_url: str, data=None, params: d return True else: if req.status == 404: - raise CyberarkException(f"404 error with URL {url}") + raise CyberarkNotFoundException(f"404 error with URL {url}") elif req.status == 401: raise CyberarkException("You are not logged, you need to login first") elif req.status == 405: diff --git a/aiobastion/exceptions.py b/aiobastion/exceptions.py index 5418c41..82c1488 100644 --- a/aiobastion/exceptions.py +++ b/aiobastion/exceptions.py @@ -2,11 +2,17 @@ class CyberarkException(Exception): """ - This Exception is often raised on 404 + This Exception is raised on unhandled Cyberark error """ pass +class CyberarkNotFoundException(CyberarkException): + """ + This exception is raised on 404 + """ + pass + class GetTokenException(Exception): """ This exception is raised when the token can't be obtained diff --git a/aiobastion/platforms.py b/aiobastion/platforms.py index 87909e1..a8d695f 100644 --- a/aiobastion/platforms.py +++ b/aiobastion/platforms.py @@ -3,13 +3,36 @@ import base64 import aiohttp -from .exceptions import CyberarkException, CyberarkAPIException +from .exceptions import CyberarkException, CyberarkAPIException, AiobastionConfigurationException class Platform: - def __init__(self, epv): + # _PLATFORM_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv - # self.session = self.epv.session + + _section = "platform" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + + def to_json(self): + serialized = {} + + for attr_name in Platform._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized async def get_target_platforms(self, active: bool = None, systemType: str = None, periodicVerify: bool = None, manualVerify: bool = None, periodicChange: bool = None, manualChange: bool = None, @@ -125,10 +148,10 @@ async def deactivate_target_platform(self, pfid: int): async def export_platform(self, pfid: str, outdir: str): """ Export platform files to outdir (existing directory) - - :param pfid: - :param outdir: - :return: + + :param pfid: + :param outdir: + :return: """ url, head = self.epv.get_url(f"API/Platforms/{str(pfid)}/Export") diff --git a/aiobastion/safe.py b/aiobastion/safe.py index 17c52b1..3491a0c 100644 --- a/aiobastion/safe.py +++ b/aiobastion/safe.py @@ -1,17 +1,56 @@ # -*- coding: utf-8 -*- +import logging import warnings -from typing import AsyncIterator +from typing import AsyncIterator, Union -from .config import permissions, DEFAULT_PERMISSIONS, get_v2_profile +from .config import permissions, DEFAULT_PERMISSIONS, get_v2_profile, validate_integer from .exceptions import ( - CyberarkAPIException, CyberarkException, AiobastionException + CyberarkAPIException, CyberarkException, AiobastionException, AiobastionConfigurationException, + CyberarkNotFoundException ) class Safe: - def __init__(self, epv): + _SAFE_DEFAULT_CPM = "" + _SAFE_DEFAULT_RETENTION = 10 + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = ["cpm", "retention"] + + def __init__(self, epv, cpm: str = None, retention: int = None, **kwargs): self.epv = epv + _section = "safe" + _config_source = self.epv.config.config_source + + # string to int or assign default value + self.retention = validate_integer(_config_source, f"{_section}/retention", + retention, Safe._SAFE_DEFAULT_RETENTION) + + if cpm is None: + self.cpm = Safe._SAFE_DEFAULT_CPM + elif not isinstance(cpm, str): + raise AiobastionConfigurationException(f"Invalid attribute '{_section}/cpm' in {_config_source}: " + f" must be a string: {cpm!r}") + else: + self.cpm = cpm + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException( + f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + def to_json(self): + serialized = {} + + for attr_name in Safe._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + # TODO : add membershipExpirationDate permissions isReadOnly async def add_member(self, safe: str, username: str, search_in: str = "Vault", useAccounts: bool = False, @@ -103,10 +142,13 @@ async def add_member(self, safe: str, username: str, search_in: str = "Vault", if not await self.exists(safe): raise AiobastionException(f"Safe : \"{safe}\" was not found") - return await self.epv.handle_request("post", url, data=data) + try: + return await self.epv.handle_request("post", url, data=data) + except CyberarkNotFoundException: + raise CyberarkException(f"Unable to add member : Safe '{safe}' or user '{username}' was not found") # TODO : Document profiles - async def add_member_profile(self, safe: str, username: str, profile: (str, dict)): + async def add_member_profile(self, safe: str, username: str, profile: Union[str, dict]): """ This functions adds the "username" user (or group) to the given safe with a relevant profile @@ -182,8 +224,8 @@ async def add(self, safe_name: str, description="", location="", olac=False, day "SafeName": safe_name, "Description": description, "OLACEnabled": olac, - "ManagingCPM": self.epv.cpm if cpm is None else cpm, - "NumberOfVersionsRetention": self.epv.retention if versions is None else versions, + "ManagingCPM": self.cpm if cpm is None else cpm, + "NumberOfVersionsRetention": self.retention if versions is None else versions, "numberOfDaysRetention": days, "AutoPurgeEnabled": auto_purge, "location": location @@ -215,10 +257,10 @@ async def add_defaults_admin(self, safe_name): await self.add_member_profile(safe_name, user, profile) except CyberarkAPIException as err: if err.http_status == 409: - warnings.warn(err.err_message) + warnings.warn(err.err_message, stacklevel=3) # pass elif err.http_status == 403: - warnings.warn(err.err_message) + warnings.warn(err.err_message, stacklevel=3) else: raise @@ -232,6 +274,88 @@ async def delete(self, safe_name): url = f"api/Safes/{safe_name}" return await self.epv.handle_request("delete", url) + async def safe_members_iterator(self, safe_name, member_type: str = None, membership_expired: bool = None, + include_predefined_users=None, search: str = None) -> AsyncIterator: + """ + This function allow to search using one or more parameters and return list of address id + + :param safe_name: Name of the safe + :param member_type: user or group + :param membership_expired: include expired memberships + :param include_predefined_users: include predefined users + :param search: free search + """ + + page = 1 + has_next_page = True + + while has_next_page: + safe_members = await self.safe_members_paginate(page=page, safe_name=safe_name, member_type=member_type, + membership_expired=membership_expired, + include_predefined_users=include_predefined_users, search= + search) + has_next_page = safe_members["has_next_page"] + page += 1 + for a in safe_members["members"]: + yield a + + async def safe_members_paginate(self, page: int = 1, size_of_page: int = 100, safe_name: str = None, + member_type: str = None, + membership_expired: bool = None, include_predefined_users=None, search: str = None): + """ + Search safes in a paginated way + + :param page: number of page + :param size_of_page: size of pages + :param safe_name: Name of the safe + :param member_type: user or group + :param membership_expired: include expired memberships + :param include_predefined_users: include predefined users + :param search: free search + + :return: + + """ + if member_type not in [None, "user", "group"]: + raise AiobastionException(f"Invalid member_type : {member_type}") + + params = {} + safe_members_filter = [] + if member_type is not None: + safe_members_filter.append(f"MemberType eq {member_type}") + if membership_expired is not None: + safe_members_filter.append(f"MembershipExpired eq {membership_expired}") + if include_predefined_users is not None: + safe_members_filter.append(f"IncludePredefinedUsers eq {include_predefined_users}") + + if len(safe_members_filter) > 0: + params["filter"] = " AND ".join(safe_members_filter) + + if search is not None: + params["search"] = f"{search}" + + if search is not None: + params["search"] = f"{search}" + + params["limit"] = size_of_page + params["offset"] = (page - 1) * size_of_page + url = f"api/Safes/{safe_name}/Members" + + self.epv.logger.debug(f"safe_members_paginate computed params : {params}") + try: + search_results = await self.epv.handle_request("get", url, params=params, + filter_func=lambda x: x) + except CyberarkAPIException as err: + raise CyberarkAPIException(404, "ERR_404", f"Safe {safe_name} doesn't exist") + + safe_members = search_results['value'] + + has_next_page = "nextLink" in search_results + return { + "members": safe_members, + "has_next_page": has_next_page + } + async def list_members(self, safe_name: str, filter_perm=None, details=False, raw=False): """ List members of a safe, optionally those with specific perm @@ -243,11 +367,6 @@ async def list_members(self, safe_name: str, filter_perm=None, details=False, ra :return: list of all users, or list of users with specific perm """ if filter_perm is not None: - # valid_filter = ['Add', 'AddRenameFolder', 'BackupSafe', 'Delete', 'DeleteFolder', 'ListContent', - # 'ManageSafe', 'ManageSafeMembers', 'MoveFilesAndFolders', 'Rename', - # 'RestrictedRetrieve', 'Retrieve', 'Unlock', 'Update', 'UpdateMetadata', - # 'ValidateSafeContent', 'ViewAudit', 'ViewMembers'] - # v2 API valid_filter = ['useAccounts', 'retrieveAccounts', 'listAccounts', 'addAccounts', 'updateAccountContent', 'updateAccountProperties', 'initiateCPMAccountManagementOperations', 'specifyNextAccountContent', 'renameAccounts', 'deleteAccounts', 'unlockAccounts', @@ -257,13 +376,8 @@ async def list_members(self, safe_name: str, filter_perm=None, details=False, ra if filter_perm not in valid_filter: raise AiobastionException(f"filter_perm {filter_perm} is not one of : {valid_filter} ") - #url = f"WebServices/PIMServices.svc/Safes/{safe_name}/Members" - url = f"api/Safes/{safe_name}/Members" - try: - members = await self.epv.handle_request("get", url, filter_func=lambda x: x["value"]) - except CyberarkException as err: - raise CyberarkAPIException(404, "ERR_404", f"Safe {safe_name} doesn't exist") - + members = [_m async for _m in self.safe_members_iterator(safe_name=safe_name)] + if raw: return members @@ -340,12 +454,11 @@ async def search_safe_paginate(self, page: int = 1, size_of_page: int = 100, sea params["includeAccounts"] = str(include_accounts) params["extendedDetails"] = str(extended_details) - params["limit"] = size_of_page params["offset"] = (page - 1) * size_of_page try: search_results = await self.epv.handle_request("get", "API/Safes", params=params, - filter_func=lambda x: x) + filter_func=lambda x: x) except CyberarkAPIException as err: if err.err_code == "CAWS00001E": raise AiobastionException("Please don't list safes with a user member of PSMMaster (Cyberark bug)") @@ -419,7 +532,7 @@ async def rename(self, safename: str, new_name: str): good_safe = next(_s for _s in found_safes if _s["safeName"].upper() == safename.upper()) except StopIteration: raise AiobastionException(f"Safe {safename} was not found") - # print(good_safe) + safe_url_id = good_safe["safeUrlId"] url = f"API/Safes/{safe_url_id}/" @@ -427,3 +540,44 @@ async def rename(self, safename: str, new_name: str): good_safe["safeName"] = new_name return await self.epv.handle_request("put", url, data=good_safe) + + async def update(self, safe_name: str, description=None, location=None, olac=None, days=None, versions=None, + cpm=None): + """ + Update existing safe + + :param safe_name: The name of the safe to update + :param description: The safe description + :param location: Safe location (must be an existing location) + :param olac: Enable OLAC for the safe (default to False) + :param days: Days of retention + :param versions: Number of versions + :param cpm: The name of the CPM user who will manage the new Safe. + :return: A dict of the updated safe details + """ + + url = f"api/Safes/{safe_name}" + data: dict = { + "SafeName": safe_name, + } + + if description is not None: + data["Description"] = description + if location is not None: + data["location"] = location + if olac is not None: + data["OLACEnabled"] = olac + if days is not None: + data["numberOfDaysRetention"] = days + if versions is not None: + data["NumberOfVersionsRetention"] = versions + if cpm is not None: + data["ManagingCPM"] = cpm + + # options are mutually exclusive + if days is not None and days >= 0: + data.pop("NumberOfVersionsRetention", None) + else: + data.pop("numberOfDaysRetention", None) + + return await self.epv.handle_request("put", url, data=data) diff --git a/aiobastion/session_management.py b/aiobastion/session_management.py index 62aa9c6..f24da4f 100644 --- a/aiobastion/session_management.py +++ b/aiobastion/session_management.py @@ -1,14 +1,38 @@ # -*- coding: utf-8 -*- -from .exceptions import CyberarkException, CyberarkAPIException +from .exceptions import CyberarkException, CyberarkAPIException, AiobastionConfigurationException class SessionManagement: - def __init__(self, epv): + # _SESSIONMANAGEMENT_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv + _section = "sessionmanagement" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + async def get_all_connection_components(self): """ :return: A list of all connection components """ return await self.epv.handle_request("get", f"API/PSM/Connectors/") + + + def to_json(self): + serialized = {} + + for attr_name in SessionManagement._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized diff --git a/aiobastion/system_health.py b/aiobastion/system_health.py index d778d28..c525177 100644 --- a/aiobastion/system_health.py +++ b/aiobastion/system_health.py @@ -1,7 +1,21 @@ +from .exceptions import AiobastionConfigurationException + class SystemHealth: - def __init__(self, epv): + # _SYSTEMHEALTH_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv + _section = "systemhealth" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + async def summary(self): url = f"API/ComponentsMonitoringSummary/" @@ -16,3 +30,16 @@ async def details(self, component_id): url = f"API//ComponentsMonitoringDetails/{component_id}/" return await self.epv.handle_request("get", url, filter_func=lambda x: x["ComponentsDetails"]) + + + def to_json(self): + serialized = {} + + for attr_name in SystemHealth._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + diff --git a/aiobastion/users.py b/aiobastion/users.py index ce368b8..9a6ae9b 100644 --- a/aiobastion/users.py +++ b/aiobastion/users.py @@ -1,12 +1,37 @@ import asyncio -from .exceptions import AiobastionException +from .exceptions import AiobastionException, AiobastionConfigurationException from typing import List class User: - def __init__(self, epv): + # _USER_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv + self.user_list = None + + _section = "user" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + def to_json(self): + serialized = {} + + for attr_name in User._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + async def get_logged_on_user_details(self): """ @@ -66,10 +91,10 @@ async def exists(self, username: str): :param username: username of the user :return: Boolean """ - if self.epv.user_list is None: + if self.user_list is None: results = await self.epv.handle_request("get", 'API/Users', filter_func=lambda result: result["Users"]) - self.epv.user_list = [u['username'].lower().strip() for u in results] - return username.lower() in self.epv.user_list + self.user_list = [u['username'].lower().strip() for u in results] + return username.lower() in self.user_list async def details(self, username: str = "", user_id=None): """ @@ -206,10 +231,55 @@ async def delete(self, username: str): return await self.epv.handle_request("delete", f"API/Users/{user_id}/") + async def safes(self, username: str, user_id=None, details=False): + """ + Returns the safes of a specific user + + :param username: the username + :param user_id: the user_id if the username is not provided + :return: user's safes list + """ + if user_id is None: + if username == "": + raise AiobastionException("You must provide username or user_id") + user_id = await self.get_id(username) + url = f"api/Users/{user_id}/safes" + if details: + return await self.epv.handle_request("get", url, filter_func=lambda x: x["Safes"]) + else: + safes = await self.epv.handle_request("get", url, filter_func=lambda x: x["Safes"]) + return [s["SafeName"] for s in safes] + + + class Group: - def __init__(self, epv): + # _GROUP_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv + _section = "group" + _config_source = self.epv.config.config_source + + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + + def to_json(self): + serialized = {} + + for attr_name in Group._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + async def list(self, pattern: str = None, group_type: str = None, details: bool = False, include_members: bool = False): """ diff --git a/aiobastion/utilities.py b/aiobastion/utilities.py index a217e05..34b77bf 100644 --- a/aiobastion/utilities.py +++ b/aiobastion/utilities.py @@ -2,7 +2,7 @@ import copy from .accounts import PrivilegedAccount -from .exceptions import AiobastionException, CyberarkAPIException +from .exceptions import AiobastionException, CyberarkAPIException, AiobastionConfigurationException def clone_privileged_account(account: PrivilegedAccount, replace: dict, update_name=True) -> PrivilegedAccount: @@ -25,10 +25,34 @@ def case_insensitive_getattr(obj, attr): class Utilities: - def __init__(self, epv): + # _UTILITIES_DEFAULT_XXX = + + # List of attributes from configuration file and serialization + _SERIALIZED_FIELDS = [] + + def __init__(self, epv, **kwargs): self.epv = epv + + _section = "utilities" + _config_source = self.epv.config.config_source + self.platform = self.Platform(epv) + # Check for unknown attributes + if kwargs: + raise AiobastionConfigurationException(f"Unknown attribute in section '{_section}' from {_config_source}: {', '.join(kwargs.keys())}") + + def to_json(self): + serialized = {} + + for attr_name in Utilities._SERIALIZED_FIELDS: + v = getattr(self, attr_name, None) + + if v is not None: + serialized[attr_name] = v + + return serialized + async def cpm_change_failed_accounts(self, address, username_filter: list = None): """ CPM Change for "red" accounts @@ -258,15 +282,36 @@ async def connection_component_usage(self): if cc_name in con_comp: con_comp[cc_name].append(pf['PlatformID']) else: - print(f"Warning for {cc_name} in {pf['PlatformID']} => {_c}") + self.epv.logger.info(f"Warning for {cc_name} in {pf['PlatformID']} => {_c} " + f"(Unknown Component)") con_comp[cc_name] = [pf['PlatformID']] result = [] for conn, pfs in con_comp.items(): - result.append(f"{conn},{len(pfs)},{','.join(pfs)}") + result.append({"Component": conn, + "PlatformCount": len(pfs), + "PlatformList": pfs}) return result + async def connection_components_by_platform(self): + """ + Return a dict with connection components by platform (key = platform name, + value = Connection component list) + """ + cc_usage = await self.connection_component_usage() + platform_dict = {} + + for cc in cc_usage: + for _p in cc["PlatformList"]: + if _p not in platform_dict: + platform_dict[_p] = [cc["Component"]] + else: + platform_dict[_p].append(cc["Component"]) + + return platform_dict + + async def migrate_platform(self, old_platform: str, new_platform: str, address_filter: list = None): """ Migrate all accounts from old platform to new platform diff --git a/docs/accounts.rst b/docs/accounts.rst index 923bb74..faad848 100644 --- a/docs/accounts.rst +++ b/docs/accounts.rst @@ -42,9 +42,20 @@ it has the following methods : * last_modified : return the last modified time (days since last password change) About linked account index : - * reconcile account index: 3 - you should NOT change it unless your system has different custom value. - * logon account index: 2 - this is different from the installation (1). The default value is kept at 2 to avoid - breaking existing users. You can override it to 1 by providing a "custom.LOGON_ACCOUNT_INDEX" value in your config. + * **reconcile account index**: This field is the linked account's extra password index (extraPasswordIndex) use with CyberArk "LinkAccount". + The index can be for a Reconcile account, Logon account, or other linked account that is defined in the Platform configuration. + + It is used in *remove_reconcile_account* function. The default value 3 should NOT be changed unless your system has different custom value. + You can override it by providing in "account" section the "reconcile_account_index" value in your configuration file or serialization. + + * **logon account index**: This field is the linked account's extra password index (extraPasswordIndex) use with CyberArk "LinkAccount". + The index can be for a Reconcile account, Logon account, or other linked account that is defined in the Platform configuration. + + It is used in *link_logon_account* and *remove_logon_account* functions. + The defaut value is 2 for compatibility. This is different from the installation (1). + You can override it to 1 by providing in "account" section the "logon_account_index" value in your configuration file or serialization. + +The linked account's extra password index. The index can be for a Reconcile account, Logon account, or other linked account that is defined in the Platform configuration. Calling functions ------------------- @@ -72,6 +83,7 @@ Account management .. autofunction:: update_file_category .. autofunction:: update_platform .. autofunction:: update_single_fc +.. autofunction:: delete_fc .. autofunction:: update_using_list .. autofunction:: move .. autofunction:: link_account @@ -84,6 +96,10 @@ Account management .. autofunction:: delete + +.. _CyberArk Central Credential Provider - REST web service: https://docs.cyberark.com/AAM-CP/Latest/en/Content/CCP/Calling-the-Web-Service-using-REST.htm + + Password Actions ------------------ .. autofunction:: change_password @@ -128,7 +144,6 @@ Miscellaneous .. autofunction:: is_valid_safename .. autofunction:: is_valid_username - .. for documenting a single function => .. autofunction:: aiobastion.accounts.Account.handle_acc_id_list diff --git a/docs/compatibility.rst b/docs/compatibility.rst index 36e1f3c..8f3bbdc 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -3,7 +3,7 @@ Component compatibility PVWA ------- -Versions known to be compatible today are 12.2.x and 12.6.x. +Versions known to be compatible today are 12.2.x and 12.6.x and 14.x. If you have an older version, some functions won't work and you'll raise a CyberarkException "Your PVWA version does not support this function". diff --git a/docs/faq.rst b/docs/faq.rst index e3c67c8..df7c647 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -81,12 +81,12 @@ Here's an example of serialization / deserialization: # Save your session in json epv_json = epv_session.to_json() - # Pass information to another program the way you want + # Pass epv_json information to another program the way you want # Other program: # Utility function to rebuild EPV object from serialized session - async def get_session(epv_session): - epv = aiobastion.EPV(serialized=epv_session) + async def get_session(epv_json): + epv = aiobastion.EPV(serialized=epv_json) if not await epv.check_token(): # Ensure that the token is still valid raise GetTokenException diff --git a/docs/login.rst b/docs/login.rst index 82c4039..02aa164 100644 --- a/docs/login.rst +++ b/docs/login.rst @@ -103,7 +103,7 @@ Sometimes you need to ask the username and password in your code. # Login to the PVWA try: - await epv_env.login(username, password) + await epv_env.login(username=username, password=password) except GetTokenException as err: print(f"An error occured while login : {err}") await epv_env.close_session() @@ -145,31 +145,51 @@ You can use the `Serialization tools`_ to extract the EPV serialization at any t async def initialize_pvwa(): # To use AIM serialization, you may specify the following information aim_config = { - "host": "aim.mycompany.com", # (required) AIM host "appid": "Automation_Application", # (required) AIM Application ID - "Cert": r"C:\Folder\AIM_Cert.pem", # (required) AIM Filename public certificate - "Key": r"C:\Folder\AIM_private_key", # (required) AIM Filename Private Key certificate - "Passphrase": "C3rtP4$$Phr4se", # (optional) AIM Private Key certificate passphrase - "Verify": r"C:\Folder\AIM_Root_CA.pem" # (optional) Directory or filename of the ROOT certificate authority (CA) + "cert": r"C:\Folder\AIM_Cert.pem", # (required) AIM Filename public certificate + "host": "aim.mycompany.com", # (required) AIM host "keep_cookies": False, # (optional) whether to keep cookies between calls, set to true if API host is behind a load balancer + # "key": r"C:\Folder\AIM_private_key", # (required) AIM Filename Private Key certificate "max_concurrent_tasks": 10, # (optional) AIM Maximum number of parallel task (default 10) - "timeout": 30 # (optional) AIM Maximum wait time in seconds before generating a timeout (default 30 seconds) + "passphrase": "C3rtP4$$Phr4se", # (optional) AIM certificate passphrase (password) + "timeout": 30, # (optional) AIM Maximum wait time in seconds before generating a timeout (default 30 seconds) + "verify": r"C:\Folder\AIM_Root_CA.pem" # (optional) Directory or filename of the ROOT certificate authority (CA) } - # PVWA serialization definition, you may specify the following information - pvwa_config = { - "api_host": "pvwa.mycompany.com", # (required) API host (eg the PVWA host) - "authtype": "LDAP", # (optional) Defaults is Cyberark. Acceptable values : Cyberark, Windows, LDAP or RADIUS + # Account definition (if needed) + account_config = { + "logon_account_index": 1, + # "reconcile_account_index": 3 + } + + # Safe definition (if needed) + safe_config = { "cpm": "PasswordManager", # (optional) CPM to assign to safes, default = "" (no CPM) - "max_concurrent_tasks": 10, # (optional) Maximum number of parallel task (default 10) - "retention": 10, # (optional) Days of retention for objects in safe, default = 10 - "timeout": 30, # (optional) Maximum wait time in seconds before generating a timeout (default 30 seconds) - "verify": r"C:\Folder\PVWA_Root_CA.pem", # (optional) set if you want to add additional ROOT ca certs - "keep_cookies": False, # (optional) whether to keep cookies between calls, set to true if API host is behind a load balancer - "AIM": aim_config # (optional) if AIM API is not needed + "retention": 30 # (optional) Days of retention for objects in safe, default = 10 + } + + # Global api options (if needed) + api_options_config = { + "deprecated_warning": False # (optional) Suppress deprecated warning, default = True (enable warning) + } + + + # PVWA serialization definition, you may specify the following information + global_config = { + "api_host": "pvwa.mycompany.com", # (required) PVWA: hostname + "authtype": "LDAP", # (optional) PVWA: Defaults is Cyberark. Acceptable values : Cyberark, Windows, LDAP or RADIUS + "max_concurrent_tasks": 10, # (optional) PVWA: Maximum number of parallel task (default 10) + "timeout": 30, # (optional) PVWA: Maximum wait time in seconds before generating a timeout (default 30 seconds) + "verify": r"C:\Folder\PVWA_Root_CA.pem", # (optional) PVWA: set if you want to add additional ROOT ca certs + "keep_cookies": False, # (optional) PVWA: whether to keep cookies between calls, set to true if API host is behind a load balancer + + "aim": aim_config, # (optional) AIM customization definition + "account": account_config, # (optional) Account customization definition + "safe": safe_config # (optional) Safe customization definition + "api_options": api_options_config # (optional) Global api options } - epv_env = aiobastion.EPV(serialized=pvwa_config) + epv_env = aiobastion.EPV(serialized=global_config) username = 'PVWAUSER001' # If PVWA username is unique @@ -182,7 +202,7 @@ You can use the `Serialization tools`_ to extract the EPV serialization at any t } try: - await epv_env.login(username=username, user_search=search_user) + await epv_env.login(username=username, user_search=pvwa_user_search) except GetTokenException as err: # handle failure here @@ -231,18 +251,16 @@ For demonstration purpose, AIM serialization is not define here. Otherwise refer aim_config = None # PVWA serialization definition, you may specify the following information - pvwa_config = { - "api_host": "pvwa.mycompany.com", # (required) API host (eg the PVWA host) - "authtype": "LDAP", # (optional) Defaults is Cyberark. Acceptable values : Cyberark, Windows, LDAP or RADIUS - "cpm": "PasswordManager", # (optional) CPM to assign to safes, default = "" (no CPM) - "max_concurrent_tasks": 10, # (optional) Maximum number of parallel task (default 10) - "retention": 10, # (optional) Days of retention for objects in safe, default = 10 - "timeout": 30, # (optional) Maximum wait time in seconds before generating a timeout (default 30 seconds) - "verify": r"C:\Folder\PVWA_Root_CA.pem", # (optional) set if you want to add additional ROOT ca certs - "AIM": aim_config + global_config = { + "api_host": "pvwa.mycompany.com", # (required) PVWA: Hostname + "authtype": "LDAP", # (optional) PVWA: Defaults is Cyberark. Acceptable values : Cyberark, Windows, LDAP or RADIUS + "max_concurrent_tasks": 10, # (optional) PVWA: Maximum number of parallel task (default 10) + "timeout": 30, # (optional) PVWA: Maximum wait time in seconds before generating a timeout (default 30 seconds) + "verify": true, # (optional) PVWA: set if you want to add additional ROOT ca certs + "aim": aim_config } - epv_env = aiobastion.EPV(serialized=pvwa_config) + epv_env = aiobastion.EPV(serialized=global_config) username = 'PVWAUSER001' # If PVWA username is unique @@ -261,10 +279,10 @@ For demonstration purpose, AIM serialization is not define here. Otherwise refer cert_file=r"C:\Folder\AIM_Cert.pem", cert_key=r"C:\Folder\AIM_private_key", passphrase="C3rtP4$$Phr4se", - root_ca=r"C:\Folder\AIM_Root_CA.pem", + verify=r"C:\Folder\AIM_Root_CA.pem", # timeout= 30, # max_concurrent_tasks= 10, - # auth_type="LDAP", + # auth_type="cyberark", username=username, user_search=pvwa_user_search) except GetTokenException as err: @@ -308,15 +326,16 @@ If you need to authenticate with RADIUS challenge / response mode, you need to c username = "PVWAUSER001" password = getpass.getpass() - pvwa_config = {'api_host': pvwa_host} + global_config = {'api_host': pvwa_host} - epv_env = aiobastion.EPV(serialized=pvwa_config) + epv_env = aiobastion.EPV(serialized=global_config) try: await epv_env.login(username=username, password=password, auth_type=authtype) except ChallengeResponseException: passcode = input("Enter passcode: ") - await epv_env.login(username, passcode, authtype) + await epv_env.login(username=username, password=passcode, auth_type=authtype) + except GetTokenException: # handle failure here await epv_env.close_session() @@ -353,13 +372,14 @@ In rare cases, you may want to connect only with the AIM interface (without PVWA def initialize_aim(): # To use AIM serialization, you may specify the following information aim_config = { - "host": "aim.mycompany.com", # (required) AIM host "appid": "Automation_Application", # (required) AIM Application ID "cert": r"C:\Folder\AIM_Cert.pem", # (required) AIM Filename public certificate + "host": "aim.mycompany.com", # (required) AIM host "key": r"C:\Folder\AIM_private_key", # (required) AIM Filename Private Key certificate - "verify": True # (optional) Directory or filename of the ROOT certificate authority (CA) "max_concurrent_tasks": 13, # (optional) AIM Maximum number of parallel task (default 10) - "timeout": 60 # (optional) AIM Maximum wait time in seconds before generating a timeout (default 30 seconds) + # "passphrase": "C3rtP4$$Phr4se", # (optional) AIM certificate passphrase (password) + "timeout": 60, # (optional) AIM Maximum wait time in seconds before generating a timeout (default 30 seconds) + "verify": True # (optional) Directory or filename of the ROOT certificate authority (CA) } aim_env = aiobastion.aim.EPV_AIM(serialized=aim_config) @@ -411,27 +431,37 @@ All sections name and field attributes **are no longer case sensitive**. The configuration file contains the following main sections: -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| Section | Type | Description + -+===============+===========+======================================================================================================================+ -| connection | Required | PVWA user login information. + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| pvwa | Required | PVWA Request management information. + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| aim | Optional | Specify the AIM Request management information (EPV.AIM). + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| cpm | Optional | CPM user name managing the new safe (EPV.cpm). + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| custom | Optional | Customer section (EPV.config.custom). + -| | | + -| | | This section is not used by aiobastion. + -| | | + -| | | It is available to custom to add their own information if necessary. + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| label | Optional | Configuration name for information only (EPV.config.label). + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ -| retention | Optional | For safe creation, the number of retained versions of every password that is stored in the Safe (EPV.retention). + -+---------------+-----------+----------------------------------------------------------------------------------------------------------------------+ ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| Section | Type | Description + ++===============+=======================+======================================================================================================================+ +| account | Optional | account management customization field + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| connection | Required | PVWA user login information. + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| pvwa | Required | PVWA Request management information. + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| aim | Optional | Specify the AIM Request management information (EPV.AIM). + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| api_options | Optional | Specify API global options. + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| cpm | Optional, deprecated | CPM user name managing the new safe. + +| | | + +| | | cpm has moved to the 'safe' section + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| custom | Optional | Customer section (EPV.config.custom). + +| | | + +| | | This section is not used by aiobastion. + +| | | + +| | | It is available to custom to add their own information if necessary. + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| label | Optional | Configuration name for information only (EPV.config.label). + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| retention | Optional, deprecated | For safe creation, the number of retained versions of every password that is stored in the Safe (EPV.retention). + +| | | + +| | | retention has moved to the 'safe' section + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ +| safe | Optional | Safe management customization field + ++---------------+-----------------------+----------------------------------------------------------------------------------------------------------------------+ CONNECTION section / field definitions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -488,10 +518,11 @@ PVWA section / field definitions +----------------------+-------------------------+ + | ca | Optional, deprecated + + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ -| keep_cookies | Optional | Keep cookies from login and send in subsequent API calls (default Fasle). You may need to + -| | | You may need to set to True when a load-balancer is present. | +| keep_cookies | Optional | Keep cookies from login and send in subsequent API calls (default False). + +| | | You may need to set to True when a load-balancer is present. + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ + AIM section / field definitions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ @@ -501,9 +532,10 @@ AIM section / field definitions +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ | cert | Required | AIM Filename public certificate + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ -| key | Required | AIM Filename private key certificate + +| keep_cookies | Optional | Keep cookies from login and send in subsequent API calls (default False). + +| | | You may need to set to True when a load-balancer is present. + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ -| passphrase | Optional | AIM Filename private key certificate passphrase + +| key | Required | AIM Filename private key certificate + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ | host | Required | AIM CyberArk host name. If not define use the host from the PVWA section. + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ @@ -512,6 +544,12 @@ AIM section / field definitions +----------------------+-------------------------+ If not define use the *max_concurrent_tasks* from the PVWA section. + | maxtasks | Optional, deprecated + + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ +| passphrase | Optional | AIM private key certificate passphrase (password) + ++----------------------+-------------------------+--------------------------------------------------------------------------------------------+ +| timeout | Optional | AIM Maximum wait time in seconds before generating a timeout (default 30 seconds). + +| | | + +| | | If not define use the *timeout* from the PVWA section. + ++----------------------+-------------------------+--------------------------------------------------------------------------------------------+ | verify | Optional | PVWA Directory or filename of the ROOT certificate authority (CA) (default False). + | | | + | | | Possible values: + @@ -523,15 +561,47 @@ AIM section / field definitions +----------------------+-------------------------+ + | ca | Optional, deprecated + + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ -| timeout | Optional | AIM Maximum wait time in seconds before generating a timeout (default 30 seconds). + -| | | + -| | | If not define use the *timeout* from the PVWA section. + + + +api_options section / field definitions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ -| keep_cookies | Optional | Keep cookies from login and send in subsequent API calls (default Fasle). You may need to + -| | | You may need to set to True when a load-balancer is present. | +| Field | Type | Description + ++======================+=========================+============================================================================================+ +| deprecated_warning | Optional | Enable/disable deprecated warning (Defaut True) + +| | | + +| | | Possible value: + +| | | - False: disable deprecated warning + +| | | - True: enable deprecated warning + +----------------------+-------------------------+--------------------------------------------------------------------------------------------+ +ACCOUNT section / field definitions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ++-------------------------+-------------------------+---------------------------------------------------------------------------------------------------+ +| Field | Type | Description + ++=========================+=========================+===================================================================================================+ +| logon_account_index | Optional | The logon account index. + +| | | + +| | | The defaut value is 2 for compatibility. + ++-------------------------+-------------------------+---------------------------------------------------------------------------------------------------+ +| reconcile_account_index | Optional | The reconcile account index. + +| | | + +| | | The default value 3 should NOT be changed unless your system has different custom value. + ++-------------------------+-------------------------+---------------------------------------------------------------------------------------------------+ + + +SAFE section / field definitions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ++-------------------------+-------------------------+---------------------------------------------------------------------------------------------------+ +| Field | Type | Description + ++=========================+=========================+===================================================================================================+ +| cpm | Optional | CPM user name managing the new safe. + ++-------------------------+-------------------------+---------------------------------------------------------------------------------------------------+ +| retention | Optional | For safe creation, the number of retained versions of every password that is stored in the Safe. + ++-------------------------+-------------------------+---------------------------------------------------------------------------------------------------+ + + A complete configuration file definition ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: yaml @@ -539,7 +609,7 @@ A complete configuration file definition label: Production Demo connection: username: "PVWAUSER001" - password: "" + # password: "" authtype: "cyberark" user_search: object: "Operating System-WinDomain-LDAP-PVWAUSER001" @@ -549,21 +619,34 @@ A complete configuration file definition pvwa: host: "pvwa.mycompany.com" - timeout: 45 + keep_cookies: false max_concurrent_tasks: 12 - verify: "C:\\Folder\\PVWA_Root_ca.pem" - - AIM: - host: "pvwa.acme.fr" - appid: "appid_prod" - cert: "C:\\Folder\\AIM_file.crt" - key: "C:\\Folder\\AIM_file.key" - verify: "C:\\Folder\\PVWA_Root_ca.pem" - timeout: 45 + timeout: 45 + verify: true + + aim: + appid: "appid_prod" + host: "pvwa.acme.fr" + cert: "C:\\Folder\\AIM_file.pem" + # key: "C:\\Folder\\AIM_file.key" + passphrase: "C3rtP4$$Phr4se" + + keep_cookies: false + verify: "C:\\Folder\\Root_ca.crt" + timeout: 45 max_concurrent_tasks: 13 - CPM: "cpm_user" - retention: 10 + account: + logon_account_index: 1 + # reconcile_account_index: 3 + + safe: + cpm: "cpm_user" + retention: 30 + + api_options: + deprecated_warning: true + custom: custom1: "info 1" custom2: "info 2" diff --git a/docs/safe.rst b/docs/safe.rst index 15992a8..f5d7b11 100644 --- a/docs/safe.rst +++ b/docs/safe.rst @@ -16,11 +16,13 @@ Main functions .. autofunction:: get_safe_details .. autofunction:: get_permissions .. autofunction:: rename +.. autofunction:: update Other functions ------------------------ .. autofunction:: search_safe_iterator .. autofunction:: search_safe_paginate +.. autofunction:: safe_members_iterator +.. autofunction:: safe_members_paginate .. autofunction:: list .. autofunction:: v1_get_safes - diff --git a/docs/users.rst b/docs/users.rst index 7135198..4cf2cdd 100644 --- a/docs/users.rst +++ b/docs/users.rst @@ -9,6 +9,7 @@ Users .. autofunction:: exists .. autofunction:: details .. autofunction:: groups +.. autofunction:: safes .. autofunction:: add_ssh_key .. autofunction:: get_ssh_keys .. autofunction:: del_ssh_key diff --git a/pyproject.toml b/pyproject.toml index d9b8669..1e25f9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aiobastion" -version = "0.1.6" +version = "0.1.8" description = "Manage your Cyberark implementation" readme = "README.md" requires-python = ">=3.7" diff --git a/tests/__init__.py b/tests/__init__.py index ab49822..072d382 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,9 @@ import logging +import os -CONFIG = '../../confs/config_tests.yml' -AIM_CONFIG = '../../confs/config_aim_hp.yml' +CONFIG = os.path.join("..", "..", "confs", "config_tests.yml") +AIM_CONFIG = os.path.join("..", "..", "confs", "config_aim_hp.yml") +API_USER = "admin_test_restapi" logging.basicConfig( level=logging.DEBUG, diff --git a/tests/test_accountgroup.py b/tests/test_accountgroup.py index 37041e2..10eccb3 100644 --- a/tests/test_accountgroup.py +++ b/tests/test_accountgroup.py @@ -1,9 +1,13 @@ +import sys import logging import random import time +import unittest +import asyncio from unittest import IsolatedAsyncioTestCase import aiobastion import tests +# from . import CONFIG from aiobastion.exceptions import CyberarkAPIException, CyberarkException, AiobastionException from aiobastion.accountgroup import PrivilegedAccountGroup @@ -303,3 +307,12 @@ async def test_move_all_account_groups(self): except: pass +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() diff --git a/tests/test_accounts.py b/tests/test_accounts.py index c7367fa..758f123 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -1,3 +1,4 @@ +import sys import asyncio import logging import os.path @@ -38,9 +39,9 @@ async def asyncTearDown(self): await asyncio.sleep(0) # await self.vault.logoff() - async def get_random_account(self, n=1) -> Union[PrivilegedAccount,List[PrivilegedAccount]]: + async def get_random_account(self, n=1, **kwargs) -> Union[PrivilegedAccount,List[PrivilegedAccount]]: accounts = await self.vault.account.search_account_by( - safe=self.test_safe + safe=self.test_safe, **kwargs ) self.assertGreaterEqual(len(accounts), 1) if n == 1: @@ -48,7 +49,7 @@ async def get_random_account(self, n=1) -> Union[PrivilegedAccount,List[Privileg else: return random.choices(accounts, k=n) - async def get_random_unix_account(self, n=1): + async def get_random_unix_account(self, n=1) -> Union[PrivilegedAccount,List[PrivilegedAccount]]: accounts = await self.vault.account.search_account_by( safe=self.test_safe, platform="UnixSSH" @@ -173,7 +174,7 @@ async def test_link_account(self): self.assertTrue(undo) async def test_link_account_by_address(self): - + # Add accounts to the safe if they don't exist for acc in (admin, recon): try: await self.vault.account.add_account_to_safe(acc) @@ -188,23 +189,29 @@ async def test_link_account_by_address(self): async def test_change_password(self): # Only unix accounts are mapped to a dummy platform - account = await self.get_random_unix_account() + account : PrivilegedAccount = await self.get_random_unix_account() + while account.cpm_status() == "Deactivated": + account = await self.get_random_unix_account() + changed = await self.vault.account.change_password(account) self.assertTrue(changed) async def test_reconcile(self): # Only unix accounts are mapped to a dummy platform - accounts = await self.get_random_unix_account(2) + rec_account = await self.get_random_unix_account() + account : PrivilegedAccount = await self.get_random_unix_account() + while account.cpm_status() == "Deactivated": + account = await self.get_random_unix_account() # link reconcile address to an address - ret = await self.vault.account.link_reconciliation_account(accounts[0], accounts[1]) + ret = await self.vault.account.link_reconciliation_account(account, rec_account) self.assertTrue(ret) # reconcile the address - ret = await self.vault.account.reconcile(accounts[0]) + ret = await self.vault.account.reconcile(account) self.assertTrue(ret) # remove the reconcile address from the address - undo = await self.vault.account.remove_reconcile_account(accounts[0]) + undo = await self.vault.account.remove_reconcile_account(account) self.assertTrue(undo) async def test_search_account_by_ip_addr(self): @@ -442,18 +449,29 @@ async def test_update_single_fc(self): updated = await self.vault.account.update_single_fc(account, "userName", account.userName) self.assertEqual(updated.userName, account.userName) + async def test_update_platform_account_properties_fc(self): + account = await self.get_random_account(platform="WinDesktopLocal") + + updated = await self.vault.account.update_single_fc(account, "Location", "Berlin") + self.assertEqual(updated.platformAccountProperties["Location"], "Berlin") + + updated = await self.vault.account.update_single_fc(account, "Location", None) + self.assertNotIn("Location",updated.platformAccountProperties) + async def test_update_file_category(self): account = await self.get_random_account() new_username = "tutu" new_address = "221.112.152.100" + new_location = "Berlin" + new_ownername = "Otto Von Bismarck" updated = await self.vault.account.update_file_category(account, - ["userName", "address"], - [new_username, new_address]) + ["userName", "address", "Location", "OwnerName"], + [new_username, new_address, new_location, new_ownername ]) self.assertEqual(updated.userName, new_username) self.assertEqual(updated.address, new_address) updated = await self.vault.account.update_file_category(account, - ["userName", "address"], - [account.userName, account.address]) + ["userName", "address", "Location", "OwnerName"], + [account.userName, account.address, None, None]) self.assertEqual(updated.userName, account.userName) self.assertEqual(updated.address, account.address) @@ -534,4 +552,11 @@ async def test_get_secret_aim(self): if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + unittest.main() diff --git a/tests/test_applications.py b/tests/test_applications.py index e874815..a64d781 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -1,3 +1,6 @@ +import sys +import unittest +import asyncio from unittest import IsolatedAsyncioTestCase import aiobastion from aiobastion.exceptions import AiobastionException @@ -152,3 +155,13 @@ async def test_add_certificate_authentication(self): # delete updated = await self.vault.application.del_authentication(self.app_name, auths[0]['authID']) self.assertTrue(updated) + +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() diff --git a/tests/test_async_in_sync.py b/tests/test_async_in_sync.py index 7186519..3985859 100644 --- a/tests/test_async_in_sync.py +++ b/tests/test_async_in_sync.py @@ -1,81 +1,81 @@ -import asyncio -import sys -import time - -import aiobastion -import tests -from aiobastion import GetTokenException - - -# Automatic login using configuration setup -async def login_cyberark(): - # tests.CONFIG="/path/to/config_file.yml" - epv_env = aiobastion.EPV(tests.CONFIG) - try: - await epv_env.login() - except GetTokenException as err: - raise f"Error while trying to login to the vault : {err}" - return epv_env - - -# Utility function to rebuild EPV object from serialized session -async def get_session(epv_session): - epv = aiobastion.EPV(serialized=epv_session) - if not await epv.check_token(): - # Ensure that the token is still valid - raise GetTokenException - return epv - -async def logoff_cyberark(epv_session): - await epv_session.logoff() - -async def list_safes_2(epv_session): - epv = await get_session(epv_session) - print(epv.session) - - return epv.session - - -# Some async function that performs tasks into PVWA -async def list_safes(epv): - return await epv.safe.list() - - -# Sync function -def main(): - print("Main program started python", sys.version) - - # Async login to Cyberark - epv_session: aiobastion.EPV = asyncio.run(login_cyberark()) - - # Doing sync stuff - for i in range(3): - time.sleep(0.5) - print(f"Main program iteration {i}") - - # Doing async stuff - safes = asyncio.run(list_safes(epv_session)) - print(f"List of Safes : {safes}") - - # Close connexion at the end, because we don't use context manager - asyncio.run(logoff_cyberark(epv_session)) - print("All good !") - - global epv_json - epv_json = epv_session.to_json() - -def main2(): - epv = asyncio.run(get_session(epv_json)) - # Doing async stuff - safes = asyncio.run(list_safes(epv)) - print(f"List of Safes : {safes}") - # pass - -if __name__ == "__main__": - # logging.basicConfig( - # level=logging.DEBUG, - # # level=logging.INFO, - # format='%(asctime)s %(levelname)08s %(name)s %(message)s', - # ) - - main() +import asyncio +import sys +import time + +import aiobastion +import tests +from aiobastion import GetTokenException + + +# Automatic login using configuration setup +async def login_cyberark(): + # tests.CONFIG="/path/to/config_file.yml" + epv_env = aiobastion.EPV(tests.CONFIG) + try: + await epv_env.login() + except GetTokenException as err: + raise f"Error while trying to login to the vault : {err}" + return epv_env + + +# Utility function to rebuild EPV object from serialized session +async def get_session(epv_session): + epv = aiobastion.EPV(serialized=epv_session) + if not await epv.check_token(): + # Ensure that the token is still valid + raise GetTokenException + return epv + +async def logoff_cyberark(epv_session): + await epv_session.logoff() + +async def list_safes_2(epv_session): + epv = await get_session(epv_session) + print(epv.session) + + return epv.session + + +# Some async function that performs tasks into PVWA +async def list_safes(epv): + return await epv.safe.list() + + +# Sync function +def main(): + print("Main program started python", sys.version) + + # Async login to Cyberark + epv_session: aiobastion.EPV = asyncio.run(login_cyberark()) + + # Doing sync stuff + for i in range(3): + time.sleep(0.5) + print(f"Main program iteration {i}") + + # Doing async stuff + safes = asyncio.run(list_safes(epv_session)) + print(f"List of Safes : {safes}") + + # Close connexion at the end, because we don't use context manager + asyncio.run(logoff_cyberark(epv_session)) + print("All good !") + + global epv_json + epv_json = epv_session.to_json() + +def main2(): + epv = asyncio.run(get_session(epv_json)) + # Doing async stuff + safes = asyncio.run(list_safes(epv)) + print(f"List of Safes : {safes}") + # pass + +if __name__ == "__main__": + # logging.basicConfig( + # level=logging.DEBUG, + # # level=logging.INFO, + # format='%(asctime)s %(levelname)08s %(name)s %(message)s', + # ) + + main() diff --git a/tests/test_config.py b/tests/test_config.py index d780da0..8d502e3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ -import asyncio import os +import sys +import asyncio import unittest from unittest import IsolatedAsyncioTestCase import aiobastion @@ -8,15 +9,6 @@ class TestEPV(IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.custom_linked_acounts = { - "custom": { - "RECONCILE_ACCOUNT_INDEX": 1, - "LOGON_ACCOUNT_INDEX": 3 - } - } - async def asyncSetUp(self): self.vault = aiobastion.EPV(tests.CONFIG) await self.vault.login() @@ -25,29 +17,9 @@ async def asyncTearDown(self): try: await self.vault.logoff() except: - # test_logoff + # test_logoff pass - def test_default_linked_accounts_from_yml(self): - vault = aiobastion.EPV(tests.CONFIG) - self.assertEqual(2, vault.LOGON_ACCOUNT_INDEX) - self.assertEqual(3, vault.RECONCILE_ACCOUNT_INDEX) - - def test_default_linked_accounts_from_obj(self): - vault = aiobastion.EPV(serialized={}) - self.assertEqual(2, vault.LOGON_ACCOUNT_INDEX) - self.assertEqual(3, vault.RECONCILE_ACCOUNT_INDEX) - - def test_custom_linked_accounts_from_yml(self): - vault = aiobastion.EPV("test_data/custom_config.yml") - self.assertEqual(3, vault.LOGON_ACCOUNT_INDEX) - self.assertEqual(1, vault.RECONCILE_ACCOUNT_INDEX) - - def test_custom_linked_accounts_from_obj(self): - vault = aiobastion.EPV(serialized=self.custom_linked_acounts) - self.assertEqual(3, vault.LOGON_ACCOUNT_INDEX) - self.assertEqual(1, vault.RECONCILE_ACCOUNT_INDEX) - async def test_logoff(self): await self.vault.logoff() self.assertFalse(await self.vault.check_token()) @@ -95,7 +67,12 @@ def test_to_json(self): # self.fail() +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -if __name__ == "__main__": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) unittest.main() diff --git a/tests/test_config_value.py b/tests/test_config_value.py new file mode 100644 index 0000000..65f2361 --- /dev/null +++ b/tests/test_config_value.py @@ -0,0 +1,1519 @@ +""" +test_config_value.py + +This is a exhaustive test of the Config and EPV class initialization. +It does not access CyberArk. +For debugging purpose, this test generates a lot of trace in the 'aiobastion_test' logger define in setUpClass. + +test 0# Check value complete base test + test 01: Check value return from config Class from the YAML complete base test + test 02: Check value return from EPV Class from the YAML complete base test + test 03: Check value return from EPV Class from the serialization complete base test + +test 1# Check mixed case key attribute (Uppercase and mixed case) + test 11: Check value return from EPV Class from a the YAML file + test 12: Check value return from EPV Class from a the serialization + +test 2# Check default initialization + test 21: Check value return from EPV Class from a tiny YAML file + test 22: Check value return from EPV Class from a tiny serialization + +test 3# Check synomym field usage + test 31: Check value return from EPV Class from a YAML file + test 32: Check value return from EPV Class from a serialization + +test 4# Check error message for all unknown field in section + test 41: Check error message from EPV Class from a tiny YAML file + test 41: Check error message from EPV Class from a tiny serialization + +test 5# Check error message for all synomym duplicate field usage + test 51: Check error message from EPV Class from a tiny YAML file + test 51: Check error message from EPV Class from a tiny serialization + +test 6# Check error message for all invalid type check + test 61: Check error message from EPV Class from a tiny YAML file + test 61: Check error message from EPV Class from a tiny serialization + +test 7# Check error message for all invalid account value check + test 71: Check error message from EPV Class from a tiny YAML file + Check error message from EPV Class from a tiny serialization + +test 8# Check error message for EPV call with no parameter + test 81: Check error message from EPV Class + +test 9# Check value return from EPV.to_json function + test 71: Check value return from EPV.to_json function from the serialization complete base test + +The base YAML file come from ./tests/test_data/custom_config.yml + - This file will not be used to access CyberArk. + - All field must be defined without any error and no synonyms. + - Key attribute must be in lowercase. + +""" +import asyncio +import copy +import datetime +import getpass +import inspect +import logging +import os +import platform +import pprint +import sys +import tempfile +import unittest +# import tests +from typing import Optional + +import yaml + +import aiobastion +from aiobastion.config import Config + +# ----------------------------------- +# constants +# ----------------------------------- +MODULE_DIRNAME = os.path.dirname(__file__) +MODULE_NAME = os.path.basename(__file__) +HEADERLINE = "---------------------------------------------" +# HEADER = f"\n\n# {HEADERLINE}\n# %s\n# {HEADERLINE}\n" +HEADER = f"# %s (start)" + +UNDEFINED_VALUE = "?? unknown ??" +EPV_OPTIONS_MODULES_LIST = [ + "api_options", + "AIM", + "account", + "accountgroup", + "application", + "group", + "platform", + "safe", + "session_management", + "system_health", + "user", + "utils", + "PrivilegedAccount"] + + +EPV_ATTRIBUTE_NAME = [ + # CyberArk attributes + "api_host", + "authtype", + "keep_cookies", + "max_concurrent_tasks", + "password", + "timeout", + "user_search", + "username", + "verify", + + # CyberArk configuration file + "config", + + # CyberArk internal attributes + "cookies", + "logger", + "request_params", + "session", + + # "__sema" + # "__token" + ] + EPV_OPTIONS_MODULES_LIST + +# ----------------------------------- +# Class Definition +# ----------------------------------- +class TestConfigEpv(unittest.TestCase): + """TestConfig_epv - Check EPV initialization """ + logger = None + pprint = pprint.PrettyPrinter(indent=3, width=120, depth=5) + + yaml_dict = None # load yaml dictionary (may generate a new yaml) + serialize_dict = None # serialization dictionary from loaded yaml dict. + epv_validation_dict = None # EPV Validation definition (adjust from serialize_dict) + + yaml_filename = os.path.join(MODULE_DIRNAME, "test_data", "custom_config.yml") + yaml_temp_name = os.path.join(tempfile.gettempdir(), + f"aiobastion_test_{MODULE_NAME}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}{os.getppid()}.yml") + logging_name = os.path.join(tempfile.gettempdir(), + f"aiobastion_test_{MODULE_NAME}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}_{os.getppid()}.trc") + + @classmethod + def setUpClass(cls): + """ + setUpClass - initialization of class test. + + 1) Create the YAML dictionary variable (cls.yaml_dict variable) + from the file ./test/custom_config.yml + This YAML file define all possible fields in every section + - without any error, + - no synonyms and + - keys must be defined in lowercases (including "aim"). + Usage: Base definiton to recreate YAML file test + + 2) Create the serialization variable (cls.serialize_dict) + from the YAML dictionary (cls.yaml_dict) + Usage: Base definiton to recreate serialization test + + 3) Create the EPV validation value variable (cls.epv_validation_dict) + from the serialization variable (cls.serialize_dict) + Usage: Base definiton to validate return value + """ + fnc_name = inspect.currentframe().f_code.co_name + + # Setup logger if needed + logger = logging.getLogger("aiobastion_test") + + logger.setLevel(logging.DEBUG) + fh = logging.FileHandler(cls.logging_name) + fh.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s %(module)20s %(funcName)45s %(lineno)5d: %(message)s') + fh.setFormatter(formatter) + logger.addHandler(fh) + + cls.logger = logger + + # File Header + cls.display_header(MODULE_NAME, cls.logging_name) + + cls.writelog(HEADER, fnc_name) + cls.writelog(f"yaml_filename: '{cls.yaml_filename}'") + cls.writelog(f"yaml_temp_name: '{cls.yaml_temp_name}'") + + # Import the complete yaml configuration file + # All field define without any error and no synonyms + # + # This dictonary will be the main source of testing + + with open(cls.yaml_filename, "r") as file: + cls.yaml_dict = yaml.safe_load(file) + + # Convert all global section and attribute in lowercase (except AIM section) (3 levels) + for section_name in list(cls.yaml_dict.keys()): + section_name_new = section_name.lower() + + if section_name_new == "aim": + section_name_new = "AIM" + + if section_name != section_name_new: + cls.yaml_dict[section_name_new] = cls.yaml_dict.pop(section_name) + + # Lowercase first level + if section_name != "custom" and isinstance(cls.yaml_dict[section_name_new], dict): + for attrName1 in list(cls.yaml_dict[section_name_new].keys()): + attr_name1_new = attrName1.lower() + + if attrName1 != attr_name1_new: + cls.yaml_dict[section_name_new][attr_name1_new] = cls.yaml_dict[section_name_new].pop(attrName1) + + # Lowercase second level + if isinstance(cls.yaml_dict[section_name_new][attr_name1_new], dict): + for attrName2 in list(cls.yaml_dict[section_name_new][attr_name1_new].keys()): + attr_name2_new = attrName2.lower() + + if attrName2 != attr_name2_new: + cls.yaml_dict[section_name_new][attr_name1_new][attr_name2_new] = cls.yaml_dict[section_name_new][attr_name1_new].pop(attrName2) + + # Lowercase third level + if isinstance(cls.yaml_dict[section_name_new][attr_name1_new][attr_name2_new], dict): + for attrName3 in list(cls.yaml_dict[section_name_new][attr_name1_new][attr_name2_new].keys()): + attr_name3_new = attrName3.lower() + + if attrName3 != attr_name3_new: + cls.yaml_dict[section_name_new][attr_name1_new][attr_name2_new][attr_name3_new] = \ + cls.yaml_dict[section_name_new][attr_name1_new][attr_name2_new].pop(attrName3) + + + cls.write_pprint("cls.yaml_dict (global definition)", cls.yaml_dict) + + # Format to serialization (restructure from yaml) + cls.serialize_dict = copy.deepcopy(cls.yaml_dict) + + if "label" in cls.serialize_dict: + del cls.serialize_dict["label"] + + for section_name in list(cls.serialize_dict.keys()): + section_name_new = section_name.lower() + + if section_name_new not in aiobastion.config.Config.CYBERARK_OPTIONS_MODULES_LIST + [ + "pvwa", + "connection", + "custom", + ]: + raise KeyError(f"Unexpected configuration file global section: {section_name}") + + # # convert section in lowercase except AIM + # if section_name_new == "aim": + # section_name_new = "AIM" + # + # if section_name == "aim": + # cls.serialize_dict[section_name_new] = cls.serialize_dict.pop(section_name) + + # elif section_name != section_name_new: + # cls.serialize_dict[section_name_new] = cls.serialize_dict.pop(section_name) + + if "connection" == section_name: + for k in list(cls.serialize_dict["connection"].keys()): + if k == "appid": + if "AIM" not in cls.serialize_dict: + cls.serialize_dict["AIM"] = {} + + cls.serialize_dict["AIM"][k] = cls.serialize_dict["connection"][k] + else: + cls.serialize_dict[k] = cls.serialize_dict["connection"][k] + + del cls.serialize_dict["connection"] + + elif "pvwa" == section_name_new: + for k in list(cls.serialize_dict[section_name_new].keys()): + if k == "host": + k_new = "api_host" + else: + k_new = k + + cls.serialize_dict[k_new] = cls.serialize_dict["pvwa"][k] + + del cls.serialize_dict["pvwa"] + + + cls.write_pprint("cls.serialize_dict (global definition)", cls.serialize_dict) + + # Adjust config.custom definition + cls.epv_validation_dict = copy.deepcopy(cls.serialize_dict) + cls.epv_validation_dict["config"] = { + "custom": cls.epv_validation_dict.pop("custom"), + "label": cls.yaml_dict["label"], + "configfile": cls.yaml_temp_name + } + + # Debug check_section_value + # - Test different value + # cls.epv_validation_dict["api_host"] = "ServerName" + + cls.write_pprint("cls.epv_validation_dict (global definition)", cls.epv_validation_dict) + + + @classmethod + def tearDownClass(cls): + """tearDownClass - cleanup of class test """ + fnc_name = inspect.currentframe().f_code.co_name + cls.writelog(HEADER, fnc_name) + + if os.path.exists(TestConfigEpv.yaml_temp_name): + os.remove(TestConfigEpv.yaml_temp_name) + + + @classmethod + def writelog(cls, *args, **kwargs): + """writelog - Write multiple line to logger""" + + if cls.logger: + stacklevel = kwargs.pop("stacklevel", 1) + 1 + + if len(args) == 1: + lines = args[0] + else: + lines = args[0] % args[1:] + + for line in lines.split('\n'): + cls.logger.debug(line, stacklevel=stacklevel, **kwargs) + + @classmethod + def write_pprint(cls, title: str, d: dict, **kwargs): + """write_pprint - Write a prettyprint dictionary to logger""" + stacklevel = kwargs.pop("stacklevel", 1) + 1 + + cls.writelog(f" {title} " .center(100, "-"), stacklevel=stacklevel, **kwargs) + cls.writelog(cls.pprint.pformat(d), stacklevel=stacklevel, **kwargs) + cls.writelog(f" {title} (end) " .center(100, "-"), stacklevel=stacklevel, **kwargs) + + @classmethod + def identify_username(cls): + """obtenir_username - Get username from environment""" + + if sys.platform == "win32": + username = getpass.getuser() + else: + import pwd + username, uid, gid, gid_name, home = pwd.getpwuid(os.getuid()) + + return username + + + @classmethod + def display_header(cls, script, tracename): + """display_header - Execution header + + Arguments: + script {str} -- Program name + tracename {str} -- Trace file name + """ + machine_os, machine_nom, machine_os_version, machine_systeme, machine_type, machine_sorte = platform.uname() + timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + username = cls.identify_username() + + s = "----------------------------------------------------------------------------------------\n" \ + "Script: %-50s Date: %s\n" \ + "User: %-50s Server: %s (%s)\n" \ + "----------------------------------------------------------------------------------------\n" \ + "Trace file: %s\n" \ + % (script, timestamp, username, machine_nom, machine_os, tracename) + + print(s) + cls.writelog(s) + + + + @classmethod + def write_file(cls, filename: str, title=None, **kwargs): + """write_file - Write file content to logger""" + stacklevel = kwargs.pop("stacklevel", 1) + 1 + + if title is None: + title = filename + + cls.writelog(f" {title} ".center(100, "-"), stacklevel=stacklevel, **kwargs) + + with open(filename, "r") as fd: + cls.writelog(fd.read(), stacklevel=stacklevel, **kwargs) + + cls.writelog(f" {title} (end) " .center(100, "-"), stacklevel=stacklevel, **kwargs) + + + @classmethod + def write_EPV(cls, title, epv_env: aiobastion.EPV, **kwargs): + """write_EPV - Write EPV class attribute to logger""" + stacklevel = kwargs.pop("stacklevel", 1) + 1 + # kwargs.setdefault("stacklevel", 3) + + d= {} + + for k in vars(epv_env): + # Is it a class ? + if k in EPV_OPTIONS_MODULES_LIST + [ + "config", + "logger", + ]: + d[k] = vars(getattr(epv_env, k, None)) + else: + d[k] = getattr(epv_env, k) + + cls.write_pprint(title, d, stacklevel=stacklevel, **kwargs) + + + def call_EPV(self, title: str, /, config_file: str = None, yaml_dict: Optional[dict] = None, serialized: Optional[dict] = None, + trace_input: bool = False, trace_epv: bool = False, trace_check: bool = False, raise_condition = False, + expected_value: Optional[dict] = None, **kwargs): + """call_EPV + + 1) if yaml_dict, create a yaml file from yaml_dict + 2) Display input definition (Yaml file or serialized) if aksed + 3) call EPV(...) + 4) if raise_condition expected and condition has not been raised + 4.1) Display input definition (Yaml file or serialized) + 5) Display EPV instance class return if asked + + + # Args: + :param title: (str): Test name + # Optional Args: + :param config_file: (str, optional): Name of a Yaml file + :param yaml_dict: (Optional[dict], optional): Yaml dictionary use to create Yaml file + :param serialized: (Optional[dict], optional): serialized dictionary + :param trace_input: (bool, optional): Display input parameters (Yaml file or serialized) ? + :param trace_epv: (bool, optional): Display EPV instance definition returned ? + :param trace_check: (bool, optional): Display check value trace ? + :param raise_condition: (bool, optional): Will it raise an error ? + :param expected_value: (Optional[dict], optional): Expected value Dictionary + + Returns: + _type_: _description_ + """ + stacklevel = kwargs.pop("stacklevel", 1) + 1 + file_name = "" + + try: + if config_file or yaml_dict: + if config_file: + file_name = config_file + else: # yaml_dict + file_name = TestConfigEpv.yaml_temp_name + + # Write the configuration file + with open(TestConfigEpv.yaml_temp_name, "w") as tmp_fd: + yaml.dump(yaml_dict, tmp_fd) + + if trace_input: + self.write_file(file_name, title=f"{title} - Yaml", stacklevel=stacklevel, **kwargs) + + if expected_value and \ + "config" in expected_value and \ + "configfile" in expected_value["config"]: + expected_value["config"]["configfile"] = file_name + + epv_env = aiobastion.EPV(configfile=file_name) + else: # serialized + if trace_input: + self.write_pprint(f"{title} - serialization", serialized, stacklevel=stacklevel, **kwargs) + + epv_env = aiobastion.EPV(serialized=serialized) + except Exception as err: + self.writelog(f"{title}: raise {err}", stacklevel=stacklevel, **kwargs) + raise err + + + if raise_condition: + # Raise condition has not been raised, display input information if not already done. + if not trace_input: + self.writelog(f"{title}: raise *** Test not raise ***", stacklevel=stacklevel, **kwargs) + + if config_file or yaml_dict: + self.write_file(file_name, title=f"{title} - Yaml", stacklevel=stacklevel, **kwargs) + else: # serialized + self.write_pprint(f"{title} - serialization", serialized, stacklevel=stacklevel, **kwargs) + + if trace_epv or raise_condition: + self.write_EPV(f"{title} - EPV", epv_env, stacklevel=stacklevel, **kwargs) + + if expected_value: + self.check_epv_value_with_dict(f"{title} - check value", epv_env, expected_value, trace_check=trace_check, stacklevel=stacklevel, **kwargs) + + return epv_env + + + def check_section_value(self, testname: str, obj, expected_value: dict, trace_check: bool = False, **kwargs): + """ check_section_value - Verify if expected values are defined """ + stacklevel = kwargs.pop("stacklevel", 1) + 1 + + # self.writelog(f"{testname} debug obj({type(obj)}) expected_value({type(expected_value)})", stacklevel=stacklevel, **kwargs) + + if expected_value is None: + if trace_check: + self.writelog(f"{testname} skip test: expected_value is None; %r", obj, stacklevel=stacklevel, **kwargs) + + # self.assertIsNone(obj, msg=f"{testname}: value not None.") + + else: + if isinstance(expected_value, dict) and isinstance(obj, dict): + for k, ev in expected_value.items(): + v = obj.get(k, UNDEFINED_VALUE) # custom + # self.writelog(f"{testname} debug dict '{k}' obj({v!r}) expected_value({ev!r})", stacklevel=stacklevel, **kwargs) + + with self.subTest(attrName=f"{testname}/{k}"): + self.check_section_value(f"{testname}/{k}", v, ev, trace_check=trace_check, stacklevel=stacklevel, **kwargs) + elif isinstance(expected_value, dict): + # We assume it is a class + for k, ev in expected_value.items(): + # keyname = k.lower() + with self.subTest(attrName=f"{testname}/{k}"): + v = getattr(obj, k, None) + self.check_section_value(f"{testname}/{k}", v, ev, trace_check=trace_check, stacklevel=stacklevel, **kwargs) + else: + if trace_check: + self.writelog(f"{testname} check - {obj} == {expected_value}", stacklevel=stacklevel, **kwargs) + + with self.subTest(testname=testname): + self.assertIsInstance(obj, type(expected_value), msg=f"{testname}: type error expecting a {type(expected_value)}") + self.assertEqual(obj, expected_value, msg=f"{testname}: value error {expected_value!r}") + + + + + def check_epv_value_with_dict(self, testname: str, epv_env: aiobastion.EPV, expected_value: dict, trace_check: bool = False, **kwargs): + """ check_epv_value_with_dict - Verify expected values + This verification is driven by the dictionary "expected_value". + + It does not verify EPV.config + """ + kwargs.setdefault("stacklevel", 1) + kwargs["stacklevel"] += 1 + + # check every field + self.assertIsInstance(epv_env, aiobastion.EPV, msg=f"""{testname}: Wrong epv_env type""") + + if trace_check: + self.writelog(testname .center(100, "-"), **kwargs) + + # Check all expected value + self.check_section_value(testname, epv_env, expected_value, trace_check=trace_check, **kwargs) + + if trace_check: + self.writelog(testname.center(100, "-"), **kwargs) + + return + + + + def test_01_Config_complete_yml(self): + """ test_01_Config_complete_yml - Test Config instance class (yaml file) + Check all fields in Config instance class return. + """ + fnc_name = inspect.currentframe().f_code.co_name + + self.writelog(HEADER, fnc_name) + + TestConfigEpv.write_file(TestConfigEpv.yaml_filename, f"{fnc_name} - original Yaml file") + #with self.assertWarns() + config_instance = aiobastion.config.Config(configfile=TestConfigEpv.yaml_filename) + + # check every field + self.assertIsInstance(config_instance, Config, msg=f"""("test_data/custom_config.yml") in error""") + + # Config class + self.assertEqual(config_instance.label, TestConfigEpv.yaml_dict["label"], msg="Label value error") + self.assertEqual(config_instance.configfile, TestConfigEpv.yaml_filename, msg="configfile value error") + self.assertIsNotNone(config_instance.custom, msg="custom section not define") + + for sectionName in config_instance.options_modules.keys(): + with self.subTest(section=sectionName): + self.assertIn(sectionName, aiobastion.config.Config.CYBERARK_OPTIONS_MODULES_LIST, + msg=f"{sectionName} Unknowed section '{sectionName}' in options_modules.") + + if sectionName == "cyberark": + # Connection + for attrName in ["authtype", "password", "user_search", "username"]: + self.assertIn(attrName, config_instance.options_modules[sectionName], + msg=f"{attrName} not define in options_modules[{sectionName}]") + self.assertEqual(config_instance.options_modules[sectionName][attrName], TestConfigEpv.yaml_dict["connection"][attrName], + msg=f"""Invalid value in options_modules[{sectionName}][{attrName}]. Expected: {TestConfigEpv.yaml_dict["connection"][attrName]!r}""") + + # pvwa host + # self.assertIn("api_host", config_instance.options_modules[sectionName], msg=f"host not define in options_modules[{sectionName}]") + # self.assertEqual(config_instance.options_modules[sectionName]["api_host"], TestConfig_epv.yaml_dict["pvwa"]["host"], + # msg=f"""Invalid value in options_modules[{sectionName}][api_host]. Expected: {TestConfig_epv.yaml_dict["pvwa"]["host"]!r}""") + + for attrName in ["host", "keep_cookies", "max_concurrent_tasks", "timeout", "verify"]: + self.assertIn(attrName, config_instance.options_modules[sectionName], + msg=f"{attrName} not define in options_modules[{sectionName}]") + self.assertEqual(config_instance.options_modules[sectionName][attrName], TestConfigEpv.yaml_dict["pvwa"][attrName], + msg=f"""Invalid value in options_modules[{sectionName}][{attrName}]. Expected: {TestConfigEpv.yaml_dict["pvwa"][attrName]!r}""") + + + elif sectionName == "aim": + # check information from "connection" + self.assertIn("appid", config_instance.options_modules[sectionName], + msg=f"appid not define in options_modules[{sectionName}]") + self.assertEqual(config_instance.options_modules[sectionName]["appid"], TestConfigEpv.yaml_dict["connection"]["appid"], + msg=f"""Invalid value in options_modules[{sectionName}][appid]. Expected: {TestConfigEpv.yaml_dict["connection"]["appid"]!r}""") + + for attrName in ["key", "max_concurrent_tasks", "passphrase", "timeout", "verify"]: + self.assertIn(attrName, config_instance.options_modules[sectionName], + msg=f"{attrName} not define in options_modules[{sectionName}]") + self.assertEqual(config_instance.options_modules[sectionName][attrName], TestConfigEpv.yaml_dict["AIM"][attrName], + msg=f"""Invalid value in options_modules[{sectionName}][{attrName}]. Expected: {TestConfigEpv.yaml_dict["AIM"][attrName]!r}""") + + elif sectionName == "account": + for attrName in ["logon_account_index", "reconcile_account_index"]: + self.assertIn(attrName, config_instance.options_modules[sectionName], + msg=f"{attrName} not define in options_modules[{sectionName}]") + self.assertEqual(config_instance.options_modules[sectionName][attrName], TestConfigEpv.yaml_dict[sectionName][attrName], + msg=f"""Invalid value in options_modules[{sectionName}][{attrName}]. Expected: {TestConfigEpv.yaml_dict[sectionName][attrName]!r}""") + + elif sectionName == "safe": + for attrName in ["cpm", "retention"]: + self.assertIn(attrName, config_instance.options_modules[sectionName], msg=f"{attrName} not define in options_modules[{sectionName}]") + self.assertEqual(config_instance.options_modules[sectionName][attrName], TestConfigEpv.yaml_dict[sectionName][attrName], + msg=f"""Invalid value in options_modules[{sectionName}][{attrName}]. Expected: {TestConfigEpv.yaml_dict[sectionName][attrName]!r}""") + elif sectionName == "api_options": + for attrName in ["deprecated_warning"]: + self.assertIn(attrName, config_instance.options_modules[sectionName], msg=f"{attrName} not define in options_modules[{sectionName}]") + else: + if config_instance.options_modules[sectionName]: + self.fail(msg=f"No validation defined for options_modules[{sectionName}]") + + + def test_02_epv_complete_yml(self): + """ test_02_epv_complete_yml - Test complete yaml file + Check all fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + epv_env = self.call_EPV(fnc_name, config_file=TestConfigEpv.yaml_filename, expected_value=TestConfigEpv.epv_validation_dict, + trace_input=True, trace_epv=True, trace_check=True) + + # Check for global unknown attributes in case new test should be added + for attrName in vars(epv_env): + if not attrName.startswith("_"): + with self.subTest(unknowned=attrName): + self.assertIn(attrName, EPV_ATTRIBUTE_NAME, + msg=f"Unknow attribute '{attrName}' in EPV (verify for new validation test): {attrName}") + + + # Check for global not defined attributes in case old test should be modified + vars_def = vars(epv_env) + + for attrName in EPV_ATTRIBUTE_NAME: + if not attrName.startswith("_"): + with self.subTest(undefined=attrName): + self.assertIn(attrName, vars_def, + msg=f"Undefined attribute '{attrName}' in EPV (verify old validation test): {attrName}") + + def test_03_epv_complete_ser(self): + """ test_03_epv_complete_ser - Test complete serialization. + Check all fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + + # Adjust validation for serialization + epv_validation_dict = copy.deepcopy(TestConfigEpv.epv_validation_dict) + + # Remove config.label and config.configfile (keep custom) + if epv_validation_dict.get("config", None) is not None: + if epv_validation_dict["config"].get("label", None) is not None: + del epv_validation_dict["config"]["label"] + + if epv_validation_dict["config"].get("configfile", None) is not None: + del epv_validation_dict["config"]["configfile"] + + + epv_env = self.call_EPV(fnc_name, serialized=TestConfigEpv.serialize_dict, expected_value=epv_validation_dict, + trace_input=True, trace_epv=True, trace_check = True) + + # Check for unknown attribute in case new test should be added + for attrName in vars(epv_env): + if not attrName.startswith("_"): + self.assertIn(attrName, EPV_ATTRIBUTE_NAME, + msg=f"Unknow attribute '{attrName}' in EPV (may add new validation): {attrName}") + + @staticmethod + def build_upperkey_yaml_dict(yaml_dict): + # account + yaml_dict["account"]["LOGON_account_INDEX"] = yaml_dict["account"].pop("logon_account_index") + yaml_dict["account"]["RECONCILE_account_INDEX"] = yaml_dict["account"].pop("reconcile_account_index") + yaml_dict["aCCount"] = yaml_dict.pop("account") + + + # safe + yaml_dict["safe"]["CPM"] = yaml_dict["safe"].pop("cpm") + yaml_dict["safe"]["reTention"] = yaml_dict["safe"].pop("retention") + yaml_dict["sAfe"] = yaml_dict.pop("safe") + + return yaml_dict + + def test_11_upperkey_from_yml(self): + """ test_11_upperkey_from_yml - Test uppercase attribute name (yaml file). + Check some fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + yaml_dict = copy.deepcopy(TestConfigEpv.yaml_dict) + + # PVWA + yaml_dict["pvwa"]["Max_Concurrent_tAsks"] = yaml_dict["pvwa"].pop("max_concurrent_tasks") + yaml_dict["PVWA"] = yaml_dict.pop("pvwa") + + yaml_dict = self.build_upperkey_yaml_dict(yaml_dict) + + self.call_EPV(fnc_name, yaml_dict=yaml_dict, expected_value=TestConfigEpv.epv_validation_dict, + trace_input=True, trace_epv=True, trace_check = True) + + + def test_12_upperkey_from_ser(self): + """ test_12_upperkey_from_ser - Test uppercase attribute name (serialization). + Check some fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + serialize_dict = copy.deepcopy(TestConfigEpv.serialize_dict) + epv_validation_dict = copy.deepcopy(TestConfigEpv.epv_validation_dict) + + # EPV + serialize_dict["Max_Concurrent_tAsks"] = serialize_dict.pop("max_concurrent_tasks") + + serialize_dict = self.build_upperkey_yaml_dict(serialize_dict) + + # Remove config validation (except custom) + if "config" in epv_validation_dict: + if "label" in epv_validation_dict["config"]: + del epv_validation_dict["config"]["label"] + if "configfile" in epv_validation_dict["config"]: + del epv_validation_dict["config"]["configfile"] + + + self.call_EPV(fnc_name, serialized=serialize_dict, expected_value=epv_validation_dict, + trace_input=True, trace_epv=True, trace_check = True) + + def epv_env_fields_check(self, epv_env): + """ + epv_env_fields_check - Check EPV instance class returned + """ + # Global API options + self.assertEqual(epv_env.api_options.deprecated_warning, + aiobastion.config.Api_options.API_OPTIONS_DEFAULT_DEPRECATED_WARNING) + # EPV + self.assertEqual(epv_env.keep_cookies, aiobastion.config.Config.CYBERARK_DEFAULT_KEEP_COOKIES) + self.assertEqual(epv_env.max_concurrent_tasks, aiobastion.config.Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS) + self.assertEqual(epv_env.timeout, aiobastion.config.Config.CYBERARK_DEFAULT_TIMEOUT) + self.assertEqual(epv_env.verify, aiobastion.config.Config.CYBERARK_DEFAULT_VERIFY) + + # account + self.assertEqual(epv_env.account._ACCOUNT_DEFAULT_LOGON_ACCOUNT_INDEX, epv_env.account.logon_account_index) + self.assertEqual(epv_env.account._ACCOUNT_DEFAULT_RECONCILE_ACCOUNT_INDEX, epv_env.account.reconcile_account_index) + + # aim + self.assertEqual(epv_env.AIM.max_concurrent_tasks, aiobastion.config.Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS) + self.assertEqual(epv_env.AIM.timeout, aiobastion.config.Config.CYBERARK_DEFAULT_TIMEOUT) + self.assertEqual(epv_env.AIM.verify, aiobastion.config.Config.CYBERARK_DEFAULT_VERIFY) + + # safe + self.assertEqual(epv_env.safe._SAFE_DEFAULT_CPM, epv_env.safe.cpm) + self.assertEqual(epv_env.safe._SAFE_DEFAULT_RETENTION, epv_env.safe.retention) + + + def test_21_default_from_yml(self): + """ test_21_default_from_yml - Test default value returned (yaml file). + Check some fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + yaml_dict = {"AIM": {"host": "host22"}} + + epv_env = self.call_EPV(fnc_name, yaml_dict=yaml_dict, + trace_input=True, trace_epv=True, trace_check = False) + + self.epv_env_fields_check(epv_env) + + + def test_22_default_from_ser(self): + """ test_22_default_from_ser - Test default value returned (serialization). + Check some fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + serialize_dict = {"AIM": {"host": "host22"}} + + epv_env = self.call_EPV(fnc_name, serialized=serialize_dict, + trace_input=True, trace_epv=True, trace_check = True) + + self.epv_env_fields_check(epv_env) + # Global API options + # self.assertEqual(epv_env.api_options.deprecated_warning, aiobastion.config.Api_options.API_OPTIONS_DEFAULT_DEPRECATED_WARNING) + # + # # EPV + # self.assertEqual(epv_env.keep_cookies, aiobastion.config.Config.CYBERARK_DEFAULT_KEEP_COOKIES) + # self.assertEqual(epv_env.max_concurrent_tasks, aiobastion.config.Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS) + # self.assertEqual(epv_env.timeout, aiobastion.config.Config.CYBERARK_DEFAULT_TIMEOUT) + # self.assertEqual(epv_env.verify, aiobastion.config.Config.CYBERARK_DEFAULT_VERIFY) + # + # # account + # self.assertEqual(epv_env.account._ACCOUNT_DEFAULT_LOGON_ACCOUNT_INDEX, epv_env.account.logon_account_index) + # self.assertEqual(epv_env.account._ACCOUNT_DEFAULT_RECONCILE_ACCOUNT_INDEX, epv_env.account.reconcile_account_index) + # + # # aim + # self.assertEqual(epv_env.AIM.max_concurrent_tasks, aiobastion.config.Config.CYBERARK_DEFAULT_MAX_CONCURRENT_TASKS) + # self.assertEqual(epv_env.AIM.timeout, aiobastion.config.Config.CYBERARK_DEFAULT_TIMEOUT) + # self.assertEqual(epv_env.AIM.verify, aiobastion.config.Config.CYBERARK_DEFAULT_VERIFY) + # + # # safe + # self.assertEqual(epv_env.safe._SAFE_DEFAULT_CPM, epv_env.safe.cpm) + # self.assertEqual(epv_env.safe._SAFE_DEFAULT_RETENTION, epv_env.safe.retention) + + + @staticmethod + def check_synonym(yaml_dict): + # account vs Custom + yaml_dict["custom"]["LOGON_ACCOUNT_INDEX"] = yaml_dict["account"].pop("logon_account_index") + yaml_dict["custom"]["RECONCILE_ACCOUNT_INDEX"] = yaml_dict["account"].pop("reconcile_account_index") + + if len(yaml_dict["account"]) == 0: + del yaml_dict["account"] + + # safe vs global section + yaml_dict["cpm"] = yaml_dict["safe"].pop("cpm") + yaml_dict["retention"] = yaml_dict["safe"].pop("retention") + + # if len(yaml_dict["safe"]) == 0: + # del yaml_dict["safe"] + + return yaml_dict + + def test_31_synonym_from_yml(self): + """ test_31_synonym_from_yml - Test synonym (yaml file). + Check some fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + yaml_dict = copy.deepcopy(TestConfigEpv.yaml_dict) + #epv_validation_dict = copy.deepcopy(TestConfig_epv.epv_validation_dict) + + # PVWA + yaml_dict["pvwa"]["ca"] = yaml_dict["pvwa"].pop("verify") + yaml_dict["pvwa"]["maxtasks"] = yaml_dict["pvwa"].pop("max_concurrent_tasks") + + if len(yaml_dict["pvwa"]) == 0: + del yaml_dict["pvwa"] + + # AIM vs Connection + yaml_dict["AIM"]["appid"] = yaml_dict["connection"].pop("appid") + + if len(yaml_dict["AIM"]) == 0: + del yaml_dict["AIM"] + + yaml_dict = self.check_synonym(yaml_dict) + # # account vs Custom + # yaml_dict["custom"]["LOGON_ACCOUNT_INDEX"] = yaml_dict["account"].pop("logon_account_index") + # yaml_dict["custom"]["RECONCILE_ACCOUNT_INDEX"] = yaml_dict["account"].pop("reconcile_account_index") + # + # if len(yaml_dict["account"]) == 0: + # del yaml_dict["account"] + # + # # safe vs global section + # yaml_dict["cpm"] = yaml_dict["safe"].pop("cpm") + # yaml_dict["retention"] = yaml_dict["safe"].pop("retention") + # + if len(yaml_dict["safe"]) == 0: + del yaml_dict["safe"] + + self.call_EPV(fnc_name, yaml_dict=yaml_dict, expected_value=TestConfigEpv.epv_validation_dict, + trace_input=True, trace_epv=True, trace_check = True) + + + def test_32_synonym_from_ser(self): + """ test_32_synonym_from_ser - Test synonym (serialization). + Check some fields in EPV instance class returned. + """ + + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + serialize_dict = copy.deepcopy(TestConfigEpv.serialize_dict) + epv_validation_dict = copy.deepcopy(TestConfigEpv.epv_validation_dict) + + serialize_dict = self.check_synonym(serialize_dict) + # account vs Custom + # serialize_dict["custom"]["LOGON_ACCOUNT_INDEX"] = serialize_dict["account"].pop("logon_account_index") + # serialize_dict["custom"]["RECONCILE_ACCOUNT_INDEX"] = serialize_dict["account"].pop("reconcile_account_index") + # + # if len(serialize_dict["account"]) == 0: + # del serialize_dict["account"] + # + # # safe vs global section + # serialize_dict["cpm"] = serialize_dict["safe"].pop("cpm") + # serialize_dict["retention"] = serialize_dict["safe"].pop("retention") + + if len(serialize_dict["safe"]) == 0: + del serialize_dict["safe"] + + if "config" in epv_validation_dict: + del epv_validation_dict["config"] + + self.call_EPV(fnc_name, serialized=serialize_dict, expected_value=epv_validation_dict, + trace_input=True, trace_epv=True, trace_check = True) + + + def test_41_raise_unknown_yml(self): + """ test_41_raise_unknown_yml - Test error for unkown attribute (yaml file). + Check section fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + yaml_dict = copy.deepcopy(TestConfigEpv.yaml_dict) + + # ------------------------------------- + # 1) Wrong global field + # ------------------------------------- + yaml_dict["a_wrong_field"] = "This-is-wrong" + + with self.subTest(section="global"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + r"^Unknown attribute in global section in"): + self.call_EPV(f"{fnc_name} - wrong field in section global", yaml_dict=yaml_dict, + raise_condition=True) + + del yaml_dict["a_wrong_field"] + + + # ------------------------------------- + # 2) Wrong EPV section (modules) + # ------------------------------------- + yaml_dict = self.check_section_fields_raise(yaml_dict, fnc_name) + + # for section_name in aiobastion.config.Config.CYBERARK_OPTIONS_MODULES_LIST: + # if section_name == "cyberark": + # continue + # + # if section_name == "aim": + # section_name = "AIM" + # + # delete_section = False + # + # if section_name not in yaml_dict: + # delete_section = True + # yaml_dict[section_name] = {} + # + # yaml_dict[section_name]["a_wrong_field"] = "This-is-wrong" + # yaml_dict[section_name]["a_wrong_field2"] = "This-is-wrong2" + # + # with self.subTest(section=section_name): + # with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + # f"^Unknown attribute in section '{section_name.lower()}' from "): + # self.call_EPV(f"{fnc_name} - wrong field in section_name {section_name}", yaml_dict=yaml_dict, + # raise_condition=True) + # + # if delete_section: + # del yaml_dict[section_name] + # else: + # del yaml_dict[section_name]["a_wrong_field"] + # del yaml_dict[section_name]["a_wrong_field2"] + + # --------------------------------------------- + # 3) Wrong EPV field (connection/user_search) + # --------------------------------------------- + yaml_dict["connection"]["user_search"]["a_wrong_field"] = "This-is-wrong" + + with self.subTest(section="user_search"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, r"^invalid parameter in "): + self.call_EPV(f"{fnc_name} - wrong field in connection/user_search", yaml_dict=yaml_dict, + trace_input=True, raise_condition=True) + + del yaml_dict["connection"]["user_search"]["a_wrong_field"] + + def check_section_fields_raise(self, yaml_dict, fnc_name, serialized=False): + + for section_name in aiobastion.config.Config.CYBERARK_OPTIONS_MODULES_LIST: + if section_name == "cyberark": + continue + + if section_name == "aim": + section_name = "AIM" + + delete_section = False + + if section_name not in yaml_dict: + delete_section = True + yaml_dict[section_name] = {} + + yaml_dict[section_name]["a_wrong_field"] = "This-is-wrong" + yaml_dict[section_name]["a_wrong_field2"] = "This-is-wrong2" + + if serialized: + with self.subTest(section=section_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Unknown attribute in section '{section_name.lower()}' from "): + self.call_EPV(f"{fnc_name} - wrong field in section_name {section_name}", serialized=yaml_dict, + raise_condition=True) + else: + with self.subTest(section=section_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Unknown attribute in section '{section_name.lower()}' from "): + self.call_EPV(f"{fnc_name} - wrong field in section_name {section_name}", yaml_dict=yaml_dict, + raise_condition=True) + + + if delete_section: + del yaml_dict[section_name] + else: + del yaml_dict[section_name]["a_wrong_field"] + del yaml_dict[section_name]["a_wrong_field2"] + + return yaml_dict + + def test_42_raise_unknown_ser(self): + """ test_42_raise_unknown_ser - Test error for unknown attribute (serialization). + Check section fields in EPV instance class returned. + """ + + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + serialize_dict = copy.deepcopy(TestConfigEpv.serialize_dict) + + # ------------------------------------- + # 1) Wrong global field + # ------------------------------------- + serialize_dict["a_wrong_field"] = "This-is-wrong" + serialize_dict["a_wrong_field2"] = "This-is-wrong2" + + with self.subTest(section="Global"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + r"^Unknown attribute 'a_wrong_field' in serialization"): + # epv_env = aiobastion.EPV(serialized=serialize_dict) + self.call_EPV(f"{fnc_name} - wrong field in section global", serialized=serialize_dict, + raise_condition=True) + + del serialize_dict["a_wrong_field"] + del serialize_dict["a_wrong_field2"] + + # ------------------------------------- + # 2) Wrong EPV field (all modules) + # ------------------------------------- + serialize_dict = self.check_section_fields_raise(serialize_dict, fnc_name, serialized=True) + + # for section_name in aiobastion.config.Config.CYBERARK_OPTIONS_MODULES_LIST: + # if section_name == "cyberark": + # continue + # + # if section_name == "aim": + # section_name = "AIM" + # + # delete_section = False + # + # if section_name not in serialize_dict: + # delete_section = True + # serialize_dict[section_name] = {} + # + # serialize_dict[section_name]["a_wrong_field"] = "This-is-wrong" + # serialize_dict[section_name]["a_wrong_field2"] = "This-is-wrong2" + # + # with self.subTest(section=section_name): + # with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + # f"^Unknown attribute in section '{section_name.lower()}' from serialized:"): + # self.call_EPV(f"{fnc_name} - wrong field in section {section_name}", serialized=serialize_dict, + # raise_condition=True) + + # if delete_section: + # del serialize_dict[section_name] + # else: + # del serialize_dict[section_name]["a_wrong_field"] + # del serialize_dict[section_name]["a_wrong_field2"] + + # --------------------------------------------- + # 3) Wrong EPV field user_search + # --------------------------------------------- + serialize_dict["user_search"]["a_wrong_field"] = "This-is-wrong" + + with self.subTest(section="user_search"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, "^invalid parameter in 'user_search': "): + self.call_EPV(f"{fnc_name} - wrong field in user_search", serialized=serialize_dict, + raise_condition=True) + + del serialize_dict["user_search"]["a_wrong_field"] + + def check_fields_raise_duplicate(self, yaml_dict, fnc_name, serialized=False): + + for attr_name in ["LOGON_ACCOUNT_INDEX", "reconcile_account_index"]: + yaml_dict["custom"][attr_name] = yaml_dict["account"][attr_name.lower()] + + if serialized: + with self.subTest(attrName=attr_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Duplicate definition: move 'logon_account_index' and 'reconcile_account_index' from 'custom' to 'account' section in"): + self.call_EPV(f"{fnc_name} - duplicate field account/{attr_name}", serialized=yaml_dict, + raise_condition=True) + else: + with self.subTest(attrName=attr_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Duplicate definition: move 'logon_account_index' and 'reconcile_account_index' from 'custom' to 'account' section in"): + self.call_EPV(f"{fnc_name} - duplicate field account/{attr_name}", yaml_dict=yaml_dict, + raise_condition=True) + + del yaml_dict["custom"][attr_name] + + # --------------------------------------------- + # 2) safe vs global section + # --------------------------------------------- + for attr_name in ["cpm", "RETENTION"]: + yaml_dict[attr_name] = yaml_dict["safe"][attr_name.lower()] + + if serialized: + with self.subTest(attrName=attr_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Duplicate definition: Move 'cpm' and 'retention' to the 'safe' definition"): + self.call_EPV(f"{fnc_name} - duplicate field safe/{attr_name}", serialized=yaml_dict, + raise_condition=True) + else: + with self.subTest(attrName=attr_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Duplicate definition: Move 'cpm' and 'retention' to the 'safe' definition"): + self.call_EPV(f"{fnc_name} - duplicate field safe/{attr_name}", yaml_dict=yaml_dict, + raise_condition=True) + + del yaml_dict[attr_name] + + return yaml_dict + + def test_51_raise_duplicate_yml(self): + """ test_51_raise_duplicate_yml - Test error for duplicate (yaml file). + Check some fields in EPV instance class returned. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + yaml_dict = copy.deepcopy(TestConfigEpv.yaml_dict) + + # --------------------------------------------- + # 1) accout vs custom + # --------------------------------------------- + yaml_dict = self.check_fields_raise_duplicate(yaml_dict, fnc_name, serialized=False) + + # for attr_name in ["LOGON_ACCOUNT_INDEX", "reconcile_account_index"]: + # yaml_dict["custom"][attr_name] = yaml_dict["account"][attr_name.lower()] + # + # with self.subTest(attrName=attr_name): + # with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + # f"^Duplicate definition: move 'logon_account_index' and 'reconcile_account_index' from 'custom' to 'account' section in"): + # self.call_EPV(f"{fnc_name} - duplicate field account/{attr_name}", yaml_dict=yaml_dict, + # raise_condition=True) + # + # del yaml_dict["custom"][attr_name] + # + # # --------------------------------------------- + # # 2) safe vs global section + # # --------------------------------------------- + # for attr_name in ["cpm", "RETENTION"]: + # yaml_dict[attr_name] = yaml_dict["safe"][attr_name.lower()] + # + # with self.subTest(attrName=attr_name): + # with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + # f"^Duplicate definition: Move 'cpm' and 'retention' to the 'safe' definition"): + # self.call_EPV(f"{fnc_name} - duplicate field safe/{attr_name}", yaml_dict=yaml_dict, + # raise_condition=True) + # + # del yaml_dict[attr_name] + + # --------------------------------------------- + # 3) connection vs AIM (appid) + # --------------------------------------------- + attr_name = "appid" + add_section = None + + if "connection" in yaml_dict and attr_name in yaml_dict["connection"]: + add_section = "AIM" + elif "AIM" in yaml_dict and attr_name in yaml_dict["AIM"]: + add_section = "connection" + + yaml_dict[add_section][attr_name] = "appid_test" + + with self.subTest(add_section=add_section, attrName=attr_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Duplicate key 'aim/appid' in "): + self.call_EPV(f"{fnc_name} - duplicate field aim/{attr_name}", yaml_dict=yaml_dict, + raise_condition=True) + + del yaml_dict[add_section]["appid"] + + # --------------------------------------------- + # 4) same key lowercase vs uppercase + # --------------------------------------------- + attr_name = "host" + yaml_dict["pvwa"]["HOST"] = yaml_dict["pvwa"][attr_name] + + with self.subTest(attrName=attr_name): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Duplicate key '/pvwa/host' in "): + self.call_EPV(f"{fnc_name} - duplicate field pvwa/host (lower/uppercase)", yaml_dict=yaml_dict, + raise_condition=True) + + del yaml_dict["pvwa"]["HOST"] + + + def test_52_raise_duplicate_ser(self): + """ test_52_raise_duplicate_ser - Test error for duplicate (serialization). + Check some fields in EPV instance class returned. + """ + + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + serialize_dict = copy.deepcopy(TestConfigEpv.serialize_dict) + + # --------------------------------------------- + # 1) accout vs custom + # --------------------------------------------- + serialize_dict = self.check_fields_raise_duplicate(serialize_dict, fnc_name, serialized=True) + + # for attrName in ["LOGON_ACCOUNT_INDEX", "reconcile_account_index"]: + # serialize_dict["custom"][attrName] = serialize_dict["account"][attrName.lower()] + # + # with self.subTest(attrName=attrName): + # with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + # f"^Duplicate definition: move 'logon_account_index' and 'reconcile_account_index' from 'custom' to 'account' section in"): + # # epv_env = aiobastion.EPV(serialized=serialize_dict) + # self.call_EPV(f"{fnc_name} - duplicate field account/{attrName}", serialized=serialize_dict, + # raise_condition=True) + # + # del serialize_dict["custom"][attrName] + # + # # --------------------------------------------- + # # 2) safe vs global section + # # --------------------------------------------- + # for attrName in ["cpm", "RETENTION"]: + # serialize_dict[attrName] = serialize_dict["safe"][attrName.lower()] + # + # with self.subTest(attrName=attrName): + # with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + # f"^Duplicate definition: Move 'cpm' and 'retention' to the 'safe' definition"): + # # epv_env = aiobastion.EPV(serialized=serialize_dict) + # self.call_EPV(f"{fnc_name} - duplicate field safe/{attrName}", serialized=serialize_dict, + # raise_condition=True) + # + # del serialize_dict[attrName] + + # --------------------------------------------- + # 2) same key lowercase vs uppercase + # --------------------------------------------- + serialize_dict["API_HOST"] = serialize_dict["api_host"] + + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, f"^Duplicate key '/"): + self.call_EPV(f"{fnc_name} - duplicate field api_host (lower/uppercase)", serialized=serialize_dict, + raise_condition=True) + + del serialize_dict["API_HOST"] + + + def test_61_raise_typecheck_yml(self): + """ test_13_raise_typecheck_yml - Test error for type definition (yaml file) + + type: integer + pvwa timeout + pvwa(1) max_concurrent_tasks + pvwa(1) maxtasks + retention + aim timeout + aim max_concurrent_tasks + custom(3) LOGON_ACCOUNT_INDEX + custom(3) RECONCILE_ACCOUNT_INDEX + account(3) logon_account_index + account(3) reconcile_account_index + safe retention + + Type: boolean + pvwa keep_cookies + + type: string or boolean + aim verify + pvwa ca + pvwa verify + + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + # ------------------------------------- + # 2) Wrong EPV field (pvwa) + # ------------------------------------- + check_int = [ + ("pvwa", "timeout"), + ("pvwa", "max_concurrent_tasks"), + ("pvwa", "maxtasks"), + ("", "retention"), + ("AIM", "timeout"), + ("AIM", "max_concurrent_tasks"), + ("custom", "LOGON_ACCOUNT_INDEX"), + ("custom", "RECONCILE_ACCOUNT_INDEX"), + ("account", "logon_account_index"), + ("account", "reconcile_account_index"), + ("safe", "retention"), + ] + + check_bool = [ + ("pvwa", "keep_cookies"), + ] + + check_bool_str = [ + ("AIM", "verify"), + ("pvwa", "ca"), + ("pvwa", "verify"), + ] + + # Test integer + for section_name, attrName in check_int: + if section_name: + yaml_dict = {section_name: {attrName: "err"}} + else: + yaml_dict = {attrName: "err"} + + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, f"^Invalid "): + self.call_EPV(f"{fnc_name} - Invalid type {section_name}/{attrName}", yaml_dict=yaml_dict, + raise_condition=True) + + # Test boolean + for section_name, attrName in check_bool: + if section_name: + yaml_dict = {section_name: {attrName: "err"}} + else: + yaml_dict = {attrName: "err"} + + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, f"^Invalid value '{section_name}/{attrName}'"): + self.call_EPV(f"{fnc_name} - Invalid type {section_name}/{attrName}", yaml_dict=yaml_dict, + raise_condition=True) + + # Test verify (string or boolean) + for section_name, attrName in check_bool_str: + if section_name: + yaml_dict = {section_name: {attrName: 1}} + else: + yaml_dict = {attrName: 1} + + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, f"^Parameter type invalid "): + self.call_EPV(f"{fnc_name} - Invalid type {section_name}/{attrName}", yaml_dict=yaml_dict, + raise_condition=True) + + + def test_62_raise_typecheck_ser(self): + """ test_14_raise_typecheck_ser - Test error for type definition (serialization) + + type: integer + timeout + max_concurrent_tasks + retention + aim timeout + aim max_concurrent_tasks + custom(3) LOGON_ACCOUNT_INDEX + custom(3) RECONCILE_ACCOUNT_INDEX + account(3) logon_account_index + account(3) reconcile_account_index + safe retention + + Type: boolean + keep_cookies + + type: string or boolean + aim verify + verify + + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + # ------------------------------------- + # 2) Wrong EPV field (pvwa) + # ------------------------------------- + check_int = [ + ("", "timeout"), + ("", "max_concurrent_tasks"), + ("", "retention"), + ("AIM", "timeout"), + ("AIM", "max_concurrent_tasks"), + ("custom", "LOGON_ACCOUNT_INDEX"), + ("custom", "RECONCILE_ACCOUNT_INDEX"), + ("account", "logon_account_index"), + ("account", "reconcile_account_index"), + ("safe", "retention"), + ] + + check_bool = [ + ("", "keep_cookies"), + ] + + check_bool_str = [ + ("AIM", "verify"), + ("", "verify"), + ] + + # Test integer + for section_name, attrName in check_int: + if section_name: + serialize_dict = {section_name: {attrName: "err"}} + else: + serialize_dict = {attrName: "err"} + + with self.subTest(attrName=attrName, type="integer"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Invalid "): + self.call_EPV(f"{fnc_name} - Invalid type {section_name}/{attrName}", serialized=serialize_dict, + raise_condition=True) + + # Test boolean + for section_name, attrName in check_bool: + if section_name: + serialize_dict = {section_name: {attrName: "err"}} + else: + serialize_dict = {attrName: "err"} + + with self.subTest(attrName=attrName, type="bool"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Invalid value '{attrName}'"): + self.call_EPV(f"{fnc_name} - Invalid type {section_name}/{attrName}", serialized=serialize_dict, + raise_condition=True) + + # Test verify (string or boolean) + for section_name, attrName in check_bool_str: + if section_name: + serialize_dict = {section_name: {attrName: 1}} + else: + serialize_dict = {attrName: 1} + + with self.subTest(attrName=attrName, type="string/bool"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Parameter type invalid "): + self.call_EPV(f"{fnc_name} - Invalid type {section_name}/{attrName}", serialized=serialize_dict, + raise_condition=True) + + + def test_71_raise_account_validation(self): + """ test_71_raise_account_validation - Test error for wrong value in Account definition (yaml file and serialization) + """ + fnc_name = inspect.currentframe().f_code.co_name + + for attrName in ["logon_account_index", "reconcile_account_index"]: + serialize_dict = {"account": {attrName: 10}} + + with self.subTest(attrName=attrName,type="ser"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Invalid value for 'account/{attrName}' "): + self.call_EPV(f"{fnc_name} - account/reconcile_account_index (ser)", serialized=serialize_dict, + raise_condition=True) + + with self.subTest(attrName=attrName,type="Yaml"): + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, + f"^Invalid value for 'account/{attrName}' "): + self.call_EPV(f"{fnc_name} - account /reconcile_account_index (Yaml)", yaml_dict=serialize_dict, + raise_condition=True) + + + def test_81_raise_no_parm(self): + """ test_81_raise_no_parm - Test error for missing parameter in EPV call + """ + + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + with self.assertRaisesRegex(aiobastion.exceptions.AiobastionConfigurationException, "Internal error: no configfile and no serialized"): + # epv_env = aiobastion.EPV(serialized=serialize_dict) + self.call_EPV(f"{fnc_name} - No param") + + + + def test_91_to_json(self): + """ test_91_to_json - Test .to_json function call + Check all expected value fro .to_json. + """ + fnc_name = inspect.currentframe().f_code.co_name + self.writelog(HEADER, fnc_name) + + # Remove hidden field + validate_to_json = copy.deepcopy(TestConfigEpv.serialize_dict) + + # Remove hidden field for validation + del validate_to_json["password"] + del validate_to_json["username"] + del validate_to_json["user_search"] + del validate_to_json["custom"] + del validate_to_json["AIM"]["passphrase"] + + epv_env = self.call_EPV(fnc_name, serialized=TestConfigEpv.serialize_dict, expected_value=None, + trace_input=True, trace_epv=True, trace_check = False) + + json_dict = epv_env.to_json() + + self.write_pprint(f" {fnc_name} - to_json return ",json_dict) + + self.writelog(f" {fnc_name} - check value ".center(100, "-")) + + + # Check all expected value + self.check_section_value(f"{fnc_name}", json_dict, validate_to_json, trace_check=True) + + self.writelog(f" {fnc_name} - check value (end) ".center(100, "-")) + + +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() diff --git a/tests/test_cyberark.py b/tests/test_cyberark.py index 200e8d0..b82d80d 100644 --- a/tests/test_cyberark.py +++ b/tests/test_cyberark.py @@ -1,3 +1,4 @@ +import sys import asyncio import os import unittest @@ -15,7 +16,7 @@ async def asyncTearDown(self): try: await self.vault.logoff() except: - # test_logoff + # test_logoff pass async def test_logoff(self): @@ -75,6 +76,12 @@ async def test_handle_request(self): self.assertFalse(ret) -if __name__ == "__main__": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + unittest.main() diff --git a/tests/test_data/custom_config.yml b/tests/test_data/custom_config.yml index 5a98aa4..9b793ab 100644 --- a/tests/test_data/custom_config.yml +++ b/tests/test_data/custom_config.yml @@ -1,10 +1,52 @@ +# All field must be define without any error and no synonyms. +# Put keys in lowercase. +# +# This definition will be the main source of testing +# the configuration file and the serialization. +# +# For a complete field definition see the comment on the following function in config.py file: +# Config._mngt_configfile +# Config._mngt_serialized + +label: Production Demo + connection: + appid: appUser authtype: cyberark - password: abc - username: def -custom: - LOGON_ACCOUNT_INDEX: 3 - RECONCILE_ACCOUNT_INDEX: 1 -label: Production Demo + password: A_password + username: pvwaUser + user_search: + OBJect: "object-windows" + pvwa: - host: host1 \ No newline at end of file + host: PVWAHost1 + timeout: 12 + max_concurrent_tasks: 5 + keep_cookies: false + verify: false + +aim: + #appid in Connection + host: AIMhost2 + cert: "CertFile.crt" + key: "KeyFile.pem" + passphrase: passPhrase1 + timeout: 35 + max_concurrent_tasks: 15 + verify: true + +account: + logon_account_index: 2 + reconcile_account_index: 3 + +safe: + cpm: "userAdm" + retention: 15 + +api_options: + deprecated_warning: True + +# Customer information only +custom: + Custion_Field1: "Hello" + diff --git a/tests/test_platforms.py b/tests/test_platforms.py index bee946f..6b30566 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -1,6 +1,9 @@ +import sys import os import random import shutil +import asyncio +import unittest from pathlib import Path from unittest import IsolatedAsyncioTestCase import aiobastion @@ -129,3 +132,13 @@ async def test_export_all_platforms(self): # Cleanup shutil.rmtree("./temp") + +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() diff --git a/tests/test_safe.py b/tests/test_safe.py index 668a096..0a270fe 100644 --- a/tests/test_safe.py +++ b/tests/test_safe.py @@ -1,3 +1,6 @@ +import sys +import unittest +import asyncio from unittest import IsolatedAsyncioTestCase import aiobastion import random @@ -9,7 +12,7 @@ class TestSafe(IsolatedAsyncioTestCase): async def asyncSetUp(self): self.vault = aiobastion.EPV(tests.CONFIG) await self.vault.login() - self.api_user = "bastion_test_usr" + self.api_user = tests.API_USER self.test_safe = "sample-it-dept" self.test_usr = "bastion_std_usr" @@ -115,6 +118,19 @@ async def test_add_defaults_admin(self): async def test_delete(self): self.skipTest("Test covered by test_create_safe") + async def test_safe_members_paginate(self): + self.skipTest("Test covered by test_safe_members_iterator") + + async def test_safe_members_iterator(self): + async for _m in self.vault.safe.safe_members_iterator(self.test_safe): + self.assertIn("safeName", _m.keys()) + + async for _m in self.vault.safe.safe_members_iterator(self.test_safe, member_type="group", include_predefined_users=True): + self.assertEqual(_m["memberType"], "Group") + + async for _m in self.vault.safe.safe_members_iterator(self.test_safe, include_predefined_users=True, search="Master"): + self.assertEqual(_m["memberName"], "Master") + async def test_get(self): safe = await self.vault.safe.get_safe_details(self.test_safe) self.assertEqual(self.test_safe, safe['safeName']) @@ -124,6 +140,11 @@ async def test_get(self): async def test_list_members(self): members = await self.vault.safe.list_members(self.test_safe) self.assertIn(self.api_user, members) + print(members) + + members = await self.vault.safe.list_members(self.test_safe, raw=True) + self.assertIsInstance(members, list) + print(members) with self.assertRaises(AiobastionException): members = await self.vault.safe.list_members(self.test_safe, filter_perm="tutu") @@ -136,6 +157,7 @@ async def test_list_members(self): async def test_get_members(self): members = await self.vault.safe.list_members(self.test_safe) + print(await self.vault.safe.list_members(self.test_safe, raw=True)) self.assertIn(self.api_user, members) async def test_is_member_of(self): @@ -174,3 +196,14 @@ async def test_rename_safe(self): # undo ret = await self.vault.safe.rename(new_name, safe_to_rename) self.assertIn(safe_to_rename, [s["safeName"] for s in await self.vault.safe.search(safe_to_rename)]) + +if __name__ == '__main__': + # if sys.platform == 'win32': + # # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # # Can't support more than 512 sockets + # # Can't use pipe + # # Can't use subprocesses + # asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() + diff --git a/tests/test_session_management.py b/tests/test_session_management.py index dca1272..df3038f 100644 --- a/tests/test_session_management.py +++ b/tests/test_session_management.py @@ -1,5 +1,6 @@ -import asyncio +import sys import os +import asyncio import random import secrets import unittest @@ -23,3 +24,13 @@ async def asyncTearDown(self): async def test_get_all_connection_components(self): all_cc = await self.vault.session_management.get_all_connection_components() self.assertGreater(all_cc["Total"], 5) + +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() diff --git a/tests/test_system_health.py b/tests/test_system_health.py index 6b39003..1edcb50 100644 --- a/tests/test_system_health.py +++ b/tests/test_system_health.py @@ -1,3 +1,6 @@ +import sys +import asyncio +import unittest from unittest import IsolatedAsyncioTestCase import aiobastion import random @@ -31,3 +34,14 @@ async def test_details(self): summary = await self.vault.system_health.details("AIM") self.assertIsInstance(summary, list) + +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main() + diff --git a/tests/test_users.py b/tests/test_users.py index f3e5216..9fc50ac 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,5 +1,8 @@ -import secrets +import sys +import asyncio +import unittest from unittest import IsolatedAsyncioTestCase +import secrets import aiobastion import random import tests @@ -10,7 +13,7 @@ class TestUsers(IsolatedAsyncioTestCase): async def asyncSetUp(self): self.vault = aiobastion.EPV(tests.CONFIG) await self.vault.login() - self.api_user = "bastion_test_usr" + self.api_user = tests.API_USER self.test_safe = "sample-it-dept" self.test_usr = "bastion_std_usr" @@ -86,6 +89,10 @@ async def test_del_user(self): with self.assertRaises(AiobastionException): await self.vault.user.delete("thisuserdoesnotexists") + async def test_safes(self): + req = await self.vault.user.safes(self.api_user) + self.assertIsInstance(req, list) + # Group Part async def test_groups(self): @@ -172,5 +179,12 @@ async def test_add_group(self): req = await self.vault.group.list() self.assertNotIn(new_group_name, req) +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - + unittest.main() diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 588caf6..b77e5b3 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -1,3 +1,4 @@ +import sys import asyncio import random import secrets @@ -89,3 +90,12 @@ async def test_connection_component_usage(self): for c in await qualif.utils.platform.connection_component_usage(): print(c) +if __name__ == '__main__': + if sys.platform == 'win32': + # Turned out, using WindowsSelectorEventLoop has functionality issues such as: + # Can't support more than 512 sockets + # Can't use pipe + # Can't use subprocesses + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + unittest.main()