diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index ccec78834..ded16103e 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.0.9' +__version__ = '18.0.10' diff --git a/keepercommander/api.py b/keepercommander/api.py index 7710f9201..1ada50da6 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -754,7 +754,7 @@ def execute_router_rest(params: KeeperParams, endpoint: str, payload: Optional[b if payload is not None: payload = crypto.encrypt_aes_v2(payload, transmission_key) rs = requests.post(url, data=payload, headers=headers, proxies=params.rest_context.proxies, - verify=params.rest_context.certificate_check) + verify=params.ssl_verify) if rs.status_code == 200: rs_body = rs.content if rs_body: diff --git a/keepercommander/attachment.py b/keepercommander/attachment.py index c2cd95803..7da4206b5 100644 --- a/keepercommander/attachment.py +++ b/keepercommander/attachment.py @@ -129,6 +129,7 @@ def __init__(self): self.name = '' self.title = '' self.thumbnail = None # type: Optional[bytes] + self.is_script = False def prepare(self): pass @@ -150,10 +151,11 @@ def open(self): class FileUploadTask(UploadTask): - def __init__(self, file_path): + def __init__(self, file_path, is_script=False): super().__init__() self.file_path = file_path self.name = os.path.basename(self.file_path) + self.is_script = is_script def prepare(self): self.file_path = os.path.expanduser(self.file_path) @@ -247,6 +249,7 @@ def upload_attachments(params, record, attachments): file.record_uid = file_uid file.record_key = crypto.encrypt_aes_v2(file_key, params.data_key) file.fileSize = task.size + 100 + file.is_script = task.is_script file_data = { 'title': task.title or task.name, 'name': task.name or '', @@ -267,6 +270,9 @@ def upload_attachments(params, record, attachments): file_uid = uo.record_uid task = file_tasks[file_uid] if uo.status != record_pb2.FA_SUCCESS: + if task.is_script: + raise Exception( + f'Uploading rotation script {task.name}: only the record owner can attach post-rotation scripts.') raise Exception(f'Uploading file {task.name}: Get upload URL error.') file_key = file_keys[file_uid] @@ -278,7 +284,7 @@ def upload_attachments(params, record, attachments): } response = requests.post(uo.url, files=files, data=json.loads(uo.parameters), proxies=params.rest_context.proxies, - verify=params.rest_context.certificate_check) + verify=params.ssl_verify) if response.status_code == uo.success_status_code: facade.file_ref.append(file_ref) if record.linked_keys is None: @@ -295,7 +301,7 @@ def upload_attachments(params, record, attachments): } requests.post(uo.url, files=files, data=json.loads(uo.thumbnail_parameters), proxies=params.rest_context.proxies, - verify=params.rest_context.certificate_check) + verify=params.ssl_verify) except Exception as e: logging.warning('Error uploading thumbnail: %s', e) else: diff --git a/keepercommander/commands/discover/__init__.py b/keepercommander/commands/discover/__init__.py index fb6ed4294..dd07290cb 100644 --- a/keepercommander/commands/discover/__init__.py +++ b/keepercommander/commands/discover/__init__.py @@ -6,6 +6,7 @@ from ..pam.config_facades import PamConfigurationRecordFacade from ..pam.router_helper import get_response_payload from ..pam.gateway_helper import get_all_gateways +from ..pam.router_helper import router_send_action_to_gateway from ..ksm import KSMCommand from ... import utils, vault_extensions from ... import vault @@ -14,16 +15,18 @@ from ...display import bcolors from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...utils import value_to_boolean +from ...proto import pam_pb2 import json import base64 import re +from packaging import version as packaging_version from typing import List, Optional, Union, Callable, Tuple, Any, Dict, TYPE_CHECKING if TYPE_CHECKING: from ...params import KeeperParams from ...vault import KeeperRecord, ApplicationRecord - from ...proto import pam_pb2 + class MultiConfigurationException(Exception): @@ -63,6 +66,7 @@ def __init__(self, configuration: KeeperRecord, facade: PamConfigurationRecordFa self.gateway = gateway self.application = application self._shared_folders = None + self._info: Optional[Dict] = None @staticmethod def all_gateways(params: KeeperParams): @@ -294,6 +298,66 @@ def get_shared_folders(self, params: KeeperParams) -> List[dict]: }) return self._shared_folders + def info(self, params: KeeperParams) -> Optional[Dict]: + if self._info is None: + from ..pam.pam_dto import GatewayActionGatewayInfo + + if self.gateway_uid is None: + raise Exception("Gateway UID is not set for getting Gateway info.") + + router_response = router_send_action_to_gateway( + params=params, + gateway_action=GatewayActionGatewayInfo(is_scheduled=False), + message_type=pam_pb2.CMT_GENERAL, + is_streaming=False, + destination_gateway_uid_str=self.gateway_uid + ) + + if router_response is None: + print(f"{bcolors.FAIL}Did not get router response.{bcolors.ENDC}") + return + + response = router_response.get("response") + logging.debug(f"Router Response: {response}") + payload = get_response_payload(router_response) + data = payload.get("data") + if data is None: + print(f"{bcolors.FAIL}The router returned a failure.{bcolors.ENDC}") + return + elif data.get("success") is False: + error = data.get("error") + logging.debug(f"gateway returned: {error}") + print(f"{bcolors.FAIL}Could not map users to Windows services.{bcolors.ENDC}") + + self._info = data + + return self._info + + def _gateway_version(self, params: KeeperParams) -> Optional[packaging_version.Version]: + + try: + info = self.info(params) + gateway_version_str = info["gateway-config"]['version']["current"] + logging.debug(f"gateway version is {gateway_version_str}") + return packaging_version.parse(gateway_version_str) + except Exception as err: + logging.debug(f"got getting trying to get gateway version: {err}") + return None + + def gateway_version_gte(self, params: KeeperParams, require_version_str: str) -> bool: + gateway_version = self._gateway_version(params) + if gateway_version is None: + return False + require_version = packaging_version.parse(require_version_str) + return gateway_version >= require_version + + def gateway_version_lte(self, params: KeeperParams, require_version_str: str) -> bool: + gateway_version = self._gateway_version(params) + if gateway_version is None: + return False + require_version = packaging_version.parse(require_version_str) + return gateway_version <= require_version + def decrypt(self, cipher_base64: bytes) -> dict: ciphertext = base64.b64decode(cipher_base64.decode()) return json.loads(decrypt_aes_v2(ciphertext, self.configuration.record_key)) diff --git a/keepercommander/commands/discover/result_process.py b/keepercommander/commands/discover/result_process.py index 1c61d234b..f00a672be 100644 --- a/keepercommander/commands/discover/result_process.py +++ b/keepercommander/commands/discover/result_process.py @@ -15,6 +15,8 @@ from ...discovery_common.dag_sort import sort_infra_vertices from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.process import Process, QuitException, NoDiscoveryDataException +from ...discovery_common.record_link import RecordLink +from ...discovery_common.user_service import UserService from ...discovery_common.types import ( DiscoveryObject, UserAcl, PromptActionEnum, PromptResult, BulkRecordAdd, BulkRecordConvert, BulkProcessResults, BulkRecordSuccess, BulkRecordFail, DirectoryInfo, NormalizedRecord, RecordField) @@ -99,7 +101,7 @@ def _is_directory_user(record_type: str) -> bool: record_type == "pamAzureConfiguration") @staticmethod - def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayContext) -> str: + def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayContext) -> Optional[str]: while True: shared_folders = gateway_context.get_shared_folders(params) index = 0 @@ -113,7 +115,7 @@ def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayC print(f"{pad}{_f('Input was not a number.')}") @staticmethod - def get_field_values(record: TypedRecord, field_type: str) -> List[Any]: + def get_field_values(record: TypedRecord, field_type: str) -> Optional[List[Any]]: return next( (f.value for f in record.fields @@ -147,7 +149,7 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte elif key_field == "host": values = self.get_field_values(record, "pamHostname") - if len(values) == 0: + if values is None or len(values) == 0: return [] host = values[0].get("hostName") @@ -171,7 +173,7 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte return [] values = self.get_field_values(record, "login") - if len(values) == 0: + if values is None or len(values) == 0: return [] keys.append(f"{resource_uid}:{values[0]}".lower()) @@ -179,7 +181,9 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte return keys @staticmethod - def _record_lookup(record_uid: str, context: Optional[Any] = None) -> Optional[NormalizedRecord]: + def _record_lookup(record_uid: str, + context: Optional[Any] = None, + allow_sm: bool = False) -> Optional[NormalizedRecord]: """ Get the record from the Vault, normalize it, and return it. @@ -286,7 +290,7 @@ def _edit_record(self, content: DiscoveryObject, pad: str, editable: List[str]) new_values = map(str.strip, new_value.split(',')) new_value = "\n".join(new_values) elif type_hint == "multiline": - print(_b(f"{pad}Enter multiline of text or a path, on the first line, " + print(_b(f"{pad}Enter multiple of text or a path, on the first line, " "to a file that contains the value.")) print(_b(f"{pad}To end, type 'END' at the start of a new line. You can paste text.")) new_value = "" @@ -449,8 +453,8 @@ def _prompt_display_relationships(vertex: DAGVertex, content: DiscoveryObject, p def _prompt(self, content: DiscoveryObject, acl: UserAcl, + parent_vertex: DAGVertex, vertex: Optional[DAGVertex] = None, - parent_vertex: Optional[DAGVertex] = None, resource_has_admin: bool = True, item_count: int = 0, items_left: int = 0, @@ -821,6 +825,10 @@ def _handle_admin_record_from_record(record: TypedRecord, password_field = next((x for x in record.fields if x.type == "password"), None) private_key_field = next((x for x in record.fields if x.type == "keyPair"), None) + if login_field is None: + logging.error("Record is missing the login field.") + return None + content.set_field_value("login", login_field.value) if password_field is not None: content.set_field_value("password", password_field.value) @@ -1251,6 +1259,8 @@ def _convert_records(cls, bulk_convert_records: List[BulkRecordConvert], context for bulk_convert_record in bulk_convert_records: record = vault.KeeperRecord.load(params, bulk_convert_record.record_uid) + if record is None: + continue rotation_disabled = False @@ -1349,18 +1359,25 @@ def preview(self, job_item: JobItem, params: KeeperParams, gateway_context: Gate } sync_point = job_item.sync_point + if sync_point is None: + sync_point = 0 infra = Infrastructure(record=gateway_context.configuration, params=params, logger=logging, debug_level=debug_level) infra.load(sync_point) - configuration = None try: configuration = infra.get_root.has_vertices()[0] except (Exception,): print(f"{bcolors.FAIL}Could not find the configuration in the infrastructure graph. " f"Has discovery been run for this gateway?{bcolors.ENDC}") + return + + if configuration is None: + print(f"{bcolors.FAIL}Could not find the configuration in the infrastructure graph. " + f"Has discovery been run for this gateway?{bcolors.ENDC}") + return record_type_to_vertices_map = sort_infra_vertices(configuration) @@ -1482,6 +1499,31 @@ def _print_cloud_user(rt: str, rule_result: str): print("") + # TODO - `context` is not being passed; run_full might need to change to work with Commander. + def map_users_to_services(self, + params: KeeperParams, + configuration_record: TypedRecord, + debug_level: int = 0): + + record_link = RecordLink(record=configuration_record, + params=params, + logger=logging, + debug_level=debug_level) + user_service = UserService(record_linking=record_link, + record=configuration_record, + params=params, + logger=logging, + debug_level=debug_level) + + infra = Infrastructure(record=configuration_record, + params=params, + logger=logging, + debug_level=debug_level) + infra.load(0) + + user_service.run_full(record_lookup_func=self._record_lookup, + infra=infra) + def execute(self, params: KeeperParams, **kwargs): if not hasattr(params, 'pam_controllers'): @@ -1609,6 +1651,16 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"{bcolors.FAIL}No records have been added.{bcolors.ENDC}") + # New users may have been added. + # Map the users to Windows services. + # try: + # self.map_users_to_services(params=params, + # configuration_record=configuration_record, + # debug_level=debug_level) + # except Exception as err: + # logging.error(err) + # print(f"{bcolors.FAIL}Could not map users to services.{bcolors.ENDC}") + except NoDiscoveryDataException: print(f"{bcolors.OKGREEN}All items have been added for this discovery job.{bcolors.ENDC}") self.remove_job(params=params, configuration_record=configuration_record, job_id=job_id) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index ffc22f5e0..031665ac2 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -84,6 +84,9 @@ from .pam_service.list import PAMActionServiceListCommand from .pam_service.add import PAMActionServiceAddCommand from .pam_service.remove import PAMActionServiceRemoveCommand +from .pam_service.enable import PAMActionServiceEnableCommand +from .pam_service.disable import PAMActionServiceDisableCommand +from .pam_service.map import PAMActionUserServiceMapCommand from .pam_saas.set import PAMActionSaasSetCommand from .pam_saas.user import PAMActionSaasUserCommand from .pam_saas.remove import PAMActionSaasRemoveCommand @@ -176,6 +179,88 @@ def parse_schedule_data(kwargs): return schedule_data +def resolve_record_rotation_revision(params, record_uid): + # type: (KeeperParams, str) -> int + """Return server-assigned rotation revision from cache, syncing when missing (not sequential).""" + cached = params.record_rotation_cache.get(record_uid) + if cached is None or cached.get('revision') is None: + api.sync_down(params) + cached = params.record_rotation_cache.get(record_uid) + if cached is None or cached.get('revision') is None: + return 0 + return cached['revision'] + + +def schedule_from_pam_config(record_pam_config): + # type: (Optional[vault.TypedRecord]) -> Optional[List] + """Return rotation schedule list from a PAM configuration defaultRotationSchedule field.""" + if not record_pam_config: + return None + schedule_field = record_pam_config.get_typed_field('schedule', 'defaultRotationSchedule') + if (schedule_field and isinstance(schedule_field.value, list) and len(schedule_field.value) > 0 + and isinstance(schedule_field.value[0], dict)): + return list(schedule_field.value) + return None + + +def resolve_record_schedule_data(schedule_data, current_record_rotation, schedule_config, record_pam_config): + # type: (Optional[List], Optional[dict], bool, Optional[vault.TypedRecord]) -> Optional[List] + """Resolve rotation schedule for pam rotation edit (Web Vault use-default-schedule parity).""" + if schedule_data is not None: + return schedule_data + if current_record_rotation and not schedule_config: + try: + current_schedule = current_record_rotation.get('schedule') + if current_schedule: + return json.loads(current_schedule) + except Exception: + pass + return None + return schedule_from_pam_config(record_pam_config) + + +def resolve_record_rotation_revision(params, record_uid): + # type: (KeeperParams, str) -> int + """Return server-assigned rotation revision from cache, syncing when missing (not sequential).""" + cached = params.record_rotation_cache.get(record_uid) + if cached is None or cached.get('revision') is None: + api.sync_down(params) + cached = params.record_rotation_cache.get(record_uid) + if cached is None or cached.get('revision') is None: + return 0 + return cached['revision'] + + +def refresh_vault_for_schedule_config(params): + # type: (KeeperParams) -> None + """Sync vault so PAM configuration defaultRotationSchedule is current for --schedule-config.""" + api.sync_down(params) + + +def uses_default_rotation_schedule(params, record_uid, configuration_uid): + # type: (KeeperParams, str, str) -> bool + """True when stored rotation schedule matches PAM config default (Web Vault parity).""" + config_record = vault.KeeperRecord.load(params, configuration_uid) + if not isinstance(config_record, vault.TypedRecord): + return False + default_schedule = schedule_from_pam_config(config_record) + if not default_schedule: + return False + cached_rotation = params.record_rotation_cache.get(record_uid) + if not cached_rotation: + return False + schedule_str = cached_rotation.get('schedule') + if not schedule_str: + return False + try: + record_schedule = json.loads(schedule_str) + except Exception: + return False + if not isinstance(record_schedule, list) or len(record_schedule) == 0: + return False + return record_schedule == default_schedule + + def register_commands(commands): commands['pam'] = PAMControllerCommand() @@ -279,6 +364,12 @@ def __init__(self): 'Add a user and machine to the mapping', 'a') self.register_command('remove', PAMActionServiceRemoveCommand(), 'Remove a user and machine from the mapping', 'r') + self.register_command('enable', PAMActionServiceEnableCommand(), + 'Enable service password rotation on a machine or user', 'e') + self.register_command('disable', PAMActionServiceDisableCommand(), + 'Disable service password rotation on a machine or user', 'd') + self.register_command('map', PAMActionUserServiceMapCommand(), + 'Map users to discovered services.', 'm') self.default_verb = 'list' @@ -555,20 +646,8 @@ def config_saas_user(_dag, target_record, saas_config_uid: str): return # 2. Schedule - record_schedule_data = schedule_data - if record_schedule_data is None: - if current_record_rotation and not schedule_config: - try: - current_schedule = current_record_rotation.get('schedule') - if current_schedule: - record_schedule_data = json.loads(current_schedule) - except: - pass - else: - schedule_field = record_pam_config.get_typed_field('schedule', 'defaultRotationSchedule') - if schedule_field and isinstance(schedule_field.value, list) and len(schedule_field.value) > 0: - if isinstance(schedule_field.value[0], dict): - record_schedule_data = [schedule_field.value[0]] + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) # 3. Password complexity if pwd_complexity_rule_list is None: @@ -655,13 +734,8 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): record_config_uid = current_record_rotation.get('configuration_uid') record_pam_config = pam_configurations.get(record_config_uid, pam_config) - record_schedule_data = schedule_data - if record_schedule_data is None: - try: - cs = current_record_rotation.get('schedule') - record_schedule_data = json.loads(cs) if cs else [] - except: - record_schedule_data = [] + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) pwd_complexity_rule_list_encrypted = utils.base64_url_decode( current_record_rotation.get('pwd_complexity', '')) record_resource_uid = current_record_rotation.get('resource_uid') @@ -697,8 +771,11 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): str(noop_field.value[0]).upper() == 'TRUE'): noop_rotation = True + rotation_revision = resolve_record_rotation_revision(params, target_record.record_uid) + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + rq = router_pb2.RouterRecordRotationRequest() - rq.revision = current_record_rotation.get('revision', 0) + rq.revision = rotation_revision rq.recordUid = utils.base64_url_decode(target_record.record_uid) rq.configurationUid = utils.base64_url_decode(record_config_uid) rq.resourceUid = utils.base64_url_decode(record_resource_uid) if record_resource_uid else b'' @@ -735,6 +812,9 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): _dag.link_user_to_resource(target_record.record_uid, old_resource_uid, belongs_to=False) _dag.link_user_to_config(target_record.record_uid) + rotation_revision = resolve_record_rotation_revision(params, target_record.record_uid) + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + # 1. PAM Configuration UID record_config_uid = _dag.record.record_uid record_pam_config = pam_config @@ -760,20 +840,8 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): return # 2. Schedule - record_schedule_data = schedule_data - if record_schedule_data is None: - if current_record_rotation and not schedule_config: - try: - current_schedule = current_record_rotation.get('schedule') - if current_schedule: - record_schedule_data = json.loads(current_schedule) - except: - pass - else: - schedule_field = record_pam_config.get_typed_field('schedule', 'defaultRotationSchedule') - if schedule_field and isinstance(schedule_field.value, list) and len(schedule_field.value) > 0: - if isinstance(schedule_field.value[0], dict): - record_schedule_data = [schedule_field.value[0]] + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) # 3. Password complexity if pwd_complexity_rule_list is None: @@ -844,8 +912,7 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): # 6. Construct Request object for IAM: empty resourceUid and noop=False rq = router_pb2.RouterRecordRotationRequest() - if current_record_rotation: - rq.revision = current_record_rotation.get('revision', 0) + rq.revision = rotation_revision rq.recordUid = utils.base64_url_decode(target_record.record_uid) rq.configurationUid = utils.base64_url_decode(record_config_uid) rq.resourceUid = b'' # non-empty resourceUid sets is as General rotation @@ -855,6 +922,168 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): rq.disabled = disabled r_requests.append(rq) + def config_scripts_only_user(_dag, target_record, target_config_uid): + schedule_only = kwargs.get('schedule_only') + + if schedule_only: + cached_rotation = params.record_rotation_cache.get(target_record.record_uid) + if kwargs.get('folder_name') and ( + not cached_rotation or cached_rotation.get('disabled')): + skipped_records.append([target_record.record_uid, target_record.title, + 'Rotation not enabled', 'Skipped']) + return + rotation_revision = resolve_record_rotation_revision(params, target_record.record_uid) + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + if not current_record_rotation: + skipped_records.append([target_record.record_uid, target_record.title, + 'No rotation info', 'Skipped']) + return + + record_config_uid = current_record_rotation.get('configuration_uid') + record_pam_config = pam_configurations.get(record_config_uid, pam_config) + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) + pwd_complexity_rule_list_encrypted = utils.base64_url_decode( + current_record_rotation.get('pwd_complexity', '')) + disabled = current_record_rotation.get('disabled', False) + + schedule = 'On-Demand' + if isinstance(record_schedule_data, list) and len(record_schedule_data) > 0: + if isinstance(record_schedule_data[0], dict): + schedule = record_schedule_data[0].get('type') + complexity = '' + if pwd_complexity_rule_list_encrypted: + try: + decrypted_complexity = crypto.decrypt_aes_v2(pwd_complexity_rule_list_encrypted, + target_record.record_key) + c = json.loads(decrypted_complexity.decode()) + complexity = f"{c.get('length', 0)},{c.get('caps', 0)},{c.get('lowercase', 0)},{c.get('digits', 0)},{c.get('special', 0)},{c.get('specialChars', PAM_DEFAULT_SPECIAL_CHAR)}" + except Exception: + pass + + valid_records.append([ + target_record.record_uid, target_record.title, not disabled, record_config_uid, + None, schedule, complexity]) + + rq = router_pb2.RouterRecordRotationRequest() + rq.revision = rotation_revision + rq.recordUid = utils.base64_url_decode(target_record.record_uid) + rq.configurationUid = utils.base64_url_decode(record_config_uid) + rq.resourceUid = b'' + rq.schedule = json.dumps(record_schedule_data) if record_schedule_data else '' + rq.pwdComplexity = pwd_complexity_rule_list_encrypted + rq.disabled = disabled + rq.noop = True + r_requests.append(rq) + return + + if _dag and not _dag.linking_dag.has_graph: + _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_config_uid, + transmission_key=transmission_key) + if not _dag or not _dag.linking_dag.has_graph: + _dag.edit_tunneling_config(rotation=True) + old_dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_record.record_uid, + transmission_key=transmission_key) + if old_dag.linking_dag.has_graph and old_dag.record.record_uid != target_config_uid: + old_dag.remove_from_dag(target_record.record_uid) + + if not _dag.user_linked_to_config_for_noop(target_record.record_uid): + old_resource_uid = _dag.get_resource_uid(target_record.record_uid) + if old_resource_uid is not None: + print( + f'{bcolors.WARNING}User "{target_record.record_uid}" is associated with another resource: ' + f'{old_resource_uid}. ' + f'Now moving it to {target_config_uid} and it will no longer be rotated on {old_resource_uid}.' + f'{bcolors.ENDC}') + if old_resource_uid == _dag.record.record_uid: + _dag.unlink_user_from_resource(target_record.record_uid, old_resource_uid) + _dag.link_user_to_resource(target_record.record_uid, old_resource_uid, belongs_to=False) + _dag.link_user_to_config_noop(target_record.record_uid) + + rotation_revision = resolve_record_rotation_revision(params, target_record.record_uid) + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + + record_config_uid = _dag.record.record_uid + record_pam_config = pam_configurations.get(record_config_uid, pam_config) + if not record_config_uid: + if current_record_rotation: + record_config_uid = current_record_rotation.get('configuration_uid') + pc = vault.KeeperRecord.load(params, record_config_uid) + if pc is None: + skipped_records.append( + [target_record.record_uid, target_record.title, 'PAM Configuration was deleted', + 'Specify a configuration UID parameter [--config]']) + return + if not isinstance(pc, vault.TypedRecord) or pc.version != 6: + skipped_records.append( + [target_record.record_uid, target_record.title, 'PAM Configuration is invalid', + 'Specify a configuration UID parameter [--config]']) + return + record_pam_config = pc + else: + skipped_records.append( + [target_record.record_uid, target_record.title, 'No current PAM Configuration', + 'Specify a configuration UID parameter [--config]']) + return + + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) + + if pwd_complexity_rule_list is None: + if current_record_rotation: + pwd_complexity_rule_list_encrypted = utils.base64_url_decode( + current_record_rotation['pwd_complexity']) + else: + pwd_complexity_rule_list_encrypted = b'' + else: + if len(pwd_complexity_rule_list) > 0: + pwd_complexity_rule_list_encrypted = router_helper.encrypt_pwd_complexity(pwd_complexity_rule_list, + target_record.record_key) + else: + pwd_complexity_rule_list_encrypted = b'' + + record_resource_uid = None + + disabled = False + if kwargs.get('enable'): + _dag.set_resource_allowed(target_config_uid, rotation=True, is_config=True) + elif kwargs.get('disable'): + _dag.set_resource_allowed(target_config_uid, rotation=False, is_config=True) + disabled = True + + schedule = 'On-Demand' + if isinstance(record_schedule_data, list) and len(record_schedule_data) > 0: + if isinstance(record_schedule_data[0], dict): + schedule = record_schedule_data[0].get('type') + complexity = '' + if pwd_complexity_rule_list_encrypted: + try: + decrypted_complexity = crypto.decrypt_aes_v2(pwd_complexity_rule_list_encrypted, + target_record.record_key) + c = json.loads(decrypted_complexity.decode()) + complexity = f"{c.get('length', 0)}," \ + f"{c.get('caps', 0)}," \ + f"{c.get('lowercase', 0)}," \ + f"{c.get('digits', 0)}," \ + f"{c.get('special', 0)}," \ + f"{c.get('specialChars', PAM_DEFAULT_SPECIAL_CHAR)}" + except: + pass + valid_records.append( + [target_record.record_uid, target_record.title, not disabled, record_config_uid, record_resource_uid, + schedule, complexity]) + + rq = router_pb2.RouterRecordRotationRequest() + rq.revision = rotation_revision + rq.recordUid = utils.base64_url_decode(target_record.record_uid) + rq.configurationUid = utils.base64_url_decode(record_config_uid) + rq.resourceUid = b'' + rq.noop = True + rq.schedule = json.dumps(record_schedule_data) if record_schedule_data else '' + rq.pwdComplexity = pwd_complexity_rule_list_encrypted + rq.disabled = disabled + r_requests.append(rq) + def config_user(_dag, target_record, target_resource_uid, target_config_uid=None, silent=None): current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) schedule_only = kwargs.get('schedule_only') @@ -873,13 +1102,8 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None record_config_uid = current_record_rotation.get('configuration_uid') record_pam_config = pam_configurations.get(record_config_uid, pam_config) - record_schedule_data = schedule_data - if record_schedule_data is None: - try: - cs = current_record_rotation.get('schedule') - record_schedule_data = json.loads(cs) if cs else [] - except: - record_schedule_data = [] + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) pwd_complexity_rule_list_encrypted = utils.base64_url_decode( current_record_rotation.get('pwd_complexity', '')) record_resource_uid = current_record_rotation.get('resource_uid') @@ -1053,22 +1277,8 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None return # 2. Schedule - record_schedule_data = schedule_data - if record_schedule_data is None: - if current_record_rotation: - try: - current_schedule = current_record_rotation.get('schedule') - if current_schedule: - record_schedule_data = json.loads(current_schedule) - except: - pass - elif record_pam_config: - schedule_field = record_pam_config.get_typed_field('schedule', 'defaultRotationSchedule') - if schedule_field and isinstance(schedule_field.value, list) and len(schedule_field.value) > 0: - if isinstance(schedule_field.value[0], dict): - record_schedule_data = [schedule_field.value[0]] - else: - record_schedule_data = [] + record_schedule_data = resolve_record_schedule_data( + schedule_data, current_record_rotation, schedule_config, record_pam_config) # 3. Password complexity if pwd_complexity_rule_list is None: @@ -1240,6 +1450,10 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None if not kwargs.get('silent'): logging.info('Selected %d PAM record(s) for rotation', len(pam_records)) + schedule_config = kwargs.get('schedule_config') is True + if schedule_config: + refresh_vault_for_schedule_config(params) + pam_configurations = {x.record_uid: x for x in vault_extensions.find_records(params, record_version=6) if isinstance(x, vault.TypedRecord)} @@ -1255,7 +1469,6 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None else: raise CommandError('', f'Record uid {config_uid} is not a PAM Configuration record.') - schedule_config = kwargs.get('schedule_config') is True schedule_data = parse_schedule_data(kwargs) pwd_complexity = kwargs.get("pwd_complexity") @@ -1338,9 +1551,18 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None f'Record uid {effective_config_uid} is not a PAM Configuration record.') config_iam_aad_user(tmp_dag, _record, effective_config_uid) elif rotation_profile == 'scripts_only': - # Set noop flag for scripts_only profile - kwargs['noop'] = 'TRUE' - config_user(tmp_dag, _record, resource_uid, config_uid, silent=kwargs.get('silent')) + effective_config_uid = config_uid + if not effective_config_uid: + current_rotation = params.record_rotation_cache.get(_record.record_uid) + if current_rotation: + effective_config_uid = current_rotation.get('configuration_uid') + if not effective_config_uid: + raise CommandError('', 'Scripts-only rotation requires a PAM Configuration. ' + 'Use --config to specify one.') + if effective_config_uid not in pam_configurations: + raise CommandError('', + f'Record uid {effective_config_uid} is not a PAM Configuration record.') + config_scripts_only_user(tmp_dag, _record, effective_config_uid) elif rotation_profile == 'general': # General rotation requires a resource if not resource_uid: @@ -2216,6 +2438,8 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): help='Domain Network CIDR') domain_group.add_argument('--domain-admin', dest='domain_administrative_credential', action='store', help='Domain administrative credential') +domain_group.add_argument('--domain-user-match', dest='domain_user_match', action='store', + help='Domain user match filter') oci_group = common_parser.add_argument_group('oci', 'OCI configuration') oci_group.add_argument('--oci-id', dest='oci_id', action='store', help='OCI ID') oci_group.add_argument('--oci-admin-id', dest='oci_admin_id', action='store', help='OCI Admin ID') @@ -2345,6 +2569,19 @@ def resolve_single_record(params, record_name, rec = recs[0] return rec + @staticmethod + def _domain_kwarg_supplied(kwargs, key, config_edit): + """Return whether a domain text kwarg should be applied to the record. + + pam config new only sets non-empty values; omitted or empty strings are skipped. + pam config edit must support unsetting: an explicit empty string clears the field, + while a missing kwarg (None) leaves the existing value unchanged. + """ + val = kwargs.get(key) + if config_edit: + return val is not None + return bool(val) + def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, vault.TypedRecord, ...) -> None extra_properties = [] self.parse_pam_configuration(params, record, **kwargs) @@ -2423,14 +2660,35 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va rg = '\n'.join(resource_groups) extra_properties.append(f'multiline.resourceGroups={rg}') elif record.record_type == 'pamDomainConfiguration': + config_edit = kwargs.get('config_edit', False) is True domain_id = kwargs.get('domain_id') - if domain_id: + if self._domain_kwarg_supplied(kwargs, 'domain_id', config_edit): extra_properties.append(f'text.pamDomainId={domain_id}') - host = str(kwargs.get('domain_hostname') or '').strip() - port = str(kwargs.get('domain_port') or '').strip() - if host or port: - val = json.dumps({"hostName": host, "port": port}) - extra_properties.append(f"f.pamHostname=$JSON:{val}") + domain_hostname = kwargs.get('domain_hostname') + domain_port = kwargs.get('domain_port') + if config_edit: + if domain_hostname is not None or domain_port is not None: + existing_host = '' + existing_port = '' + pam_host_field = record.get_typed_field('pamHostname') + if pam_host_field: + current = pam_host_field.get_default_value(dict) or {} + if isinstance(current, dict): + existing_host = str(current.get('hostName') or '').strip() + existing_port = str(current.get('port') or '').strip() + host = str(domain_hostname or '').strip() if domain_hostname is not None else existing_host + port = str(domain_port or '').strip() if domain_port is not None else existing_port + if host or port: + val = json.dumps({"hostName": host, "port": port}) + extra_properties.append(f"f.pamHostname=$JSON:{val}") + else: + extra_properties.append('f.pamHostname=') + else: + host = str(domain_hostname or '').strip() + port = str(domain_port or '').strip() + if host or port: + val = json.dumps({"hostName": host, "port": port}) + extra_properties.append(f"f.pamHostname=$JSON:{val}") domain_use_ssl = utils.value_to_boolean(kwargs.get('domain_use_ssl')) if domain_use_ssl is not None: val = 'true' if domain_use_ssl else 'false' @@ -2440,8 +2698,11 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va val = 'true' if domain_scan_dc_cidr else 'false' extra_properties.append(f'checkbox.scanDCCIDR={val}') domain_network_cidr = kwargs.get('domain_network_cidr') - if domain_network_cidr: + if self._domain_kwarg_supplied(kwargs, 'domain_network_cidr', config_edit): extra_properties.append(f'text.networkCIDR={domain_network_cidr}') + domain_user_match = kwargs.get('domain_user_match') + if self._domain_kwarg_supplied(kwargs, 'domain_user_match', config_edit): + extra_properties.append(f'text.userMatch={domain_user_match}') domain_administrative_credential = kwargs.get('domain_administrative_credential') dac = str(domain_administrative_credential or '') if dac: @@ -2728,7 +2989,7 @@ def execute(self, params, **kwargs): orig_shared_folder_uid = value.get('folderUid') or '' orig_admin_cred_ref = value.get('adminCredentialRef') or '' - self.parse_properties(params, configuration, **kwargs) + self.parse_properties(params, configuration, config_edit=True, **kwargs) self.verify_required(configuration) record_management.update_record(params, configuration) @@ -2897,6 +3158,8 @@ def is_resource_ok(resource_id, params, configuration_uid): 'password_complexity_detail': pwd_complexity_detail, 'schedule_type': schedule_type, 'schedule_data': schedule_data, + 'use_default_rotation_schedule': uses_default_rotation_schedule( + params, record_uid, configuration_uid), 'disabled': rri.disabled, 'script_name': rri.scriptName if rri.scriptName else None, } @@ -2953,7 +3216,11 @@ def is_resource_ok(resource_id, params, configuration_uid): print(f"\nCommand to manually rotate: {bcolors.OKGREEN}pam action rotate -r {record_uid}{bcolors.ENDC}") else: if format_type == 'json': - return json.dumps({'status': rri_status_name, 'ready_to_rotate': False}) + return json.dumps({ + 'status': rri_status_name, + 'ready_to_rotate': False, + 'use_default_rotation_schedule': False, + }) print(f'{bcolors.WARNING}Rotation Status: Not ready to rotate ({rri_status_name}){bcolors.ENDC}') @@ -3044,7 +3311,7 @@ def execute(self, params, **kwargs): facade = record_facades.FileRefRecordFacade() facade.record = record pre = set(facade.file_ref) - upload_task = attachment.FileUploadTask(full_name) + upload_task = attachment.FileUploadTask(full_name, is_script=True) attachment.upload_attachments(params, record, [upload_task]) post = set(facade.file_ref) df = post.difference(pre) diff --git a/keepercommander/commands/discoveryrotation_v1.py b/keepercommander/commands/discoveryrotation_v1.py index a754b39de..d25e0d4b6 100644 --- a/keepercommander/commands/discoveryrotation_v1.py +++ b/keepercommander/commands/discoveryrotation_v1.py @@ -1355,7 +1355,7 @@ def execute(self, params, **kwargs): facade = record_facades.FileRefRecordFacade() facade.record = record pre = set(facade.file_ref) - upload_task = attachment.FileUploadTask(full_name) + upload_task = attachment.FileUploadTask(full_name, is_script=True) attachment.upload_attachments(params, record, [upload_task]) post = set(facade.file_ref) df = post.difference(pre) diff --git a/keepercommander/commands/mc_transfer.py b/keepercommander/commands/mc_transfer.py index 41b47af77..692647b05 100644 --- a/keepercommander/commands/mc_transfer.py +++ b/keepercommander/commands/mc_transfer.py @@ -3,8 +3,8 @@ import logging from typing import Optional, Tuple, List, Set, Any -from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey, EllipticCurvePrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey, RSAPrivateKey from . import base, enterprise_common from .helpers import report_utils @@ -314,6 +314,9 @@ def execute(self, params, **kwargs): else: raise error.CommandError('mc-transfer perform', 'Failed to get transfer key') + enterprise_rsa_key: Optional[RSAPrivateKey] = None + enterprise_ec_key: Optional[EllipticCurvePrivateKey] = None + enterprise_tree_key = params.enterprise['unencrypted_tree_key'] transfer_self = False if len(transfer.mcTransferEnterprises) > 0: @@ -325,11 +328,28 @@ def execute(self, params, **kwargs): transfer_self = True else: mc = next((x for x in managed_companies if x.get('mc_enterprise_id') == id_mc), None) - if mc: + if isinstance(mc, dict): encrypted_tree_key = utils.base64_url_decode(mc['tree_key']) + tree_key_type_id = mc.get('tree_key_type_id') or 0 if enterprise_tree_key: try: - tree_key = crypto.decrypt_aes_v2(encrypted_tree_key, enterprise_tree_key) + if tree_key_type_id == 1 or len(encrypted_tree_key) > 200: # RSA + if enterprise_rsa_key is None: + private_key_bytes = utils.base64_url_decode(params.enterprise['keys']['rsa_encrypted_private_key']) + private_key_bytes = crypto.decrypt_aes_v2(private_key_bytes, enterprise_tree_key) + enterprise_rsa_key = crypto.load_rsa_private_key(private_key_bytes) + assert isinstance(enterprise_rsa_key, RSAPrivateKey) + tree_key = crypto.decrypt_rsa(encrypted_tree_key, enterprise_rsa_key) + elif tree_key_type_id == 4 or len(encrypted_tree_key) == 125: # EC + if enterprise_ec_key is None: + private_key_bytes = utils.base64_url_decode(params.enterprise['keys']['ecc_encrypted_private_key']) + private_key_bytes = crypto.decrypt_aes_v2(private_key_bytes, enterprise_tree_key) + enterprise_ec_key = crypto.load_ec_private_key(private_key_bytes) + assert isinstance(enterprise_ec_key, EllipticCurvePrivateKey) + tree_key = crypto.decrypt_ec(encrypted_tree_key, enterprise_ec_key) + else: + tree_key = crypto.decrypt_aes_v2(encrypted_tree_key, enterprise_tree_key) + key = MCTransfer_pb2.MCTransferTreeKey() key.enterpriseId = id_mc if ec_key: diff --git a/keepercommander/commands/nested_share_folder/folder_commands.py b/keepercommander/commands/nested_share_folder/folder_commands.py index d19e7c148..57fa7c348 100644 --- a/keepercommander/commands/nested_share_folder/folder_commands.py +++ b/keepercommander/commands/nested_share_folder/folder_commands.py @@ -27,7 +27,7 @@ command_error_handler, check_result, check_folder_edit_permission, check_folder_share_permission, check_folder_delete_permission, classify_share_recipient, - ensure_nested_share_folder, + ensure_nested_share_folder, is_nested_share_folder, ) from .parsers import ( nested_share_folder_mkdir_parser, @@ -69,7 +69,7 @@ def execute(self, params, **kwargs): for idx, segment in enumerate(segments): is_leaf = (idx == last_idx) - existing_uid = self._find_existing_child(params, segment, parent_uid) + existing_uid = self._resolve_segment(params, segment, parent_uid) if existing_uid: if is_leaf: logging.warning('nsf-mkdir: Folder "%s" already exists', segment) @@ -134,6 +134,18 @@ def _cache_new_folder(params, folder_uid, name, parent_uid, folder_key=None): entry['folder_key_unencrypted'] = folder_key nsf[folder_uid] = entry + @staticmethod + def _resolve_segment(params, segment, parent_uid): + """Resolve *segment* to an existing NSF folder UID. + + UID segments are resolved globally, matching legacy ``mkdir`` path + behavior. Name segments are matched as children of *parent_uid*. + """ + if is_nested_share_folder(params, segment): + return segment + return NestedShareFolderMkdirCommand._find_existing_child( + params, segment, parent_uid) + @staticmethod def _find_existing_child(params, folder_name, parent_uid): """Find an existing NSF folder named *folder_name* whose parent matches diff --git a/keepercommander/commands/nested_share_folder/parsers.py b/keepercommander/commands/nested_share_folder/parsers.py index 9d09692be..ff9b3ac9f 100644 --- a/keepercommander/commands/nested_share_folder/parsers.py +++ b/keepercommander/commands/nested_share_folder/parsers.py @@ -36,7 +36,8 @@ def _make_parser(prog, description): 'nsf-mkdir', 'Create a new Nested Share Folder using v3 API') nested_share_folder_mkdir_parser.add_argument( 'folder', type=str, - help='Folder name or path (e.g. "Parent/Child/Grand") to create. ' + help='Folder name or path (e.g. "Parent/Child" or "ParentUID/Child"). ' + 'Parent segments may be an existing folder name or UID. ' 'Intermediate folders are created automatically. ' 'Use "//" to embed a literal "/" in a segment name.') nested_share_folder_mkdir_parser.add_argument( diff --git a/keepercommander/commands/pam/router_helper.py b/keepercommander/commands/pam/router_helper.py index e74dfaa05..cf0594cef 100644 --- a/keepercommander/commands/pam/router_helper.py +++ b/keepercommander/commands/pam/router_helper.py @@ -19,8 +19,6 @@ from ...params import KeeperParams from ...proto import pam_pb2, router_pb2 -VERIFY_SSL = bool(os.environ.get("VERIFY_SSL", "TRUE") == "TRUE") - # `RouterResponseError` lives in `_layer_b` to avoid the pre-existing circular # import chain (gateway_helper -> commands.utils -> ksm -> record). It's raised @@ -162,7 +160,7 @@ def _post_request_to_router(params, path, rq_proto=None, rs_type=None, method='p rs = requests.request(method, krouter_host + path, params=query_params, - verify=VERIFY_SSL, + verify=params.ssl_verify, headers={ 'TransmissionKey': bytes_to_base64(encrypted_transmission_key), 'Authorization': f'KeeperUser {bytes_to_base64(encrypted_session_token)}' @@ -359,14 +357,14 @@ def router_send_message_to_gateway(params, transmission_key, rq_proto, if http_session is not None: rs = http_session.post( krouter_host + "/api/user/send_controller_message", - verify=VERIFY_SSL, + verify=params.ssl_verify, headers=headers, data=encrypted_payload if rq_proto else None ) else: rs = requests.post( krouter_host + "/api/user/send_controller_message", - verify=VERIFY_SSL, + verify=params.ssl_verify, headers=headers, data=encrypted_payload if rq_proto else None ) @@ -406,7 +404,7 @@ def get_dag_leafs(params, encrypted_session_token, encrypted_transmission_key, r try: rs = requests.request('post', krouter_host + path, - verify=VERIFY_SSL, + verify=params.ssl_verify, headers={ 'TransmissionKey': bytes_to_base64(encrypted_transmission_key), 'Authorization': f'KeeperUser {bytes_to_base64(encrypted_session_token)}' diff --git a/keepercommander/commands/pam_debug/acl.py b/keepercommander/commands/pam_debug/acl.py index 9a7bca7d2..a85f49286 100644 --- a/keepercommander/commands/pam_debug/acl.py +++ b/keepercommander/commands/pam_debug/acl.py @@ -57,7 +57,7 @@ def execute(self, params: KeeperParams, **kwargs): record_link = RecordLink(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] if user_record is None: diff --git a/keepercommander/commands/pam_debug/gateway.py b/keepercommander/commands/pam_debug/gateway.py index 3642668ce..bd6999000 100644 --- a/keepercommander/commands/pam_debug/gateway.py +++ b/keepercommander/commands/pam_debug/gateway.py @@ -5,7 +5,6 @@ from ...display import bcolors from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink -from ...discovery_common.user_service import UserService from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from typing import TYPE_CHECKING @@ -49,11 +48,12 @@ def execute(self, params: KeeperParams, **kwargs): multi_conf_msg(gateway, err) return - infra = Infrastructure(record=gateway_context.configuration, params=params, fail_on_corrupt=False) + infra = Infrastructure(record=gateway_context.configuration, params=params, fail_on_corrupt=False, + use_per_graph_endpoints=False) infra.load() - record_link = RecordLink(record=gateway_context.configuration, params=params, fail_on_corrupt=False) - user_service = UserService(record=gateway_context.configuration, params=params, fail_on_corrupt=False) + record_link = RecordLink(record=gateway_context.configuration, params=params, fail_on_corrupt=False, + use_per_graph_endpoints=False) if gateway_context is None: print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") @@ -86,16 +86,12 @@ def execute(self, params: KeeperParams, **kwargs): print(self._h("Record Linking Graph")) graph.do_list(params=params, gateway_context=gateway_context, graph_type="rl", debug_level=debug_level, indent=1) - else: - print(f"{self._f('The gateway configuration does not have a record linking graph.')}") - print("") - - if user_service.dag.has_graph is True: + print("") print(self._h("User to Service/Task Graph")) graph.do_list(params=params, gateway_context=gateway_context, graph_type="service", debug_level=debug_level, indent=1) else: - print(f"{self._f('The gateway configuration does not have a user to service/task graph.')}") + print(f"{self._f('The gateway configuration does not have a record linking graph.')}") print("") diff --git a/keepercommander/commands/pam_debug/graph.py b/keepercommander/commands/pam_debug/graph.py index 6c985a4cb..b19003010 100644 --- a/keepercommander/commands/pam_debug/graph.py +++ b/keepercommander/commands/pam_debug/graph.py @@ -7,19 +7,18 @@ from ... import vault from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink -from ...discovery_common.user_service import UserService from ...discovery_common.jobs import Jobs from ...discovery_common.constants import (PAM_USER, PAM_DIRECTORY, PAM_MACHINE, PAM_DATABASE, VERTICES_SORT_MAP, - DIS_INFRA_GRAPH_ID, RECORD_LINK_GRAPH_ID, USER_SERVICE_GRAPH_ID, - DIS_JOBS_GRAPH_ID) + DIS_INFRA_GRAPH_ID, RECORD_LINK_GRAPH_ID, DIS_JOBS_GRAPH_ID) from ...discovery_common.types import (DiscoveryObject, DiscoveryUser, DiscoveryDirectory, DiscoveryMachine, - DiscoveryDatabase, JobContent) + DiscoveryDatabase, JobContent, ServiceAcl) from ...discovery_common.dag_sort import sort_infra_vertices from ...keeper_dag import DAG from ...keeper_dag.connection.commander import Connection as CommanderConnection from ...keeper_dag.connection.local import Connection as LocalConnection from ...keeper_dag.types import GRAPH_ID_TO_ENDPOINT, PamEndpoints from ...keeper_dag.vertex import DAGVertex +from ...keeper_dag.edge import DAGEdge, EdgeType from typing import Optional, Union, TYPE_CHECKING Connection = Union[CommanderConnection, LocalConnection] @@ -40,7 +39,7 @@ class PAMDebugGraphCommand(PAMGatewayActionDiscoverCommandBase): parser.add_argument('--configuration-uid', "-c", required=False, dest='configuration_uid', action='store', help='PAM configuration UID, if gateway has multiple.') - parser.add_argument('--type', '-t', required=True, choices=['infra', 'rl', 'service', 'jobs'], + parser.add_argument('--type', '-t', required=True, choices=['infra', 'rl', 'jobs'], dest='graph_type', action='store', help='Graph type', default='infra') parser.add_argument('--raw', required=False, dest='raw', action='store_true', help='Render raw graph. Will render corrupt graphs.') @@ -67,7 +66,6 @@ class PAMDebugGraphCommand(PAMGatewayActionDiscoverCommandBase): graph_id_map = { "infra": DIS_INFRA_GRAPH_ID, "rl": RECORD_LINK_GRAPH_ID, - "service": USER_SERVICE_GRAPH_ID, "jobs": DIS_JOBS_GRAPH_ID } @@ -78,7 +76,7 @@ def _do_text_list_infra(self, params: KeeperParams, gateway_context: GatewayCont indent: int = 0): infra = Infrastructure(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) infra.load(sync_point=0) try: @@ -164,7 +162,7 @@ def _do_text_list_rl(self, params: KeeperParams, gateway_context: GatewayContext record_link = RecordLink(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) configuration = record_link.dag.get_root record = vault.KeeperRecord.load(params, configuration.uid) # type: Optional[TypedRecord] @@ -219,7 +217,7 @@ def _group(configuration_vertex: DAGVertex) -> dict: for item in group[record_type]: vertex = item.get("v") # type: DAGVertex record = item.get("r") # type: TypedRecord - text = self._gr(f"{record.title}; {record.record_uid}") + text = self._gr(f"{record.title}; {record.record_uid} ") if not vertex.active: text += " " + self._f("Inactive") print(f"{pad} * {text}") @@ -315,56 +313,112 @@ def _group(configuration_vertex: DAGVertex) -> dict: def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayContext, debug_level: int = 0, indent: int = 0): - user_service = UserService(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) - configuration = user_service.dag.get_root + pad = "" + if indent > 0: + pad = "".ljust(2 * indent, ' ') + + record_link = RecordLink(record=gateway_context.configuration, params=params, logger=logging, + debug_level=debug_level, + use_per_graph_endpoints=False) + configuration = record_link.dag.get_root - def _handle(current_vertex: DAGVertex, parent_vertex: Optional[DAGVertex] = None, indent: int = 0): + machine_dict = {} - pad = "" - if indent > 0: - pad = "".ljust(2 * indent, ' ') + "* " + # New user/service mapping using PAM graph + for resource_vertex in configuration.has_vertices(): + if not resource_vertex.active: + continue + + machine_record = vault.KeeperRecord.load(params, resource_vertex.uid) # type: Optional[TypedRecord] + if machine_record is None or machine_record.record_type != PAM_MACHINE: + continue + + if machine_record is not None: + machine_name = f"{machine_record.title}, {machine_record.record_uid}" + else: + machine_name = self._f(f"Record vertex {resource_vertex.uid} has no record.") + + for user_vertex in resource_vertex.has_vertices(): + if not user_vertex.active: + continue - record = vault.KeeperRecord.load(params, current_vertex.uid) # type: Optional[TypedRecord] - if record is None: - if not current_vertex.active: - print(f"{pad}Record {current_vertex.uid} does not exists, inactive in the graph.") + user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + acl = record_link.get_acl(parent_record_uid=resource_vertex.uid, record_uid=user_vertex.uid) + if acl is not None and acl.controls_services: + if resource_vertex.uid not in machine_dict: + machine_dict[resource_vertex.uid] = { + "machine_name": machine_name, + "users": [] + } + + if user_record is not None: + user_name = f"{user_record.title}, {user_record.record_uid}" + else: + user_name = self._f(f"Record vertex {user_vertex.uid} has no record.") + + machine_dict[resource_vertex.uid]["users"].append(user_name) + + # Old user/service mapping using graph 13. + dag = DAG(conn=record_link.conn, graph_id=13, record=gateway_context.configuration) + if dag.has_graph: + for us_machine_vertex in dag.get_root.has_vertices(): + if not us_machine_vertex.active: + continue + + machine_record = vault.KeeperRecord.load(params, us_machine_vertex.uid) # type: Optional[TypedRecord] + if machine_record is not None: + machine_name = f"{machine_record.title}, {machine_record.record_uid}" else: - print(f"{pad}Record {current_vertex.uid} does not exists, active in the graph.") - return - elif not current_vertex.active: - print(f"{pad}{record.record_type}, {record.title}, {record.record_uid} exists, " - "inactive in the graph.") - return + machine_name = self._f(f"Record vertex {us_machine_vertex.uid} has no record.") - acl_text = "" - if parent_vertex is not None: - acl = user_service.get_acl(resource_uid=parent_vertex.uid, user_uid=current_vertex.uid) - if acl is not None: - acl_text = self._f("No Services") - acl_parts = [] - if acl.is_service: - acl_parts.append(self._bl("Service")) - if acl.is_task: - acl_parts.append(self._bl("Task")) - if acl.is_iis_pool: - acl_parts.append(self._bl("IIS Pool")) - if len(acl_parts) > 0: - acl_text = ", ".join(acl_parts) - acl_text = f" -> {acl_text}" - - print(f"{pad}{record.record_type}, {record.title}, {record.record_uid}{acl_text}") - - for vertex in current_vertex.has_vertices(): - _handle(current_vertex=vertex, parent_vertex=current_vertex, indent=indent+1) - - _handle(current_vertex=configuration, parent_vertex=None, indent=indent) + for us_user_vertex in us_machine_vertex.has_vertices(): + if not us_user_vertex.active: + continue + acl_edge = us_user_vertex.get_edge(us_machine_vertex, edge_type=EdgeType.ACL) + if acl_edge is None: + continue + service_acl = acl_edge.content_as_object(ServiceAcl) + if service_acl is None: + continue + + user_record = vault.KeeperRecord.load(params, us_user_vertex.uid) # type: Optional[TypedRecord] + + if us_machine_vertex.uid not in machine_dict: + machine_dict[us_machine_vertex.uid] = { + "machine_name": machine_name, + "users": [] + } + + types = [] + if service_acl.is_service: + types.append("Services") + if service_acl.is_task: + types.append("Tasks") + if service_acl.is_iis_pool: + types.append("IIS Pool") + text = "" + if len(types) > 0: + text = "(" + ", ".join(types) + ")" + + if user_record is not None: + user_name = f"OLD: {user_record.title}, {user_record.record_uid}, {text}" + else: + user_name = self._f(f"OLD: Record vertex {us_user_vertex.uid} has no record.") + + machine_dict[us_machine_vertex.uid]["users"].append(user_name) + else: + logging.debug("no old user service graph") + + for machine in machine_dict.values(): + print(f"{pad}{machine['machine_name']}") + for user in machine["users"]: + print(f"{pad} * {user}") def _do_text_list_jobs(self, params: KeeperParams, gateway_context: GatewayContext, debug_level: int = 0, indent: int = 0): infra = Infrastructure(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level, fail_on_corrupt=False) + debug_level=debug_level, fail_on_corrupt=False, use_per_graph_endpoints=False) infra.load(sync_point=0) pad = "" @@ -461,7 +515,7 @@ def _do_render_infra(self, params: KeeperParams, gateway_context: GatewayContext debug_level: int = 0): infra = Infrastructure(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) infra.load(sync_point=0) print("") @@ -487,7 +541,7 @@ def _do_render_rl(self, params: KeeperParams, gateway_context: GatewayContext, f rl = RecordLink(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) print("") dot_instance = rl.to_dot( @@ -506,33 +560,11 @@ def _do_render_rl(self, params: KeeperParams, gateway_context: GatewayContext, f raise err print("") - def _do_render_service(self, params: KeeperParams, gateway_context: GatewayContext, filepath: str, - graph_format: str, debug_level: int = 0): - - service = UserService(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) - - print("") - dot_instance = service.to_dot( - graph_type=graph_format if graph_format != "raw" else "dot", - show_only_active_vertices=False, - show_only_active_edges=False - ) - if graph_format == "raw": - print(dot_instance) - else: - try: - dot_instance.render(filepath) - print(f"User service/tasks graph rendered to {self._gr(filepath)}") - except Exception as err: - print(self._f(f"Could not generate graph: {err}")) - raise err - print("") - def _do_render_jobs(self, params: KeeperParams, gateway_context: GatewayContext, filepath: str, graph_format: str, debug_level: int = 0): - jobs = Jobs(record=gateway_context.configuration, params=params, logger=logging, debug_level=debug_level) + jobs = Jobs(record=gateway_context.configuration, params=params, logger=logging, debug_level=debug_level, + use_per_graph_endpoints=False) print("") dot_instance = jobs.dag.to_dot() diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index b1f2b648a..1707a0a9d 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -5,12 +5,12 @@ from ... import vault, vault_extensions from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink -from ...discovery_common.user_service import UserService from ...discovery_common.types import UserAcl, DiscoveryObject from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...keeper_dag import EdgeType import time import re +import json from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: @@ -65,7 +65,7 @@ def execute(self, params: KeeperParams, **kwargs): for configuration_record in configuration_records: - record_link = RecordLink(record=configuration_record, params=params) + record_link = RecordLink(record=configuration_record, params=params, use_per_graph_endpoints=False) record_vertex = record_link.dag.get_vertex(record.record_uid) if record_vertex is not None and record_vertex.active is True: controller_uid = configuration_record.record_uid @@ -95,10 +95,9 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.FAIL}Could not find the gateway for configuration record.{controller_uid}{bcolors.ENDC}") return - infra = Infrastructure(record=configuration_record, params=params) + infra = Infrastructure(record=configuration_record, params=params, use_per_graph_endpoints=False) infra.load() - record_link = RecordLink(record=configuration_record, params=params) - user_service = UserService(record=configuration_record, params=params) + record_link = RecordLink(record=configuration_record, params=params, use_per_graph_endpoints=False) print("") print(self._h("Record Information")) @@ -161,6 +160,11 @@ def _print_field(f): if record_vertex is not None: print(self._h("Record Linking")) + + print(self._b(" Record Data (meta)")) + print(f" Raw JSON: {json.dumps(record_vertex.content_as_dict)}") + print("") + record_parent_vertices = record_vertex.belongs_to_vertices() print(self._b(" Parent Records")) if len(record_parent_vertices) > 0: @@ -178,6 +182,7 @@ def _print_field(f): acl_content = acl_edge.content_as_object(UserAcl) # type: UserAcl print(f" * ACL to {self._n(parent_record.record_type)}; {parent_record.title}; " f"{record_parent_vertex.uid}") + print(f" . Raw JSON: {json.dumps(acl_edge.content_as_dict)}") if acl_content.is_admin: print(f" . Is {self._gr('Admin')}") if acl_content.belongs_to: @@ -185,11 +190,6 @@ def _print_field(f): else: print(f" . Is {self._bl('Remote user')}") - if acl_content.is_iam_user: - print(f" . Is an IAM User (user belonging to configuration)") - else: - print(f" . Is NOT an IAM User (user belonging to configuration)") - if acl_content.rotation_settings is None: print(f"{bcolors.FAIL} . There are no rotation settings!{bcolors.ENDC}") else: @@ -279,124 +279,90 @@ def _print_field(f): if record.record_type == PAM_USER or record.record_type == PAM_MACHINE: # Get the user to service/task vertex. - user_service_vertex = user_service.dag.get_vertex(record_uid) + record_vertex = record_link.dag.get_vertex(record_uid) - if user_service_vertex is not None: + if record_vertex is not None: # If the record is a PAM User if record.record_type == PAM_USER: - user_results = { - "is_task": [], - "is_service": [] - } + machines = [] # Get a list of all the resources the user is the username/password on service/task. - for us_machine_vertex in user_service.get_resource_vertices(record_uid): - - # Get the resource record - us_machine_record = ( - vault.KeeperRecord.load(params, us_machine_vertex.uid)) # type: Optional[TypedRecord] - - acl = user_service.get_acl(us_machine_vertex.uid, user_service_vertex.uid) - for attr in ["is_task", "is_service"]: - value = getattr(acl, attr) - if value is True: - - # If the resource record does not exist. - if us_machine_record is None: - - # Default the title to Unknown (in red). - # See if we have an infrastructure vertex with this record UID. - # If we do have it, use the title inside the first vertex's data content. - title = self._f("Unknown") - infra_resource_vertices = infra.dag.search_content( - {"record_uid": us_machine_vertex.uid}) - if len(infra_resource_vertices) > 0: - infra_resource_vertex = infra_resource_vertices[0] - if infra_resource_vertex.has_data is True: - content = DiscoveryObject.get_discovery_object(infra_resource_vertex) - title = content.title - - user_results[attr].append(f" * Record {us_machine_vertex.uid}, " - f"{title} does not exists.") - - # Record exists; just use information from the record. - else: - user_results[attr].append(f" * {us_machine_record.title}, " - f"{us_machine_vertex.uid}") - - print(f"{bcolors.HEADER}Service on Machines{bcolors.ENDC}") - if len(user_results["is_service"]) > 0: - for service in user_results["is_service"]: - print(service) - else: - print(" PAM User is not used for any services.") - print("") + for machine_vertex in record_vertex.belongs_to_vertices(): + + acl = record_link.get_acl(machine_vertex.uid, record_vertex.uid) + if (acl is not None + and acl.rotation_settings is not None + and acl.controls_services): + + # Get the resource record + machine_record = vault.KeeperRecord.load(params, + machine_vertex.uid) # type: Optional[TypedRecord] + + # If the resource record does not exist. + if machine_record is None: + + # Default the title to Unknown (in red). + # See if we have an infrastructure vertex with this record UID. + # If we do have it, use the title inside the first vertex's data content. + title = self._f("Unknown") + infra_resource_vertices = infra.dag.search_content( + {"record_uid": machine_vertex.uid}) + if len(infra_resource_vertices) > 0: + infra_resource_vertex = infra_resource_vertices[0] + if infra_resource_vertex.has_data is True: + content = DiscoveryObject.get_discovery_object(infra_resource_vertex) + title = content.title + + machines.append(f" * Record {machine_vertex.uid}, {title} does not exists.") + + # Record exists; just use information from the record. + else: + machines.append(f" * {machine_record.title}, {machine_record.record_uid}, " + f"vertex {machine_vertex.uid}") - print(f"{bcolors.HEADER}Scheduled Tasks on Machines{bcolors.ENDC}") - if len(user_results["is_task"]) > 0: - for task in user_results["is_task"]: - print(task) - else: - print(" PAM User is not used for any scheduled tasks.") - print("") + if len(machines) > 0: + print(f"{bcolors.HEADER}Controls Services on Machine{bcolors.ENDC}") + for item in machines: + print(item) # If the record is a PAM Machine - else: - user_results = { - "is_task": [], - "is_service": [] - } + elif record.record_type == PAM_MACHINE: + users = [] # Get the users that are used for tasks/services on this machine. - for us_user_vertex in user_service.get_user_vertices(record_uid): - - us_user_record = vault.KeeperRecord.load(params, - us_user_vertex.uid) # type: Optional[TypedRecord] - acl = user_service.get_acl(user_service_vertex.uid, us_user_vertex.uid) - for attr in ["is_task", "is_service"]: - value = getattr(acl, attr) - if value is True: - - # If the user record does not exist. - if us_user_record is None: - - # Default the title to Unknown (in red). - # See if we have an infrastructure vertex with this record UID. - # If we do have it, use the title inside the first vertex's data content. - title = self._f("Unknown") - infra_resource_vertices = infra.dag.search_content( - {"record_uid": us_user_vertex.uid}) - if len(infra_resource_vertices) > 0: - infra_resource_vertex = infra_resource_vertices[0] - if infra_resource_vertex.has_data is True: - content = DiscoveryObject.get_discovery_object(infra_resource_vertex) - title = content.title - - user_results[attr].append(f" * Record {us_user_vertex.uid}, " - f"{title} does not exists.") - - # Record exists; just use information from the record. - else: - user_results[attr].append(f" * {us_user_record.title}, " - f"{us_user_vertex.uid}") + for user_vertex in record_vertex.has_vertices(): + user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + acl = record_link.get_acl(record_vertex.uid, user_vertex.uid) + if acl is not None and acl.controls_services: + # If the user record does not exist. + if user_record is None: + + # Default the title to Unknown (in red). + # See if we have an infrastructure vertex with this record UID. + # If we do have it, use the title inside the first vertex's data content. + title = self._f("Unknown") + infra_resource_vertices = infra.dag.search_content( + {"record_uid": user_vertex.uid}) + if len(infra_resource_vertices) > 0: + infra_resource_vertex = infra_resource_vertices[0] + if infra_resource_vertex.has_data is True: + content = DiscoveryObject.get_discovery_object(infra_resource_vertex) + title = content.title + + users.append(f" * Record {user_vertex.uid}, {title} does not exists.") + + # Record exists; just use information from the record. + else: + users.append(f" * {user_record.title}, {user_record.record_uid}, " + f"vertex {user_vertex.uid}") - print(f"{bcolors.HEADER}Users that are used for Services{bcolors.ENDC}") - if len(user_results["is_service"]) > 0: - for service in user_results["is_service"]: - print(service) - else: - print(" Machine does not use any non-builtin users for services.") - print("") + if len(users) > 0: + print(f"{bcolors.HEADER}Users that are used for services{bcolors.ENDC}") + for item in users: + print(item) - print(f"{bcolors.HEADER}Users that are used for Scheduled Tasks{bcolors.ENDC}") - if len(user_results["is_task"]) > 0: - for task in user_results["is_task"]: - print(task) - else: - print(" Machine does not use any non-builtin users for scheduled tasks.") - print("") else: print(self._f("There are no services or schedule tasks associated with this record.")) print("") diff --git a/keepercommander/commands/pam_debug/krouter.py b/keepercommander/commands/pam_debug/krouter.py index bc1a28ac3..a8afaf644 100644 --- a/keepercommander/commands/pam_debug/krouter.py +++ b/keepercommander/commands/pam_debug/krouter.py @@ -46,16 +46,15 @@ def execute(self, params: KeeperParams, **kwargs): url = get_router_url(params) url = url.rstrip('/') + '/healthcheck' - verify_ssl = os.environ.get('VERIFY_SSL', 'TRUE') == 'TRUE' timeout = kwargs.get('timeout') or 10.0 raw = bool(kwargs.get('raw')) try: - rs = requests.get(url, verify=verify_ssl, timeout=timeout) + rs = requests.get(url, verify=params.ssl_verify, timeout=timeout) rs.raise_for_status() except requests.exceptions.SSLError as err: print(f"{bcolors.FAIL}SSL verification failed: {err}{bcolors.ENDC}") - print(' Set VERIFY_SSL=FALSE to bypass for development krouter builds.') + print(' Set VERIFY_SSL=FALSE or KEEPER_SSL_CERT_FILE=none to bypass SSL verification.') return except requests.exceptions.ConnectionError as err: print(f"{bcolors.FAIL}Cannot reach krouter at {url}: {err}{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_debug/link.py b/keepercommander/commands/pam_debug/link.py index 766d27d16..b8bddba8d 100644 --- a/keepercommander/commands/pam_debug/link.py +++ b/keepercommander/commands/pam_debug/link.py @@ -53,7 +53,7 @@ def execute(self, params: KeeperParams, **kwargs): record_link = RecordLink(record=gateway_context.configuration, params=params, logger=logging, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) resource_record = vault.KeeperRecord.load(params, resource_uid) # type: Optional[TypedRecord] if resource_record is None: diff --git a/keepercommander/commands/pam_debug/rotation_setting.py b/keepercommander/commands/pam_debug/rotation_setting.py index 8e475b487..2557f8a15 100644 --- a/keepercommander/commands/pam_debug/rotation_setting.py +++ b/keepercommander/commands/pam_debug/rotation_setting.py @@ -164,7 +164,7 @@ def execute(self, params: KeeperParams, **kwargs): f"It's a {resource_record.record_type}.{bcolors.ENDC}") return - record_link = RecordLink(record=configuration_record, params=params) + record_link = RecordLink(record=configuration_record, params=params, use_per_graph_endpoints=False) parent_uid = resource_record_uid or configuration_record_uid parent_vertex = record_link.get_record_link(parent_uid) diff --git a/keepercommander/commands/pam_debug/vertex.py b/keepercommander/commands/pam_debug/vertex.py index 3d37e61dc..05bbe493d 100644 --- a/keepercommander/commands/pam_debug/vertex.py +++ b/keepercommander/commands/pam_debug/vertex.py @@ -53,7 +53,7 @@ def execute(self, params: KeeperParams, **kwargs): return infra = Infrastructure(record=gateway_context.configuration, params=params, fail_on_corrupt=False, - debug_level=debug_level) + debug_level=debug_level, use_per_graph_endpoints=False) infra.load() vertex_uid = kwargs.get("vertex_uid") diff --git a/keepercommander/commands/pam_import/README.md b/keepercommander/commands/pam_import/README.md index 706225643..78d62be8b 100644 --- a/keepercommander/commands/pam_import/README.md +++ b/keepercommander/commands/pam_import/README.md @@ -253,6 +253,7 @@ _You can have only one `pam_configuration` section and the only required paramet "dom_use_ssl": true, "dom_scan_dc_cidr": true, "dom_network_cidr": "192.168.1.0/28", + "dom_user_match": "OU=Office Users,DC=example,DC=com", "dom_administrative_credential": "admin1" } } diff --git a/keepercommander/commands/pam_import/base.py b/keepercommander/commands/pam_import/base.py index ef2b6dabc..3ad394261 100644 --- a/keepercommander/commands/pam_import/base.py +++ b/keepercommander/commands/pam_import/base.py @@ -156,6 +156,7 @@ def _initialize(self): self.dom_use_ssl: bool = False # required, checkbox:useSSL self.dom_scan_dc_cidr: bool = False # optional, checkbox:scanDCCIDR self.dom_network_cidr: str = "" # optional, text:networkCIDR + self.dom_user_match: str = "" # optional, text:userMatch self.dom_administrative_credential: str = "" # required, existing pamUser: pamResources.value[0][adminCredentialRef] self.admin_credential_ref: str = "" # UID resolved from dom_administrative_credential by record title # Domain Administrator User: pamUser record should have an ACL edge to the pamDomainConfiguration record with is_admin = True @@ -303,6 +304,8 @@ def __init__(self, environment_type:str, settings:dict, controller_uid:str, fold if isinstance(val, bool): self.dom_scan_dc_cidr = val val = settings.get("dom_network_cidr", None) # optional if isinstance(val, str): self.dom_network_cidr = val + val = settings.get("dom_user_match", None) # optional + if isinstance(val, str): self.dom_user_match = val val = settings.get("dom_administrative_credential", None) # required, existing pamUser if isinstance(val, str): self.dom_administrative_credential = val # self.admin_credential_ref - will be resolved from dom_administrative_credential (later) @@ -3695,7 +3698,7 @@ def add_pam_scripts(params, record, scripts): facade = record_facades.FileRefRecordFacade() facade.record = rec pre = set(facade.file_ref) - upload_task = attachment.FileUploadTask(full_name) + upload_task = attachment.FileUploadTask(full_name, is_script=True) attachment.upload_attachments(params, rec, [upload_task]) post = set(facade.file_ref) df = post.difference(pre) diff --git a/keepercommander/commands/pam_import/edit.py b/keepercommander/commands/pam_import/edit.py index d80fd8354..149938837 100644 --- a/keepercommander/commands/pam_import/edit.py +++ b/keepercommander/commands/pam_import/edit.py @@ -508,6 +508,7 @@ def process_pam_config(self, params, project: dict) -> dict: if pce.dom_use_ssl is not None: args["domain_use_ssl"] = pce.dom_use_ssl if pce.dom_scan_dc_cidr is not None: args["domain_scan_dc_cidr"] = pce.dom_scan_dc_cidr if pce.dom_network_cidr: args["domain_network_cidr"] = pce.dom_network_cidr + if pce.dom_user_match: args["domain_user_match"] = pce.dom_user_match if pce.admin_credential_ref: args["domain_administrative_credential"] = pce.admin_credential_ref args["force_domain_admin"] = True # add now - ACL link later diff --git a/keepercommander/commands/pam_launch/terminal_connection.py b/keepercommander/commands/pam_launch/terminal_connection.py index 970dfde05..445bcab01 100644 --- a/keepercommander/commands/pam_launch/terminal_connection.py +++ b/keepercommander/commands/pam_launch/terminal_connection.py @@ -65,7 +65,6 @@ router_send_action_to_gateway, router_get_relay_access_creds, get_router_url, - VERIFY_SSL, ) from ...proto import pam_pb2 from ...display import bcolors @@ -213,7 +212,7 @@ def _notify_gateway_connection_close(params, router_token, terminated=True): response = requests.post( f"{router_url}/api/device/connect_state", json=payload, - verify=VERIFY_SSL, + verify=params.ssl_verify, timeout=10, ) if response.status_code >= 400: @@ -512,7 +511,7 @@ def extract_terminal_settings( elif protocol == ConnectionProtocol.KUBERNETES.value: settings['protocol_specific'] = _extract_kubernetes_settings(connection) elif protocol in DATABASE: - settings['protocol_specific'] = _extract_database_settings(connection, protocol) + settings['protocol_specific'] = _extract_database_settings(connection) # allowSupplyHost is at top level of pamSettings value, not inside connection settings['allowSupplyHost'] = pam_settings_value.get('allowSupplyHost', False) @@ -574,53 +573,48 @@ def extract_terminal_settings( def _extract_ssh_settings(connection: Dict[str, Any]) -> Dict[str, Any]: - """Extract SSH-specific settings""" + """Extract SSH-specific settings from pamSettings.connection (record JSON).""" + sftp = connection.get('sftp') or {} return { - 'publicHostKey': connection.get('publicHostKey', ''), - 'executeCommand': connection.get('executeCommand', ''), - 'sftpEnabled': connection.get('sftpEnabled', False), + 'publicHostKey': connection.get('hostKey', ''), + 'executeCommand': connection.get('command', ''), + 'sftpEnabled': bool(sftp.get('enableSftp', False)), + 'sftpRootDirectory': sftp.get('sftpRootDirectory', ''), } def _extract_telnet_settings(connection: Dict[str, Any]) -> Dict[str, Any]: - """Extract Telnet-specific settings""" + """Extract Telnet-specific settings from pamSettings.connection (record JSON).""" return { 'usernameRegex': connection.get('usernameRegex', ''), 'passwordRegex': connection.get('passwordRegex', ''), + 'loginSuccessRegex': connection.get('loginSuccessRegex', ''), + 'loginFailureRegex': connection.get('loginFailureRegex', ''), } def _extract_kubernetes_settings(connection: Dict[str, Any]) -> Dict[str, Any]: - """Extract Kubernetes-specific settings""" + """Extract Kubernetes-specific settings from pamSettings.connection (record JSON).""" return { 'namespace': connection.get('namespace', 'default'), 'pod': connection.get('pod', ''), 'container': connection.get('container', ''), - 'ignoreServerCertificate': connection.get('ignoreServerCertificate', False), - 'caCertificate': connection.get('caCertificate', ''), - 'clientCertificate': connection.get('clientCertificate', ''), + 'useSSL': connection.get('useSSL', False), + 'ignoreServerCertificate': connection.get('ignoreCert', False), + 'caCertificate': connection.get('caCert', ''), + 'clientCertificate': connection.get('clientCert', ''), 'clientKey': connection.get('clientKey', ''), } -def _extract_database_settings(connection: Dict[str, Any], protocol: str) -> Dict[str, Any]: - """Extract database-specific settings""" - settings = { - 'defaultDatabase': connection.get('defaultDatabase', ''), +def _extract_database_settings(connection: Dict[str, Any]) -> Dict[str, Any]: + """Extract database-specific settings from pamSettings.connection (record JSON).""" + return { + 'defaultDatabase': connection.get('database', ''), 'disableCsvExport': connection.get('disableCsvExport', False), 'disableCsvImport': connection.get('disableCsvImport', False), } - # Add protocol-specific database settings - if protocol == ConnectionProtocol.MYSQL.value: - settings['useSSL'] = connection.get('useSSL', False) - elif protocol == ConnectionProtocol.POSTGRESQL.value: - settings['useSSL'] = connection.get('useSSL', False) - elif protocol == ConnectionProtocol.SQLSERVER.value: - settings['useSSL'] = connection.get('useSSL', True) # SQL Server typically uses SSL by default - - return settings - def create_connection_context(params: KeeperParams, record_uid: str, @@ -1118,6 +1112,8 @@ def _build_guacamole_connection_settings( # Enable SFTP if configured if protocol_specific.get('sftpEnabled'): guacd_params['enable-sftp'] = 'true' + if protocol_specific.get('sftpRootDirectory'): + guacd_params['sftp-root-directory'] = protocol_specific['sftpRootDirectory'] elif protocol == ConnectionProtocol.TELNET.value: # Telnet-specific params @@ -1125,6 +1121,10 @@ def _build_guacamole_connection_settings( guacd_params['username-regex'] = protocol_specific['usernameRegex'] if protocol_specific.get('passwordRegex'): guacd_params['password-regex'] = protocol_specific['passwordRegex'] + if protocol_specific.get('loginSuccessRegex'): + guacd_params['login-success-regex'] = protocol_specific['loginSuccessRegex'] + if protocol_specific.get('loginFailureRegex'): + guacd_params['login-failure-regex'] = protocol_specific['loginFailureRegex'] elif protocol == ConnectionProtocol.KUBERNETES.value: # Kubernetes-specific params @@ -1142,11 +1142,17 @@ def _build_guacamole_connection_settings( guacd_params['client-key'] = protocol_specific['clientKey'] if protocol_specific.get('ignoreServerCertificate'): guacd_params['ignore-cert'] = 'true' + if protocol_specific.get('useSSL'): + guacd_params['use-ssl'] = 'true' elif protocol in DATABASE: # Database-specific params if protocol_specific.get('defaultDatabase'): guacd_params['database'] = protocol_specific['defaultDatabase'] + if protocol_specific.get('disableCsvExport'): + guacd_params['disable-csv-export'] = 'true' + if protocol_specific.get('disableCsvImport'): + guacd_params['disable-csv-import'] = 'true' # CLI mode: named pipe for terminal STDOUT (guacr terminal handlers; not graphical RDP/VNC) guacd_params['enable-pipe'] = 'true' @@ -1360,7 +1366,7 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, krouter_host = get_router_url(params) try: bind_url = krouter_host + "/api/user/bind_to_controller/" + gateway_uid - http_session.get(bind_url, verify=VERIFY_SSL, timeout=10) + http_session.get(bind_url, verify=params.ssl_verify, timeout=10) except Exception as e: logging.debug("bind_to_controller GET failed (continuing): %s", e) if http_session.cookies: diff --git a/keepercommander/commands/pam_saas/__init__.py b/keepercommander/commands/pam_saas/__init__.py index af36fc76b..706f5e629 100644 --- a/keepercommander/commands/pam_saas/__init__.py +++ b/keepercommander/commands/pam_saas/__init__.py @@ -7,11 +7,11 @@ from ...display import bcolors from ... import vault from ...discovery_common.record_link import RecordLink -from ... import utils import logging import hmac import hashlib import os +import requests from pydantic import BaseModel from typing import Optional, List, Any, TYPE_CHECKING @@ -237,7 +237,7 @@ def get_plugins_map(params: KeeperParams, gateway_context: GatewayContext) -> Op # Get the latest release of the catalog.json api_url = f"https://api.github.com/repos/{CATALOG_REPO}/releases/latest" - res = utils.ssl_aware_get(api_url) + res = requests.get(api_url, verify=params.ssl_verify) if res.ok is False: print("") print(f"{bcolors.FAIL}Could not get plugin catalog from GitHub.{bcolors.ENDC}") @@ -251,7 +251,7 @@ def get_plugins_map(params: KeeperParams, gateway_context: GatewayContext) -> Op logging.debug(f"download {asset['name']} from {download_url}") # Download the latest the catalog.yml - res = utils.ssl_aware_get(download_url) + res = requests.get(download_url, verify=params.ssl_verify) if res.ok is False: print("") print(f"{bcolors.FAIL}Could not download the plugin catalog from GitHub.{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_saas/config.py b/keepercommander/commands/pam_saas/config.py index 36d8e82be..1ecb51d1b 100644 --- a/keepercommander/commands/pam_saas/config.py +++ b/keepercommander/commands/pam_saas/config.py @@ -11,6 +11,7 @@ from tempfile import TemporaryDirectory import os import json +import requests from typing import Optional, List, TYPE_CHECKING if TYPE_CHECKING: @@ -347,7 +348,7 @@ def execute(self, params: KeeperParams, **kwargs): # For catalog plugins, we need to download the python file from GitHub. plugin_code_bytes = None if plugin.type == "catalog" and plugin.file: - res = utils.ssl_aware_get(plugin.file) + res = requests.get(plugin.file, verify=params.ssl_verify) if res.ok is False: print("") print(f"{bcolors.FAIL}Could download the script from GitHub.{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_saas/update.py b/keepercommander/commands/pam_saas/update.py index 08b136141..02f1d8411 100644 --- a/keepercommander/commands/pam_saas/update.py +++ b/keepercommander/commands/pam_saas/update.py @@ -4,11 +4,12 @@ import traceback from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors -from ... import api, vault, vault_extensions, attachment, record_management, utils +from ... import api, vault, vault_extensions, attachment, record_management from . import (get_plugins_map, make_script_signature, SaasCatalog, get_field_input, get_record_field_value, set_record_field_value) from tempfile import TemporaryDirectory import os +import requests from typing import List, Optional, TYPE_CHECKING if TYPE_CHECKING: @@ -64,7 +65,7 @@ def _update_script(cls, params: KeeperParams, config_record: TypedRecord, plugin raise ValueError("Plugin does not have a file name.") print(" * downloading updated plugin script") - res = utils.ssl_aware_get(plugin.file) + res = requests.get(plugin.file, verify=params.ssl_verify) if res.ok is False: raise ValueError("Could download updated script from GitHub") plugin_code_bytes = res.content diff --git a/keepercommander/commands/pam_service/add.py b/keepercommander/commands/pam_service/add.py index e471a5c4f..92aa13bee 100644 --- a/keepercommander/commands/pam_service/add.py +++ b/keepercommander/commands/pam_service/add.py @@ -1,12 +1,13 @@ from __future__ import annotations import argparse +import logging from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors from ... import vault from ...discovery_common.user_service import UserService from ...discovery_common.record_link import RecordLink from ...discovery_common.constants import PAM_USER, PAM_MACHINE -from ...discovery_common.types import ServiceAcl +from ...discovery_common.types import UserAcl from ...keeper_dag.types import RefType, EdgeType from ... import __version__ from typing import Optional, TYPE_CHECKING @@ -17,7 +18,7 @@ class PAMActionServiceAddCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam-action-service-add') + parser = argparse.ArgumentParser(prog='pam action service add') # The record to base everything on. parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', @@ -29,18 +30,15 @@ class PAMActionServiceAddCommand(PAMGatewayActionDiscoverCommandBase): help='The UID of the Windows Machine record') parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') - parser.add_argument('--type', '-t', required=True, choices=['service', 'task', 'iis'], dest='type', - action='store', help='Relationship to add [service, task, iis]') def get_parser(self): return PAMActionServiceAddCommand.parser def execute(self, params: KeeperParams, **kwargs): - gateway = kwargs.get("gateway") - machine_uid = kwargs.get("machine_uid") - user_uid = kwargs.get("user_uid") - rel_type = kwargs.get("type") + gateway = kwargs.get("gateway", "not_set") + machine_uid = kwargs.get("machine_uid", "not_set") + user_uid = kwargs.get("user_uid", "not_set") print("") @@ -59,10 +57,16 @@ def execute(self, params: KeeperParams, **kwargs): print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") return - user_service = UserService(record=gateway_context.configuration, params=params, fail_on_corrupt=False, + record_link = RecordLink(record=gateway_context.configuration, + params=params, + fail_on_corrupt=False, + agent=f"Cmdr/{__version__}", + use_per_graph_endpoints=False) + user_service = UserService(record=gateway_context.configuration, + record_linking=record_link, + params=params, + fail_on_corrupt=False, agent=f"Cmdr/{__version__}") - record_link = RecordLink(record=gateway_context.configuration, params=params, fail_on_corrupt=False, - agent=f"Cmdr/{__version__}") ############### @@ -85,8 +89,13 @@ def execute(self, params: KeeperParams, **kwargs): # Edges from provider and machine might be wrong. # Should be a LINK edge, could be an ACL edge. - if (machine_rl.get_edge(record_link.dag.get_root, edge_type=EdgeType.LINK) is None and - machine_rl.get_edge(record_link.dag.get_root, edge_type=EdgeType.ACL) is None): + root_vertex = record_link.dag.get_root + if root_vertex is None: + print(self._f("Could not get the root of the graph.")) + return + + if (machine_rl.get_edge(root_vertex, edge_type=EdgeType.LINK) is None and + machine_rl.get_edge(root_vertex, edge_type=EdgeType.ACL) is None): print(self._f("The machine record does not belong to this gateway.")) return @@ -127,67 +136,54 @@ def execute(self, params: KeeperParams, **kwargs): if os_type is None: print(self._f("The operating system field of the machine record is blank.")) return - if os_type != "windows": + if os_type.lower() != "windows": print(self._f("The operating system is not Windows. " "PAM can only rotate the services and scheduled task password on Windows.")) return # Get the machine service vertex. # If it doesn't exist, create one. - machine_vertex = user_service.get_record_link(machine_record.record_uid) + machine_vertex = record_link.dag.get_vertex(machine_record.record_uid) if machine_vertex is None: - machine_vertex = user_service.dag.add_vertex( + machine_vertex = record_link.dag.add_vertex( uid=machine_record.record_uid, name=machine_record.title, vertex_type=RefType.PAM_MACHINE) # Get the user service vertex. # If it doesn't exist, create one. - user_vertex = user_service.get_record_link(user_record.record_uid) + user_vertex = record_link.dag.get_vertex(user_record.record_uid) if user_vertex is None: - user_vertex = user_service.dag.add_vertex( + user_vertex = record_link.dag.add_vertex( uid=user_record.record_uid, name=user_record.title, vertex_type=RefType.PAM_USER) # Get the existing service ACL and set the proper attribute. + # If one does not exist, create a default ACL acl = user_service.get_acl(machine_vertex.uid, user_vertex.uid) if acl is None: - acl = ServiceAcl() - if rel_type == "service": - acl.is_service = True - elif rel_type == "task": - acl.is_task = True - else: - acl.is_iis_pool = True + acl = UserAcl.default() - # Make sure the machine has a LINK connection to the configuration. - if not user_service.dag.get_root.has(machine_vertex): - user_service.belongs_to(gateway_context.configuration_uid, machine_vertex.uid) + if not hasattr(acl, "controls_services") or not acl.controls_services: + acl.controls_services = True - # Add our new ACL edge between the machine and the yser. - user_service.belongs_to(machine_vertex.uid, user_vertex.uid, acl=acl) + # Make sure the machine has a LINK connection to the configuration. + if not root_vertex.has(machine_vertex): + machine_vertex.belongs_to_root(edge_type=EdgeType.LINK) - user_service.save() + # Add our new ACL edge between the machine and the user. + user_service.set_acl(resource_uid=machine_vertex.uid, + user_uid=user_vertex.uid, + acl=acl) - if rel_type == "service": - print( - self._gr( - f"Success: Services running on this machine, using this user, will be updated and restarted after " - "password rotation." - ) - ) - elif rel_type == "task": - print( - self._gr( - f"Success: Scheduled tasks running on this machine, using this user, will be updated after " - "password rotation." - ) - ) + record_link.save() else: - print( - self._gr( - f"Success: IIS pools running on this machine, using this user, will be updated after " - "password rotation." - ) + logging.debug("user already set to control services on this machine.") + + print( + self._gr( + "Success: When the user's password is rotated, service passwords, " + "on this machine, will also be changed." ) + ) diff --git a/keepercommander/commands/pam_service/disable.py b/keepercommander/commands/pam_service/disable.py new file mode 100644 index 000000000..3d84437d5 --- /dev/null +++ b/keepercommander/commands/pam_service/disable.py @@ -0,0 +1,187 @@ +from __future__ import annotations +import argparse +from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg +from ...display import bcolors +from ... import vault +from ...discovery_common.record_link import RecordLink +from ...discovery_common.constants import PAM_USER, PAM_MACHINE +from ...discovery_common.types import MachineData, UserData +from ...keeper_dag.types import EdgeType +from ... import __version__ +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...vault import TypedRecord + from ...params import KeeperParams + + +class PAMActionServiceDisableCommand(PAMGatewayActionDiscoverCommandBase): + parser = argparse.ArgumentParser(prog='pam action service disable') + + # The record to base everything on. + parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', + help='Gateway name or UID') + parser.add_argument('--configuration-uid', '-c', required=False, dest='configuration_uid', + action='store', help='PAM configuration UID, if gateway has multiple.') + + parser.add_argument('--machine-uid', '-m', required=False, dest='machine_uid', action='store', + help='The UID of the Windows Machine record to disable.') + parser.add_argument('--user-uid', '-u', required=False, dest='user_uid', action='store', + help='The UID of the User record to disable.') + + def get_parser(self): + return PAMActionServiceDisableCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + + gateway = kwargs.get("gateway", "none_set") + machine_uid = kwargs.get("machine_uid") # type: Optional[str] + user_uid = kwargs.get("user_uid") # type: Optional[str] + + print("") + + if machine_uid is None and user_uid is None: + print(f"{bcolors.FAIL}Either --machine-uid or --user-uid are required. Both are missing.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --machine-uid to disable service password " + "rotation on the machine.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --user-uid to disable service password rotation for this user.{bcolors.ENDC}") + return + + if machine_uid is not None and user_uid is not None: + print(f"{bcolors.FAIL}Both --machine-uid and --user-uid are set; only set one.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --machine-uid to disable service password " + "rotation on the machine.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --user-uid to disable service password rotation for this user.{bcolors.ENDC}") + return + + try: + gateway_context = GatewayContext.from_gateway(params=params, + gateway=gateway, + configuration_uid=kwargs.get('configuration_uid')) + if gateway_context is None: + print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + return + except MultiConfigurationException as err: + multi_conf_msg(gateway, err) + return + + if gateway_context is None: + print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") + return + + record_link = RecordLink(record=gateway_context.configuration, + params=params, + fail_on_corrupt=False, + agent=f"Cmdr/{__version__}", + use_per_graph_endpoints=False) + + ############### + + root_vertex = record_link.dag.get_root + if root_vertex is None: + print(self._f("Could not get the root of the graph.")) + return + + if machine_uid is not None: + + # Check to see if the record exists. + machine_record = vault.KeeperRecord.load(params, machine_uid) # type: Optional[TypedRecord] + if machine_record is None: + print(self._f("The machine record does not exists.")) + return + + # Make sure the record is a PAM Machine. + if machine_record.record_type != PAM_MACHINE: + print(self._f("The machine record is not a PAM Machine.")) + return + + # Make sure this machine is linked to the configuration record. + machine_vertex = record_link.get_record_link(machine_record.record_uid) + if machine_vertex is None: + print(self._f("The machine record does not exists in the graph.")) + return + + # Edges from provider and machine might be wrong. + # Should be a LINK edge, could be an ACL edge. + if (machine_vertex.get_edge(root_vertex, edge_type=EdgeType.LINK) is None and + machine_vertex.get_edge(root_vertex, edge_type=EdgeType.ACL) is None): + print(self._f("The machine record does not belong to this gateway.")) + return + + # Make sure we are setting up a Windows machine. + # Linux and Mac do not use passwords in services and cron jobs; no need to link. + os_field = next((x for x in machine_record.fields if x.label == "operatingSystem"), None) + if os_field is None: + print(self._f("Cannot find the operating system field in this record.")) + return + os_type = None + if len(os_field.value) > 0: + os_type = os_field.value[0] + if os_type is None: + print(self._f("The operating system field of the machine record is blank.")) + return + if os_type != "windows": + print(self._f("The operating system is not Windows. " + "PAM can only rotate the services and scheduled task password on Windows.")) + return + + data_edge = machine_vertex.get_data() + if data_edge is None: + machine_data = MachineData() + else: + machine_data = data_edge.content_as_object(MachineData) + + if machine_data is None: + print(self._f("Could not get the machine's data.")) + return + + if not machine_data.no_update_services: + machine_data.no_update_services = True + machine_vertex.add_data(machine_data.model_dump_json(), needs_encryption=False, path="meta") + record_link.save() + + print( + self._gr( + "Success: Machine will NOT allow services password to be changed during rotation." + ) + ) + elif user_uid is not None: + # Check to see if the record exists. + user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] + if user_record is None: + print(self._f("The user record does not exists.")) + return + + # Make sure this user is a PAM User. + if user_record.record_type != PAM_USER: + print(self._f("The user record is not a PAM User.")) + return + + # Get the user service vertex. + # If it doesn't exist, create one. + user_vertex = record_link.dag.get_vertex(user_record.record_uid) + if user_vertex is None: + if user_vertex is None: + print(self._f("The machine record does not exists in the graph.")) + return + + data_edge = user_vertex.get_data() + if data_edge is None: + user_data = UserData() + else: + user_data = data_edge.content_as_object(UserData) + + if user_data is None: + print(self._f("Could not get the user's data.")) + return + + if not user_data.no_update_services: + user_data.no_update_services = True + user_vertex.add_data(user_data.model_dump_json(), needs_encryption=False, path="meta") + record_link.save() + + print( + self._gr( + "Success: When rotating the user's password, service passwords will NOT be changed, if applicable." + ) + ) diff --git a/keepercommander/commands/pam_service/enable.py b/keepercommander/commands/pam_service/enable.py new file mode 100644 index 000000000..6479aebfa --- /dev/null +++ b/keepercommander/commands/pam_service/enable.py @@ -0,0 +1,182 @@ +from __future__ import annotations +import argparse +from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg +from ...display import bcolors +from ... import vault +from ...discovery_common.record_link import RecordLink +from ...discovery_common.constants import PAM_USER, PAM_MACHINE +from ...discovery_common.types import MachineData, UserData +from ...keeper_dag.types import EdgeType +from ... import __version__ +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...vault import TypedRecord + from ...params import KeeperParams + + +class PAMActionServiceEnableCommand(PAMGatewayActionDiscoverCommandBase): + parser = argparse.ArgumentParser(prog='pam action service enable') + + # The record to base everything on. + parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', + help='Gateway name or UID') + parser.add_argument('--configuration-uid', '-c', required=False, dest='configuration_uid', + action='store', help='PAM configuration UID, if gateway has multiple.') + + parser.add_argument('--machine-uid', '-m', required=False, dest='machine_uid', action='store', + help='The UID of the Windows Machine record to disable.') + parser.add_argument('--user-uid', '-u', required=False, dest='user_uid', action='store', + help='The UID of the User record to disable.') + + def get_parser(self): + return PAMActionServiceEnableCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + + gateway = kwargs.get("gateway", "none_set") + machine_uid = kwargs.get("machine_uid") + user_uid = kwargs.get("user_uid") + + print("") + + if machine_uid is None and user_uid is None: + print(f"{bcolors.FAIL}Either --machine-uid or --user-uid are required. Both are missing.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --machine-uid to disable service password " + "rotation on the machine.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --user-uid to disable service password rotation for this user.{bcolors.ENDC}") + return + + if machine_uid is not None and user_uid is not None: + print(f"{bcolors.FAIL}Both --machine-uid and --user-uid are set; only set one.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --machine-uid to disable service password " + "rotation on the machine.{bcolors.ENDC}") + print(f"{bcolors.FAIL} Use --user-uid to disable service password rotation for this user.{bcolors.ENDC}") + return + + try: + gateway_context = GatewayContext.from_gateway(params=params, + gateway=gateway, + configuration_uid=kwargs.get('configuration_uid')) + if gateway_context is None: + print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + return + except MultiConfigurationException as err: + multi_conf_msg(gateway, err) + return + + if gateway_context is None: + print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") + return + + record_link = RecordLink(record=gateway_context.configuration, + params=params, + fail_on_corrupt=False, + agent=f"Cmdr/{__version__}", + use_per_graph_endpoints=False) + + ############### + + if machine_uid is not None: + + # Check to see if the record exists. + machine_record = vault.KeeperRecord.load(params, machine_uid) # type: Optional[TypedRecord] + if machine_record is None: + print(self._f("The machine record does not exists.")) + return + + # Make sure the record is a PAM Machine. + if machine_record.record_type != PAM_MACHINE: + print(self._f("The machine record is not a PAM Machine.")) + return + + # Make sure this machine is linked to the configuration record. + machine_vertex = record_link.get_record_link(machine_record.record_uid) + if machine_vertex is None: + print(self._f("The machine record does not exists in the graph.")) + return + + # Edges from provider and machine might be wrong. + # Should be a LINK edge, could be an ACL edge. + if (machine_vertex.get_edge(record_link.dag.get_root, edge_type=EdgeType.LINK) is None and + machine_vertex.get_edge(record_link.dag.get_root, edge_type=EdgeType.ACL) is None): + print(self._f("The machine record does not belong to this gateway.")) + return + + # Make sure we are setting up a Windows machine. + # Linux and Mac do not use passwords in services and cron jobs; no need to link. + os_field = next((x for x in machine_record.fields if x.label == "operatingSystem"), None) + if os_field is None: + print(self._f("Cannot find the operating system field in this record.")) + return + os_type = None + if len(os_field.value) > 0: + os_type = os_field.value[0] + if os_type is None: + print(self._f("The operating system field of the machine record is blank.")) + return + if os_type != "windows": + print(self._f("The operating system is not Windows. " + "PAM can only rotate the services and scheduled task password on Windows.")) + return + + data_edge = machine_vertex.get_data() + if data_edge is None: + machine_data = MachineData() + else: + machine_data = data_edge.content_as_object(MachineData) + + if machine_data is None: + print(self._f("Could not get data from the resource.")) + return + + if machine_data.no_update_services: + machine_data.no_update_services = False + machine_vertex.add_data(machine_data.model_dump_json(), needs_encryption=False, path="meta") + record_link.save() + + print( + self._gr( + "Success: Machine will allow services password to be changed during rotation." + ) + ) + elif user_uid is not None: + # Check to see if the record exists. + user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] + if user_record is None: + print(self._f("The user record does not exists.")) + return + + # Make sure this user is a PAM User. + if user_record.record_type != PAM_USER: + print(self._f("The user record is not a PAM User.")) + return + + # Get the user service vertex. + # If it doesn't exist, create one. + user_vertex = record_link.dag.get_vertex(user_record.record_uid) + if user_vertex is None: + if user_vertex is None: + print(self._f("The machine record does not exists in the graph.")) + return + + data_edge = user_vertex.get_data() + if data_edge is None: + user_data = UserData() + else: + user_data = data_edge.content_as_object(UserData) + + if user_data is None: + print(self._f("Could not get data from the user.")) + return + + if user_data.no_update_services: + user_data.no_update_services = False + user_vertex.add_data(user_data.model_dump_json(), needs_encryption=False, path="meta") + record_link.save() + + print( + self._gr( + "Success: When rotating the user's password, service passwords will be changed, if applicable." + ) + ) diff --git a/keepercommander/commands/pam_service/list.py b/keepercommander/commands/pam_service/list.py index 995be7d1d..df571a3d9 100644 --- a/keepercommander/commands/pam_service/list.py +++ b/keepercommander/commands/pam_service/list.py @@ -4,7 +4,9 @@ from ...display import bcolors from ... import vault from ...discovery_common.user_service import UserService +from ...discovery_common.record_link import RecordLink from ...discovery_common.constants import PAM_MACHINE +from ...discovery_common.types import UserData, MachineData from ...keeper_dag import EdgeType from ... import __version__ from typing import Optional, TYPE_CHECKING @@ -15,40 +17,33 @@ class PAMActionServiceListCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam-action-service-list') + parser = argparse.ArgumentParser(prog='pam action service list') # The record to base everything on. parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', help='Gateway name or UID') parser.add_argument('--configuration-uid', '-c', required=False, dest='configuration_uid', action='store', help='PAM configuration UID, if gateway has multiple.') + parser.add_argument('--by-machine', '-m', required=False, dest='do_by_machine', action='store_true', + help='List by machine') def get_parser(self): return PAMActionServiceListCommand.parser - def execute(self, params: KeeperParams, **kwargs): - - gateway = kwargs.get("gateway") - - try: - gateway_context = GatewayContext.from_gateway(params=params, - gateway=gateway, - configuration_uid=kwargs.get('configuration_uid')) - if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") - return - except MultiConfigurationException as err: - multi_conf_msg(gateway, err) - return - - user_service = UserService(record=gateway_context.configuration, params=params, fail_on_corrupt=False, - agent=f"Cmdr/{__version__}") - + def _by_user(self, params: KeeperParams, record_link: RecordLink, user_service: UserService): service_map = {} - for resource_vertex in user_service.dag.get_root.has_vertices(edge_type=EdgeType.LINK): + for resource_vertex in record_link.dag.get_root.has_vertices(edge_type=EdgeType.LINK): + resource_record = vault.KeeperRecord.load(params, resource_vertex.uid) # type: Optional[TypedRecord] if resource_record is None or resource_record.record_type != PAM_MACHINE: continue + + resource_active = True + user_data_edge = resource_vertex.get_data() + if user_data_edge is not None: + user_data = user_data_edge.content_as_object(MachineData) + resource_active = not user_data.no_update_services + user_vertices = user_service.get_user_vertices(resource_vertex.uid) if len(user_vertices) > 0: for user_vertex in user_vertices: @@ -56,22 +51,24 @@ def execute(self, params: KeeperParams, **kwargs): if user_record is None: continue acl = user_service.get_acl(resource_record.record_uid, user_record.record_uid) - if acl is None or (acl.is_service is False and acl.is_task is False and acl.is_iis_pool is False): + if acl is None or not acl.controls_services: continue + + user_active = True + user_data_edge = user_vertex.get_data() + if user_data_edge is not None: + user_data = user_data_edge.content_as_object(UserData) + user_active = not user_data.no_update_services + if user_record.record_uid not in service_map: service_map[user_record.record_uid] = { "title": user_record.title, + "active": user_active, "machines": [] } - text = f"{resource_record.title} ({resource_record.record_uid}) :" - comma = "" - if acl.is_service: - text += f" {bcolors.OKGREEN}Services{bcolors.ENDC}" - comma = "," - if acl.is_task: - text += f"{comma} {bcolors.OKGREEN}Scheduled Tasks{bcolors.ENDC}" - if acl.is_iis_pool: - text += f"{comma} {bcolors.OKGREEN}IIS Pools{bcolors.ENDC}" + text = f"{resource_record.title} ({resource_record.record_uid})" + if not resource_active: + text += f" : {bcolors.FAIL}Disabled{bcolors.ENDC}" service_map[user_record.record_uid]["machines"].append(text) print("") @@ -80,9 +77,105 @@ def execute(self, params: KeeperParams, **kwargs): for user_uid in service_map: user = service_map[user_uid] printed_something = True - print(f" {self._b(user['title'])} ({user_uid})") + active_text = "" + if not user['active']: + active_text = f" {bcolors.FAIL}Disabled{bcolors.ENDC}" + print(f" {self._b(user['title'])} ({user_uid}){active_text}") for machine in user["machines"]: print(f" * {machine}") print("") if not printed_something: print(f" {bcolors.FAIL}There are no service mappings.{bcolors.ENDC}") + + def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_service: UserService): + service_map = {} + for resource_vertex in record_link.dag.get_root.has_vertices(edge_type=EdgeType.LINK): + resource_record = vault.KeeperRecord.load(params, resource_vertex.uid) # type: Optional[TypedRecord] + if resource_record is None or resource_record.record_type != PAM_MACHINE: + continue + + resource_active = True + user_data_edge = resource_vertex.get_data() + if user_data_edge is not None: + user_data = user_data_edge.content_as_object(MachineData) + resource_active = not user_data.no_update_services + + user_vertices = user_service.get_user_vertices(resource_vertex.uid) + if len(user_vertices) > 0: + for user_vertex in user_vertices: + user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + if user_record is None: + continue + acl = user_service.get_acl(resource_record.record_uid, user_record.record_uid) + if acl is None or not acl.controls_services: + continue + + user_active = True + user_data_edge = user_vertex.get_data() + if user_data_edge is not None: + user_data = user_data_edge.content_as_object(UserData) + user_active = not user_data.no_update_services + + if resource_record.record_uid not in service_map: + service_map[resource_record.record_uid] = { + "title": resource_record.title, + "active": resource_active, + "users": [] + } + text = f"{user_record.title} ({user_record.record_uid})" + if not user_active: + text += f" : {bcolors.FAIL}Disabled{bcolors.ENDC}" + service_map[resource_record.record_uid]["users"].append(text) + + print("") + printed_something = False + print(self._h("Machine Mapping")) + for resource_uid in service_map: + user = service_map[resource_uid] + printed_something = True + active_text = "" + if not user['active']: + active_text = f" {bcolors.FAIL}Disabled{bcolors.ENDC}" + print(f" {self._b(user['title'])} ({resource_uid}){active_text}") + for user in user["users"]: + print(f" * {user}") + print("") + if not printed_something: + print(f" {bcolors.FAIL}There are no service mappings.{bcolors.ENDC}") + + def execute(self, params: KeeperParams, **kwargs): + + gateway = kwargs.get("gateway", "none_set") + + try: + gateway_context = GatewayContext.from_gateway(params=params, + gateway=gateway, + configuration_uid=kwargs.get('configuration_uid')) + if gateway_context is None: + print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + return + except MultiConfigurationException as err: + multi_conf_msg(gateway, err) + return + + record_link = RecordLink(record=gateway_context.configuration, + params=params, + fail_on_corrupt=False, + agent=f"Cmdr/{__version__}", + use_per_graph_endpoints=False) + + # This will trigger the migration. + user_service = UserService(record=gateway_context.configuration, + record_linking=record_link, + params=params, + fail_on_corrupt=False, + agent=f"Cmdr/{__version__}") + + if kwargs.get("do_by_machine"): + self._by_machine(params=params, + record_link=record_link, + user_service=user_service) + else: + self._by_user(params=params, + record_link=record_link, + user_service=user_service) diff --git a/keepercommander/commands/pam_service/map.py b/keepercommander/commands/pam_service/map.py new file mode 100644 index 000000000..599a50c5a --- /dev/null +++ b/keepercommander/commands/pam_service/map.py @@ -0,0 +1,117 @@ +from __future__ import annotations +import logging +import argparse +from ..pam.pam_dto import GatewayAction +from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg +from ...display import bcolors +from ..pam.router_helper import router_send_action_to_gateway +from ..pam.router_helper import get_response_payload +from ...proto import pam_pb2 +from .list import PAMActionServiceListCommand +import json +from typing import Optional, List, TYPE_CHECKING + +if TYPE_CHECKING: + from ...params import KeeperParams + + +class GatewayActionUserServiceMapCommandInputs: + + def __init__(self, + configuration_uid: str, + languages: Optional[List[str]] = None, + ): + + if languages is None: + languages = ["en_US"] + + self.configurationUid = configuration_uid + self.languages = languages + + def toJSON(self): + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) + + +class GatewayActionUserServiceMapCommand(GatewayAction): + + def __init__(self, inputs: GatewayActionUserServiceMapCommandInputs, conversation_id=None): + super().__init__('user-service-map', inputs=inputs, conversation_id=conversation_id, is_scheduled=True) + + def toJSON(self): + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) + +class PAMActionUserServiceMapCommand(PAMGatewayActionDiscoverCommandBase): + + parser = argparse.ArgumentParser(prog='pam action saas config') + + parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', + help='Gateway name of UID.') + parser.add_argument('--configuration-uid', '-c', required=False, dest='configuration_uid', + action='store', help='PAM configuration UID, if gateway has multiple.') + parser.add_argument('--by-machine', '-m', required=False, dest='do_by_machine', action='store_true', + help='List by machine') + + def get_parser(self): + return PAMActionUserServiceMapCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + + print("") + + gateway = kwargs.get("gateway") # type: str + configuration_uid = kwargs.get('configuration_uid') # type Optional[str] + + try: + gateway_context = GatewayContext.from_gateway(params=params, + gateway=gateway, + configuration_uid=configuration_uid) + if gateway_context is None: + print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + return + + if not gateway_context.gateway_version_gte(params, "1.8.5"): + print(f"{bcolors.FAIL}Cannot run this command. " + f"Gateway required to be version 1.8.5 or greater.{bcolors.ENDC}") + return + except MultiConfigurationException as err: + multi_conf_msg(gateway, err) + + action_inputs = GatewayActionUserServiceMapCommandInputs( + configuration_uid=gateway_context.configuration_uid, + ) + + conversation_id = GatewayAction.generate_conversation_id() + router_response = router_send_action_to_gateway( + params=params, + gateway_action=GatewayActionUserServiceMapCommand( + inputs=action_inputs, + conversation_id=conversation_id), + message_type=pam_pb2.CMT_GENERAL, + is_streaming=False, + destination_gateway_uid_str=gateway_context.gateway_uid + ) + + if router_response is None: + print(f"{bcolors.FAIL}Did not get router response.{bcolors.ENDC}") + return + + response = router_response.get("response") + logging.debug(f"Router Response: {response}") + payload = get_response_payload(router_response) + data = payload.get("data") + if data is None: + print(f"{bcolors.FAIL}The router returned a failure.{bcolors.ENDC}") + return + elif data.get("success") is False: + error = data.get("error") + logging.debug(f"gateway returned: {error}") + print(f"{bcolors.FAIL}Could not map users to Windows services.{bcolors.ENDC}") + else: + + print(f"{bcolors.OKGREEN}Finished mapping users to Windows services.{bcolors.ENDC}") + + list_command = PAMActionServiceListCommand() + list_command.execute(params=params, + gateway=gateway, + configuration_uid=configuration_uid, + do_by_machine=kwargs.get("do_by_machine", False)) diff --git a/keepercommander/commands/pam_service/remove.py b/keepercommander/commands/pam_service/remove.py index e4b68d25f..3f6f7087c 100644 --- a/keepercommander/commands/pam_service/remove.py +++ b/keepercommander/commands/pam_service/remove.py @@ -4,6 +4,7 @@ from ... import vault from ...discovery_common.constants import PAM_USER, PAM_MACHINE from ...discovery_common.user_service import UserService +from ...discovery_common.record_link import RecordLink from ...display import bcolors from ... import __version__ from typing import Optional, TYPE_CHECKING @@ -14,7 +15,7 @@ class PAMActionServiceRemoveCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam-action-service-remove') + parser = argparse.ArgumentParser(prog='pam action service remove') # The record to base everything on. parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', @@ -26,18 +27,15 @@ class PAMActionServiceRemoveCommand(PAMGatewayActionDiscoverCommandBase): help='The UID of the Windows Machine record') parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') - parser.add_argument('--type', '-t', required=True, choices=['service', 'task', 'iis'], dest='type', - action='store', help='Relationship to remove [service, task, iis]') def get_parser(self): return PAMActionServiceRemoveCommand.parser def execute(self, params: KeeperParams, **kwargs): - gateway = kwargs.get("gateway") - machine_uid = kwargs.get("machine_uid") - user_uid = kwargs.get("user_uid") - rel_type = kwargs.get("type") + gateway = kwargs.get("gateway", "not_set") + machine_uid = kwargs.get("machine_uid", "not_set") + user_uid = kwargs.get("user_uid", "not_set") print("") @@ -56,7 +54,15 @@ def execute(self, params: KeeperParams, **kwargs): print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") return - user_service = UserService(record=gateway_context.configuration, params=params, fail_on_corrupt=False, + record_link = RecordLink(record=gateway_context.configuration, + params=params, + fail_on_corrupt=False, + agent=f"Cmdr/{__version__}", + use_per_graph_endpoints=False) + user_service = UserService(record=gateway_context.configuration, + record_linking=record_link, + params=params, + fail_on_corrupt=False, agent=f"Cmdr/{__version__}") machine_record = vault.KeeperRecord.load(params, machine_uid) # type: Optional[TypedRecord] @@ -77,53 +83,32 @@ def execute(self, params: KeeperParams, **kwargs): print(self._f("The user record is not a PAM User.")) return - machine_vertex = user_service.get_record_link(machine_record.record_uid) + machine_vertex = record_link.dag.get_vertex(machine_record.record_uid) if machine_vertex is None: print(self._f(f"The machine does not exist in the mapping.")) return - user_vertex = user_service.get_record_link(user_record.record_uid) + user_vertex = record_link.dag.get_vertex(user_record.record_uid) if user_vertex is None: print(self._f(f"The user does not exist in the mapping.")) return acl = user_service.get_acl(machine_vertex.uid, user_vertex.uid) - if acl is None: + if acl is None or acl.rotation_settings is None: print(f"{bcolors.WARNING}The user did not control any services, " f"scheduled tasks, or IIS pools on the machine.{bcolors.ENDC}") return - if rel_type == "service": - acl.is_service = False - elif rel_type == "task": - acl.is_task = False - else: - acl.is_iis_pool = False - - if not user_service.dag.get_root.has(machine_vertex): - user_service.belongs_to(gateway_context.configuration_uid, machine_vertex.uid) - - user_service.belongs_to(machine_vertex.uid, user_vertex.uid, acl=acl) - user_service.save() - - if rel_type == "service": - print( - self._gr( - "Success: Services running on this machine will no longer have their password changed when this " - "user's password is rotated." - ) - ) - elif rel_type == "task": - print( - self._gr( - "Success: Scheduled tasks running on this machine will no longer have their password changed " - "when this user's password is rotated." - ) - ) - else: - print( - self._gr( - "Success: IIP pools running on this machine will no longer have their password changed " - "when this user's password is rotated." - ) + if not hasattr(acl, "controls_services") or acl.controls_services: + acl.controls_services = False + + user_service.set_acl(resource_uid=machine_vertex.uid, + user_uid=user_vertex.uid, + acl=acl) + record_link.save() + + print( + self._gr( + "Success: When the user's password is rotated, service passwords will NOT be changed on this machine." ) + ) diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index 87639cf0f..3f58bb0f8 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -290,6 +290,66 @@ class RecordGetUidCommand(Command): def get_parser(self): return get_info_parser + @staticmethod + def _build_folder_json(params, f): + folder_type = 'nested_share_folder' if f.type == BaseFolderNode.NestedShareFolderType else 'classic_folder' + parent_uid = f.parent_uid or None + fo = { + 'folder_uid': f.uid, + 'type': folder_type, + 'name': f.name, + 'path': get_folder_path(params, f.uid), + 'parent_uid': parent_uid, + 'folder': { + 'uid': parent_uid, + 'path': get_folder_path(params, parent_uid) if parent_uid else '/' + } + } + if isinstance(f, subfolder.SharedFolderFolderNode): + fo['shared_folder_uid'] = f.shared_folder_uid + if f.type == BaseFolderNode.UserFolderType: + record_uids = params.subfolder_record_cache.get(f.uid, set()) + records_list = [] + for r_uid in record_uids: + entry = {'record_uid': r_uid} + rec = vault.KeeperRecord.load(params, r_uid) + if rec: + entry['record_name'] = rec.title + records_list.append(entry) + fo['records'] = records_list + elif f.type == BaseFolderNode.NestedShareFolderType: + from .nested_share_folder.helpers import collect_records_in_folder + record_uids = collect_records_in_folder(params, f.uid, recursive=False) + records_list = [] + for r_uid in record_uids: + entry = {'record_uid': r_uid} + rec = vault.KeeperRecord.load(params, r_uid) + if rec: + entry['record_name'] = rec.title + records_list.append(entry) + fo['records'] = records_list + return fo + + @staticmethod + def _print_folder_detail(params, f): + fo = RecordGetUidCommand._build_folder_json(params, f) + uid_label = 'Nested Share Folder UID' if f.type == BaseFolderNode.NestedShareFolderType else 'Folder UID' + print('') + print('{0:>25s}: {1:<20s}'.format(uid_label, f.uid)) + print('{0:>25s}: {1:<20s}'.format('Folder Type', f.get_folder_type())) + print('{0:>25s}: {1}'.format('Name', f.name)) + print('{0:>25s}: {1}'.format('Path', fo['path'])) + if f.parent_uid: + print('{0:>25s}: {1:<20s}'.format('Parent Folder UID', f.parent_uid)) + if isinstance(f, subfolder.SharedFolderFolderNode): + print('{0:>25s}: {1:<20s}'.format('Shared Folder UID', f.shared_folder_uid)) + records = fo.get('records') or [] + if records: + print('{0:>25s}:'.format('Records')) + for entry in records: + name = entry.get('record_name') or '' + print(' {0:>23s} {1}'.format(entry['record_uid'], name)) + def execute(self, params, **kwargs): uid = kwargs.get('uid') if not uid: @@ -372,45 +432,9 @@ def _format_expiration(expiration_value): if uid in params.folder_cache: f = params.folder_cache[uid] if fmt == 'json': - folder_type = 'nested_share_folder' if f.type == BaseFolderNode.NestedShareFolderType else 'classic_folder' - parent_uid = f.parent_uid or None - fo = { - 'folder_uid': f.uid, - 'type': folder_type, - 'name': f.name, - 'path': get_folder_path(params, f.uid), - 'parent_uid': parent_uid, - 'folder': { - 'uid': parent_uid, - 'path': get_folder_path(params, parent_uid) if parent_uid else '/' - } - } - if isinstance(f, subfolder.SharedFolderFolderNode): - fo['shared_folder_uid'] = f.shared_folder_uid - if f.type == BaseFolderNode.UserFolderType: - record_uids = params.subfolder_record_cache.get(f.uid, set()) - records_list = [] - for r_uid in record_uids: - entry = {'record_uid': r_uid} - rec = vault.KeeperRecord.load(params, r_uid) - if rec: - entry['record_name'] = rec.title - records_list.append(entry) - fo['records'] = records_list - elif f.type == BaseFolderNode.NestedShareFolderType: - from .nested_share_folder.helpers import collect_records_in_folder - record_uids = collect_records_in_folder(params, f.uid, recursive=False) - records_list = [] - for r_uid in record_uids: - entry = {'record_uid': r_uid} - rec = vault.KeeperRecord.load(params, r_uid) - if rec: - entry['record_name'] = rec.title - records_list.append(entry) - fo['records'] = records_list - print(json.dumps(fo, indent=2)) + print(json.dumps(self._build_folder_json(params, f), indent=2)) else: - f.display() + self._print_folder_detail(params, f) if f.type == BaseFolderNode.NestedShareFolderType: try: from .nested_share_folder.display_commands import NestedShareGetCommand @@ -850,33 +874,18 @@ def _format_expiration(expiration_value): include_dag=kwargs.get('include_dag') ) elif len(matched_folders) == 1: - uid = matched_folders[0].uid - f = params.folder_cache[uid] + f = matched_folders[0] sf_uid = f.uid if isinstance(f, subfolder.SharedFolderNode) else \ (f.shared_folder_uid if isinstance(f, subfolder.SharedFolderFolderNode) else None) - if sf_uid and api.is_shared_folder(params, sf_uid): - return self.execute( - params, - uid=sf_uid, - format=fmt, - unmask=kwargs.get('unmask'), - legacy=kwargs.get('legacy'), - include_dag=kwargs.get('include_dag') - ) - if fmt == 'json': - fo = { - 'folder_uid': f.uid, - 'type': f.type, - 'name': f.name - } - if sf_uid: - fo['shared_folder_uid'] = sf_uid - if f.parent_uid: - fo['parent_folder_uid'] = f.parent_uid - print(json.dumps(fo, indent=2)) - else: - f.display() - return + resolve_uid = sf_uid if (sf_uid and api.is_shared_folder(params, sf_uid)) else f.uid + return self.execute( + params, + uid=resolve_uid, + format=fmt, + unmask=kwargs.get('unmask'), + legacy=kwargs.get('legacy'), + include_dag=kwargs.get('include_dag') + ) elif len(matched_teams) == 1: team_uid = matched_teams[0].get('team_uid') if api.is_team(params, team_uid): diff --git a/keepercommander/commands/recordv3.py b/keepercommander/commands/recordv3.py index 2ccc6fd41..f286800b6 100644 --- a/keepercommander/commands/recordv3.py +++ b/keepercommander/commands/recordv3.py @@ -380,7 +380,7 @@ def GCM_TAG_LEN(): return 16 logging.info('Uploading %s ...', file['full_path']) response = requests.post(url, data=form_params, files=form_files, proxies=params.rest_context.proxies, - verify=params.rest_context.certificate_check) + verify=params.ssl_verify) if 'success_action_status' in form_params and str(response.status_code) == form_params['success_action_status']: attachments.append(file) # params.queue_audit_event('file_attachment_uploaded', record_uid=record_uid, attachment_id=a['file_id']) diff --git a/keepercommander/commands/sso_cloud/metadata_commands.py b/keepercommander/commands/sso_cloud/metadata_commands.py index 3f7bec2cf..3bd7b7de0 100644 --- a/keepercommander/commands/sso_cloud/metadata_commands.py +++ b/keepercommander/commands/sso_cloud/metadata_commands.py @@ -112,7 +112,7 @@ def execute(self, params, **kwargs): rs = http_requests.get( metadata_url, proxies=params.rest_context.proxies, - verify=params.rest_context.certificate_check, + verify=params.ssl_verify, timeout=30, ) if rs.status_code != 200: diff --git a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py index ab35794ed..5ffd784f9 100644 --- a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py +++ b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py @@ -375,6 +375,18 @@ def link_resource_to_config(self, resource_uid): resource_vertex.belongs_to(config_vertex, EdgeType.LINK) self.linking_dag.save() + def user_linked_to_config_for_noop(self, user_uid): + if not self.linking_dag.has_graph: + return False + user_vertex = self.linking_dag.get_vertex_by_uid(user_uid) + config_vertex = self.linking_dag.get_vertex_by_uid(self.record.record_uid) + if not (user_vertex and config_vertex and config_vertex.has(user_vertex, EdgeType.ACL)): + return False + acl_edge = user_vertex.get_edge(config_vertex, EdgeType.ACL) + content = acl_edge.content_as_dict or {} + rotation_settings = content.get('rotation_settings') or {} + return bool(rotation_settings.get('noop')) + def link_user_to_config(self, user_uid): config_vertex = self.linking_dag.get_vertex(self.record.record_uid) if config_vertex is None: @@ -387,6 +399,41 @@ def link_user_to_config(self, user_uid): self._permission_check_iam_user_link(user_uid) self.link_user(user_uid, config_vertex, belongs_to=True, is_iam_user=True) + def link_user_to_config_noop(self, user_uid): + """Link a pamUser to a PAM configuration for scripts-only (noop) rotation.""" + if not self.linking_dag.has_graph: + logging.error("linking graph is empty") + return False + + config_vertex = self.linking_dag.get_vertex(self.record.record_uid) + if config_vertex is None: + config_vertex = self.linking_dag.add_vertex(uid=self.record.record_uid) + + user_vertex = self.linking_dag.get_vertex(user_uid) + if user_vertex is None: + user_vertex = self.linking_dag.add_vertex(uid=user_uid, vertex_type=RefType.PAM_USER) + + acl_edge = user_vertex.get_edge(vertex=config_vertex, edge_type=EdgeType.ACL) + if acl_edge is not None: + acl = acl_edge.content_as_object(UserAcl) + else: + acl = UserAcl.default() + + if acl is not None and acl.rotation_settings is None: + acl.rotation_settings = UserAclRotationSettings() + + acl.belongs_to = False + acl.rotation_settings.noop = True + acl.is_iam_user = False + acl.is_admin = False + + user_vertex.belongs_to(vertex=config_vertex, + edge_type=EdgeType.ACL, + content=acl, + is_encrypted=False) + self.linking_dag.save() + return True + def _permission_check_iam_user_link(self, user_uid): """Call set_record_rotation(recordUid=user_uid, noop=False) to permission-check an is_iam_user link write. Raises on permission denial; no fallback. The @@ -733,13 +780,16 @@ def set_launch_credentials(self, resource_uid, launch_uid=None, admin_uid=None): ALREADY-EXISTING ACL edge: a standalone configure_resource(adminUid) with no connectUsers no-ops on an existing edge (UserRest.kt:331-341 only touches is_launch_credential), whereas adminUid alongside connectUsers flips is_admin - on that edge (UserRest.kt:295-318). Sent on a user NOT in connectUsers so the - admin does not also become a launch credential. + on that edge (UserRest.kt:295-318). When admin and launch are different + users, adminUid must not appear in connectUsers. When they are the same + pamUser, krouter sets both is_admin and is_launch_credential on one edge + (UserRest.kt:258-273). For a fresh launch_uid (no existing edge), krouter creates the new edge with - belongs_to=null; a follow-up local DAG-write of belongs_to=True preserves - legacy parity. For existing edges, krouter preserves belongs_to already so - the follow-up is a no-op. + belongs_to=null; a follow-up local DAG-write must set belongs_to=True AND + preserve is_launch_credential (and is_admin when admin_uid == launch_uid). + Writing belongs_to alone clobbers krouter flags (KC-1330). For existing + edges where belongs_to is already true, an unchanged follow-up is a no-op. Fallback on RRC_NOT_ALLOWED* (or feature-disabled) with KEEPER_DAG_LB_FALLBACK enabled: legacy clear + link (if set) + admin link (if set) + meta-upgrade. @@ -775,7 +825,10 @@ def set_launch_credentials(self, resource_uid, launch_uid=None, admin_uid=None): f"launch_uid={launch_uid} admin_uid={admin_uid} via configure_resource" ) if launch_uid is not None: - self.link_user(launch_uid, resource_vertex, belongs_to=True) + link_kwargs = dict(belongs_to=True, is_launch_credential=True) + if admin_uid is not None and admin_uid == launch_uid: + link_kwargs['is_admin'] = True + self.link_user(launch_uid, resource_vertex, **link_kwargs) return except Exception as err: if not should_fallback_on_layer_b_error(err, host=host, endpoint=endpoint): diff --git a/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py b/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py index 10655dbda..902939437 100644 --- a/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py +++ b/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py @@ -174,6 +174,7 @@ def print_above_keeper_prompt(msg): READ_TIMEOUT = 1.5 KRELAY_URL = 'KRELAY_SERVER' GATEWAY_TIMEOUT = int(os.getenv('GATEWAY_TIMEOUT')) if os.getenv('GATEWAY_TIMEOUT') else 30000 +# VERIFY_SSL applies to WebSocket SSL only; HTTP uses params.ssl_verify. VERIFY_SSL = bool(os.environ.get("VERIFY_SSL", "TRUE") == "TRUE") # ICE candidate buffering - store until SDP answer is received @@ -2475,10 +2476,9 @@ def start_rust_tunnel(params, record_uid, gateway_uid, host, port, logging.debug("Using shared router tokens for WebSocket and streaming HTTP") http_session = requests.Session() krouter_host = get_router_url(params) - verify_ssl = bool(os.environ.get("VERIFY_SSL", "TRUE").upper() == "TRUE") try: bind_url = krouter_host + "/api/user/bind_to_controller/" + gateway_uid - http_session.get(bind_url, verify=verify_ssl, timeout=10) + http_session.get(bind_url, verify=params.ssl_verify, timeout=10) except Exception as e: logging.debug(f"bind_to_controller GET failed (continuing): %s", e) if http_session.cookies: diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 1edcb905e..65aaecd84 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -1934,6 +1934,7 @@ def _val(v): print(f' {self._green("connection.protocol"):<36}{_val(cn.get("protocol"))}') print(f' {self._green("connection.httpCredentialsUid"):<36}{_val(cn.get("httpCredentialsUid") or None)}') print(f' {self._green("connection.recordingIncludeKeys"):<36}{_val(cn.get("recordingIncludeKeys"))}') + print(f' {self._green("connection.ignoreInitialSslCert"):<36}{_val(cn.get("ignoreInitialSslCert"))}') else: pam_settings_field = record_obj.get_typed_field('pamSettings') if record_obj else None ps = {} @@ -1948,6 +1949,8 @@ def _val(v): print(f' {self._green("connection.protocol"):<36}{_val(cn.get("protocol"))}') print(f' {self._green("connection.allowKeeperDBProxy"):<36}{_val(cn.get("allowKeeperDBProxy"))}') print(f' {self._green("connection.recordingIncludeKeys"):<36}{_val(cn.get("recordingIncludeKeys"))}') + print(f' {self._green("connection.security"):<36}{_val(cn.get("security"))}') + print(f' {self._green("connection.ignoreCert"):<36}{_val(cn.get("ignoreCert"))}') print(f' {self._green("allowSupplyHost"):<36}{_val(ps.get("allowSupplyHost"))}') if ps.get('configUid'): print(f' {self._green("configUid"):<36}{_val(ps.get("configUid"))}') @@ -2668,6 +2671,13 @@ class PAMConnectionEditCommand(Command): help='Maximum Scrollback Size (terminal history). Integer to set, ' 'empty string to remove. Supported for pamDatabase (mysql/postgresql/sql-server) ' 'and pamMachine/pamDirectory (ssh/telnet/kubernetes).') + parser.add_argument('--ignore-server-cert', '-isc', required=False, dest='ignore_server_cert', choices=choices, + help='Ignore server certificate errors (on/off/default). Supported for rdp and ' + 'kubernetes protocols.') + parser.add_argument('--security-mode', '-sm', required=False, dest='security_mode', + choices=['any', 'nla', 'tls', 'vmconnect', 'rdp', 'default'], + help='RDP Security Mode (any/nla/tls/vmconnect/rdp, or default to unset). ' + 'Supported for rdp protocol only.') parser.add_argument('--rotate-on-termination', required=False, dest='rotate_on_termination', choices=['on', 'off'], help='Rotate launch credentials when the PAM session ends (DAG resource meta)') @@ -2716,6 +2726,19 @@ def execute(self, params, **kwargs): f"pamRemoteBrowser, pamNetworkConfiguration pamAwsConfiguration, and " f"pamAzureConfiguration records{bcolors.ENDC}") + # Effective protocol (existing, or the one being set by this same call via + # --connections=on --protocol) — shared by --scrollback, --ignore-server-cert, + # and --security-mode gating below. + def _get_effective_protocol(): + existing_ps = record.get_typed_field('pamSettings') + existing_protocol = '' + if existing_ps and existing_ps.value and isinstance(existing_ps.value[0], dict): + existing_protocol = existing_ps.value[0].get('connection', {}).get('protocol') or '' + new_protocol_arg = kwargs.get('protocol', None) + if kwargs.get('connections') == 'on' and new_protocol_arg is not None: + return new_protocol_arg # may be '' to clear + return existing_protocol + # --scrollback: validate record type + effective protocol before any mutation scrollback_arg = kwargs.get('scrollback', None) scrollback_clear = False @@ -2732,15 +2755,7 @@ def execute(self, params, **kwargs): f'{bcolors.FAIL}--scrollback is only supported for pamDatabase, pamMachine, and pamDirectory ' f'records. Record "{record_uid}" is of type "{record_type}".{bcolors.ENDC}') - existing_ps = record.get_typed_field('pamSettings') - existing_protocol = '' - if existing_ps and existing_ps.value and isinstance(existing_ps.value[0], dict): - existing_protocol = existing_ps.value[0].get('connection', {}).get('protocol') or '' - new_protocol_arg = kwargs.get('protocol', None) - if kwargs.get('connections') == 'on' and new_protocol_arg is not None: - effective_protocol = new_protocol_arg # may be '' to clear - else: - effective_protocol = existing_protocol + effective_protocol = _get_effective_protocol() if effective_protocol not in allowed_protocols: raise CommandError('pam connection edit', f'{bcolors.FAIL}--scrollback is not supported for protocol "{effective_protocol or "(unset)"}" ' @@ -2760,6 +2775,37 @@ def execute(self, params, **kwargs): f'{bcolors.FAIL}--scrollback must be a non-negative integer or empty string. ' f'Got: "{scrollback_arg}".{bcolors.ENDC}') + pam_settings_record_types = ('pamMachine', 'pamDatabase', 'pamDirectory') + + # --ignore-server-cert: validate record type + effective protocol before any mutation + ignore_server_cert_arg = kwargs.get('ignore_server_cert', None) + if ignore_server_cert_arg is not None: + if record_type not in pam_settings_record_types: + raise CommandError('pam connection edit', + f'{bcolors.FAIL}--ignore-server-cert is only supported for pamMachine, pamDatabase, and ' + f'pamDirectory records. Record "{record_uid}" is of type "{record_type}". For pamRemoteBrowser ' + f'records, use `pam rbi edit --ignore-server-cert` instead.{bcolors.ENDC}') + effective_protocol = _get_effective_protocol() + if effective_protocol not in ('rdp', 'kubernetes'): + raise CommandError('pam connection edit', + f'{bcolors.FAIL}--ignore-server-cert is not supported for protocol ' + f'"{effective_protocol or "(unset)"}" on {record_type} records. ' + f'Allowed protocols: kubernetes, rdp.{bcolors.ENDC}') + + # --security-mode: validate record type + effective protocol before any mutation + security_mode_arg = kwargs.get('security_mode', None) + if security_mode_arg is not None: + if record_type not in pam_settings_record_types: + raise CommandError('pam connection edit', + f'{bcolors.FAIL}--security-mode is only supported for pamMachine, pamDatabase, and ' + f'pamDirectory records. Record "{record_uid}" is of type "{record_type}".{bcolors.ENDC}') + effective_protocol = _get_effective_protocol() + if effective_protocol != 'rdp': + raise CommandError('pam connection edit', + f'{bcolors.FAIL}--security-mode is not supported for protocol ' + f'"{effective_protocol or "(unset)"}" on {record_type} records. ' + f'Allowed protocols: rdp.{bcolors.ENDC}') + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) if record_type in "pamNetworkConfiguration pamAwsConfiguration pamAzureConfiguration".split(): tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, record_uid, is_config=True, @@ -2870,6 +2916,46 @@ def execute(self, params, **kwargs): else: logging.debug(f'scrollback is already {scrollback_value} on record={record_uid}') + # --ignore-server-cert: apply (validated above; record_type + effective protocol already checked) + if ignore_server_cert_arg is not None: + psv = pam_settings.value[0] if pam_settings and pam_settings.value else {} + vcon = psv.get('connection', {}) if isinstance(psv, dict) else {} + current_ic = vcon.get('ignoreCert') if isinstance(vcon, dict) else None + if ignore_server_cert_arg == 'default': + if current_ic is not None: + pam_settings.value[0]["connection"].pop('ignoreCert', None) + dirty = True + else: + logging.debug(f'ignoreCert is already unset on record={record_uid}') + else: + target_ic = value_to_boolean(ignore_server_cert_arg) + if current_ic != target_ic: + pam_settings.value[0]["connection"]["ignoreCert"] = target_ic + dirty = True + else: + logging.debug(f'ignoreCert is already {target_ic} on record={record_uid}') + + # --security-mode: apply (validated above; record_type + effective protocol already checked) + if security_mode_arg is not None: + from .pam_import.base import RDPSecurity + psv = pam_settings.value[0] if pam_settings and pam_settings.value else {} + vcon = psv.get('connection', {}) if isinstance(psv, dict) else {} + current_sec = vcon.get('security') if isinstance(vcon, dict) else None + if security_mode_arg == 'default': + if current_sec is not None: + pam_settings.value[0]["connection"].pop('security', None) + dirty = True + else: + logging.debug(f'security is already unset on record={record_uid}') + else: + mapped_security = RDPSecurity.map(security_mode_arg) + target_sec = mapped_security.value if mapped_security else security_mode_arg + if current_sec != target_sec: + pam_settings.value[0]["connection"]["security"] = target_sec + dirty = True + else: + logging.debug(f'security is already {target_sec} on record={record_uid}') + if dirty: record_management.update_record(params, record) api.sync_down(params) diff --git a/keepercommander/discovery_common/__version__.py b/keepercommander/discovery_common/__version__.py index 7025f5029..ec5c76229 100644 --- a/keepercommander/discovery_common/__version__.py +++ b/keepercommander/discovery_common/__version__.py @@ -1 +1 @@ -__version__ = '1.1.15' +__version__ = '1.1.16' diff --git a/keepercommander/discovery_common/process.py b/keepercommander/discovery_common/process.py index a99ee2b06..33f5a5fd4 100644 --- a/keepercommander/discovery_common/process.py +++ b/keepercommander/discovery_common/process.py @@ -88,7 +88,11 @@ def __init__(self, record: Any, job_id: str, logger: Optional[Any] = None, debug fail_on_corrupt=False, **kwargs) self.record_link = RecordLink(record=record, logger=logger, debug_level=debug_level, **kwargs) - self.user_service = UserService(record=record, logger=logger, debug_level=debug_level, **kwargs) + self.user_service = UserService(record=record, + logger=logger, + debug_level=debug_level, + record_linking=self.record_link, + **kwargs) # This is the root UID for all graphs; get it from one of them. self.configuration_uid = self.jobs.dag.uid diff --git a/keepercommander/discovery_common/record_link.py b/keepercommander/discovery_common/record_link.py index db55bcfdb..89cb72824 100644 --- a/keepercommander/discovery_common/record_link.py +++ b/keepercommander/discovery_common/record_link.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging from .utils import get_connection, make_agent -from .types import UserAcl, DiscoveryObject +from .types import UserAcl, UserAclRotationSettings, DiscoveryObject from ..keeper_dag import DAG, EdgeType from ..keeper_dag.types import PamGraphId, PamEndpoints import importlib @@ -135,7 +135,7 @@ def get_parent_uid(self, uid: str) -> Optional[str]: """ Get the vertex that the UID belongs to. - This method will check the vertex ACL to see which edge has a True value for belongs_to. + This method will check the vertex ACL to see which edge has a True value for set_acl. If it is found, the record UID that the head points at will be returned. If not found, None is returned. """ @@ -145,7 +145,7 @@ def get_parent_uid(self, uid: str) -> Optional[str]: for edge in vertex.edges: if edge.edge_type == EdgeType.ACL: content = edge.content_as_object(UserAcl) - if content.belongs_to is True: + if content.belongs_to: return edge.head_uid return None @@ -284,7 +284,13 @@ def get_acl(self, record_uid: str, parent_record_uid: str, record_name: Optional if acl_edge is None: return None - return acl_edge.content_as_object(UserAcl) + # Get the ACL + # Add rotation settings if missing. + acl = acl_edge.content_as_object(UserAcl) + if acl.rotation_settings is None: + acl.rotation_settings = UserAclRotationSettings() + + return acl def acl_has_belong_to_vertex(self, discovery_vertex: DAGVertex) -> Optional[DAGVertex]: """ @@ -310,7 +316,7 @@ def acl_has_belong_to_record_uid(self, record_uid: str) -> Optional[DAGVertex]: if edge.edge_type != EdgeType.ACL: continue content = edge.content_as_object(UserAcl) - if content.belongs_to is True: + if content.belongs_to: return self.dag.get_vertex(edge.head_uid) return None @@ -390,7 +396,7 @@ def get_admin_record_uid(self, record_uid: str) -> Optional[str]: continue if edge.edge_type == EdgeType.ACL: content = edge.content_as_object(UserAcl) # type: UserAcl - if content.is_admin is True: + if content.is_admin: return vertex.uid return None @@ -463,10 +469,10 @@ def to_dot(self, graph_format: str = "svg", show_version: bool = True, show_only style = "solid" # To reduce the number of edges, only show the active edges - if edge.active is True: + if edge.active: color = "black" style = "bold" - elif show_only_active_edges is True: + elif show_only_active_edges: continue # If the vertex is not active, gray out the DATA edge @@ -479,9 +485,9 @@ def to_dot(self, graph_format: str = "svg", show_version: bool = True, show_only edge_tip = "" if edge.edge_type == EdgeType.ACL and v.active is True: content = edge.content_as_dict - if content.get("is_admin") is True: + if content.get("is_admin"): color = "red" - if content.get("belongs_to") is True: + if content.get("belongs_to"): if color == "red": color = "purple" else: @@ -497,7 +503,7 @@ def to_dot(self, graph_format: str = "svg", show_version: bool = True, show_only label = "UNK" if edge.path is not None and edge.path != "": label += f"\\npath={edge.path}" - if show_version is True: + if show_version: label += f"\\nv={edge.version}" # tail, head (arrow side), label, ... @@ -506,7 +512,7 @@ def to_dot(self, graph_format: str = "svg", show_version: bool = True, show_only shape = "ellipse" fillcolor = "white" color = "black" - if v.active is False: + if not v.active: fillcolor = "grey" label = f"uid={v.uid}" diff --git a/keepercommander/discovery_common/types.py b/keepercommander/discovery_common/types.py index 6942e87eb..83512ab9b 100644 --- a/keepercommander/discovery_common/types.py +++ b/keepercommander/discovery_common/types.py @@ -365,6 +365,9 @@ class UserAcl(BaseModel): # No clue what this is. Vault team adding stuff without telling others. is_launch_credential: bool = False + # Do we need to rotate service passwords on this machine when this password is rotated? + controls_services: bool = False + rotation_settings: Optional[UserAclRotationSettings] = None @staticmethod @@ -377,6 +380,32 @@ def default(): ) +# ------------- + +class UserData(BaseModel): + + # If True, user password change does not change service passwords. + no_update_services: bool = False + +# ------------- + + +class MachineDataAllowSettings(BaseModel): + connections: bool = False + portForwards: bool = False + rotation: bool = False + sessionRecording: bool = False + typescriptRecording: bool = False + + +class MachineData(BaseModel): + + allowedSettings: MachineDataAllowSettings = MachineDataAllowSettings() + no_update_services: bool = False + +# ------------- + + class DiscoveryItem(BaseModel): pass @@ -842,6 +871,8 @@ def success_count(self) -> int: # Service/Schedule Task/IIS Pool + +# This is still used to migrate; do not remove class ServiceAcl(BaseModel): is_service: bool = False is_task: bool = False diff --git a/keepercommander/discovery_common/user_service.py b/keepercommander/discovery_common/user_service.py index bce2db673..04aab341c 100644 --- a/keepercommander/discovery_common/user_service.py +++ b/keepercommander/discovery_common/user_service.py @@ -4,11 +4,11 @@ from .constants import PAM_MACHINE, PAM_USER, PAM_DIRECTORY, DOMAIN_USER_CONFIGS from .utils import get_connection, make_agent, split_user_and_domain, value_to_boolean -from .types import DiscoveryObject, ServiceAcl, NormalizedRecord +from .types import DiscoveryObject, ServiceAcl, NormalizedRecord, UserAcl from .infrastructure import Infrastructure from .record_link import RecordLink from ..keeper_dag import DAG, EdgeType -from ..keeper_dag.types import PamEndpoints, PamGraphId +from ..keeper_dag.types import PamGraphId import importlib from typing import Any, Optional, List, Callable, Dict, TYPE_CHECKING @@ -19,23 +19,42 @@ class UserService: - def __init__(self, record: Any, logger: Optional[Any] = None, history_level: int = 0, - debug_level: int = 0, fail_on_corrupt: bool = True, log_prefix: str = "GS Services/Tasks", - save_batch_count: int = 200, agent: Optional[str] = None, + """ + Map when a user's password is rotated, which machine services need to also be changed. + + In Windows, services, scheduled tasks, iis pool, etc. require the password for the user that service will be run as. + When a user's password is changed, each of these services need have their password changed. + + We store the map in the PAM graph, in the rotation settings, `controls_services`. + When a user's password is changed, each ACL edge connected to user will be checked. + If `controls_services` is True, the machine will be logged in and each service will be checked to see if this + user controls them. + If the user does, then the password on the service is changed. + + This used to be stored in graph 13, but has been moved the PAM graph. + A migration is run to moved items from the old graph and placed in the PAM graph. + + """ + + def __init__(self, + record_linking: RecordLink, + record: Any, + logger: Optional[Any] = None, + history_level: int = 0, + debug_level: int = 0, + fail_on_corrupt: bool = True, + log_prefix: str = "GS Services", + save_batch_count: int = 200, + agent: Optional[str] = None, use_per_graph_endpoints: bool = False, **kwargs): - """ - :param use_per_graph_endpoints: If True, use the per-graph URL transport - (`/api/user/graph-sync/service_links/...`) via `read_endpoint` / - `write_endpoint`. If False (default), use the legacy single-endpoint - transport via `graph_id=PamGraphId.SERVICE_LINKS`. The legacy - default is preserved for backward compatibility; it will be removed - in a future major version once all consumers have migrated. - """ # Keep these for other graphs self._params = kwargs.get("params") self._ksm = kwargs.get("ksm") + self.record_linking = record_linking + self.service_graph = None + self.did_migration = False self.conn = get_connection(**kwargs) @@ -72,6 +91,8 @@ def __init__(self, record: Any, logger: Optional[Any] = None, history_level: int except (Exception,): pass + self._migrate() + def debug(self, msg, level: int = 0, secret: bool = False): if self.log_finer_level >= level: if secret: @@ -80,44 +101,50 @@ def debug(self, msg, level: int = 0, secret: bool = False): else: self.logger.debug(msg) - @property - def dag(self) -> DAG: - if self._dag is None: - - if self.use_per_graph_endpoints: - self._dag = DAG(conn=self.conn, - record=self.record, - read_endpoint=PamEndpoints.SERVICE_LINKS, - write_endpoint=PamEndpoints.SERVICE_LINKS, - auto_save=False, - logger=self.logger, - history_level=self.history_level, - debug_level=self.debug_level, - name="Discovery Services", - fail_on_corrupt=self.fail_on_corrupt, - log_prefix=self.log_prefix, - save_batch_count=self.save_batch_count, - agent=self.agent) - else: - self._dag = DAG(conn=self.conn, - record=self.record, - graph_id=PamGraphId.SERVICE_LINKS, - auto_save=False, - logger=self.logger, - history_level=self.history_level, - debug_level=self.debug_level, - name="Discovery Services", - fail_on_corrupt=self.fail_on_corrupt, - log_prefix=self.log_prefix, - save_batch_count=self.save_batch_count, - agent=self.agent) - - self._dag.load(sync_point=0) - - # If an empty graph, call root get create a vertex. - _ = self._dag.get_root - - return self._dag + def _migrate(self): + + """ + Migrate items from the old user service graph to the PAM graph. + """ + + self.debug("perform user service graph migration") + + self.service_graph = DAG(conn=self.conn, + record=self.record, + graph_id=PamGraphId.SERVICE_LINKS, + auto_save=False, + logger=self.logger, + history_level=self.history_level, + debug_level=self.debug_level, + name="Discovery Services", + fail_on_corrupt=self.fail_on_corrupt, + log_prefix=self.log_prefix, + save_batch_count=self.save_batch_count, + agent=self.agent) + + self.service_graph.load(sync_point=0) + if self.service_graph.has_graph: + for resource_vertex in self.service_graph.get_root.has_vertices(): + for user_vertex in resource_vertex.has_vertices(): + acl_edge = user_vertex.get_edge(resource_vertex, edge_type=EdgeType.ACL) + if acl_edge is not None: + service_acl = acl_edge.content_as_object(ServiceAcl) # type: ServiceAcl + if service_acl.is_used: + user_acl = self.record_linking.get_acl(user_vertex.uid, resource_vertex.uid) + if user_acl is None: + user_acl = UserAcl.default() + user_acl.controls_services = True + self.record_linking.belongs_to(user_vertex.uid, resource_vertex.uid, acl=user_acl) + user_vertex.delete() + resource_vertex.delete() + self.did_migration = True + + if self.did_migration: + self.service_graph.save() + self.record_linking.save() + self.debug(" items were migrated") + else: + self.debug(" no items to migrated") def close(self): """ @@ -142,15 +169,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): def __del__(self): self.close() - @property - def has_graph(self) -> bool: - return self.dag.has_graph - - def reload(self): - self._dag.load(sync_point=0) - - def get_record_link(self, uid: str) -> DAGVertex: - return self.dag.get_vertex(uid) + def get_record_link(self, uid: str) -> Optional[DAGVertex]: + return self.record_linking.dag.get_vertex(uid) @staticmethod def get_record_uid(discovery_vertex: DAGVertex) -> str: @@ -167,16 +187,15 @@ def get_record_uid(discovery_vertex: DAGVertex) -> str: return content.record_uid raise Exception(f"The discovery vertex {discovery_vertex.uid} data does not have a populated record UID.") - def belongs_to(self, - resource_uid: str, - user_uid: str, acl: Optional[ServiceAcl] = None, - resource_name: Optional[str] = None, - user_name: Optional[str] = None): + def set_acl(self, + resource_uid: str, + user_uid: str, + acl: UserAcl, + resource_name: Optional[str] = None, + user_name: Optional[str] = None): """ - Link vault records using record UIDs. - - If a link already exists, no additional link will be created. + Update the record linking ACL with control_services """ if resource_uid is None: @@ -188,76 +207,65 @@ def belongs_to(self, # Get thr record vertices. # If a vertex does not exist, then add the vertex using the record UID - resource_vertex = self.dag.get_vertex(resource_uid) + resource_vertex = self.record_linking.dag.get_vertex(resource_uid) if resource_vertex is None: self.debug(f"adding resource vertex for record UID {resource_uid} ({resource_name})") - resource_vertex = self.dag.add_vertex(uid=resource_uid, name=resource_name) + resource_vertex = self.record_linking.dag.add_vertex(uid=resource_uid, name=resource_name) - user_vertex = self.dag.get_vertex(user_uid) + user_vertex = self.record_linking.dag.get_vertex(user_uid) if user_vertex is None: self.debug(f"adding user vertex for record UID {user_uid} ({user_name})") - user_vertex = self.dag.add_vertex(uid=user_uid, name=user_name) + user_vertex = self.record_linking.dag.add_vertex(uid=user_uid, name=user_name) self.debug(f"user {user_vertex.uid} controls services on {resource_vertex.uid}") + user_vertex.belongs_to(resource_vertex, edge_type=EdgeType.ACL, content=acl) - edge_type = EdgeType.LINK - if acl is not None: - edge_type = EdgeType.ACL - - self.debug(f"Connect {user_vertex.uid} to {resource_vertex.uid}") - user_vertex.belongs_to(resource_vertex, edge_type=edge_type, content=acl) + link_exists = resource_vertex.get_edge(self.record_linking.dag.get_root, EdgeType.LINK) + if link_exists is None: + self.debug(f" * no link to configuration, adding.") + resource_vertex.belongs_to_root(edge_type=EdgeType.LINK) def disconnect_from(self, resource_uid: str, user_uid: str): - resource_vertex = self.dag.get_vertex(resource_uid) - user_vertex = self.dag.get_vertex(user_uid) - user_vertex.disconnect_from(resource_vertex) + resource_vertex = self.record_linking.dag.get_vertex(resource_uid) + if resource_vertex is not None: + user_vertex = self.record_linking.dag.get_vertex(user_uid) + if user_vertex is not None: + user_vertex.disconnect_from(resource_vertex) - def get_acl(self, resource_uid, user_uid) -> Optional[ServiceAcl]: + def get_acl(self, resource_uid, user_uid) -> Optional[UserAcl]: """ - Get the service/task ACL between a resource and the user. - + Get the user ACL from the record linking graph. """ - - resource_vertex = self.dag.get_vertex(resource_uid) - user_vertex = self.dag.get_vertex(user_uid) - if resource_vertex is None or user_vertex is None: - if resource_vertex is None: - self.debug("The resource vertex does not exists get; return default ACL") - if user_vertex is None: - self.debug("The user vertex does not exists get; return default ACL") - return ServiceAcl() - - acl_edge = user_vertex.get_edge(resource_vertex, edge_type=EdgeType.ACL) # type: DAGEdge - if acl_edge is None: - self.debug(f"ACL does not exists between resource {resource_uid} and user {user_vertex} doesn't " - "exist; return None") - return None - - return acl_edge.content_as_object(ServiceAcl) + return self.record_linking.get_acl(user_uid, resource_uid) def resource_has_link(self, resource_uid) -> bool: """ Is this resource linked to the configuration? """ - resource_vertex = self.dag.get_vertex(resource_uid) + resource_vertex = self.record_linking.dag.get_vertex(resource_uid) if resource_vertex is None: return False - link_edge = resource_vertex.get_edge(self.dag.get_root, edge_type=EdgeType.LINK) # type: DAGEdge + link_edge = resource_vertex.get_edge(self.record_linking.dag.get_root, edge_type=EdgeType.LINK) # type: DAGEdge return link_edge is not None def get_resource_vertices(self, user_uid: str) -> List[DAGVertex]: """ - Get the resource vertices where the user is used for a service or task. - + Get the resource vertices where the user controls a service. """ - user_vertex = self.dag.get_vertex(user_uid) + user_vertex = self.record_linking.dag.get_vertex(user_uid) if user_vertex is None: return [] - return user_vertex.belongs_to_vertices() + resource_vertices = [] + for resource_vertex in user_vertex.belongs_to_vertices(): + user_acl = self.record_linking.get_acl(user_uid, resource_vertex.uid) + if user_acl: + if user_acl.controls_services: + resource_vertices.append(resource_vertex) + return resource_vertices def get_user_vertices(self, resource_uid: str) -> List[DAGVertex]: @@ -265,10 +273,16 @@ def get_user_vertices(self, resource_uid: str) -> List[DAGVertex]: Get the user vertices that control a service or task on this machine. """ - resource_vertex = self.dag.get_vertex(resource_uid) + resource_vertex = self.record_linking.dag.get_vertex(resource_uid) if resource_vertex is None: return [] - return resource_vertex.has_vertices() + user_vertices = [] + for user_vertex in resource_vertex.has_vertices(): + user_acl = self.record_linking.get_acl(user_vertex.uid, resource_uid) + if user_acl: + if user_acl.controls_services: + user_vertices.append(user_vertex) + return user_vertices @staticmethod def delete(vertex: DAGVertex): @@ -276,21 +290,16 @@ def delete(vertex: DAGVertex): vertex.delete() def save(self): - if self.dag.has_graph: - self.debug("saving the service user.") - self.dag.save(delta_graph=False) - else: - self.debug("the service user graph does not contain any data, was not saved.") + self.record_linking.save() - def to_dot(self, graph_format: str = "svg", show_version: bool = True, show_only_active_vertices: bool = True, - show_only_active_edges: bool = True, graph_type: str = "dot"): + def to_dot(self, graph_format: str = "svg", graph_type: str = "dot"): try: mod = importlib.import_module("graphviz") except ImportError: raise Exception("Cannot to_dot(), graphviz module is not installed.") - dot = getattr(mod, "Digraph")(comment=f"DAG for Services/Tasks", format=graph_format) + dot = getattr(mod, "Digraph")(comment=f"DAG for Services", format=graph_format) if graph_type == "dot": dot.attr(rankdir='RL') @@ -301,74 +310,24 @@ def to_dot(self, graph_format: str = "svg", show_version: bool = True, show_only else: dot.attr(layout=graph_type) - self.debug(f"have {len(self.dag.all_vertices)} vertices") - for v in self.dag.all_vertices: - if show_only_active_vertices is True and v.active is False: + # This will get all the resources (and cloud users) + for resource_vertex in self.record_linking.dag.get_root.has_vertices(): + if not resource_vertex.active: continue - tooltip = "" - - for edge in v.edges: - - color = "grey" - style = "solid" - - # To reduce the number of edges, only show the active edges - if edge.active: - color = "black" - style = "bold" - elif show_only_active_edges: + plotted_resource = False + for user_vertex in resource_vertex.has_vertices(): + acl = self.record_linking.get_acl(user_vertex.uid, resource_vertex.uid) + if acl is None or not acl.controls_services: continue - # If the vertex is not active, gray out the DATA edge - if edge.edge_type == EdgeType.DATA and v.active is False: - color = "grey" - - if edge.edge_type == EdgeType.DELETION: - style = "dotted" - - edge_tip = "" - if edge.edge_type == EdgeType.ACL and v.active is True: - content = edge.content_as_dict - red = "00" - green = "00" - blue = "00" - if content.get("is_service"): - red = "FF" - if content.get("is_task"): - blue = "FF" - if content.get("is_iis_pool"): - green = "FF" - if red == "FF" and blue == "FF" and green == "FF": - color = "#808080" - else: - color = f"#{red}{green}{blue}" - style = "bold" - - tooltip += f"TO {edge.head_uid}\\n" - for k, val in content.items(): - tooltip += f" * {k}={val}\\n" - tooltip += f"--------------------\\n\\n" - - label = DAG.EDGE_LABEL.get(edge.edge_type) - if label is None: - label = "UNK" - if edge.path is not None and edge.path != "": - label += f"\\npath={edge.path}" - if show_version: - label += f"\\nv={edge.version}" - - # tail, head (arrow side), label, ... - dot.edge(v.uid, edge.head_uid, label, style=style, fontcolor=color, color=color, tooltip=edge_tip) - - shape = "ellipse" - fillcolor = "white" - color = "black" - if not v.active: - fillcolor = "grey" - - label = f"uid={v.uid}" - dot.node(v.uid, label, color=color, fillcolor=fillcolor, style="filled", shape=shape, tooltip=tooltip) + if not plotted_resource: + dot.edge(resource_vertex.uid, self.record_linking.dag.get_root.uid) + dot.node(resource_vertex.uid) + plotted_resource = True + + dot.edge(user_vertex.uid, resource_vertex.uid) + dot.node(user_vertex.uid) return dot @@ -381,11 +340,13 @@ def _init_cleanup_user_mapping(self): """ self.cleanup_mapping = {} - for user_service_machine in self.dag.get_root.has_vertices(): - if user_service_machine.uid not in self.cleanup_mapping: - self.cleanup_mapping[user_service_machine.uid] = {} - for user_service_user in user_service_machine.has_vertices(): - self.cleanup_mapping[user_service_machine.uid][user_service_user.uid] = False + for resource_vertex in self.record_linking.dag.get_root.has_vertices(): + if resource_vertex.uid not in self.cleanup_mapping: + self.cleanup_mapping[resource_vertex.uid] = {} + for user_vertex in resource_vertex.has_vertices(): + acl = self.record_linking.get_acl(user_vertex.uid, resource_vertex.uid) + if acl is not None and acl.controls_services: + self.cleanup_mapping[resource_vertex.uid][user_vertex.uid] = False def _user_is_used(self, machine_record_uid: str, user_record_uid: str): @@ -408,9 +369,13 @@ def _cleanup_users(self): for machine_record_uid in self.cleanup_mapping: for user_record_uid in self.cleanup_mapping[machine_record_uid]: if not self.cleanup_mapping[machine_record_uid][user_record_uid]: - self.debug(f" * disconnect user {user_record_uid} from machine {machine_record_uid}") - did_something = True - self.disconnect_from(machine_record_uid, user_record_uid) + acl = self.record_linking.get_acl(user_record_uid, machine_record_uid) + if acl is not None and acl.controls_services: + self.debug(f" * set controls_services for user {user_record_uid} " + f"from machine {machine_record_uid} to false") + did_something = True + acl.controls_services = False + self.record_linking.belongs_to(user_record_uid, machine_record_uid, acl=acl) if not did_something: self.debug(f" nothing to cleanup") @@ -457,7 +422,6 @@ def _get_local_users_from_infra(record_lookup_func: Callable, return user_records def _get_directory_users_from_conf_record(self, - record_linking: RecordLink, domain_name: str, record_lookup_func: Callable, netbios: Optional[str] = None) -> Dict[str, str]: @@ -475,7 +439,7 @@ def _get_directory_users_from_conf_record(self, # If the domain name is not set, or it is, and we match the one that machine is joined to. if (config_domain_name is None or (config_domain_name.lower() == domain_name or config_domain_name.lower() == netbios)): - config_vertex = record_linking.dag.get_vertex(configuration_record.record_uid) + config_vertex = self.record_linking.dag.get_vertex(configuration_record.record_uid) for child_vertex in config_vertex.has_vertices(): user_record = record_lookup_func(child_vertex.uid, allow_sm=False) # type: NormalizedRecord if not user_record: @@ -535,7 +499,6 @@ def _get_directory_users_from_conf_infra(self, return user_records def _get_directory_users_from_records(self, - record_linking: RecordLink, domain_name: str, record_lookup_func: Callable) -> Dict[str, str]: @@ -543,7 +506,7 @@ def _get_directory_users_from_records(self, # From the record linking graph, check each record connected to the configuration to see if it is a # PAM directory record. - for rl_resource_vertex in record_linking.dag.get_root.has_vertices(): + for rl_resource_vertex in self.record_linking.dag.get_root.has_vertices(): directory_record = record_lookup_func(rl_resource_vertex.uid, allow_sm=False) # type: NormalizedRecord if directory_record and directory_record.record_type == PAM_DIRECTORY: record_domain_name = directory_record.get_value(label="domainName") @@ -612,7 +575,6 @@ def _get_users(self, infra: Infrastructure, infra_machine_content: DiscoveryObject, infra_machine_vertex: DAGVertex, - record_linking: RecordLink, record_lookup_func: Callable, netbios: Optional[str] = None, domain_name: Optional[str] = None) -> Dict[str, str]: @@ -625,7 +587,7 @@ def _get_users(self, It will first check the records linking graph. Then check the infrastructure graph. """ - self.debug(f" getting users for {infra_machine_content.name}, {infra_machine_content.record_uid}") + self.debug(f" getting users for {infra_machine_content.name}, {infra_machine_content.record_uid}") if netbios is not None: netbios = netbios.lower() @@ -646,41 +608,39 @@ def _get_users(self, # Cache them so we don't have to get them again. if self.directory_user_cache is not None: directory_user_records = self.directory_user_cache.get(domain_name) - self.debug(f" using directory user cache for {domain_name}, " + self.debug(f" using directory user cache for {domain_name}, " f"{len(directory_user_records)} users") using_directory_user_cache = True ########################### # Find the users using the record linking graph. - self.debug(f" getting users from record linking", level=1) - record_link_vertex = record_linking.dag.get_vertex(infra_machine_content.record_uid) + self.debug(f" getting users from record linking", level=1) + record_link_vertex = self.record_linking.dag.get_vertex(infra_machine_content.record_uid) if record_link_vertex is None: self.debug(" record uid {machine_record_uid} does not exist in the Vault.", level=1) else: # Get the local users from records - self.debug(" getting local users from records", level=1) + self.debug(" getting local users from records", level=1) user_records = self._get_local_users_from_record(rl_machine_vertex=record_link_vertex, record_lookup_func=record_lookup_func) - self.debug(f" * found {len(user_records)} local users from records", level=1) + self.debug(f" * found {len(user_records)} local users from records", level=1) local_user_records = {**local_user_records, **user_records} if not using_directory_user_cache and domain_name is not None: self.debug(" getting directory users from the configuration record", level=1) - user_records = self._get_directory_users_from_conf_record(record_linking=record_linking, - domain_name=domain_name, + user_records = self._get_directory_users_from_conf_record(domain_name=domain_name, netbios=netbios, record_lookup_func=record_lookup_func) - self.debug(f" * found {len(user_records)} directory users records from " + self.debug(f" * found {len(user_records)} directory users records from " "the configuration record", level=1) directory_user_records = {**directory_user_records, **user_records} - self.debug(" getting directory users from directory records", level=1) - user_records = self._get_directory_users_from_records(record_linking=record_linking, - domain_name=domain_name, + self.debug(" getting directory users from directory records", level=1) + user_records = self._get_directory_users_from_records(domain_name=domain_name, record_lookup_func=record_lookup_func) self.debug(f" * found {len(user_records)} directory users from records for {domain_name}", level=1) @@ -691,30 +651,30 @@ def _get_users(self, # Find the users via infrastructure graph - self.debug(f" getting users from infrastructure", level=1) - self.debug(" getting local users from infrastructure", level=1) + self.debug(f" getting users from infrastructure", level=1) + self.debug(" getting local users from infrastructure", level=1) user_records = self._get_local_users_from_infra(infra_machine_vertex=infra_machine_vertex, record_lookup_func=record_lookup_func) - self.debug(f" * found {len(user_records)} local users from graph", level=1) + self.debug(f" * found {len(user_records)} local users from graph", level=1) local_user_records = {**user_records, **local_user_records} if not using_directory_user_cache and domain_name is not None: - self.debug(" getting directory users from configuration infrastructure", level=1) + self.debug(" getting directory users from configuration infrastructure", level=1) user_records = self._get_directory_users_from_conf_infra(infra=infra, domain_name=domain_name, record_lookup_func=record_lookup_func) - self.debug(f" * found {len(user_records)} directory users from configuration for {domain_name}", + self.debug(f" * found {len(user_records)} directory users from configuration for {domain_name}", level=1) directory_user_records = {**user_records, **directory_user_records} # ------------- - self.debug(" getting directory users from directory infrastructure", level=1) + self.debug(" getting directory users from directory infrastructure", level=1) user_records = self._get_directory_users_from_infra(infra_machine_vertex=infra_machine_vertex, domain_name=domain_name, record_lookup_func=record_lookup_func) - self.debug(f" * found {len(user_records)} directory users from graph for {domain_name}", level=1) + self.debug(f" * found {len(user_records)} directory users from graph for {domain_name}", level=1) directory_user_records = {**user_records, **directory_user_records} # If we were not using the directory cache, cache them. @@ -725,7 +685,7 @@ def _get_users(self, all_record = {**directory_user_records, **local_user_records} - self.debug(f" total union of users count {len(all_record.keys())}") + self.debug(f" total union of users count {len(all_record.keys())}") return all_record @@ -733,7 +693,6 @@ def _connect_users_to_services(self, infra: Infrastructure, infra_machine_content: DiscoveryObject, infra_machine_vertex: DAGVertex, - record_linking: RecordLink, record_lookup_func: Callable, strict: bool = False, domain_name: Optional[str] = None, @@ -763,10 +722,12 @@ def _connect_users_to_services(self, f"({infra_machine_vertex.uid})") # We don't care about the name of the service, we just need a list users. + service_users = [] for service_user in getattr(infra_machine_content.item.facts, f"{service_type}s"): self.debug(f" * {service_type}: {service_user.name} ({service_user.user})", secret=True) user = service_user.user.lower() + if not strict: user, domain = split_user_and_domain(user) if user is not None: @@ -781,45 +742,43 @@ def _connect_users_to_services(self, else: service_users.append(user) - service_users = list(set(service_users)) + service_users = list(set(service_users)) - if len(service_users) == 0: - self.debug(f" no users control {service_type}s, skipping.") - continue + if len(service_users) == 0: + self.debug(f" no users control {service_type}s, skipping.") + continue users = self._get_users(infra=infra, infra_machine_content=infra_machine_content, infra_machine_vertex=infra_machine_vertex, - record_linking=record_linking, record_lookup_func=record_lookup_func, netbios=netbios, domain_name=domain_name) if self.log_finer_level >= 2 and self.insecure_debug: for k, v in users.items(): - self.debug(f"> {k} = {v}") + self.debug(f" > {k} = {v}") - self.debug(f"users to check: {service_users}", secret=True) + self.debug(f" users to check: {service_users}", secret=True) for service_user in service_users: - self.debug(f" * {service_user}", secret=True) + self.debug(f" . {service_user}", secret=True) if service_user in users: record_uid = users[service_user] - self.debug(f" found user {service_user} for {service_type}", secret=True) + self.debug(f" found user {service_user} for {service_type}", secret=True) acl = self.get_acl(infra_machine_content.record_uid, record_uid) if acl is None: - acl = ServiceAcl() - acl_attr = "is_" + service_type + acl = UserAcl().default() # Flag the user was found; don't disconnect self._user_is_used(machine_record_uid=infra_machine_content.record_uid, user_record_uid=record_uid) # Only update if the attribute is currently False; reduce edges. - if getattr(acl, acl_attr) is False: - setattr(acl, acl_attr, True) - self.belongs_to(resource_uid=infra_machine_content.record_uid, - user_uid=record_uid, - acl=acl) + if not acl.controls_services: + acl.controls_services = True + self.set_acl(resource_uid=infra_machine_content.record_uid, + user_uid=record_uid, + acl=acl) def _get_resource_info(self, record_uid: str, @@ -873,7 +832,6 @@ def run_user(self): def run_full(self, record_lookup_func: Callable, infra: Optional[Infrastructure] = None, - record_linking: Optional[RecordLink] = None, domain_name: Optional[str] = None, netbios: Optional[str] = None, **kwargs): @@ -883,7 +841,6 @@ def run_full(self, This is driven by the record linking graph. :param infra: Instance of Infrastructure graph. - :param record_linking: Instance of the Record Linking graph. :param record_lookup_func: A function that will return a record by record id. Returns a normalize record. :param domain_name: Domain name if there is a directory (i.e. example.com) :param netbios: NetBIOS of the domain controller (i.e. EXMAPLE) @@ -897,7 +854,6 @@ def run_full(self, # Load fresh created_infra = False - created_record_linking = False try: @@ -909,12 +865,8 @@ def run_full(self, infra.load(sync_point=0) created_infra = True - if not record_linking: - record_linking = RecordLink(record=self.record, logger=self.logger, ksm=self._ksm, params=self._params) - created_record_linking = True - # The PAM Configuration record is the root vertex of the PAM/record linking graph. - rl_configuration_vertex = record_linking.dag.get_root + rl_configuration_vertex = self.record_linking.dag.get_root # At this level the vertex will either be a resource or a cloud user. for rl_resource_vertex in rl_configuration_vertex.has_vertices(): @@ -959,16 +911,17 @@ def run_full(self, self.debug(" machine has no user controlled services, skipping") continue - user_service_machine_vertex = self.dag.get_vertex(infra_machine_content.record_uid) + user_service_machine_vertex = self.record_linking.dag.get_vertex(infra_machine_content.record_uid) # If the resource does not exist in the user service graph, add a vertex and link it to the # user service root/configuration vertex. if user_service_machine_vertex is None: - user_service_machine_vertex = self.dag.add_vertex(uid=infra_machine_content.record_uid, - name=infra_machine_content.name) + user_service_machine_vertex = self.record_linking.dag.add_vertex( + uid=infra_machine_content.record_uid, + name=infra_machine_content.name) # If the UserService resource vertex is not connect to root, connect it. - if not self.dag.get_root.has(user_service_machine_vertex): + if not self.record_linking.dag.get_root.has(user_service_machine_vertex): user_service_machine_vertex.belongs_to_root(EdgeType.LINK) self.debug("-" * 40) @@ -976,7 +929,6 @@ def run_full(self, infra=infra, infra_machine_content=infra_machine_content, infra_machine_vertex=infra_machine_vertex, - record_linking=record_linking, record_lookup_func=record_lookup_func, domain_name=domain_name, netbios=netbios) @@ -985,6 +937,7 @@ def run_full(self, # Disconnect any users not used. # TODO - Handle this better. # If a machine is off, or we cannot connect, we might disconnect users. + # We can uses the infrastructure graph to see this. # This needs more testing. # self._cleanup_users() @@ -997,5 +950,3 @@ def run_full(self, finally: if created_infra: infra.close() - if created_record_linking: - record_linking.close() diff --git a/keepercommander/discovery_common/verify.py b/keepercommander/discovery_common/verify.py index dd6e6a3fd..e15c1a742 100644 --- a/keepercommander/discovery_common/verify.py +++ b/keepercommander/discovery_common/verify.py @@ -59,6 +59,7 @@ def __init__(self, record: Any, logger: Optional[Any] = None, debug_level: int = self.record_link = RecordLink(record=record, logger=logger, debug_level=debug_level, fail_on_corrupt=False, **kwargs) self.user_service = UserService(record=record, logger=logger, debug_level=debug_level, fail_on_corrupt=False, + record_linking= self.record_link, **kwargs) if logger is None: @@ -230,9 +231,9 @@ def _get_infra_configuration(self): # Check to make sure the user service graph exists. # The "UserService" instance should do this, but we want to make sure. - if self.user_service.dag.has_graph is False: + if self.record_link.dag.has_graph is False: self.logger.debug("the user service graph contains no data") - configuration_vertex = self.user_service.dag.get_root + configuration_vertex = self.record_link.dag.get_root if configuration_vertex.uid != self.conn.get_record_uid(self.record): raise Exception("The user service graph root/con does not match ") @@ -260,7 +261,7 @@ def verify_user_service(self, fix: bool = False): color_name=Verify.UNK) continue - user_service_resource_vertex = self.user_service.dag.get_vertex(resource_content.record_uid) + user_service_resource_vertex = self.record_link.dag.get_vertex(resource_content.record_uid) if user_service_resource_vertex is None: self._msg(f" * Machine {resource_content.name} does not have a vertex in the user service graph") if fix is True: diff --git a/keepercommander/enterprise.py b/keepercommander/enterprise.py index e96a0dbaf..30ef9edf0 100644 --- a/keepercommander/enterprise.py +++ b/keepercommander/enterprise.py @@ -879,6 +879,7 @@ def to_keeper_entity(self, proto_entity, keeper_entity): # type: (proto.Managed _set_or_remove(keeper_entity, 'paused', proto_entity.isExpired) _set_or_remove(keeper_entity, 'tree_key', proto_entity.treeKey if proto_entity.treeKey else None) _set_or_remove(keeper_entity, 'tree_key_role', proto_entity.tree_key_role) + _set_or_remove(keeper_entity, 'tree_key_type_id', proto_entity.treeKeyTypeId) _set_or_remove(keeper_entity, 'file_plan_type', proto_entity.filePlanType) _set_or_remove(keeper_entity, 'add_ons', [{ 'name': x.name, diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index 99efb826b..38703a9dc 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -1411,7 +1411,7 @@ def upload_v3_attachments(params, records_with_attachments): # type: (KeeperPar print(f'{atta.name} ... ', file=sys.stderr, end='', flush=True) response = requests.post(f.url, data=form_params, files=form_files, proxies=params.rest_context.proxies, - verify=params.rest_context.certificate_check) + verify=params.ssl_verify) if str(response.status_code) == form_params.get('success_action_status'): print('Done') diff --git a/keepercommander/importer/lastpass/lastpass.py b/keepercommander/importer/lastpass/lastpass.py index dd5f01fbf..fef9131ee 100644 --- a/keepercommander/importer/lastpass/lastpass.py +++ b/keepercommander/importer/lastpass/lastpass.py @@ -137,7 +137,7 @@ def do_import(self, name, users_only=False, old_domain=None, new_domain=None, tm params = kwargs.get('params') if isinstance(params, KeeperParams): request_settings['proxies'] = params.rest_context.proxies - request_settings['certificate_check'] = params.rest_context.certificate_check + request_settings['certificate_check'] = params.ssl_verify if 'filter_folder' in kwargs and kwargs['filter_folder']: request_settings['filter_folder'] = kwargs['filter_folder'] @@ -711,7 +711,7 @@ def download_membership(self, params, **kwargs): session = None request_settings = { 'proxies': params.rest_context.proxies, - 'certificate_check': params.rest_context.certificate_check + 'certificate_check': params.ssl_verify } try: session = fetcher.login(username, password, twofa_code, **request_settings) diff --git a/keepercommander/params.py b/keepercommander/params.py index f1c57b328..d74297abe 100644 --- a/keepercommander/params.py +++ b/keepercommander/params.py @@ -13,7 +13,7 @@ import threading import warnings from datetime import datetime -from typing import Dict, NamedTuple, Optional, Set +from typing import Dict, NamedTuple, Optional, Set, Union from urllib.parse import urlparse, urlunparse from urllib3.exceptions import InsecureRequestWarning @@ -45,6 +45,7 @@ def __init__(self, server='https://keepersecurity.com/api/v2/', locale='en_US'): self.__store_server_key = False self.proxies = None self._certificate_check = True + self._resolved_verify = None self.fail_on_throttle = False self.client_ec_private_key = None # EC private key for QRC ECDH exchange @@ -132,13 +133,38 @@ def set_proxy(self, proxy_server): self.proxies = None @property - def certificate_check(self): - return self._certificate_check + def certificate_check(self) -> Union[bool, str]: + """SSL verification value for ``requests``' ``verify=`` parameter. + + Despite the name and the plain-bool ``_certificate_check`` backing field, + this getter never returns a bare ``True``/``bool``. It resolves + ``_certificate_check`` (and the ``KEEPER_SSL_CERT_FILE`` env var, via + ``utils.resolve_ssl_verify``/``utils.get_ssl_cert_file``) into one of: + + - ``False`` - certificate verification disabled. + - ``str`` - path to the CA bundle to verify against (the default + system/certifi bundle, or a user override). + + In practice the return type is ``False | str`` (never ``True``), even + though the hint is the more permissive ``Union[bool, str]``. Callers + must not do ``is True`` checks against this value - it will always be + false. Use ``if certificate_check:`` to test enabled/disabled, or + ``isinstance(certificate_check, str)`` to detect a resolved bundle + path. The old plain-bool value is still available as the private + ``_certificate_check`` attribute for code that only cares about the + user's on/off intent, not the resolved ``requests`` verify value. + """ + if self._resolved_verify is None: + from . import utils + self._resolved_verify = utils.resolve_ssl_verify( + certificate_check_enabled=self._certificate_check) + return self._resolved_verify @certificate_check.setter def certificate_check(self, value): if isinstance(value, bool): self._certificate_check = value + self._resolved_verify = None if value: warnings.simplefilter('default', InsecureRequestWarning) else: @@ -363,6 +389,10 @@ def queue_audit_event(self, name, **kwargs): server = property(__get_server, __set_server) rest_context = property(__get_rest_context) + @property + def ssl_verify(self): + return self.rest_context.certificate_check + def is_feature_disallowed(self, feature_name): # type: (str) -> bool return isinstance(self.disallowed_features, list) and feature_name in self.disallowed_features diff --git a/keepercommander/utils.py b/keepercommander/utils.py index d9c8c7cef..3a6f84119 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -530,31 +530,42 @@ def value_to_boolean(value): else: return None +_SSL_CERT_UNSET = object() +_cached_ssl_cert_file = _SSL_CERT_UNSET + + +def _legacy_ssl_verify_disabled(): + return os.environ.get('VERIFY_SSL', 'TRUE').upper() == 'FALSE' + + def get_ssl_cert_file(): - """Resolve the SSL CA bundle path. + """Resolve KEEPER_SSL_CERT_FILE to a CA bundle path, or False to disable verification.""" + global _cached_ssl_cert_file + if _cached_ssl_cert_file is not _SSL_CERT_UNSET: + return _cached_ssl_cert_file - KEEPER_SSL_CERT_FILE accepts: 'certifi', 'system', 'none'/'false', or a PEM path. - Defaults to the bundled certifi store; does not consult SSL_CERT_FILE from the - environment to avoid silent trust-store hijacking by unrelated tools. - """ import certifi user_cert_file = os.getenv('KEEPER_SSL_CERT_FILE') if user_cert_file: choice = user_cert_file.lower() if choice == 'certifi': - return certifi.where() + _cached_ssl_cert_file = certifi.where() + return _cached_ssl_cert_file if choice in ('none', 'false'): - return False + _cached_ssl_cert_file = False + return _cached_ssl_cert_file if choice != 'system': if os.path.exists(user_cert_file): - return user_cert_file + _cached_ssl_cert_file = user_cert_file + return _cached_ssl_cert_file print( f"Warning: KEEPER_SSL_CERT_FILE points to a non-existent file: " f"{user_cert_file}; falling back to the bundled certifi store.", file=sys.stderr, ) - return certifi.where() + _cached_ssl_cert_file = certifi.where() + return _cached_ssl_cert_file try: system = platform.system() @@ -575,32 +586,30 @@ def get_ssl_cert_file(): candidates = () for ca_path in candidates: if os.path.exists(ca_path): - return ca_path + _cached_ssl_cert_file = ca_path + return _cached_ssl_cert_file except Exception: pass - return certifi.where() + _cached_ssl_cert_file = certifi.where() + return _cached_ssl_cert_file -def ssl_aware_request(method, url, **kwargs): - """Make an SSL-aware HTTP request using system CA certificates when available""" - import requests - - # Only set verify if not already specified - if 'verify' not in kwargs: - cert_file = get_ssl_cert_file() - if cert_file is False: - kwargs['verify'] = False - elif cert_file: - kwargs['verify'] = cert_file - # If cert_file is None, let requests use its default - - return requests.request(method, url, **kwargs) +def resolve_ssl_verify(*, certificate_check_enabled=True): + """Return the ``requests`` ``verify`` value (False or a CA bundle path).""" + if _legacy_ssl_verify_disabled(): + return False + if not certificate_check_enabled: + return False + return get_ssl_cert_file() + +def resolve_http_ssl_verify(params=None): + """Resolve HTTP SSL verification for Commander or standalone DAG clients.""" + if params is not None: + return params.ssl_verify + return resolve_ssl_verify() -def ssl_aware_get(url, **kwargs): - """SSL-aware GET request using system CA certificates when available""" - return ssl_aware_request('GET', url, **kwargs) def is_windows_11(): if sys.platform != "win32": diff --git a/unit-tests/pam/test_dag_layer_b_migration.py b/unit-tests/pam/test_dag_layer_b_migration.py index abb5f591b..ba381cdcb 100644 --- a/unit-tests/pam/test_dag_layer_b_migration.py +++ b/unit-tests/pam/test_dag_layer_b_migration.py @@ -823,6 +823,20 @@ def test_link_user_to_config_no_fallback_on_permission_denial(self): # Critical: legacy link_user did NOT run link_user_mock.assert_not_called() + def test_link_user_to_config_noop_writes_dag_without_set_record_rotation(self): + """link_user_to_config_noop() writes the noop ACL locally; rotation is saved separately.""" + tg, config_vertex = self._build_tg() + tg.linking_dag.has_graph = True + user_vertex = MagicMock() + tg.linking_dag.get_vertex.side_effect = lambda uid: user_vertex if uid == USER_UID_STR else config_vertex + tg.linking_dag.add_vertex.return_value = user_vertex + user_vertex.get_edge.return_value = None + + with patch('keepercommander.commands.pam.router_helper.router_set_record_rotation_information') as set_rotation: + assert tg.link_user_to_config_noop(USER_UID_STR) is True + set_rotation.assert_not_called() + tg.linking_dag.save.assert_called_once() + def test_link_user_to_config_with_options_iam_user_true_permission_checks(self): """link_user_to_config_with_options(is_iam_user=True) routes through set_record_rotation BEFORE the local ACL mutation. is_admin and diff --git a/unit-tests/pam/test_pam_connection_edit_security.py b/unit-tests/pam/test_pam_connection_edit_security.py new file mode 100644 index 000000000..50735c68e --- /dev/null +++ b/unit-tests/pam/test_pam_connection_edit_security.py @@ -0,0 +1,377 @@ +""" +Unit tests for PAM Connection Edit `--ignore-server-cert` and `--security-mode` flags. + +Covers argument parsing and the up-front validation that runs before any DAG / +record mutation: allowed record types (pamMachine, pamDatabase, pamDirectory), +allowed protocols per flag (rdp/kubernetes for cert, rdp-only for security mode), +and the record mutation itself (connection.ignoreCert / connection.security). +""" + +import unittest +from unittest import mock + +skip_tests = False +skip_reason = "" +try: + from keepercommander.commands.tunnel_and_connections import PAMConnectionEditCommand + from keepercommander.error import CommandError + from keepercommander import vault +except ImportError as e: + skip_tests = True + skip_reason = f"Cannot import tunnel_and_connections: {e}" + + +@unittest.skipIf(skip_tests, skip_reason) +class TestPamConnectionEditSecurityArgs(unittest.TestCase): + def setUp(self): + self.parser = PAMConnectionEditCommand.parser + + def test_ignore_server_cert_on(self): + args = self.parser.parse_args(['rec', '--ignore-server-cert', 'on']) + self.assertEqual(args.ignore_server_cert, 'on') + + def test_ignore_server_cert_off(self): + args = self.parser.parse_args(['rec', '--ignore-server-cert', 'off']) + self.assertEqual(args.ignore_server_cert, 'off') + + def test_ignore_server_cert_default(self): + args = self.parser.parse_args(['rec', '--ignore-server-cert', 'default']) + self.assertEqual(args.ignore_server_cert, 'default') + + def test_ignore_server_cert_short_alias(self): + args = self.parser.parse_args(['rec', '-isc', 'on']) + self.assertEqual(args.ignore_server_cert, 'on') + + def test_ignore_server_cert_invalid_choice_rejected(self): + with self.assertRaises(SystemExit): + self.parser.parse_args(['rec', '--ignore-server-cert', 'bogus']) + + def test_ignore_server_cert_not_provided(self): + args = self.parser.parse_args(['rec']) + self.assertIsNone(args.ignore_server_cert) + + def test_security_mode_choices(self): + for mode in ('any', 'nla', 'tls', 'vmconnect', 'rdp', 'default'): + with self.subTest(mode=mode): + args = self.parser.parse_args(['rec', '--security-mode', mode]) + self.assertEqual(args.security_mode, mode) + + def test_security_mode_short_alias(self): + args = self.parser.parse_args(['rec', '-sm', 'nla']) + self.assertEqual(args.security_mode, 'nla') + + def test_security_mode_invalid_choice_rejected(self): + with self.assertRaises(SystemExit): + self.parser.parse_args(['rec', '--security-mode', 'bogus']) + + def test_security_mode_not_provided(self): + args = self.parser.parse_args(['rec']) + self.assertIsNone(args.security_mode) + + def test_help_includes_new_flags(self): + help_text = self.parser.format_help() + self.assertIn('--ignore-server-cert', help_text) + self.assertIn('-isc', help_text) + self.assertIn('--security-mode', help_text) + self.assertIn('-sm', help_text) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestPamConnectionEditSecurityValidation(unittest.TestCase): + """Validation runs before DAG / token operations, so we can drive execute() + with mocks that only need to satisfy resolve_single_record + the typed-field + accessor for pamSettings.""" + + def _mock_record(self, record_type, protocol): + rec = mock.MagicMock(spec=vault.TypedRecord) + rec.record_uid = 'rec-uid' + rec.record_type = record_type + rec.version = 3 + ps_field = mock.MagicMock() + if protocol is None: + ps_field.value = [] + else: + ps_field.value = [{'connection': {'protocol': protocol}}] + rec.get_typed_field.side_effect = lambda name: ps_field if name == 'pamSettings' else None + return rec + + def _execute(self, record, **kwargs): + cmd = PAMConnectionEditCommand() + params = mock.MagicMock() + with mock.patch( + 'keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record', + return_value=record, + ): + cmd.execute(params, record='rec', **kwargs) + + # --ignore-server-cert: record-type gating + + def test_ignore_server_cert_pam_remote_browser_rejected(self): + rec = self._mock_record('pamRemoteBrowser', 'http') + with self.assertRaises(CommandError) as ctx: + self._execute(rec, ignore_server_cert='on') + msg = str(ctx.exception) + self.assertIn('--ignore-server-cert is only supported for pamMachine, pamDatabase, and pamDirectory', msg) + self.assertIn('pam rbi edit', msg) + + def test_ignore_server_cert_pam_network_configuration_rejected(self): + rec = self._mock_record('pamNetworkConfiguration', None) + with self.assertRaises(CommandError) as ctx: + self._execute(rec, ignore_server_cert='on') + self.assertIn('--ignore-server-cert is only supported for pamMachine, pamDatabase, and pamDirectory', + str(ctx.exception)) + + # --ignore-server-cert: protocol gating + + def test_ignore_server_cert_ssh_rejected(self): + rec = self._mock_record('pamMachine', 'ssh') + with self.assertRaises(CommandError) as ctx: + self._execute(rec, ignore_server_cert='on') + msg = str(ctx.exception) + self.assertIn('not supported for protocol "ssh"', msg) + self.assertIn('kubernetes, rdp', msg) + + def test_ignore_server_cert_no_protocol_rejected(self): + rec = self._mock_record('pamMachine', None) + with self.assertRaises(CommandError) as ctx: + self._execute(rec, ignore_server_cert='on') + self.assertIn('not supported for protocol "(unset)"', str(ctx.exception)) + + def test_ignore_server_cert_rdp_accepted(self): + rec = self._mock_record('pamMachine', 'rdp') + try: + self._execute(rec, ignore_server_cert='on') + except CommandError as e: + self.assertNotIn('--ignore-server-cert is', str(e)) + except Exception: + pass # downstream failures not under test + + def test_ignore_server_cert_kubernetes_accepted(self): + rec = self._mock_record('pamDirectory', 'kubernetes') + try: + self._execute(rec, ignore_server_cert='on') + except CommandError as e: + self.assertNotIn('--ignore-server-cert is', str(e)) + except Exception: + pass # downstream failures not under test + + # --security-mode: record-type gating + + def test_security_mode_pam_remote_browser_rejected(self): + rec = self._mock_record('pamRemoteBrowser', 'http') + with self.assertRaises(CommandError) as ctx: + self._execute(rec, security_mode='nla') + self.assertIn('--security-mode is only supported for pamMachine, pamDatabase, and pamDirectory', + str(ctx.exception)) + + # --security-mode: protocol gating + + def test_security_mode_kubernetes_rejected(self): + rec = self._mock_record('pamMachine', 'kubernetes') + with self.assertRaises(CommandError) as ctx: + self._execute(rec, security_mode='nla') + msg = str(ctx.exception) + self.assertIn('not supported for protocol "kubernetes"', msg) + + def test_security_mode_no_protocol_rejected(self): + rec = self._mock_record('pamMachine', None) + with self.assertRaises(CommandError) as ctx: + self._execute(rec, security_mode='nla') + self.assertIn('not supported for protocol "(unset)"', str(ctx.exception)) + + def test_security_mode_rdp_accepted(self): + rec = self._mock_record('pamMachine', 'rdp') + try: + self._execute(rec, security_mode='nla') + except CommandError as e: + self.assertNotIn('--security-mode is', str(e)) + except Exception: + pass # downstream failures not under test + + def test_protocol_change_in_same_command_validated_against_new(self): + """When --connections=on and --protocol=ssh are passed alongside --security-mode, + validation uses the new (post-mutation) protocol -> ssh -> reject.""" + rec = self._mock_record('pamMachine', 'rdp') + with self.assertRaises(CommandError) as ctx: + self._execute(rec, security_mode='nla', connections='on', protocol='ssh') + self.assertIn('not supported for protocol "ssh"', str(ctx.exception)) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestPamConnectionEditSecurityMutation(unittest.TestCase): + """Verifies the actual JSON keys written to pamSettings.connection.""" + + def _mock_record(self, record_type='pamMachine', protocol='rdp', existing_connection=None): + rec = mock.MagicMock(spec=vault.TypedRecord) + rec.record_uid = 'rec-uid' + rec.record_type = record_type + rec.version = 3 + rec.fields = [] + rec.custom = [] + connection = {'protocol': protocol} + if existing_connection: + connection.update(existing_connection) + pam_settings_value = [{'connection': connection, 'portForward': {}}] + ps_field = mock.MagicMock() + ps_field.value = pam_settings_value + rec.get_typed_field.side_effect = lambda name: ps_field if name == 'pamSettings' else ( + mock.MagicMock(value=['seed']) if name == 'trafficEncryptionSeed' else None + ) + return rec, ps_field + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_ignore_server_cert_on_writes_true(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec, ps_field = self._mock_record(protocol='rdp') + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', ignore_server_cert='on') + self.assertEqual(ps_field.value[0]['connection'].get('ignoreCert'), True) + mock_update.assert_called_once() + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_ignore_server_cert_off_writes_false(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec, ps_field = self._mock_record(protocol='kubernetes', existing_connection={'ignoreCert': True}) + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', ignore_server_cert='off') + self.assertEqual(ps_field.value[0]['connection'].get('ignoreCert'), False) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_ignore_server_cert_default_removes_key(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec, ps_field = self._mock_record(protocol='rdp', existing_connection={'ignoreCert': True}) + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', ignore_server_cert='default') + self.assertNotIn('ignoreCert', ps_field.value[0]['connection']) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_security_mode_writes_lowercase_value(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec, ps_field = self._mock_record(protocol='rdp') + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', security_mode='NLA'.lower()) + self.assertEqual(ps_field.value[0]['connection'].get('security'), 'nla') + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_security_mode_default_removes_key(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec, ps_field = self._mock_record(protocol='rdp', existing_connection={'security': 'tls'}) + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', security_mode='default') + self.assertNotIn('security', ps_field.value[0]['connection']) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + def test_no_change_does_not_mark_dirty(self, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + """Setting a value that already matches the current one should not trigger update_record. + + TunnelDAG is deliberately left unmocked here (unlike the other tests in this file): + mocking the whole class also mocks its `_convert_allowed_setting` staticmethod, turning + `_connections`/`_recording`/`_typescript_recording` into truthy MagicMocks regardless of + input and forcing a spurious dirty=True. Since neither flag under test touches the DAG + path, the real staticmethod (called unconditionally near the top of execute()) is safe + to leave in place. + """ + rec, ps_field = self._mock_record(protocol='rdp', existing_connection={'ignoreCert': True, 'security': 'nla'}) + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', ignore_server_cert='on', security_mode='nla') + mock_update.assert_not_called() + + +@unittest.skipIf(skip_tests, skip_reason) +class TestPamConnectionEditSecurityEarlyReturn(unittest.TestCase): + """--ignore-server-cert / --security-mode are record-only edits; passing them alone + should not touch the DAG/config lookup, mirroring the --scrollback early-return.""" + + def _mock_record(self, record_type='pamMachine', protocol='rdp'): + rec = mock.MagicMock(spec=vault.TypedRecord) + rec.record_uid = 'rec-uid' + rec.record_type = record_type + rec.version = 3 + rec.fields = [] + rec.custom = [] + ps_field = mock.MagicMock() + ps_field.value = [{'connection': {'protocol': protocol}}] + rec.get_typed_field.side_effect = lambda name: ps_field if name == 'pamSettings' else ( + mock.MagicMock(value=['seed']) if name == 'trafficEncryptionSeed' else None + ) + return rec + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_ignore_server_cert_alone_skips_dag(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec = self._mock_record() + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', ignore_server_cert='on') + mock_get_config_uid.assert_not_called() + mock_tdag.assert_not_called() + mock_update.assert_called_once() + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') + @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', + return_value=(b'st', b'tk', b'tr')) + @mock.patch('keepercommander.commands.tunnel_and_connections.get_config_uid') + @mock.patch('keepercommander.commands.tunnel_and_connections.TunnelDAG') + def test_security_mode_alone_skips_dag(self, mock_tdag, mock_get_config_uid, + mock_tokens, mock_sync, mock_update, mock_resolve): + rec = self._mock_record() + mock_resolve.return_value = rec + cmd = PAMConnectionEditCommand() + cmd.execute(mock.MagicMock(), record='rec', security_mode='tls') + mock_get_config_uid.assert_not_called() + mock_tdag.assert_not_called() + mock_update.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_pam_import_security_roundtrip.py b/unit-tests/pam/test_pam_import_security_roundtrip.py new file mode 100644 index 000000000..a87e15e8a --- /dev/null +++ b/unit-tests/pam/test_pam_import_security_roundtrip.py @@ -0,0 +1,123 @@ +""" +Round-trip unit tests for RDP `security` mode and cert-ignore fields across the +three connection types that carry them: RDP (security + ignoreCert), Kubernetes +(ignoreCert), and RBI/HTTP (ignoreInitialSslCert). + +Each test loads a `pam project import` YAML/JSON-shaped connection dict via +`ConnectionSettings*.load()` and re-serializes it via `to_record_dict()`, asserting +the on-record JSON keys match what Web Vault / Commander expect +(field-data-pam-settings.ts): `security`, `ignoreCert`, `ignoreInitialSslCert`. +""" + +import unittest + +skip_tests = False +skip_reason = "" +try: + from keepercommander.commands.pam_import.base import ( + ConnectionSettingsRDP, ConnectionSettingsKubernetes, ConnectionSettingsHTTP, RDPSecurity, + ) +except ImportError as e: + skip_tests = True + skip_reason = f"Cannot import pam_import.base: {e}" + + +@unittest.skipIf(skip_tests, skip_reason) +class TestRDPSecurityRoundTrip(unittest.TestCase): + def test_security_and_ignore_cert_round_trip(self): + for mode in ('any', 'nla', 'tls', 'vmconnect', 'rdp'): + with self.subTest(mode=mode): + obj = ConnectionSettingsRDP.load({ + 'protocol': 'rdp', + 'port': '3389', + 'security': mode, + 'ignore_server_cert': True, + }) + self.assertEqual(obj.security, RDPSecurity.map(mode)) + self.assertTrue(obj.ignoreCert) + record_dict = obj.to_record_dict() + self.assertEqual(record_dict['security'], mode) + self.assertEqual(record_dict['ignoreCert'], True) + + def test_ignore_cert_false_round_trip(self): + obj = ConnectionSettingsRDP.load({'protocol': 'rdp', 'ignore_server_cert': False}) + self.assertFalse(obj.ignoreCert) + record_dict = obj.to_record_dict() + self.assertEqual(record_dict['ignoreCert'], False) + + def test_security_absent_not_written(self): + obj = ConnectionSettingsRDP.load({'protocol': 'rdp'}) + self.assertIsNone(obj.security) + record_dict = obj.to_record_dict() + self.assertNotIn('security', record_dict) + self.assertNotIn('ignoreCert', record_dict) + + def test_invalid_security_mode_maps_to_none(self): + obj = ConnectionSettingsRDP.load({'protocol': 'rdp', 'security': 'bogus'}) + self.assertIsNone(obj.security) + record_dict = obj.to_record_dict() + self.assertNotIn('security', record_dict) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestKubernetesIgnoreCertRoundTrip(unittest.TestCase): + def test_ignore_cert_round_trip(self): + obj = ConnectionSettingsKubernetes.load({ + 'protocol': 'kubernetes', + 'ignore_server_cert': True, + 'ca_certificate': 'ca-data', + 'client_certificate': 'client-cert-data', + 'client_key': 'client-key-data', + 'namespace': 'prod', + }) + self.assertTrue(obj.ignoreCert) + record_dict = obj.to_record_dict() + self.assertEqual(record_dict['ignoreCert'], True) + self.assertEqual(record_dict['caCert'], 'ca-data') + self.assertEqual(record_dict['clientCert'], 'client-cert-data') + self.assertEqual(record_dict['clientKey'], 'client-key-data') + self.assertEqual(record_dict['namespace'], 'prod') + + def test_ignore_cert_false_round_trip(self): + obj = ConnectionSettingsKubernetes.load({'protocol': 'kubernetes', 'ignore_server_cert': False}) + self.assertFalse(obj.ignoreCert) + record_dict = obj.to_record_dict() + self.assertEqual(record_dict['ignoreCert'], False) + + def test_ignore_cert_absent_not_written(self): + obj = ConnectionSettingsKubernetes.load({'protocol': 'kubernetes'}) + self.assertIsNone(obj.ignoreCert) + record_dict = obj.to_record_dict() + self.assertNotIn('ignoreCert', record_dict) + + def test_no_security_field_on_kubernetes(self): + """Kubernetes has no security-mode concept (RDP-only).""" + self.assertFalse(hasattr(ConnectionSettingsKubernetes(), 'security')) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestRBIIgnoreInitialSslCertRoundTrip(unittest.TestCase): + def test_ignore_server_cert_maps_to_ignore_initial_ssl_cert(self): + """RBI/HTTP uses the same YAML key (ignore_server_cert) as RDP/K8s, but the + on-record field name differs: ignoreInitialSslCert, not ignoreCert.""" + obj = ConnectionSettingsHTTP.load({'protocol': 'http', 'ignore_server_cert': True}) + self.assertTrue(obj.ignoreInitialSslCert) + record_dict = obj.to_record_dict() + self.assertEqual(record_dict['ignoreInitialSslCert'], True) + self.assertNotIn('ignoreCert', record_dict) + + def test_ignore_server_cert_false_round_trip(self): + obj = ConnectionSettingsHTTP.load({'protocol': 'http', 'ignore_server_cert': False}) + self.assertFalse(obj.ignoreInitialSslCert) + record_dict = obj.to_record_dict() + self.assertEqual(record_dict['ignoreInitialSslCert'], False) + + def test_ignore_server_cert_absent_not_written(self): + obj = ConnectionSettingsHTTP.load({'protocol': 'http'}) + self.assertIsNone(obj.ignoreInitialSslCert) + record_dict = obj.to_record_dict() + self.assertNotIn('ignoreInitialSslCert', record_dict) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_pam_launch_database_settings.py b/unit-tests/pam/test_pam_launch_database_settings.py new file mode 100644 index 000000000..51035e5a7 --- /dev/null +++ b/unit-tests/pam/test_pam_launch_database_settings.py @@ -0,0 +1,51 @@ +""" +Unit tests for `_extract_database_settings` in pam_launch/terminal_connection.py. + +Record JSON stores the default DB name as `database` (not `defaultDatabase`). +`useSSL` lives on the pamDatabase record checkbox for rotation/proxy — not on +pamSettings.connection. +""" + +import unittest + +skip_tests = False +skip_reason = "" +try: + from keepercommander.commands.pam_launch.terminal_connection import _extract_database_settings +except ImportError as e: + skip_tests = True + skip_reason = f"Cannot import terminal_connection: {e}" + + +@unittest.skipIf(skip_tests, skip_reason) +class TestExtractDatabaseSettings(unittest.TestCase): + def test_reads_record_shaped_fields(self): + connection = { + 'database': 'mydb', + 'disableCsvExport': True, + 'disableCsvImport': True, + } + result = _extract_database_settings(connection) + self.assertEqual(result['defaultDatabase'], 'mydb') + self.assertTrue(result['disableCsvExport']) + self.assertTrue(result['disableCsvImport']) + + def test_legacy_default_database_key_is_ignored(self): + connection = {'defaultDatabase': 'wrong-key'} + result = _extract_database_settings(connection) + self.assertEqual(result['defaultDatabase'], '') + + def test_connection_use_ssl_is_ignored(self): + connection = {'database': 'mydb', 'useSSL': True} + result = _extract_database_settings(connection) + self.assertNotIn('useSSL', result) + + def test_defaults_when_fields_absent(self): + result = _extract_database_settings({}) + self.assertEqual(result['defaultDatabase'], '') + self.assertFalse(result['disableCsvExport']) + self.assertFalse(result['disableCsvImport']) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_pam_launch_kubernetes_settings.py b/unit-tests/pam/test_pam_launch_kubernetes_settings.py new file mode 100644 index 000000000..d81f38abd --- /dev/null +++ b/unit-tests/pam/test_pam_launch_kubernetes_settings.py @@ -0,0 +1,67 @@ +""" +Unit tests for `_extract_kubernetes_settings` in pam_launch/terminal_connection.py. + +The record (and Web Vault) store Kubernetes cert fields as `ignoreCert` / `caCert` / +`clientCert` (matching `pam_import/base.py` ConnectionSettingsKubernetes and +field-data-pam-settings.ts). This extractor previously read the wrong, non-existent +keys (`ignoreServerCertificate` / `caCertificate` / `clientCertificate`), so cert-ignore +and custom CA/client certs never reached the guacd connection for Kubernetes launches. +""" + +import unittest + +skip_tests = False +skip_reason = "" +try: + from keepercommander.commands.pam_launch.terminal_connection import _extract_kubernetes_settings +except ImportError as e: + skip_tests = True + skip_reason = f"Cannot import terminal_connection: {e}" + + +@unittest.skipIf(skip_tests, skip_reason) +class TestExtractKubernetesSettings(unittest.TestCase): + def test_reads_record_shaped_cert_fields(self): + connection = { + 'namespace': 'prod', + 'pod': 'my-pod', + 'container': 'my-container', + 'useSSL': True, + 'ignoreCert': True, + 'caCert': 'ca-cert-data', + 'clientCert': 'client-cert-data', + 'clientKey': 'client-key-data', + } + result = _extract_kubernetes_settings(connection) + self.assertTrue(result['useSSL']) + self.assertEqual(result['ignoreServerCertificate'], True) + self.assertEqual(result['caCertificate'], 'ca-cert-data') + self.assertEqual(result['clientCertificate'], 'client-cert-data') + self.assertEqual(result['clientKey'], 'client-key-data') + + def test_legacy_expanded_keys_are_ignored(self): + """Confirms the fix: the old (wrong) key names are no longer read.""" + connection = { + 'ignoreServerCertificate': True, + 'caCertificate': 'legacy-ca', + 'clientCertificate': 'legacy-client-cert', + } + result = _extract_kubernetes_settings(connection) + self.assertEqual(result['ignoreServerCertificate'], False) + self.assertEqual(result['caCertificate'], '') + self.assertEqual(result['clientCertificate'], '') + + def test_defaults_when_fields_absent(self): + result = _extract_kubernetes_settings({}) + self.assertEqual(result['namespace'], 'default') + self.assertEqual(result['pod'], '') + self.assertEqual(result['container'], '') + self.assertFalse(result['useSSL']) + self.assertEqual(result['ignoreServerCertificate'], False) + self.assertEqual(result['caCertificate'], '') + self.assertEqual(result['clientCertificate'], '') + self.assertEqual(result['clientKey'], '') + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_pam_launch_ssh_settings.py b/unit-tests/pam/test_pam_launch_ssh_settings.py new file mode 100644 index 000000000..ca2bb6048 --- /dev/null +++ b/unit-tests/pam/test_pam_launch_ssh_settings.py @@ -0,0 +1,54 @@ +""" +Unit tests for `_extract_ssh_settings` in pam_launch/terminal_connection.py. + +Record JSON (Web Vault / pam_import) stores SSH fields as `hostKey`, `command`, and +`sftp.enableSftp`. The extractor maps them to protocol_specific names consumed by +`_build_guacamole_connection_settings` (host-key, command, enable-sftp). +""" + +import unittest + +skip_tests = False +skip_reason = "" +try: + from keepercommander.commands.pam_launch.terminal_connection import _extract_ssh_settings +except ImportError as e: + skip_tests = True + skip_reason = f"Cannot import terminal_connection: {e}" + + +@unittest.skipIf(skip_tests, skip_reason) +class TestExtractSshSettings(unittest.TestCase): + def test_reads_record_shaped_fields(self): + connection = { + 'hostKey': 'AAAAhostkey', + 'command': '/bin/bash -l', + 'sftp': {'enableSftp': True, 'sftpRootDirectory': '/uploads'}, + } + result = _extract_ssh_settings(connection) + self.assertEqual(result['publicHostKey'], 'AAAAhostkey') + self.assertEqual(result['executeCommand'], '/bin/bash -l') + self.assertTrue(result['sftpEnabled']) + self.assertEqual(result['sftpRootDirectory'], '/uploads') + + def test_legacy_wrong_keys_are_ignored(self): + connection = { + 'publicHostKey': 'legacy-key', + 'executeCommand': 'legacy-cmd', + 'sftpEnabled': True, + } + result = _extract_ssh_settings(connection) + self.assertEqual(result['publicHostKey'], '') + self.assertEqual(result['executeCommand'], '') + self.assertFalse(result['sftpEnabled']) + + def test_defaults_when_fields_absent(self): + result = _extract_ssh_settings({}) + self.assertEqual(result['publicHostKey'], '') + self.assertEqual(result['executeCommand'], '') + self.assertFalse(result['sftpEnabled']) + self.assertEqual(result['sftpRootDirectory'], '') + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_pam_launch_telnet_settings.py b/unit-tests/pam/test_pam_launch_telnet_settings.py new file mode 100644 index 000000000..5016598d4 --- /dev/null +++ b/unit-tests/pam/test_pam_launch_telnet_settings.py @@ -0,0 +1,40 @@ +""" +Unit tests for `_extract_telnet_settings` in pam_launch/terminal_connection.py. +""" + +import unittest + +skip_tests = False +skip_reason = "" +try: + from keepercommander.commands.pam_launch.terminal_connection import _extract_telnet_settings +except ImportError as e: + skip_tests = True + skip_reason = f"Cannot import terminal_connection: {e}" + + +@unittest.skipIf(skip_tests, skip_reason) +class TestExtractTelnetSettings(unittest.TestCase): + def test_reads_all_regex_fields(self): + connection = { + 'usernameRegex': 'User:', + 'passwordRegex': 'Pass:', + 'loginSuccessRegex': 'Welcome', + 'loginFailureRegex': 'Denied', + } + result = _extract_telnet_settings(connection) + self.assertEqual(result['usernameRegex'], 'User:') + self.assertEqual(result['passwordRegex'], 'Pass:') + self.assertEqual(result['loginSuccessRegex'], 'Welcome') + self.assertEqual(result['loginFailureRegex'], 'Denied') + + def test_defaults_when_fields_absent(self): + result = _extract_telnet_settings({}) + self.assertEqual(result['usernameRegex'], '') + self.assertEqual(result['passwordRegex'], '') + self.assertEqual(result['loginSuccessRegex'], '') + self.assertEqual(result['loginFailureRegex'], '') + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_pam_rotation.py b/unit-tests/pam/test_pam_rotation.py index 09b96d6db..73220880b 100644 --- a/unit-tests/pam/test_pam_rotation.py +++ b/unit-tests/pam/test_pam_rotation.py @@ -56,7 +56,11 @@ def create_mock_params(): from keepercommander import crypto, utils from keepercommander.commands.discoveryrotation import (PAMCreateRecordRotationCommand, PAMListRecordRotationCommand, - PAMGatewayListCommand, PAMRouterGetRotationInfo) + PAMGatewayListCommand, PAMRouterGetRotationInfo, + resolve_record_schedule_data, schedule_from_pam_config, + resolve_record_rotation_revision, + refresh_vault_for_schedule_config, + uses_default_rotation_schedule) class TestPAMCreateRecordRotationCommand(unittest.TestCase): @@ -622,6 +626,29 @@ def test_json_online_status(self, mock_rrg, mock_schedules): self.assertEqual(data['gateway_name'], 'gw-test') self.assertIn('schedule_type', data) self.assertEqual(data['schedule_type'], 'scheduled') + self.assertIn('use_default_rotation_schedule', data) + self.assertFalse(data['use_default_rotation_schedule']) + + @patch('keepercommander.commands.discoveryrotation.uses_default_rotation_schedule', return_value=True) + @patch('keepercommander.commands.discoveryrotation.router_get_rotation_schedules') + @patch('keepercommander.commands.discoveryrotation.record_rotation_get') + def test_json_includes_use_default_rotation_schedule(self, mock_rrg, mock_schedules, _mock_default): + from keeper_secrets_manager_core.utils import url_safe_str_to_bytes + record_uid = 'test_record_uid_' + record_uid_bytes = url_safe_str_to_bytes(record_uid) + + mock_rrg.return_value = self._make_rri('RRS_ONLINE') + sched_mock = MagicMock() + sched_mock.schedules = [self._make_schedule(record_uid_bytes)] + mock_schedules.return_value = sched_mock + + mock_params = create_mock_params() + mock_params.record_cache = {} + + cmd = PAMRouterGetRotationInfo() + result = cmd.execute(mock_params, record_uid=record_uid, format='json') + data = json.loads(result) + self.assertTrue(data['use_default_rotation_schedule']) @patch('keepercommander.commands.discoveryrotation.router_get_rotation_schedules') @patch('keepercommander.commands.discoveryrotation.record_rotation_get') @@ -640,6 +667,8 @@ def test_json_non_online_status(self, mock_rrg, mock_schedules): data = json.loads(result) self.assertIn('status', data) self.assertFalse(data['ready_to_rotate']) + self.assertIn('use_default_rotation_schedule', data) + self.assertFalse(data['use_default_rotation_schedule']) @patch('keepercommander.commands.discoveryrotation.router_get_rotation_schedules') @patch('keepercommander.commands.discoveryrotation.record_rotation_get') @@ -662,3 +691,320 @@ def test_table_mode_returns_none(self, mock_rrg, mock_schedules): result = cmd.execute(mock_params, record_uid=record_uid, format='table') self.assertIsNone(result) + +class TestUsesDefaultRotationSchedule(unittest.TestCase): + + DEFAULT_SCHEDULE = [{"type": "CRON", "cron": "0 0 3 ? * 2", "tz": "Etc/UTC"}] + CUSTOM_SCHEDULE = [{"type": "CRON", "cron": "0 0 4 ? * 3", "tz": "Etc/UTC"}] + CONFIG_UID = 'config_uid_____' + RECORD_UID = 'test_record_uid_' + + def _params_with_rotation(self, schedule_json): + params = create_mock_params() + params.record_rotation_cache = { + self.RECORD_UID: { + 'schedule': schedule_json, + 'configuration_uid': self.CONFIG_UID, + } + } + config = MagicMock(spec=vault.TypedRecord) + field = MagicMock() + field.value = self.DEFAULT_SCHEDULE + config.get_typed_field.return_value = field + params.record_cache = { + self.CONFIG_UID: { + 'record_uid': self.CONFIG_UID, + 'version': 6, + 'data_unencrypted': json.dumps({'type': 'pamMachineConfiguration', 'title': 'cfg'}), + 'record_key_unencrypted': b'key', + } + } + return params, config + + @patch('keepercommander.commands.discoveryrotation.vault.KeeperRecord.load') + def test_true_when_schedule_matches_config_default(self, mock_load): + params, config = self._params_with_rotation(json.dumps(self.DEFAULT_SCHEDULE)) + mock_load.return_value = config + self.assertTrue(uses_default_rotation_schedule(params, self.RECORD_UID, self.CONFIG_UID)) + + @patch('keepercommander.commands.discoveryrotation.vault.KeeperRecord.load') + def test_false_when_schedule_differs(self, mock_load): + params, config = self._params_with_rotation(json.dumps(self.CUSTOM_SCHEDULE)) + mock_load.return_value = config + self.assertFalse(uses_default_rotation_schedule(params, self.RECORD_UID, self.CONFIG_UID)) + + @patch('keepercommander.commands.discoveryrotation.vault.KeeperRecord.load') + def test_false_when_on_demand_empty_schedule(self, mock_load): + params, config = self._params_with_rotation('') + mock_load.return_value = config + self.assertFalse(uses_default_rotation_schedule(params, self.RECORD_UID, self.CONFIG_UID)) + + @patch('keepercommander.commands.discoveryrotation.vault.KeeperRecord.load') + def test_false_when_config_has_no_default_schedule(self, mock_load): + params = create_mock_params() + params.record_rotation_cache = { + self.RECORD_UID: {'schedule': json.dumps(self.DEFAULT_SCHEDULE)}, + } + config = MagicMock(spec=vault.TypedRecord) + config.get_typed_field.return_value = None + mock_load.return_value = config + self.assertFalse(uses_default_rotation_schedule(params, self.RECORD_UID, self.CONFIG_UID)) + + +class TestResolveRecordScheduleData(unittest.TestCase): + + DEFAULT_SCHEDULE = [{"type": "DAILY", "utcTime": "03:00", "intervalCount": 1, "tz": "Etc/UTC"}] + EXISTING_SCHEDULE = [{"type": "WEEKLY", "utcTime": "12:00", "weekday": "MONDAY", "intervalCount": 1, "tz": "Etc/UTC"}] + + def _pam_config_with_schedule(self, schedule=None): + config = MagicMock(spec=vault.TypedRecord) + field = MagicMock() + field.value = schedule if schedule is not None else self.DEFAULT_SCHEDULE + config.get_typed_field.return_value = field + return config + + def test_explicit_schedule_data_takes_precedence(self): + explicit = self.EXISTING_SCHEDULE + current = {'schedule': json.dumps(self.DEFAULT_SCHEDULE)} + result = resolve_record_schedule_data(explicit, current, True, self._pam_config_with_schedule()) + self.assertEqual(result, explicit) + + def test_schedule_config_overrides_existing_on_demand(self): + current = {'schedule': ''} + config = self._pam_config_with_schedule() + result = resolve_record_schedule_data(None, current, True, config) + self.assertEqual(result, self.DEFAULT_SCHEDULE) + + def test_preserves_existing_schedule_without_schedule_config(self): + current = {'schedule': json.dumps(self.EXISTING_SCHEDULE)} + config = self._pam_config_with_schedule() + result = resolve_record_schedule_data(None, current, False, config) + self.assertEqual(result, self.EXISTING_SCHEDULE) + + def test_new_record_without_schedule_config_uses_pam_config_default(self): + config = self._pam_config_with_schedule() + result = resolve_record_schedule_data(None, None, False, config) + self.assertEqual(result, self.DEFAULT_SCHEDULE) + + def test_schedule_from_pam_config_ignores_on_demand_string(self): + config = self._pam_config_with_schedule(schedule=['On-Demand']) + self.assertIsNone(schedule_from_pam_config(config)) + + def test_schedule_from_pam_config_returns_full_schedule_array(self): + multi = self.DEFAULT_SCHEDULE + [{"type": "CRON", "cron": "0 0 * * *", "tz": "Etc/UTC"}] + config = self._pam_config_with_schedule(schedule=multi) + self.assertEqual(schedule_from_pam_config(config), multi) + + +class TestRefreshVaultForScheduleConfig(unittest.TestCase): + + def test_syncs_vault(self): + params = MagicMock() + with patch('keepercommander.commands.discoveryrotation.api.sync_down') as sync_down: + refresh_vault_for_schedule_config(params) + sync_down.assert_called_once_with(params) + + +class TestResolveRecordRotationRevision(unittest.TestCase): + + def test_returns_cached_revision_without_sync(self): + params = MagicMock() + params.record_rotation_cache = {'record_uid': {'revision': 42}} + with patch('keepercommander.commands.discoveryrotation.api.sync_down') as sync_down: + revision = resolve_record_rotation_revision(params, 'record_uid') + self.assertEqual(revision, 42) + sync_down.assert_not_called() + + def test_syncs_when_revision_missing_from_cache(self): + params = MagicMock() + params.record_rotation_cache = {} + + def _sync(p): + params.record_rotation_cache['record_uid'] = {'revision': 17} + + with patch('keepercommander.commands.discoveryrotation.api.sync_down', side_effect=_sync): + revision = resolve_record_rotation_revision(params, 'record_uid') + self.assertEqual(revision, 17) + + def test_returns_zero_when_no_rotation_after_sync(self): + params = MagicMock() + params.record_rotation_cache = {} + with patch('keepercommander.commands.discoveryrotation.api.sync_down'): + revision = resolve_record_rotation_revision(params, 'record_uid') + self.assertEqual(revision, 0) + + +USER_UID = 'AAAAAAAAAAAAAAAAAAAAAA' +CONFIG_UID = 'BBBBBBBBBBBBBBBBBBBBBB' +CRON_SCHEDULE_JSON = '{"type":"CRON","cron":"0 0 3 ? * 2","tz":"Etc/UTC"}' + + +class TestResolveRecordRotationRevision(unittest.TestCase): + + def test_returns_cached_revision_without_sync(self): + params = MagicMock() + params.record_rotation_cache = {USER_UID: {'revision': 42}} + with patch('keepercommander.commands.discoveryrotation.api.sync_down') as sync_down: + revision = resolve_record_rotation_revision(params, USER_UID) + self.assertEqual(revision, 42) + sync_down.assert_not_called() + + def test_syncs_when_revision_missing_from_cache(self): + params = MagicMock() + params.record_rotation_cache = {} + + def _sync(p): + params.record_rotation_cache[USER_UID] = {'revision': 17} + + with patch('keepercommander.commands.discoveryrotation.api.sync_down', side_effect=_sync): + revision = resolve_record_rotation_revision(params, USER_UID) + self.assertEqual(revision, 17) + + def test_returns_zero_when_no_rotation_after_sync(self): + params = MagicMock() + params.record_rotation_cache = {} + with patch('keepercommander.commands.discoveryrotation.api.sync_down'): + revision = resolve_record_rotation_revision(params, USER_UID) + self.assertEqual(revision, 0) + + +class TestIamUserRotationScheduleOnCreate(unittest.TestCase): + + def setUp(self): + self.command = PAMCreateRecordRotationCommand() + + @patch('keepercommander.commands.discoveryrotation.router_set_record_rotation_information') + @patch('keepercommander.commands.discoveryrotation.dump_report_data') + @patch('keepercommander.commands.discoveryrotation.api.sync_down') + @patch('keepercommander.vault_extensions.find_records') + @patch('keepercommander.vault.KeeperRecord.load') + @patch('keepercommander.commands.discoveryrotation.get_keeper_tokens') + @patch('keepercommander.commands.discoveryrotation.TunnelDAG') + @patch('keepercommander.rest_api.SERVER_PUBLIC_KEYS', {8: ec.generate_private_key(ec.SECP256R1()).public_key()}) + def test_iam_user_initial_create_applies_schedule_with_synced_revision( + self, mock_tunnel_dag, mock_get_keeper_tokens, mock_load, mock_find_records, + mock_sync_down, mock_dump_report, mock_set_rotation): + mock_params = MagicMock() + mock_params.rest_context.server_key_id = 8 + mock_params.session_token = 'base64_encoded_session_token' + mock_params.record_cache = {USER_UID: MagicMock()} + mock_params.record_rotation_cache = {} + mock_params.rest_context.server_base = 'https://fake.keepersecurity.com' + + mock_user = MagicMock(spec=vault.TypedRecord) + mock_user.record_type = 'pamUser' + mock_user.record_uid = USER_UID + mock_user.title = 'IAM User' + mock_user.record_key = b'\x00' * 16 + mock_user.get_typed_field.return_value = None + + mock_config = MagicMock(spec=vault.TypedRecord) + mock_config.record_uid = CONFIG_UID + mock_config.version = 6 + mock_config.get_typed_field.return_value = None + + mock_load.return_value = mock_user + mock_find_records.return_value = [mock_config] + mock_get_keeper_tokens.return_value = (b'token', b'encrypted_key', b'transmission_key') + + mock_dag = mock_tunnel_dag.return_value + mock_dag.linking_dag.has_graph = True + mock_dag.user_belongs_to_config.return_value = False + mock_dag.get_resource_uid.return_value = None + mock_dag.record.record_uid = CONFIG_UID + + def _sync_down(params): + params.record_rotation_cache[USER_UID] = { + 'revision': 1, + 'configuration_uid': CONFIG_UID, + 'schedule': '', + 'pwd_complexity': '', + } + + mock_sync_down.side_effect = _sync_down + + kwargs = { + 'record_name': USER_UID, + 'rotation_profile': 'iam_user', + 'config': CONFIG_UID, + 'enable': True, + 'force': True, + 'schedule_json_data': [CRON_SCHEDULE_JSON], + } + + self.command.execute(mock_params, **kwargs) + + mock_dag.link_user_to_config.assert_called_once_with(USER_UID) + mock_sync_down.assert_called_once_with(mock_params) + mock_set_rotation.assert_called_once() + rq = mock_set_rotation.call_args[0][1] + self.assertEqual(rq.revision, 1) + self.assertIn('CRON', rq.schedule) + self.assertEqual(json.loads(rq.schedule)[0]['cron'], '0 0 3 ? * 2') + self.assertEqual(rq.resourceUid, b'') + self.assertFalse(rq.noop) + + @patch('keepercommander.commands.discoveryrotation.router_set_record_rotation_information') + @patch('keepercommander.commands.discoveryrotation.dump_report_data') + @patch('keepercommander.commands.discoveryrotation.api.sync_down') + @patch('keepercommander.vault_extensions.find_records') + @patch('keepercommander.vault.KeeperRecord.load') + @patch('keepercommander.commands.discoveryrotation.get_keeper_tokens') + @patch('keepercommander.commands.discoveryrotation.TunnelDAG') + @patch('keepercommander.rest_api.SERVER_PUBLIC_KEYS', {8: ec.generate_private_key(ec.SECP256R1()).public_key()}) + def test_iam_user_already_linked_skips_link_but_still_applies_schedule( + self, mock_tunnel_dag, mock_get_keeper_tokens, mock_load, mock_find_records, + mock_sync_down, mock_dump_report, mock_set_rotation): + mock_params = MagicMock() + mock_params.rest_context.server_key_id = 8 + mock_params.session_token = 'base64_encoded_session_token' + mock_params.record_cache = {USER_UID: MagicMock()} + mock_params.record_rotation_cache = { + USER_UID: { + 'revision': 3, + 'configuration_uid': CONFIG_UID, + 'schedule': '', + 'pwd_complexity': '', + } + } + mock_params.rest_context.server_base = 'https://fake.keepersecurity.com' + + mock_user = MagicMock(spec=vault.TypedRecord) + mock_user.record_type = 'pamUser' + mock_user.record_uid = USER_UID + mock_user.title = 'IAM User' + mock_user.record_key = b'\x00' * 16 + mock_user.get_typed_field.return_value = None + + mock_config = MagicMock(spec=vault.TypedRecord) + mock_config.record_uid = CONFIG_UID + mock_config.version = 6 + mock_config.get_typed_field.return_value = None + + mock_load.return_value = mock_user + mock_find_records.return_value = [mock_config] + mock_get_keeper_tokens.return_value = (b'token', b'encrypted_key', b'transmission_key') + + mock_dag = mock_tunnel_dag.return_value + mock_dag.linking_dag.has_graph = True + mock_dag.user_belongs_to_config.return_value = True + mock_dag.record.record_uid = CONFIG_UID + + kwargs = { + 'record_name': USER_UID, + 'rotation_profile': 'iam_user', + 'config': CONFIG_UID, + 'enable': True, + 'force': True, + 'schedule_json_data': [CRON_SCHEDULE_JSON], + } + + self.command.execute(mock_params, **kwargs) + + mock_dag.link_user_to_config.assert_not_called() + mock_sync_down.assert_not_called() + mock_set_rotation.assert_called_once() + rq = mock_set_rotation.call_args[0][1] + self.assertEqual(rq.revision, 3) + self.assertIn('CRON', rq.schedule) + diff --git a/unit-tests/pam/test_set_launch_credentials.py b/unit-tests/pam/test_set_launch_credentials.py index 76c832ba8..9103ac9b5 100644 --- a/unit-tests/pam/test_set_launch_credentials.py +++ b/unit-tests/pam/test_set_launch_credentials.py @@ -7,7 +7,7 @@ | Case | configure_resource sent? | connectUsers.uids | meta has version=1? | local link_user(belongs_to=True) follow-up? | | --------------------------------- | ------------------------ | ----------------------- | ------------------- | ------------------------------------------- | -| set, Layer-B enabled, success | yes | [launch_uid bytes] | yes | yes (preserve legacy parity) | +| set, Layer-B enabled, success | yes | [launch_uid bytes] | yes | yes (belongs_to + is_launch_credential) | | clear, Layer-B enabled, success | yes | [] | yes | no (no launch user to mark) | | set, Layer-B feature-disabled | no | n/a | n/a | legacy fallback (link+clear+meta) | | set, Layer-B RRC_NOT_ALLOWED + FB | yes (then fall back) | n/a | n/a | legacy fallback | @@ -104,11 +104,13 @@ def _capture(params, rq): assert len(rq.connectUsers.uids[0]) == 16 # decoded launch_uid meta = json.loads(rq.meta.decode()) assert meta['version'] == RESOURCE_META_VERSION_V1 - # belongs_to=True follow-up fires only for SET path. + # Follow-up preserves krouter flags (KC-1330). tdag.link_user.assert_called_once() args, kwargs = tdag.link_user.call_args assert args[0] == LAUNCH_UID assert kwargs.get('belongs_to') is True + assert kwargs.get('is_launch_credential') is True + assert 'is_admin' not in kwargs # No legacy fallback paths invoked. tdag.clear_launch_credential_for_resource.assert_not_called() tdag.link_user_to_resource.assert_not_called() @@ -188,9 +190,8 @@ def _capture(params, rq): def test_set_path_with_admin_sends_admin_uid_alongside_connect_users(): - """Admin + launch in one configure_resource: connectUsers carries the launch - uid AND adminUid is set (so krouter flips is_admin even on an existing edge), - with admin NOT in connectUsers (so it does not also become a launch cred).""" + """Admin + launch (different users): connectUsers carries the launch uid AND + adminUid is set; follow-up preserves is_launch_credential on the launch edge.""" tdag, _ = _make_tdag(initial_meta={'allowedSettings': {'connections': True}}) captured = {} @@ -212,8 +213,38 @@ def _capture(params, rq): meta = json.loads(rq.meta.decode()) assert meta['version'] == RESOURCE_META_VERSION_V1 assert meta['allowedSettings'] == {} - # belongs_to follow-up fires for the launch user only. tdag.link_user.assert_called_once() + _, kwargs = tdag.link_user.call_args + assert kwargs.get('belongs_to') is True + assert kwargs.get('is_launch_credential') is True + assert 'is_admin' not in kwargs + + +def test_set_path_same_admin_and_launch_preserves_both_flags_in_follow_up(): + """Same pamUser for admin + launch: krouter sets both flags; follow-up must + not clobber is_admin (KC-1330).""" + tdag, _ = _make_tdag(initial_meta={'allowedSettings': {'connections': True}}) + + captured = {} + + def _capture(params, rq): + captured['rq'] = rq + + with patch('keepercommander.commands.pam._layer_b.is_layer_b_feature_disabled', return_value=False), \ + patch('keepercommander.commands.pam.router_helper.get_router_url', return_value='krouter.test'), \ + patch('keepercommander.commands.pam.router_helper.router_configure_resource', + side_effect=_capture): + tdag.set_launch_credentials(RESOURCE_UID, launch_uid=LAUNCH_UID, admin_uid=LAUNCH_UID) + + rq = captured['rq'] + assert len(rq.connectUsers.uids) == 1 + assert bytes(rq.adminUid) == bytes(rq.connectUsers.uids[0]) + tdag.link_user.assert_called_once() + args, kwargs = tdag.link_user.call_args + assert args[0] == LAUNCH_UID + assert kwargs.get('belongs_to') is True + assert kwargs.get('is_launch_credential') is True + assert kwargs.get('is_admin') is True def test_admin_only_sends_admin_uid_with_empty_connect_users(): diff --git a/unit-tests/test_command_record.py b/unit-tests/test_command_record.py index e326e2abe..03de045fc 100644 --- a/unit-tests/test_command_record.py +++ b/unit-tests/test_command_record.py @@ -304,6 +304,25 @@ def test_get_shared_folder_uid(self): cmd.execute(params, uid=shared_folder_uid) cmd.execute(params, format='json', uid=shared_folder_uid) + def test_get_user_folder_json_consistent_by_name_and_uid(self): + params = get_synced_params() + cmd = record.RecordGetUidCommand() + user_folder = next( + x for x in params.folder_cache.values() + if x.type == 'user_folder') + captured = [] + with mock.patch('builtins.print', side_effect=captured.append): + cmd.execute(params, uid=user_folder.uid, format='json') + by_uid = json.loads(captured[-1]) + cmd.execute(params, uid=user_folder.name, format='json') + by_name = json.loads(captured[-1]) + self.assertEqual(by_name, by_uid) + self.assertEqual(by_uid['type'], 'classic_folder') + self.assertIn('path', by_uid) + self.assertIn('parent_uid', by_uid) + self.assertIn('folder', by_uid) + self.assertIn('records', by_uid) + def test_get_team_uid(self): params = get_synced_params() cmd = record.RecordGetUidCommand() diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index a9e784ae5..301afc3ed 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -231,7 +231,7 @@ def setUp(self): def tearDown(self): mock.patch.stopall() - @patch('keepercommander.nested_share_folder.folder_api.create_folder_v3') + @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') def test_mkdir(self, mock_create): from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand mock_create.return_value = { @@ -243,6 +243,88 @@ def test_mkdir(self, mock_create): cmd.execute(_make_params(), folder='NewFolder') mock_create.assert_called_once() + @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') + def test_mkdir_resolves_parent_uid_in_path(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand + parent_uid = 'tY6D-RanxY252zzBY_xU4A' + child_uid = utils.generate_uid() + mock_create.return_value = { + 'folder_uid': child_uid, 'status': 'SUCCESS', + 'message': '', 'success': True, + } + parent_fuid, parent_fobj = _make_folder( + folder_uid=parent_uid, name='Real Parent') + cmd = NestedShareFolderMkdirCommand() + with mock.patch('builtins.print'): + cmd.execute( + _make_params(nested_share_folders={parent_uid: parent_fobj}), + folder=f'{parent_uid}/My Child Folder', + ) + mock_create.assert_called_once_with( + params=mock.ANY, + folder_name='My Child Folder', + parent_uid=parent_uid, + color=None, + inherit_permissions=True, + ) + + @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') + def test_mkdir_resolves_parent_name_in_path(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand + parent_fuid, parent_fobj = _make_folder(name='Engineering') + child_uid = utils.generate_uid() + mock_create.return_value = { + 'folder_uid': child_uid, 'status': 'SUCCESS', + 'message': '', 'success': True, + } + cmd = NestedShareFolderMkdirCommand() + with mock.patch('builtins.print'): + cmd.execute( + _make_params(nested_share_folders={parent_fuid: parent_fobj}), + folder='Engineering/New Folder KD 1 June', + ) + mock_create.assert_called_once_with( + params=mock.ANY, + folder_name='New Folder KD 1 June', + parent_uid=parent_fuid, + color=None, + inherit_permissions=True, + ) + + @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') + def test_mkdir_creates_intermediate_name_segments(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand + eng_uid = utils.generate_uid() + child_uid = utils.generate_uid() + mock_create.side_effect = [ + { + 'folder_uid': eng_uid, 'status': 'SUCCESS', + 'message': '', 'success': True, + }, + { + 'folder_uid': child_uid, 'status': 'SUCCESS', + 'message': '', 'success': True, + }, + ] + cmd = NestedShareFolderMkdirCommand() + with mock.patch('builtins.print'): + cmd.execute(_make_params(), folder='Engineering/New Folder KD 1 June') + self.assertEqual(mock_create.call_count, 2) + mock_create.assert_any_call( + params=mock.ANY, + folder_name='Engineering', + parent_uid=None, + color=None, + inherit_permissions=True, + ) + mock_create.assert_any_call( + params=mock.ANY, + folder_name='New Folder KD 1 June', + parent_uid=eng_uid, + color=None, + inherit_permissions=True, + ) + @patch('keepercommander.nested_share_folder.folder_api.update_folder_v3') def test_update_folder(self, mock_update): from keepercommander.commands.nested_share_folder import NestedShareFolderUpdateCommand diff --git a/unit-tests/test_ssl_verify.py b/unit-tests/test_ssl_verify.py new file mode 100644 index 000000000..f5b0c81bd --- /dev/null +++ b/unit-tests/test_ssl_verify.py @@ -0,0 +1,59 @@ +import os +import tempfile +import unittest + +from keepercommander import utils +from keepercommander.params import KeeperParams + + +def _reset_ssl_cert_cache(): + utils._cached_ssl_cert_file = utils._SSL_CERT_UNSET + + +class TestSslVerify(unittest.TestCase): + def setUp(self): + self._saved_env = { + 'VERIFY_SSL': os.environ.get('VERIFY_SSL'), + 'KEEPER_SSL_CERT_FILE': os.environ.get('KEEPER_SSL_CERT_FILE'), + } + _reset_ssl_cert_cache() + # PAM DAG tests set VERIFY_SSL=false without always restoring it. + os.environ['VERIFY_SSL'] = 'TRUE' + os.environ.pop('KEEPER_SSL_CERT_FILE', None) + + def tearDown(self): + for key, value in self._saved_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + _reset_ssl_cert_cache() + + def test_keeper_ssl_cert_file_none_disables_http_verify(self): + os.environ['KEEPER_SSL_CERT_FILE'] = 'none' + params = KeeperParams() + self.assertFalse(params.ssl_verify) + + def test_verify_ssl_false_legacy_fallback(self): + os.environ['VERIFY_SSL'] = 'FALSE' + self.assertFalse(utils.resolve_ssl_verify()) + + def test_config_certificate_check_false_disables_verify(self): + params = KeeperParams() + params.rest_context.certificate_check = False + self.assertFalse(params.ssl_verify) + + def test_keeper_ssl_cert_file_custom_path_via_ssl_verify(self): + with tempfile.NamedTemporaryFile(suffix='.pem', delete=False) as cert_file: + cert_file.write(b'fake-ca') + cert_path = cert_file.name + try: + os.environ['KEEPER_SSL_CERT_FILE'] = cert_path + params = KeeperParams() + self.assertEqual(params.ssl_verify, cert_path) + finally: + os.unlink(cert_path) + + +if __name__ == '__main__': + unittest.main()