From 3436314a10b168dfe5b37ac333d3418e0550a4b6 Mon Sep 17 00:00:00 2001 From: msawczynk <146010367+msawczynk@users.noreply.github.com> Date: Thu, 31 Jul 2025 03:38:01 +0100 Subject: [PATCH] Update discoveryrotation.py --- keepercommander/commands/discoveryrotation.py | 163 +++++++++++++++++- 1 file changed, 158 insertions(+), 5 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 5c852037f..45ad8bfce 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -291,6 +291,8 @@ class PAMCreateRecordRotationCommand(Command): action='store_true', help='Schedule On Demand') schedule_group.add_argument('--schedule-config', '-sf', required=False, dest='schedule_config', action='store_true', help='Schedule from Configuration') + parser.add_argument('--schedule-only', '-so', dest='schedule_only', action='store_true', + help='Only update the rotation schedule without changing other settings') parser.add_argument('--complexity', '-x', required=False, dest='pwd_complexity', action='store', help='Password complexity: length, upper, lower, digits, symbols. Ex. 32,5,5,5,5[,SPECIAL CHARS]') parser.add_argument('--admin-user', '-a', required=False, dest='admin', action='store', @@ -303,6 +305,14 @@ def get_parser(self): return PAMCreateRecordRotationCommand.parser def execute(self, params, **kwargs): + """Configure rotation settings for one or multiple PAM records. + + The command accepts either ``--record`` or ``--folder`` to target + records. It validates schedule options, password complexity and + resource linkage and then submits rotation requests to the Keeper + PAM router service. + """ + record_uids = set() # type: Set[str] folder_uids = set() @@ -496,6 +506,73 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): _dag.print_tunneling_config(target_record.record_uid, config_uid=target_config_uid) def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + schedule_only = kwargs.get('schedule_only') + + # Handle schedule-only operations first to avoid unnecessary resource validation + if schedule_only: + if kwargs.get('folder_name') and (not current_record_rotation or current_record_rotation.get('disabled')): + skipped_records.append([target_record.record_uid, target_record.title, + 'Rotation not enabled', 'Skipped']) + return + 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 = 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 = [] + pwd_complexity_rule_list_encrypted = utils.base64_url_decode(current_record_rotation.get('pwd_complexity', '')) + record_resource_uid = current_record_rotation.get('resourceUid') + 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, + record_resource_uid, schedule, complexity]) + + # Check if we have NOOP rotation for schedule-only operations + noop_rotation = str(kwargs.get('noop', False) or False).upper() == 'TRUE' + if target_record and not noop_rotation: # check from record data + noop_field = target_record.get_typed_field('text', 'NOOP') + if (noop_field and noop_field.value and + isinstance(noop_field.value, list) and + str(noop_field.value[0]).upper() == 'TRUE'): + noop_rotation = True + + rq = router_pb2.RouterRecordRotationRequest() + rq.revision = current_record_rotation.get('revision', 0) + 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'' + rq.schedule = json.dumps(record_schedule_data) if record_schedule_data else '' + rq.pwdComplexity = pwd_complexity_rule_list_encrypted + rq.disabled = disabled + if noop_rotation: + rq.noop = True + rq.resourceUid = b'' + r_requests.append(rq) + return + if _dag and not _dag.linking_dag.has_graph: _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_iam_aad_config_uid) if not _dag or not _dag.linking_dag.has_graph: @@ -519,7 +596,6 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): _dag.link_user_to_config(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 @@ -633,7 +709,74 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): r_requests.append(rq) def config_user(_dag, target_record, target_resource_uid, target_config_uid=None, silent=None): - # NOOP rotation + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + schedule_only = kwargs.get('schedule_only') + + # Handle schedule-only operations first to avoid unnecessary resource validation + if schedule_only: + if kwargs.get('folder_name') and (not current_record_rotation or current_record_rotation.get('disabled')): + skipped_records.append([target_record.record_uid, target_record.title, + 'Rotation not enabled', 'Skipped']) + return + 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 = 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 = [] + pwd_complexity_rule_list_encrypted = utils.base64_url_decode(current_record_rotation.get('pwd_complexity', '')) + record_resource_uid = current_record_rotation.get('resourceUid') + 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, + record_resource_uid, schedule, complexity]) + + # Check if we have NOOP rotation for schedule-only operations + noop_rotation = str(kwargs.get('noop', False) or False).upper() == 'TRUE' + if target_record and not noop_rotation: # check from record data + noop_field = target_record.get_typed_field('text', 'NOOP') + if (noop_field and noop_field.value and + isinstance(noop_field.value, list) and + str(noop_field.value[0]).upper() == 'TRUE'): + noop_rotation = True + + rq = router_pb2.RouterRecordRotationRequest() + rq.revision = current_record_rotation.get('revision', 0) + 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'' + rq.schedule = json.dumps(record_schedule_data) if record_schedule_data else '' + rq.pwdComplexity = pwd_complexity_rule_list_encrypted + rq.disabled = disabled + if noop_rotation: + rq.noop = True + rq.resourceUid = b'' + r_requests.append(rq) + return + + # NOOP rotation (for non-schedule-only operations) noop_rotation = str(kwargs.get('noop', False) or False).upper() == 'TRUE' if target_record and not noop_rotation: # check from record data noop_field = target_record.get_typed_field('text', 'NOOP') @@ -690,9 +833,19 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None # Get the resource configuration from DAG resource_uids = _dag.get_all_owners(target_record.record_uid) if len(resource_uids) > 1: - raise CommandError('', f'{bcolors.FAIL}Record "{target_record.record_uid}" is ' - f'associated with multiple resources so you must supply ' - f'{bcolors.OKBLUE}"--resource/-rs RESOURCE".{bcolors.ENDC}') + # When processing folders, skip records with multiple resources + if folder_name: + skipped_records.append([ + target_record.record_uid, + target_record.title, + 'Multiple Resources', + f'Record is associated with {len(resource_uids)} resources. Use --record with --resource to configure individually.' + ]) + return + else: + raise CommandError('', f'{bcolors.FAIL}Record "{target_record.record_uid}" is ' + f'associated with multiple resources so you must supply ' + f'{bcolors.OKBLUE}"--resource/-rs RESOURCE".{bcolors.ENDC}') elif len(resource_uids) == 0: raise CommandError('', f'{bcolors.FAIL}Record "{target_record.record_uid}" is not associated with'