From 7aab005dad32e66bef26100cad8a9ecc2b5adaf7 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Fri, 14 Aug 2026 13:58:33 -0700 Subject: [PATCH 01/14] Updated krouter endpoint changes for GitHub USS configuration. Regenerated from proto. Fixed bug where pamGitHubConfiguration was not recognized as a PAM configuration type. Added tests. --- keepercommander/commands/discoveryrotation.py | 17 +- .../commands/discoveryrotation_v1.py | 14 +- .../commands/universalsecretsync.py | 193 +++++++++++++- keepercommander/proto/pam_pb2.py | 142 ++++++----- keepercommander/proto/pam_pb2.pyi | 119 ++++++++- unit-tests/pam/test_pam_nsf_config.py | 107 ++++++++ unit-tests/pam/test_uss_github_config.py | 239 ++++++++++++++++++ 7 files changed, 747 insertions(+), 84 deletions(-) create mode 100644 unit-tests/pam/test_uss_github_config.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 028693d2f..29a2797d7 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -2566,6 +2566,7 @@ class PamConfigurationEditMixin(RecordEditMixin): PAM_CONFIG_RECORD_TYPES = frozenset({ 'pamAwsConfiguration', 'pamAzureConfiguration', 'pamGcpConfiguration', 'pamDomainConfiguration', 'pamNetworkConfiguration', 'pamOciConfiguration', + 'pamGitHubConfiguration', }) PAM_RESOURCE_RECORD_TYPES = frozenset({ 'pamDatabase', 'pamDirectory', 'pamMachine', 'pamRemoteBrowser', @@ -2758,7 +2759,7 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va extra_properties.append(f'text.pamGitHubId={github_id}') personal_access_token = kwargs.get('personal_access_token') if personal_access_token: - extra_properties.append(f'secret.personalAccessToken={personal_access_token}') + extra_properties.append(f'secret.pamGitHubPersonalAccessToken={personal_access_token}') github_base_url = kwargs.get('github_base_url') if github_base_url: extra_properties.append(f'text.pamGitHubBaseUrl={github_base_url}') @@ -2870,7 +2871,8 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va # Fields that the backend previously required but now treats as optional for pamAzureConfiguration. AZURE_OPTIONAL_FIELDS = frozenset({'clientId', 'clientSecret'}) - def verify_required(self, record): # type: (vault.TypedRecord) -> None + def verify_required(self, record, command=''): # type: (vault.TypedRecord, str) -> None + missing_fields = [] for field in record.fields: if field.required: if len(field.value) == 0: @@ -2882,10 +2884,15 @@ def verify_required(self, record): # type: (vault.TypedRecord) -> None and field.label in self.AZURE_OPTIONAL_FIELDS): pass else: - self.warnings.append(f'Empty required field: "{field.get_field_name()}"') + missing_fields.append(field.get_field_name()) for custom in record.custom: if custom.required: custom.required = False + if missing_fields: + if len(missing_fields) == 1: + raise CommandError(command, f'Empty required field: "{missing_fields[0]}"') + fields_text = ', '.join(f'"{x}"' for x in missing_fields) + raise CommandError(command, f'Empty required fields: {fields_text}') class PAMConfigurationNewCommand(Command, PamConfigurationEditMixin): @@ -2986,7 +2993,7 @@ def execute(self, params, **kwargs): if not gateway_uid: logging.warning(f'Gateway "{gw_name}" not found.') - self.verify_required(record) + self.verify_required(record, command='pam-config-new') create_pam_configuration_in_folder(params, record, shared_folder_uid, command='pam-config-new') @@ -3119,7 +3126,7 @@ def execute(self, params, **kwargs): orig_admin_cred_ref = value.get('adminCredentialRef') or '' self.parse_properties(params, configuration, config_edit=True, **kwargs) - self.verify_required(configuration) + self.verify_required(configuration, command='pam-config-edit') update_pam_record(params, configuration, command='pam-config-edit') diff --git a/keepercommander/commands/discoveryrotation_v1.py b/keepercommander/commands/discoveryrotation_v1.py index cadd5f0c9..8d2d21502 100644 --- a/keepercommander/commands/discoveryrotation_v1.py +++ b/keepercommander/commands/discoveryrotation_v1.py @@ -970,7 +970,8 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va if extra_properties: self.assign_typed_fields(record, [RecordEditMixin.parse_field(x) for x in extra_properties]) - def verify_required(self, record): # type: (vault.TypedRecord) -> None + def verify_required(self, record, command=''): # type: (vault.TypedRecord, str) -> None + missing_fields = [] for field in record.fields: if field.required: if len(field.value) == 0: @@ -981,10 +982,15 @@ def verify_required(self, record): # type: (vault.TypedRecord) -> None 'tz': 'Etc/UTC', }] else: - self.warnings.append(f'Empty required field: "{field.get_field_name()}"') + missing_fields.append(field.get_field_name()) for custom in record.custom: if custom.required: custom.required = False + if missing_fields: + if len(missing_fields) == 1: + raise CommandError(command, f'Empty required field: "{missing_fields[0]}"') + fields_text = ', '.join(f'"{x}"' for x in missing_fields) + raise CommandError(command, f'Empty required fields: {fields_text}') class PAMConfigurationNewCommand(Command, PamConfigurationEditMixin): @@ -1037,7 +1043,7 @@ def execute(self, params, **kwargs): if not shared_folder_uid: raise CommandError('pam-config-new', '--shared_folder parameter is required to create a PAM configuration') - self.verify_required(record) + self.verify_required(record, command='pam-config-new') pam_configuration_create_record_v6(params, record, shared_folder_uid) @@ -1126,7 +1132,7 @@ def execute(self, params, **kwargs): orig_shared_folder_uid = value.get('folderUid') or '' self.parse_properties(params, configuration, **kwargs) - self.verify_required(configuration) + self.verify_required(configuration, command='pam-config-edit') record_management.update_record(params, configuration) diff --git a/keepercommander/commands/universalsecretsync.py b/keepercommander/commands/universalsecretsync.py index 3dddfacfb..5bbda109e 100644 --- a/keepercommander/commands/universalsecretsync.py +++ b/keepercommander/commands/universalsecretsync.py @@ -78,7 +78,8 @@ def print_uss_configurations_list(params, format_type='table'): configurations = list(vault_extensions.find_records(params, record_version=6)) # Only process these specific configuration types - uss_supported_types = ('pamGcpConfiguration', 'pamAzureConfiguration', 'pamAwsConfiguration') + uss_supported_types = ('pamGcpConfiguration', 'pamAzureConfiguration', 'pamAwsConfiguration', + 'pamGitHubConfiguration') configs_data = [] for record in configurations: @@ -111,6 +112,9 @@ def print_uss_configurations_list(params, format_type='table'): # Get the configuration data config_data = universal_sync_edge.content_as_dict or {} + # GitHub-specific fields are nested under the 'github' key. + github_data = config_data.get('github') or {} + # Decrypt vault_name if present. The router stores it under the # 'vaultName' key as a base64-url string of the encrypted bytes. vault_name = 'N/A' @@ -143,6 +147,44 @@ def print_uss_configurations_list(params, format_type='table'): logging.debug(f"Failed to decrypt sync_identity for record {record.record_uid}: {e}") sync_identity = 'N/A' + # Decrypt owner if present. The router stores it under the + # 'github.owner' key as a base64-url string of the encrypted bytes. + owner = 'N/A' + owner_encrypted = github_data.get('owner') + if owner_encrypted: + try: + owner_bytes = crypto.decrypt_aes_v2( + utils.base64_url_decode(owner_encrypted), record.record_key) + owner = owner_bytes.decode('utf-8') + except Exception as e: + logging.debug(f"Failed to decrypt owner for record {record.record_uid}: {e}") + owner = 'N/A' + + # scope/organizationVisibility are stored unencrypted (plain enum values) + scope_value = github_data.get('scope') + try: + scope_str = pam_pb2.GitHubScope.Name(scope_value) if scope_value is not None else 'N/A' + except ValueError: + scope_str = 'N/A' + + org_visibility_value = github_data.get('organizationVisibility') + try: + org_visibility_str = pam_pb2.GitHubOrganizationVisibility.Name(org_visibility_value) \ + if org_visibility_value is not None else 'N/A' + except ValueError: + org_visibility_str = 'N/A' + + # Decrypt GitHub repository names if present. The router stores them under + # the 'github.repos' key as a list of base64-url strings of the encrypted bytes. + repo_names = [] + for repo_encrypted in github_data.get('repos', []): + try: + repo_bytes = crypto.decrypt_aes_v2( + utils.base64_url_decode(repo_encrypted), record.record_key) + repo_names.append(repo_bytes.decode('utf-8')) + except Exception as e: + logging.debug(f"Failed to decrypt repo name for record {record.record_uid}: {e}") + # Get folder UIDs - folders connected via universal_sync_folder (simple count only) folder_count = 0 for vertex in root.has_vertices(): @@ -160,6 +202,10 @@ def print_uss_configurations_list(params, format_type='table'): 'folder_count': folder_count, 'vault_name': vault_name, 'sync_identity': sync_identity, + 'scope': scope_str, + 'owner': owner, + 'organization_visibility': org_visibility_str, + 'repos': repo_names, }) except Exception as e: # Skip records that fail to load or don't have USS config @@ -180,13 +226,16 @@ def print_uss_configurations_list(params, format_type='table'): # Display as simple summary table table = [] - headers = ['Network UID', 'Title', 'Type', 'Enabled', 'Dry Run', 'Folders', 'Vault Name', 'Sync Identity'] + headers = ['Network UID', 'Title', 'Type', 'Enabled', 'Dry Run', 'Folders', 'Vault Name', 'Sync Identity', + 'Scope', 'Owner', 'Org Visibility', 'Repos'] for config in configs_data: enabled_str = f"{bcolors.OKGREEN}Yes{bcolors.ENDC}" if config['enabled'] else f"{bcolors.FAIL}No{bcolors.ENDC}" dry_run_str = f"{bcolors.WARNING}Yes{bcolors.ENDC}" if config['dry_run_enabled'] else "No" folder_count = config.get('folder_count', 0) folders_str = f"{folder_count} folder(s)" if folder_count > 0 else "None" + repos = config.get('repos', []) + repos_str = f"{len(repos)} repo(s)" if repos else "None" row = [ config['record_uid'], @@ -196,7 +245,11 @@ def print_uss_configurations_list(params, format_type='table'): dry_run_str, folders_str, config['vault_name'], - config['sync_identity'] + config['sync_identity'], + config.get('scope', 'N/A'), + config.get('owner', 'N/A'), + config.get('organization_visibility', 'N/A'), + repos_str ] table.append(row) @@ -219,7 +272,8 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): raise CommandError('', f'{bcolors.FAIL}Network "{network_uid}" not found{bcolors.ENDC}') # Check if it's a supported USS configuration type - uss_supported_types = ('pamGcpConfiguration', 'pamAzureConfiguration', 'pamAwsConfiguration') + uss_supported_types = ('pamGcpConfiguration', 'pamAzureConfiguration', 'pamAwsConfiguration', + 'pamGitHubConfiguration') if not isinstance(network, vault.TypedRecord) or network.record_type not in uss_supported_types: if format_type == 'json': return json.dumps({"error": f'Record "{network_uid}" is not a USS configuration'}) @@ -253,6 +307,9 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): # Get the configuration data config_data = universal_sync_edge.content_as_dict or {} + # GitHub-specific fields are nested under the 'github' key. + github_data = config_data.get('github') or {} + # Decrypt vault_name if present. The router stores it under the # 'vaultName' key as a base64-url string of the encrypted bytes. vault_name = 'N/A' @@ -285,6 +342,44 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): logging.debug(f"Failed to decrypt sync_identity for network {network.record_uid}: {e}") sync_identity = 'N/A' + # Decrypt owner if present. The router stores it under the + # 'github.owner' key as a base64-url string of the encrypted bytes. + owner = 'N/A' + owner_encrypted = github_data.get('owner') + if owner_encrypted: + try: + owner_bytes = crypto.decrypt_aes_v2( + utils.base64_url_decode(owner_encrypted), network.record_key) + owner = owner_bytes.decode('utf-8') + except Exception as e: + logging.debug(f"Failed to decrypt owner for network {network.record_uid}: {e}") + owner = 'N/A' + + # scope/organizationVisibility are stored unencrypted (plain enum values) + scope_value = github_data.get('scope') + try: + scope_str = pam_pb2.GitHubScope.Name(scope_value) if scope_value is not None else 'N/A' + except ValueError: + scope_str = 'N/A' + + org_visibility_value = github_data.get('organizationVisibility') + try: + org_visibility_str = pam_pb2.GitHubOrganizationVisibility.Name(org_visibility_value) \ + if org_visibility_value is not None else 'N/A' + except ValueError: + org_visibility_str = 'N/A' + + # Decrypt GitHub repository names if present. The router stores them under + # the 'github.repos' key as a list of base64-url strings of the encrypted bytes. + repo_names = [] + for repo_encrypted in github_data.get('repos', []): + try: + repo_bytes = crypto.decrypt_aes_v2( + utils.base64_url_decode(repo_encrypted), network.record_key) + repo_names.append(repo_bytes.decode('utf-8')) + except Exception as e: + logging.debug(f"Failed to decrypt repo name for network {network.record_uid}: {e}") + # Get folder vertices with sync status folder_vertices = [] for vertex in root.has_vertices(): @@ -339,6 +434,10 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): 'dry_run_enabled': config_data.get('dryRunEnabled', False), 'vault_name': vault_name, 'sync_identity': sync_identity, + 'scope': scope_str, + 'owner': owner, + 'organization_visibility': org_visibility_str, + 'repos': repo_names, 'folders': [] } @@ -370,6 +469,10 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): table.append(['Dry Run', 'Yes' if config_data.get('dryRunEnabled', False) else 'No']) table.append(['Vault Name', vault_name]) table.append(['Sync Identity', sync_identity]) + table.append(['Scope', scope_str]) + table.append(['Owner', owner]) + table.append(['Org Visibility', org_visibility_str]) + table.append(['Repos', ', '.join(repo_names) if repo_names else 'None']) table.append(['', '']) # Blank row separator # Display folder sync details @@ -430,6 +533,16 @@ class PAMUniversalSyncConfigAddCommand(Command): help='Identity record UID to use for syncing') parser.add_argument('--vault-name', '-vn', dest='vault_name', action='store', help='Vault name for universal sync') + parser.add_argument('--scope', '-sc', dest='scope', action='store', + choices=['repository', 'organization'], + help='GitHub sync scope: a single repository or an entire organization') + parser.add_argument('--owner', '-o', dest='owner', action='store', + help='GitHub organization or user name that owns the repositories') + parser.add_argument('--org-visibility', '-ov', dest='org_visibility', action='store', + choices=['all', 'private', 'selected'], + help='Repository visibility to sync when scope is organization') + parser.add_argument('--repo', '-r', dest='repo', action='append', + help='GitHub repository name to sync (can be specified multiple times; scope must be repository)') def get_parser(self): return PAMUniversalSyncConfigAddCommand.parser @@ -480,6 +593,29 @@ def execute(self, params, **kwargs): encrypted_vault_name = crypto.encrypt_aes_v2(vault_name_bytes, network.record_key) rq.vaultName = encrypted_vault_name + scope = kwargs.get('scope') + if scope is not None: + rq.github.scope = pam_pb2.ORGANIZATION if scope == 'organization' else pam_pb2.REPOSITORY + + owner = kwargs.get('owner') + if owner: + owner_bytes = string_to_bytes(owner) + encrypted_owner = crypto.encrypt_aes_v2(owner_bytes, network.record_key) + rq.github.owner = encrypted_owner + + org_visibility = kwargs.get('org_visibility') + if org_visibility is not None: + org_visibility_map = {'all': pam_pb2.ALL, 'private': pam_pb2.PRIVATE, 'selected': pam_pb2.SELECTED} + rq.github.organizationVisibility = org_visibility_map[org_visibility] + + repos = kwargs.get('repo') + if repos: + for repo_name in repos: + repo_bytes = string_to_bytes(repo_name) + repo_obj = pam_pb2.GitHubRepository() + repo_obj.name = crypto.encrypt_aes_v2(repo_bytes, network.record_key) + rq.github.repos.append(repo_obj) + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) try: @@ -505,6 +641,16 @@ class PAMUniversalSyncConfigEditCommand(Command): help='Identity record UID to use for syncing') parser.add_argument('--vault-name', '-vn', dest='vault_name', action='store', help='Vault name for universal sync') + parser.add_argument('--scope', '-sc', dest='scope', action='store', + choices=['repository', 'organization'], + help='GitHub sync scope: a single repository or an entire organization') + parser.add_argument('--owner', '-o', dest='owner', action='store', + help='GitHub organization or user name that owns the repositories') + parser.add_argument('--org-visibility', '-ov', dest='org_visibility', action='store', + choices=['all', 'private', 'selected'], + help='Repository visibility to sync when scope is organization') + parser.add_argument('--repo', '-r', dest='repo', action='append', + help='GitHub repository name to sync (can be specified multiple times; scope must be repository)') def get_parser(self): return PAMUniversalSyncConfigEditCommand.parser @@ -601,6 +747,45 @@ def execute(self, params, **kwargs): elif existing_config.get('vaultName'): rq.vaultName = utils.base64_url_decode(existing_config['vaultName']) + # GitHub-specific fields live under the nested 'github' object, both in the + # request message (rq.github) and in the existing DAG edge content. + existing_github = existing_config.get('github') or {} + + scope = kwargs.get('scope') + if scope is not None: + rq.github.scope = pam_pb2.ORGANIZATION if scope == 'organization' else pam_pb2.REPOSITORY + elif 'scope' in existing_github: + rq.github.scope = existing_github['scope'] + + owner = kwargs.get('owner') + if owner: + owner_bytes = string_to_bytes(owner) + encrypted_owner = crypto.encrypt_aes_v2(owner_bytes, network.record_key) + rq.github.owner = encrypted_owner + elif existing_github.get('owner'): + rq.github.owner = utils.base64_url_decode(existing_github['owner']) + + org_visibility = kwargs.get('org_visibility') + if org_visibility is not None: + org_visibility_map = {'all': pam_pb2.ALL, 'private': pam_pb2.PRIVATE, 'selected': pam_pb2.SELECTED} + rq.github.organizationVisibility = org_visibility_map[org_visibility] + elif 'organizationVisibility' in existing_github: + rq.github.organizationVisibility = existing_github['organizationVisibility'] + + # Handle repos - if provided, replace all; if not, keep existing (re-encrypted values carried forward as-is) + repos = kwargs.get('repo') + if repos: + for repo_name in repos: + repo_bytes = string_to_bytes(repo_name) + repo_obj = pam_pb2.GitHubRepository() + repo_obj.name = crypto.encrypt_aes_v2(repo_bytes, network.record_key) + rq.github.repos.append(repo_obj) + else: + for existing_repo in existing_github.get('repos', []): + repo_obj = pam_pb2.GitHubRepository() + repo_obj.name = utils.base64_url_decode(existing_repo) + rq.github.repos.append(repo_obj) + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) try: diff --git a/keepercommander/proto/pam_pb2.py b/keepercommander/proto/pam_pb2.py index 05f9d0a0c..b773f6c8e 100644 --- a/keepercommander/proto/pam_pb2.py +++ b/keepercommander/proto/pam_pb2.py @@ -26,7 +26,7 @@ from . import record_pb2 as record__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xff\x01\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\x89\x02\n\x1cPAMRecordingsForUsersRequest\x12\x11\n\tusernames\x18\x01 \x03(\t\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\x84\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettings\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"\xfc\x01\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultName\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"N\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xab\x02\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\x12\x18\n\x10maxInstanceCount\x18\x0b \x01(\x05\x12\x10\n\x08lastSeen\x18\x0c \x01(\x03\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\\\n\x10KturnAccessCreds\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\x0c\x12\x18\n\x10\x63\x65rt_fingerprint\x18\x03 \x01(\t\x12\x12\n\nexpires_at\x18\x04 \x01(\x04\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\x89\x02\n\x1cPAMRecordingsForUsersRequest\x12\x11\n\tusernames\x18\x01 \x03(\t\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"5\n\x0fPAMServiceNames\x12\x13\n\x0bresourceUid\x18\x01 \x01(\x0c\x12\r\n\x05names\x18\x02 \x01(\x0c\"\xb4\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x12\x1b\n\x0eupdateServices\x18\n \x01(\x08H\x08\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettingsB\x11\n\x0f_updateServices\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\" \n\x10GitHubRepository\x12\x0c\n\x04name\x18\x01 \x01(\x0c\"\xd6\x01\n\x0cGitHubConfig\x12$\n\x05scope\x18\x01 \x01(\x0e\x32\x10.PAM.GitHubScopeH\x00\x88\x01\x01\x12\r\n\x05owner\x18\x02 \x01(\x0c\x12\x46\n\x16organizationVisibility\x18\x03 \x01(\x0e\x32!.PAM.GitHubOrganizationVisibilityH\x01\x88\x01\x01\x12$\n\x05repos\x18\x04 \x03(\x0b\x32\x15.PAM.GitHubRepositoryB\x08\n\x06_scopeB\x19\n\x17_organizationVisibility\"\xaf\x02\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x12&\n\x06github\x18\x07 \x01(\x0b\x32\x11.PAM.GitHubConfigH\x04\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultNameB\t\n\x07_github\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"^\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\x12\x0e\n\x06\x61ppUid\x18\x04 \x01(\t\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry\"6\n\x1dSetNhiKsmEffectiveDateRequest\x12\x15\n\reffectiveDate\x18\x01 \x01(\x03\"L\n\x1eGetNhiKsmEffectiveDateResponse\x12\x15\n\reffectiveDate\x18\x01 \x01(\x03\x12\x13\n\x0b\x64\x65\x66\x61ultDate\x18\x02 \x01(\x03\"I\n\x1fPAMUniversalSyncPreCheckRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x12\n\nfolderUids\x18\x02 \x03(\x0c\"C\n\x1ePAMUniversalSyncPreCheckResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x0e\n\x06isUsed\x18\x02 \x01(\x08\"X\n PAMUniversalSyncPreCheckResponse\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.PAM.PAMUniversalSyncPreCheckResult*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*/\n\x0bGitHubScope\x12\x0e\n\nREPOSITORY\x10\x00\x12\x10\n\x0cORGANIZATION\x10\x01*B\n\x1cGitHubOrganizationVisibility\x12\x07\n\x03\x41LL\x10\x00\x12\x0b\n\x07PRIVATE\x10\x01\x12\x0c\n\x08SELECTED\x10\x02*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,20 +36,24 @@ _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\003PAM' _globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._loaded_options = None _globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._serialized_options = b'\030\001' - _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=5340 - _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=5498 - _globals['_PAMOPERATIONTYPE']._serialized_start=5500 - _globals['_PAMOPERATIONTYPE']._serialized_end=5564 - _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=5566 - _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=5678 - _globals['_CONTROLLERMESSAGETYPE']._serialized_start=5681 - _globals['_CONTROLLERMESSAGETYPE']._serialized_end=5882 - _globals['_PAMRECORDINGTYPE']._serialized_start=5884 - _globals['_PAMRECORDINGTYPE']._serialized_end=5970 - _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=5972 - _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=6077 - _globals['_NHICATEGORY']._serialized_start=6079 - _globals['_NHICATEGORY']._serialized_end=6175 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=6267 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=6425 + _globals['_PAMOPERATIONTYPE']._serialized_start=6427 + _globals['_PAMOPERATIONTYPE']._serialized_end=6491 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=6493 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=6605 + _globals['_CONTROLLERMESSAGETYPE']._serialized_start=6608 + _globals['_CONTROLLERMESSAGETYPE']._serialized_end=6809 + _globals['_PAMRECORDINGTYPE']._serialized_start=6811 + _globals['_PAMRECORDINGTYPE']._serialized_end=6897 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=6899 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=7004 + _globals['_GITHUBSCOPE']._serialized_start=7006 + _globals['_GITHUBSCOPE']._serialized_end=7053 + _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_start=7055 + _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_end=7121 + _globals['_NHICATEGORY']._serialized_start=7123 + _globals['_NHICATEGORY']._serialized_end=7219 _globals['_PAMROTATIONSCHEDULE']._serialized_start=51 _globals['_PAMROTATIONSCHEDULE']._serialized_end=182 _globals['_PAMROTATIONSCHEDULESRESPONSE']._serialized_start=184 @@ -91,49 +95,67 @@ _globals['_PAMCONFIGURATIONS']._serialized_start=1883 _globals['_PAMCONFIGURATIONS']._serialized_end=1949 _globals['_PAMCONTROLLER']._serialized_start=1952 - _globals['_PAMCONTROLLER']._serialized_end=2207 - _globals['_PAMSETMAXINSTANCECOUNTREQUEST']._serialized_start=2209 - _globals['_PAMSETMAXINSTANCECOUNTREQUEST']._serialized_end=2289 - _globals['_CONTROLLERRESPONSE']._serialized_start=2291 - _globals['_CONTROLLERRESPONSE']._serialized_end=2328 - _globals['_PAMCONFIGURATIONCONTROLLER']._serialized_start=2330 - _globals['_PAMCONFIGURATIONCONTROLLER']._serialized_end=2407 - _globals['_CONFIGURATIONADDREQUEST']._serialized_start=2410 - _globals['_CONFIGURATIONADDREQUEST']._serialized_end=2573 - _globals['_RELAYACCESSCREDS']._serialized_start=2575 - _globals['_RELAYACCESSCREDS']._serialized_end=2649 - _globals['_PAMRECORDINGSREQUEST']._serialized_start=2652 - _globals['_PAMRECORDINGSREQUEST']._serialized_end=2909 - _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_start=2912 - _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_end=3177 - _globals['_PAMRECORDING']._serialized_start=3180 - _globals['_PAMRECORDING']._serialized_end=3520 - _globals['_PAMRECORDINGSRESPONSE']._serialized_start=3522 - _globals['_PAMRECORDINGSRESPONSE']._serialized_end=3601 - _globals['_PAMDATA']._serialized_start=3603 - _globals['_PAMDATA']._serialized_end=3645 - _globals['_UIDLIST']._serialized_start=3647 - _globals['_UIDLIST']._serialized_end=3670 - _globals['_PAMRESOURCECONFIG']._serialized_start=3673 - _globals['_PAMRESOURCECONFIG']._serialized_end=4061 - _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_start=4063 - _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_end=4100 - _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=4103 - _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4355 - _globals['_NHIMETRICSREQUEST']._serialized_start=4357 - _globals['_NHIMETRICSREQUEST']._serialized_end=4412 - _globals['_PAMUSAGEBYUSER']._serialized_start=4415 - _globals['_PAMUSAGEBYUSER']._serialized_end=4699 - _globals['_NHIUSAGEBYUSER']._serialized_start=4701 - _globals['_NHIUSAGEBYUSER']._serialized_end=4813 - _globals['_NHIMETRICSRESPONSE']._serialized_start=4816 - _globals['_NHIMETRICSRESPONSE']._serialized_end=5076 - _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5078 - _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5146 - _globals['_NHIUIDENTRY']._serialized_start=5148 - _globals['_NHIUIDENTRY']._serialized_end=5226 - _globals['_GETNHIUIDSREQUEST']._serialized_start=5228 - _globals['_GETNHIUIDSREQUEST']._serialized_end=5283 - _globals['_GETNHIUIDSRESPONSE']._serialized_start=5285 - _globals['_GETNHIUIDSRESPONSE']._serialized_end=5337 + _globals['_PAMCONTROLLER']._serialized_end=2251 + _globals['_PAMSETMAXINSTANCECOUNTREQUEST']._serialized_start=2253 + _globals['_PAMSETMAXINSTANCECOUNTREQUEST']._serialized_end=2333 + _globals['_CONTROLLERRESPONSE']._serialized_start=2335 + _globals['_CONTROLLERRESPONSE']._serialized_end=2372 + _globals['_PAMCONFIGURATIONCONTROLLER']._serialized_start=2374 + _globals['_PAMCONFIGURATIONCONTROLLER']._serialized_end=2451 + _globals['_CONFIGURATIONADDREQUEST']._serialized_start=2454 + _globals['_CONFIGURATIONADDREQUEST']._serialized_end=2617 + _globals['_RELAYACCESSCREDS']._serialized_start=2619 + _globals['_RELAYACCESSCREDS']._serialized_end=2693 + _globals['_KTURNACCESSCREDS']._serialized_start=2695 + _globals['_KTURNACCESSCREDS']._serialized_end=2787 + _globals['_PAMRECORDINGSREQUEST']._serialized_start=2790 + _globals['_PAMRECORDINGSREQUEST']._serialized_end=3047 + _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_start=3050 + _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_end=3315 + _globals['_PAMRECORDING']._serialized_start=3318 + _globals['_PAMRECORDING']._serialized_end=3658 + _globals['_PAMRECORDINGSRESPONSE']._serialized_start=3660 + _globals['_PAMRECORDINGSRESPONSE']._serialized_end=3739 + _globals['_PAMDATA']._serialized_start=3741 + _globals['_PAMDATA']._serialized_end=3783 + _globals['_UIDLIST']._serialized_start=3785 + _globals['_UIDLIST']._serialized_end=3808 + _globals['_PAMSERVICENAMES']._serialized_start=3810 + _globals['_PAMSERVICENAMES']._serialized_end=3863 + _globals['_PAMRESOURCECONFIG']._serialized_start=3866 + _globals['_PAMRESOURCECONFIG']._serialized_end=4302 + _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_start=4304 + _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_end=4341 + _globals['_GITHUBREPOSITORY']._serialized_start=4343 + _globals['_GITHUBREPOSITORY']._serialized_end=4375 + _globals['_GITHUBCONFIG']._serialized_start=4378 + _globals['_GITHUBCONFIG']._serialized_end=4592 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=4595 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4898 + _globals['_NHIMETRICSREQUEST']._serialized_start=4900 + _globals['_NHIMETRICSREQUEST']._serialized_end=4955 + _globals['_PAMUSAGEBYUSER']._serialized_start=4958 + _globals['_PAMUSAGEBYUSER']._serialized_end=5242 + _globals['_NHIUSAGEBYUSER']._serialized_start=5244 + _globals['_NHIUSAGEBYUSER']._serialized_end=5356 + _globals['_NHIMETRICSRESPONSE']._serialized_start=5359 + _globals['_NHIMETRICSRESPONSE']._serialized_end=5619 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5621 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5689 + _globals['_NHIUIDENTRY']._serialized_start=5691 + _globals['_NHIUIDENTRY']._serialized_end=5785 + _globals['_GETNHIUIDSREQUEST']._serialized_start=5787 + _globals['_GETNHIUIDSREQUEST']._serialized_end=5842 + _globals['_GETNHIUIDSRESPONSE']._serialized_start=5844 + _globals['_GETNHIUIDSRESPONSE']._serialized_end=5896 + _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_start=5898 + _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_end=5952 + _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_start=5954 + _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_end=6030 + _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_start=6032 + _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_end=6105 + _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_start=6107 + _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_end=6174 + _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_start=6176 + _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_end=6264 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/pam_pb2.pyi b/keepercommander/proto/pam_pb2.pyi index b5a56975a..b83fdc735 100644 --- a/keepercommander/proto/pam_pb2.pyi +++ b/keepercommander/proto/pam_pb2.pyi @@ -4,7 +4,8 @@ from google.protobuf.internal import containers as _containers from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor @@ -63,6 +64,17 @@ class PAMRecordingRiskLevel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): PRR_HIGH: _ClassVar[PAMRecordingRiskLevel] PRR_CRITICAL: _ClassVar[PAMRecordingRiskLevel] +class GitHubScope(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + REPOSITORY: _ClassVar[GitHubScope] + ORGANIZATION: _ClassVar[GitHubScope] + +class GitHubOrganizationVisibility(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ALL: _ClassVar[GitHubOrganizationVisibility] + PRIVATE: _ClassVar[GitHubOrganizationVisibility] + SELECTED: _ClassVar[GitHubOrganizationVisibility] + class NhiCategory(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () NHI_CATEGORY_UNKNOWN: _ClassVar[NhiCategory] @@ -107,6 +119,11 @@ PRR_LOW: PAMRecordingRiskLevel PRR_MEDIUM: PAMRecordingRiskLevel PRR_HIGH: PAMRecordingRiskLevel PRR_CRITICAL: PAMRecordingRiskLevel +REPOSITORY: GitHubScope +ORGANIZATION: GitHubScope +ALL: GitHubOrganizationVisibility +PRIVATE: GitHubOrganizationVisibility +SELECTED: GitHubOrganizationVisibility NHI_CATEGORY_UNKNOWN: NhiCategory PAM_USER: NhiCategory PAM_RESOURCE: NhiCategory @@ -125,7 +142,7 @@ class PAMRotationSchedule(_message.Message): controllerUid: bytes scheduleData: str noSchedule: bool - def __init__(self, recordUid: _Optional[bytes] = ..., configurationUid: _Optional[bytes] = ..., controllerUid: _Optional[bytes] = ..., scheduleData: _Optional[str] = ..., noSchedule: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., configurationUid: _Optional[bytes] = ..., controllerUid: _Optional[bytes] = ..., scheduleData: _Optional[str] = ..., noSchedule: _Optional[bool] = ...) -> None: ... class PAMRotationSchedulesResponse(_message.Message): __slots__ = ("schedules",) @@ -304,7 +321,7 @@ class PAMConfigurations(_message.Message): def __init__(self, configurations: _Optional[_Iterable[_Union[PAMConfiguration, _Mapping]]] = ...) -> None: ... class PAMController(_message.Message): - __slots__ = ("controllerUid", "controllerName", "deviceToken", "deviceName", "nodeId", "created", "lastModified", "applicationUid", "appClientType", "isInitialized") + __slots__ = ("controllerUid", "controllerName", "deviceToken", "deviceName", "nodeId", "created", "lastModified", "applicationUid", "appClientType", "isInitialized", "maxInstanceCount", "lastSeen") CONTROLLERUID_FIELD_NUMBER: _ClassVar[int] CONTROLLERNAME_FIELD_NUMBER: _ClassVar[int] DEVICETOKEN_FIELD_NUMBER: _ClassVar[int] @@ -315,6 +332,8 @@ class PAMController(_message.Message): APPLICATIONUID_FIELD_NUMBER: _ClassVar[int] APPCLIENTTYPE_FIELD_NUMBER: _ClassVar[int] ISINITIALIZED_FIELD_NUMBER: _ClassVar[int] + MAXINSTANCECOUNT_FIELD_NUMBER: _ClassVar[int] + LASTSEEN_FIELD_NUMBER: _ClassVar[int] controllerUid: bytes controllerName: str deviceToken: str @@ -325,7 +344,9 @@ class PAMController(_message.Message): applicationUid: bytes appClientType: _enterprise_pb2.AppClientType isInitialized: bool - def __init__(self, controllerUid: _Optional[bytes] = ..., controllerName: _Optional[str] = ..., deviceToken: _Optional[str] = ..., deviceName: _Optional[str] = ..., nodeId: _Optional[int] = ..., created: _Optional[int] = ..., lastModified: _Optional[int] = ..., applicationUid: _Optional[bytes] = ..., appClientType: _Optional[_Union[_enterprise_pb2.AppClientType, str]] = ..., isInitialized: bool = ...) -> None: ... + maxInstanceCount: int + lastSeen: int + def __init__(self, controllerUid: _Optional[bytes] = ..., controllerName: _Optional[str] = ..., deviceToken: _Optional[str] = ..., deviceName: _Optional[str] = ..., nodeId: _Optional[int] = ..., created: _Optional[int] = ..., lastModified: _Optional[int] = ..., applicationUid: _Optional[bytes] = ..., appClientType: _Optional[_Union[_enterprise_pb2.AppClientType, str]] = ..., isInitialized: _Optional[bool] = ..., maxInstanceCount: _Optional[int] = ..., lastSeen: _Optional[int] = ...) -> None: ... class PAMSetMaxInstanceCountRequest(_message.Message): __slots__ = ("controllerUid", "maxInstanceCount") @@ -373,6 +394,18 @@ class RelayAccessCreds(_message.Message): serverTime: int def __init__(self, username: _Optional[str] = ..., password: _Optional[str] = ..., serverTime: _Optional[int] = ...) -> None: ... +class KturnAccessCreds(_message.Message): + __slots__ = ("url", "token", "cert_fingerprint", "expires_at") + URL_FIELD_NUMBER: _ClassVar[int] + TOKEN_FIELD_NUMBER: _ClassVar[int] + CERT_FINGERPRINT_FIELD_NUMBER: _ClassVar[int] + EXPIRES_AT_FIELD_NUMBER: _ClassVar[int] + url: str + token: bytes + cert_fingerprint: str + expires_at: int + def __init__(self, url: _Optional[str] = ..., token: _Optional[bytes] = ..., cert_fingerprint: _Optional[str] = ..., expires_at: _Optional[int] = ...) -> None: ... + class PAMRecordingsRequest(_message.Message): __slots__ = ("recordUid", "maxCount", "rangeStart", "rangeEnd", "types", "risks", "protocols", "closeReasons") RECORDUID_FIELD_NUMBER: _ClassVar[int] @@ -393,6 +426,26 @@ class PAMRecordingsRequest(_message.Message): closeReasons: _containers.RepeatedScalarFieldContainer[int] def __init__(self, recordUid: _Optional[bytes] = ..., maxCount: _Optional[int] = ..., rangeStart: _Optional[int] = ..., rangeEnd: _Optional[int] = ..., types: _Optional[_Iterable[_Union[PAMRecordingType, str]]] = ..., risks: _Optional[_Iterable[_Union[PAMRecordingRiskLevel, str]]] = ..., protocols: _Optional[_Iterable[str]] = ..., closeReasons: _Optional[_Iterable[int]] = ...) -> None: ... +class PAMRecordingsForUsersRequest(_message.Message): + __slots__ = ("usernames", "maxCount", "rangeStart", "rangeEnd", "types", "risks", "protocols", "closeReasons") + USERNAMES_FIELD_NUMBER: _ClassVar[int] + MAXCOUNT_FIELD_NUMBER: _ClassVar[int] + RANGESTART_FIELD_NUMBER: _ClassVar[int] + RANGEEND_FIELD_NUMBER: _ClassVar[int] + TYPES_FIELD_NUMBER: _ClassVar[int] + RISKS_FIELD_NUMBER: _ClassVar[int] + PROTOCOLS_FIELD_NUMBER: _ClassVar[int] + CLOSEREASONS_FIELD_NUMBER: _ClassVar[int] + usernames: _containers.RepeatedScalarFieldContainer[str] + maxCount: int + rangeStart: int + rangeEnd: int + types: _containers.RepeatedScalarFieldContainer[PAMRecordingType] + risks: _containers.RepeatedScalarFieldContainer[PAMRecordingRiskLevel] + protocols: _containers.RepeatedScalarFieldContainer[str] + closeReasons: _containers.RepeatedScalarFieldContainer[int] + def __init__(self, usernames: _Optional[_Iterable[str]] = ..., maxCount: _Optional[int] = ..., rangeStart: _Optional[int] = ..., rangeEnd: _Optional[int] = ..., types: _Optional[_Iterable[_Union[PAMRecordingType, str]]] = ..., risks: _Optional[_Iterable[_Union[PAMRecordingRiskLevel, str]]] = ..., protocols: _Optional[_Iterable[str]] = ..., closeReasons: _Optional[_Iterable[int]] = ...) -> None: ... + class PAMRecording(_message.Message): __slots__ = ("connectionUid", "recordingType", "recordUid", "userName", "startedOn", "length", "fileSize", "createdOn", "protocol", "closeReason", "recordingDuration", "aiOverallRiskLevel", "aiOverallSummary") CONNECTIONUID_FIELD_NUMBER: _ClassVar[int] @@ -429,7 +482,7 @@ class PAMRecordingsResponse(_message.Message): HASMORE_FIELD_NUMBER: _ClassVar[int] recordings: _containers.RepeatedCompositeFieldContainer[PAMRecording] hasMore: bool - def __init__(self, recordings: _Optional[_Iterable[_Union[PAMRecording, _Mapping]]] = ..., hasMore: bool = ...) -> None: ... + def __init__(self, recordings: _Optional[_Iterable[_Union[PAMRecording, _Mapping]]] = ..., hasMore: _Optional[bool] = ...) -> None: ... class PAMData(_message.Message): __slots__ = ("vertex", "content") @@ -445,6 +498,14 @@ class UidList(_message.Message): uids: _containers.RepeatedScalarFieldContainer[bytes] def __init__(self, uids: _Optional[_Iterable[bytes]] = ...) -> None: ... +class PAMServiceNames(_message.Message): + __slots__ = ("resourceUid", "names") + RESOURCEUID_FIELD_NUMBER: _ClassVar[int] + NAMES_FIELD_NUMBER: _ClassVar[int] + resourceUid: bytes + names: bytes + def __init__(self, resourceUid: _Optional[bytes] = ..., names: _Optional[bytes] = ...) -> None: ... + class PAMResourceConfig(_message.Message): __slots__ = ("recordUid", "networkUid", "adminUid", "meta", "connectionSettings", "connectUsers", "domainUid", "jitSettings", "keeperAiSettings", "updateServices") RECORDUID_FIELD_NUMBER: _ClassVar[int] @@ -467,7 +528,7 @@ class PAMResourceConfig(_message.Message): jitSettings: bytes keeperAiSettings: bytes updateServices: bool - def __init__(self, recordUid: _Optional[bytes] = ..., networkUid: _Optional[bytes] = ..., adminUid: _Optional[bytes] = ..., meta: _Optional[bytes] = ..., connectionSettings: _Optional[bytes] = ..., connectUsers: _Optional[_Union[UidList, _Mapping]] = ..., domainUid: _Optional[bytes] = ..., jitSettings: _Optional[bytes] = ..., keeperAiSettings: _Optional[bytes] = ..., updateServices: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., networkUid: _Optional[bytes] = ..., adminUid: _Optional[bytes] = ..., meta: _Optional[bytes] = ..., connectionSettings: _Optional[bytes] = ..., connectUsers: _Optional[_Union[UidList, _Mapping]] = ..., domainUid: _Optional[bytes] = ..., jitSettings: _Optional[bytes] = ..., keeperAiSettings: _Optional[bytes] = ..., updateServices: _Optional[bool] = ...) -> None: ... class PAMUniversalSyncFolder(_message.Message): __slots__ = ("uid",) @@ -475,21 +536,41 @@ class PAMUniversalSyncFolder(_message.Message): uid: bytes def __init__(self, uid: _Optional[bytes] = ...) -> None: ... +class GitHubRepository(_message.Message): + __slots__ = ("name",) + NAME_FIELD_NUMBER: _ClassVar[int] + name: bytes + def __init__(self, name: _Optional[bytes] = ...) -> None: ... + +class GitHubConfig(_message.Message): + __slots__ = ("scope", "owner", "organizationVisibility", "repos") + SCOPE_FIELD_NUMBER: _ClassVar[int] + OWNER_FIELD_NUMBER: _ClassVar[int] + ORGANIZATIONVISIBILITY_FIELD_NUMBER: _ClassVar[int] + REPOS_FIELD_NUMBER: _ClassVar[int] + scope: GitHubScope + owner: bytes + organizationVisibility: GitHubOrganizationVisibility + repos: _containers.RepeatedCompositeFieldContainer[GitHubRepository] + def __init__(self, scope: _Optional[_Union[GitHubScope, str]] = ..., owner: _Optional[bytes] = ..., organizationVisibility: _Optional[_Union[GitHubOrganizationVisibility, str]] = ..., repos: _Optional[_Iterable[_Union[GitHubRepository, _Mapping]]] = ...) -> None: ... + class PAMUniversalSyncConfig(_message.Message): - __slots__ = ("networkUid", "enabled", "dryRunEnabled", "folders", "syncIdentity", "vaultName") + __slots__ = ("networkUid", "enabled", "dryRunEnabled", "folders", "syncIdentity", "vaultName", "github") NETWORKUID_FIELD_NUMBER: _ClassVar[int] ENABLED_FIELD_NUMBER: _ClassVar[int] DRYRUNENABLED_FIELD_NUMBER: _ClassVar[int] FOLDERS_FIELD_NUMBER: _ClassVar[int] SYNCIDENTITY_FIELD_NUMBER: _ClassVar[int] VAULTNAME_FIELD_NUMBER: _ClassVar[int] + GITHUB_FIELD_NUMBER: _ClassVar[int] networkUid: bytes enabled: bool dryRunEnabled: bool folders: _containers.RepeatedCompositeFieldContainer[PAMUniversalSyncFolder] syncIdentity: bytes vaultName: bytes - def __init__(self, networkUid: _Optional[bytes] = ..., enabled: bool = ..., dryRunEnabled: bool = ..., folders: _Optional[_Iterable[_Union[PAMUniversalSyncFolder, _Mapping]]] = ..., syncIdentity: _Optional[bytes] = ..., vaultName: _Optional[bytes] = ...) -> None: ... + github: GitHubConfig + def __init__(self, networkUid: _Optional[bytes] = ..., enabled: _Optional[bool] = ..., dryRunEnabled: _Optional[bool] = ..., folders: _Optional[_Iterable[_Union[PAMUniversalSyncFolder, _Mapping]]] = ..., syncIdentity: _Optional[bytes] = ..., vaultName: _Optional[bytes] = ..., github: _Optional[_Union[GitHubConfig, _Mapping]] = ...) -> None: ... class NhiMetricsRequest(_message.Message): __slots__ = ("startTime", "endTime") @@ -564,14 +645,16 @@ class NhiBulkMetricsResponse(_message.Message): def __init__(self, responses: _Optional[_Iterable[_Union[NhiMetricsResponse, _Mapping]]] = ...) -> None: ... class NhiUidEntry(_message.Message): - __slots__ = ("uid", "category", "ksmNhi") + __slots__ = ("uid", "category", "ksmNhi", "appUid") UID_FIELD_NUMBER: _ClassVar[int] CATEGORY_FIELD_NUMBER: _ClassVar[int] KSMNHI_FIELD_NUMBER: _ClassVar[int] + APPUID_FIELD_NUMBER: _ClassVar[int] uid: str category: NhiCategory ksmNhi: bool - def __init__(self, uid: _Optional[str] = ..., category: _Optional[_Union[NhiCategory, str]] = ..., ksmNhi: bool = ...) -> None: ... + appUid: str + def __init__(self, uid: _Optional[str] = ..., category: _Optional[_Union[NhiCategory, str]] = ..., ksmNhi: _Optional[bool] = ..., appUid: _Optional[str] = ...) -> None: ... class GetNhiUidsRequest(_message.Message): __slots__ = ("startTime", "endTime") @@ -587,6 +670,20 @@ class GetNhiUidsResponse(_message.Message): uids: _containers.RepeatedCompositeFieldContainer[NhiUidEntry] def __init__(self, uids: _Optional[_Iterable[_Union[NhiUidEntry, _Mapping]]] = ...) -> None: ... +class SetNhiKsmEffectiveDateRequest(_message.Message): + __slots__ = ("effectiveDate",) + EFFECTIVEDATE_FIELD_NUMBER: _ClassVar[int] + effectiveDate: int + def __init__(self, effectiveDate: _Optional[int] = ...) -> None: ... + +class GetNhiKsmEffectiveDateResponse(_message.Message): + __slots__ = ("effectiveDate", "defaultDate") + EFFECTIVEDATE_FIELD_NUMBER: _ClassVar[int] + DEFAULTDATE_FIELD_NUMBER: _ClassVar[int] + effectiveDate: int + defaultDate: int + def __init__(self, effectiveDate: _Optional[int] = ..., defaultDate: _Optional[int] = ...) -> None: ... + class PAMUniversalSyncPreCheckRequest(_message.Message): __slots__ = ("networkUid", "folderUids") NETWORKUID_FIELD_NUMBER: _ClassVar[int] @@ -601,7 +698,7 @@ class PAMUniversalSyncPreCheckResult(_message.Message): ISUSED_FIELD_NUMBER: _ClassVar[int] folderUid: bytes isUsed: bool - def __init__(self, folderUid: _Optional[bytes] = ..., isUsed: bool = ...) -> None: ... + def __init__(self, folderUid: _Optional[bytes] = ..., isUsed: _Optional[bool] = ...) -> None: ... class PAMUniversalSyncPreCheckResponse(_message.Message): __slots__ = ("results",) diff --git a/unit-tests/pam/test_pam_nsf_config.py b/unit-tests/pam/test_pam_nsf_config.py index aa75e930c..30086837a 100644 --- a/unit-tests/pam/test_pam_nsf_config.py +++ b/unit-tests/pam/test_pam_nsf_config.py @@ -345,6 +345,98 @@ def parse_properties(_, record, **kwargs): params, 'config_uid', 'legacy_folder', command='pam-config-edit') +class TestPamConfigRequiredFieldValidation(unittest.TestCase): + """Regression tests: an empty required field (e.g. a GitHub config with no PAT) must abort + the whole operation instead of just logging a warning after the record was already saved.""" + + @staticmethod + def _make_missing_pat_field(): + field = vault.TypedField.new_field('secret', None, 'pamGitHubPersonalAccessToken') + field.required = True + return field + + @mock.patch('keepercommander.commands.discoveryrotation.create_pam_configuration_in_folder') + @mock.patch('keepercommander.commands.discoveryrotation.RecordEditMixin.get_record_type_fields', + return_value=[]) + def test_new_raises_and_does_not_create_record_when_required_field_empty( + self, mock_record_fields, mock_create_config): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + command = PAMConfigurationNewCommand() + + def parse_properties(_, record, **kwargs): + record.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'legacy_folder'})) + record.fields.append(self._make_missing_pat_field()) + + with mock.patch.object(command, 'parse_properties', side_effect=parse_properties): + with self.assertRaises(CommandError) as ctx: + command.execute(params, config_type='github', title='GitHub Config') + + self.assertIn('pamGitHubPersonalAccessToken', str(ctx.exception)) + mock_create_config.assert_not_called() + + @mock.patch('keepercommander.commands.discoveryrotation.update_pam_record') + @mock.patch('keepercommander.commands.discoveryrotation.RecordEditMixin.get_record_type_fields', + return_value=[]) + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_edit_raises_and_does_not_update_record_when_required_field_empty( + self, mock_load, mock_record_fields, mock_update_record): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + params.record_cache = {'config_uid': {}} + command = PAMConfigurationEditCommand() + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamGitHubConfiguration' + configuration.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'legacy_folder'})) + mock_load.return_value = configuration + + def parse_properties(_, record, **kwargs): + record.fields.append(self._make_missing_pat_field()) + + with mock.patch.object(command, 'parse_properties', side_effect=parse_properties): + with self.assertRaises(CommandError) as ctx: + command.execute(params, uid='config_uid') + + self.assertIn('pamGitHubPersonalAccessToken', str(ctx.exception)) + mock_update_record.assert_not_called() + + +class TestPamConfigGitHubFieldMapping(unittest.TestCase): + """Regression test: --personal-access-token must land on the schema field the backend + actually marks required ("(secret).pamGitHubPersonalAccessToken"), not on a differently + labeled field, or the value is silently dropped and "pam config new" always aborts.""" + + def test_personal_access_token_populates_the_required_schema_field(self): + params = _make_params() + record = vault.TypedRecord(version=6) + record.type_name = 'pamGitHubConfiguration' + record.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'legacy_folder'})) + required_field = vault.TypedField.new_field('secret', None, 'pamGitHubPersonalAccessToken') + required_field.required = True + record.fields.append(required_field) + + command = PAMConfigurationNewCommand() + command.parse_properties( + params, record, github_id='github-test-acct', personal_access_token='github_pat_example') + + field = record.get_typed_field('secret', 'pamGitHubPersonalAccessToken') + self.assertIsNotNone(field) + self.assertEqual(field.get_default_value(str), 'github_pat_example') + # Regression guard: previously the mismatched label ("personalAccessToken" instead of + # "pamGitHubPersonalAccessToken") caused the value to be misfiled into a new custom field, + # leaving the actual required field empty. + self.assertIsNone(record.get_typed_field('secret', 'personalAccessToken')) + + class TestPamConfigRemoveNsf(unittest.TestCase): @mock.patch('keepercommander.vault.KeeperRecord.load') @@ -389,6 +481,21 @@ def test_resolve_pam_config_uid_finds_nsf_record_by_title(self, mock_load): params, 'PAM NSF Test Configuration') self.assertEqual(uid, 'config_uid') + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_resolve_pam_config_uid_finds_github_config_by_uid(self, mock_load): + # Regression test: pamGitHubConfiguration must be resolvable like every other + # PAM configuration type, or "pam config edit"/"pam config remove" can never find it. + params = _make_params() + params.record_cache = {'config_uid': {}} + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamGitHubConfiguration' + mock_load.return_value = configuration + + uid = PAMConfigurationRemoveCommand._resolve_pam_config_uid(params, 'config_uid') + self.assertEqual(uid, 'config_uid') + @mock.patch('keepercommander.commands.pam.config_helper.RecordRemoveCommand') @mock.patch('keepercommander.commands.nested_share_folder.helpers.is_nested_share_record', return_value=False) diff --git a/unit-tests/pam/test_uss_github_config.py b/unit-tests/pam/test_uss_github_config.py new file mode 100644 index 000000000..cb05596f5 --- /dev/null +++ b/unit-tests/pam/test_uss_github_config.py @@ -0,0 +1,239 @@ +""" +Unit tests for the Universal Secrets Sync (USS) GitHub configuration fields on +`pam universal-sync-config add / edit / list`. + +Covers the nested `GitHubConfig` shape documented in krouter's +`docs/User API/ConfigureUniversalSync.md`: GitHub's scope/owner/organizationVisibility/repos +live under a single `github` message field on `PAMUniversalSyncConfig`, not as flat fields. +""" +import json +import unittest +from unittest import mock + +from keepercommander import crypto, utils, vault +from keepercommander.commands.universalsecretsync import ( + PAMUniversalSyncConfigAddCommand, + PAMUniversalSyncConfigEditCommand, + PAMUniversalSyncConfigListCommand, +) +from keepercommander.proto import pam_pb2 + +NETWORK_UID = 'AAAAAAAAAAAAAAAAAAAAAA' # roundtrip-safe base64url, 22 chars +RECORD_KEY = bytes(range(16)) +FAKE_TOKENS = (b'enc-session-token', b'enc-transmission-key', b'transmission-key') + + +def _make_network_record(record_type='pamGitHubConfiguration'): + network = vault.TypedRecord() + network.record_uid = NETWORK_UID + network.record_key = RECORD_KEY + network.title = 'GitHub Network' + network.type_name = record_type + return network + + +class FakeEdge: + def __init__(self, path, head_uid, content=None): + self.path = path + self.head_uid = head_uid + self._content = content + + @property + def content_as_dict(self): + return self._content + + +class FakeVertex: + def __init__(self, uid, edges=None, child_vertices=None): + self.uid = uid + self.edges = edges or [] + self._child_vertices = child_vertices or [] + + def has_vertices(self, *_args, **_kwargs): + return self._child_vertices + + +def _patch_dag(existing_config=None): + """Build mock DAG/Connection classes matching the lazy `from ..keeper_dag import DAG` + imports inside universalsecretsync.py. Returns (dag_class, connection_class, root).""" + root = FakeVertex(uid=NETWORK_UID) + if existing_config is not None: + root.edges = [FakeEdge('universal_sync', head_uid=NETWORK_UID, content=existing_config)] + + dag_instance = mock.MagicMock() + dag_instance.get_root = root + dag_instance.load.return_value = None + + dag_class = mock.MagicMock(return_value=dag_instance) + connection_class = mock.MagicMock() + return dag_class, connection_class, root + + +class TestPAMUniversalSyncConfigAddCommand(unittest.TestCase): + def setUp(self): + self.params = mock.MagicMock() + self.network = _make_network_record() + mock.patch('keepercommander.commands.universalsecretsync.get_keeper_tokens', + return_value=FAKE_TOKENS).start() + mock.patch('keepercommander.vault.KeeperRecord.load', return_value=self.network).start() + self.mock_router = mock.patch( + 'keepercommander.commands.universalsecretsync.router_helper.router_configure_universal_sync').start() + self.addCleanup(mock.patch.stopall) + + def _execute(self, **kwargs): + kwargs.setdefault('network', NETWORK_UID) + kwargs.setdefault('enabled', 'true') + kwargs.setdefault('dry_run', 'false') + PAMUniversalSyncConfigAddCommand().execute(self.params, **kwargs) + self.assertEqual(self.mock_router.call_count, 1) + return self.mock_router.call_args[0][1] + + def test_writes_github_config_under_nested_field(self): + rq = self._execute(scope='organization', owner='my-org', org_visibility='selected', + repo=['repo-a', 'repo-b']) + + self.assertTrue(rq.HasField('github')) + self.assertEqual(rq.github.scope, pam_pb2.ORGANIZATION) + self.assertEqual(rq.github.organizationVisibility, pam_pb2.SELECTED) + self.assertEqual(crypto.decrypt_aes_v2(rq.github.owner, RECORD_KEY), b'my-org') + + self.assertEqual(len(rq.github.repos), 2) + decrypted_repos = [crypto.decrypt_aes_v2(r.name, RECORD_KEY).decode('utf-8') for r in rq.github.repos] + self.assertEqual(decrypted_repos, ['repo-a', 'repo-b']) + + def test_repository_scope_maps_correctly(self): + rq = self._execute(scope='repository', owner='my-user') + self.assertEqual(rq.github.scope, pam_pb2.REPOSITORY) + + def test_no_github_flags_leaves_github_field_unset(self): + rq = self._execute() + self.assertFalse(rq.HasField('github')) + + def test_flat_fields_no_longer_exist_on_the_message(self): + rq = pam_pb2.PAMUniversalSyncConfig() + for flat_field in ('scope', 'owner', 'organizationVisibility', 'repos'): + with self.assertRaises(AttributeError): + getattr(rq, flat_field) + + +class TestPAMUniversalSyncConfigEditCommand(unittest.TestCase): + def setUp(self): + self.params = mock.MagicMock() + self.network = _make_network_record() + mock.patch('keepercommander.commands.universalsecretsync.get_keeper_tokens', + return_value=FAKE_TOKENS).start() + mock.patch('keepercommander.vault.KeeperRecord.load', return_value=self.network).start() + self.mock_router = mock.patch( + 'keepercommander.commands.universalsecretsync.router_helper.router_configure_universal_sync').start() + self.addCleanup(mock.patch.stopall) + + @staticmethod + def _existing_github_config(repo_names, scope=pam_pb2.REPOSITORY, org_visibility=pam_pb2.PRIVATE, + owner='existing-owner'): + encrypted_owner = crypto.encrypt_aes_v2(owner.encode('utf-8'), RECORD_KEY) + repos_b64 = [utils.base64_url_encode(crypto.encrypt_aes_v2(name.encode('utf-8'), RECORD_KEY)) + for name in repo_names] + return { + 'scope': scope, + 'owner': utils.base64_url_encode(encrypted_owner), + 'organizationVisibility': org_visibility, + 'repos': repos_b64, + } + + def _execute_with_dag(self, existing_config, **kwargs): + dag_class, connection_class, _root = _patch_dag(existing_config) + kwargs.setdefault('network', NETWORK_UID) + with mock.patch('keepercommander.keeper_dag.DAG', dag_class), \ + mock.patch('keepercommander.keeper_dag.connection.commander.Connection', connection_class): + PAMUniversalSyncConfigEditCommand().execute(self.params, **kwargs) + # Guard against the broad try/except in the command silently swallowing a bad mock wire-up. + self.assertEqual(dag_class.call_count, 1) + self.assertEqual(self.mock_router.call_count, 1) + return self.mock_router.call_args[0][1] + + def test_preserves_existing_github_config_when_not_overridden(self): + existing = {'github': self._existing_github_config(['repo-1', 'repo-2'])} + rq = self._execute_with_dag(existing) + + self.assertTrue(rq.HasField('github')) + self.assertEqual(rq.github.scope, pam_pb2.REPOSITORY) + self.assertEqual(rq.github.organizationVisibility, pam_pb2.PRIVATE) + self.assertEqual(crypto.decrypt_aes_v2(rq.github.owner, RECORD_KEY), b'existing-owner') + decrypted = [crypto.decrypt_aes_v2(r.name, RECORD_KEY).decode('utf-8') for r in rq.github.repos] + self.assertEqual(decrypted, ['repo-1', 'repo-2']) + + def test_overrides_take_precedence_over_existing(self): + existing = {'github': self._existing_github_config(['old-repo'])} + rq = self._execute_with_dag(existing, scope='organization', owner='new-owner', + org_visibility='all', repo=['new-repo']) + + self.assertEqual(rq.github.scope, pam_pb2.ORGANIZATION) + self.assertEqual(rq.github.organizationVisibility, pam_pb2.ALL) + self.assertEqual(crypto.decrypt_aes_v2(rq.github.owner, RECORD_KEY), b'new-owner') + decrypted = [crypto.decrypt_aes_v2(r.name, RECORD_KEY).decode('utf-8') for r in rq.github.repos] + self.assertEqual(decrypted, ['new-repo']) + + def test_partial_override_preserves_the_rest(self): + # Only --owner is overridden; scope/org-visibility/repos should carry forward. + existing = {'github': self._existing_github_config(['repo-1'], scope=pam_pb2.ORGANIZATION, + org_visibility=pam_pb2.SELECTED)} + rq = self._execute_with_dag(existing, owner='new-owner') + + self.assertEqual(crypto.decrypt_aes_v2(rq.github.owner, RECORD_KEY), b'new-owner') + self.assertEqual(rq.github.scope, pam_pb2.ORGANIZATION) + self.assertEqual(rq.github.organizationVisibility, pam_pb2.SELECTED) + decrypted = [crypto.decrypt_aes_v2(r.name, RECORD_KEY).decode('utf-8') for r in rq.github.repos] + self.assertEqual(decrypted, ['repo-1']) + + def test_no_existing_and_no_override_leaves_github_unset(self): + rq = self._execute_with_dag(None) + self.assertFalse(rq.HasField('github')) + + +class TestPAMUniversalSyncConfigListDetails(unittest.TestCase): + def setUp(self): + self.params = mock.MagicMock() + self.params.folder_cache = {} + self.params.subfolder_cache = {} + self.network = _make_network_record() + mock.patch('keepercommander.vault.KeeperRecord.load', return_value=self.network).start() + self.addCleanup(mock.patch.stopall) + + def _print_details(self, existing_config, format_type='json'): + dag_class, connection_class, _root = _patch_dag(existing_config) + with mock.patch('keepercommander.keeper_dag.DAG', dag_class), \ + mock.patch('keepercommander.keeper_dag.connection.commander.Connection', connection_class): + return PAMUniversalSyncConfigListCommand.print_uss_configuration_details( + self.params, NETWORK_UID, format_type=format_type) + + def test_json_details_reads_nested_github_fields(self): + encrypted_owner = crypto.encrypt_aes_v2(b'my-org', RECORD_KEY) + encrypted_repo = crypto.encrypt_aes_v2(b'my-repo', RECORD_KEY) + existing_config = { + 'enabled': True, + 'github': { + 'scope': pam_pb2.ORGANIZATION, + 'owner': utils.base64_url_encode(encrypted_owner), + 'organizationVisibility': pam_pb2.ALL, + 'repos': [utils.base64_url_encode(encrypted_repo)], + }, + } + + result = json.loads(self._print_details(existing_config)) + + self.assertEqual(result['scope'], 'ORGANIZATION') + self.assertEqual(result['organization_visibility'], 'ALL') + self.assertEqual(result['owner'], 'my-org') + self.assertEqual(result['repos'], ['my-repo']) + + def test_json_details_without_github_config(self): + result = json.loads(self._print_details({'enabled': True})) + + self.assertEqual(result['scope'], 'N/A') + self.assertEqual(result['organization_visibility'], 'N/A') + self.assertEqual(result['owner'], 'N/A') + self.assertEqual(result['repos'], []) + + +if __name__ == '__main__': + unittest.main() From b677e086df625c435737a3fdcea619e3e8e8488e Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Mon, 17 Aug 2026 15:21:59 -0700 Subject: [PATCH 02/14] epm scim: use objectSid for Active Directory groups --- keepercommander/commands/pedm/pedm_admin.py | 3 ++- keepercommander/scim/data_sources.py | 9 ++++++--- keepercommander/scim/models.py | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/keepercommander/commands/pedm/pedm_admin.py b/keepercommander/commands/pedm/pedm_admin.py index 278d55fe0..70068bbb6 100644 --- a/keepercommander/commands/pedm/pedm_admin.py +++ b/keepercommander/commands/pedm/pedm_admin.py @@ -476,8 +476,9 @@ def build_group(group: ScimGroup) -> Optional[Tuple[admin_types.CollectionData, if not group.name: return None group_domain = group.domain or domain_name + group_id = group.sid or group.id data = { - 'Id': group.id, + 'Id': group_id, 'Domainname': group_domain, 'GroupName': group.name, } diff --git a/keepercommander/scim/data_sources.py b/keepercommander/scim/data_sources.py index b90951a1a..866b433a4 100644 --- a/keepercommander/scim/data_sources.py +++ b/keepercommander/scim/data_sources.py @@ -221,16 +221,18 @@ def populate(self): rs = connection.extend.standard.paged_search( root_dn, '(objectClass=group)', search_scope=ldap3.SUBTREE, paged_size=1000, generator=True, - attributes=['objectGUID', 'name']) + attributes=['objectGUID', 'objectSid', 'name']) for entry in rs: if entry.get('type') != 'searchResEntry': continue attrs = entry.get('attributes') or {} group_id = attrs.get('objectGUID') + group_sid = attrs.get('objectSid') group_name = attrs.get('name') if group_id and group_name: g = ScimGroup() g.id = group_id + g.sid = group_sid g.external_id = group_id g.name = group_name g.domain = default_domain @@ -240,11 +242,11 @@ def populate(self): if scim_group.lower().startswith('cn='): rs = connection.extend.standard.paged_search( scim_group, f'(objectClass=group)', - search_scope=ldap3.BASE, attributes=['objectGUID', 'name'], generator=False) + search_scope=ldap3.BASE, attributes=['objectGUID', 'objectSid', 'name'], generator=False) else: rs = connection.extend.standard.paged_search( root_dn, f'(&(objectClass=group)(name={escape_filter_chars(scim_group)}))', - search_scope=ldap3.SUBTREE, attributes=['objectGUID', 'name'], generator=False) + search_scope=ldap3.SUBTREE, attributes=['objectGUID', 'objectSid', 'name'], generator=False) group_entry = next((x for x in rs if x.get('type') == 'searchResEntry'), None) if group_entry: @@ -255,6 +257,7 @@ def populate(self): attrs = group_entry['attributes'] scim_group_obj = ScimGroup() scim_group_obj.id = attrs.get('objectGUID') + scim_group_obj.sid = attrs.get('objectSid') scim_group_obj.external_id = attrs.get('objectGUID') scim_group_obj.name = attrs.get('name') scim_group_obj.domain = default_domain diff --git a/keepercommander/scim/models.py b/keepercommander/scim/models.py index a3ba5b4a4..e561e7d93 100644 --- a/keepercommander/scim/models.py +++ b/keepercommander/scim/models.py @@ -35,6 +35,7 @@ def __str__(self): @dataclass class ScimGroup: id: str = '' + sid: str = '' external_id: str = '' name: str = '' domain: str = '' @@ -42,6 +43,7 @@ class ScimGroup: def __str__(self): scim_group = { 'id': self.id, + 'sid': self.sid, 'external_id': self.external_id, 'name': self.name, 'domain': self.domain, From c65d84c263f0c269d118c1041af08ef469ececdc Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 18 Aug 2026 20:21:50 +0530 Subject: [PATCH 03/14] KC-1407: Add terraform-app-setup with Min-Commander-Version gate (#2302) * KC-1407: Add terraform-app-setup with Min-Commander-Version gate (#2299) * Add terraform-app-setup with API v2 defaults and Min-Commander-Version gate * Change error message * Separate commands as tuple and improve test cases * Update container name to include terraform * Fix for review comments * Add min-commander-version gate to terraform container --- keepercommander/command_categories.py | 3 +- keepercommander/commands/start_service.py | 3 + .../service/commands/service_docker_setup.py | 155 +------------ .../service/commands/terraform_app_setup.py | 147 +++++++++++++ .../decorators/min_commander_version.py | 110 ++++++++++ keepercommander/service/decorators/unified.py | 4 +- keepercommander/service/docker/__init__.py | 5 +- keepercommander/service/docker/printer.py | 5 +- keepercommander/service/docker/setup_base.py | 135 ++++++++++++ setup.cfg | 1 + .../service/test_min_commander_version.py | 203 ++++++++++++++++++ .../service/test_terraform_app_setup.py | 164 ++++++++++++++ 12 files changed, 781 insertions(+), 154 deletions(-) create mode 100644 keepercommander/service/commands/terraform_app_setup.py create mode 100644 keepercommander/service/decorators/min_commander_version.py create mode 100644 unit-tests/service/test_min_commander_version.py create mode 100644 unit-tests/service/test_terraform_app_setup.py diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py index c2a6f6b54..b0ae463de 100644 --- a/keepercommander/command_categories.py +++ b/keepercommander/command_categories.py @@ -83,7 +83,8 @@ # Service Mode REST API 'Service Mode REST API': { 'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status', - 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup', + 'service-config-add', 'service-docker-setup', 'terraform-app-setup', + 'slack-app-setup', 'teams-app-setup', 'sailpoint-app-setup', 'gchat-app-setup' }, diff --git a/keepercommander/commands/start_service.py b/keepercommander/commands/start_service.py index 0d7eaded9..4037acc38 100644 --- a/keepercommander/commands/start_service.py +++ b/keepercommander/commands/start_service.py @@ -13,6 +13,7 @@ from ..service.commands.config_operation import AddConfigService from ..service.commands.handle_service import StartService, StopService, ServiceStatus from ..service.commands.service_docker_setup import ServiceDockerSetupCommand +from ..service.commands.terraform_app_setup import TerraformAppSetupCommand from ..service.commands.integrations import ( GChatAppSetupCommand, SlackAppSetupCommand, @@ -27,6 +28,7 @@ def register_commands(commands): commands['service-stop'] = StopService() commands['service-status'] = ServiceStatus() commands['service-docker-setup'] = ServiceDockerSetupCommand() + commands['terraform-app-setup'] = TerraformAppSetupCommand() commands['slack-app-setup'] = SlackAppSetupCommand() commands['teams-app-setup'] = TeamsAppSetupCommand() commands['sailpoint-app-setup'] = SailPointAppSetupCommand() @@ -40,6 +42,7 @@ def register_command_info(aliases, command_info): StopService, ServiceStatus, ServiceDockerSetupCommand, + TerraformAppSetupCommand, SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand, diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py index f3795b1ef..e94d6a835 100644 --- a/keepercommander/service/commands/service_docker_setup.py +++ b/keepercommander/service/commands/service_docker_setup.py @@ -16,11 +16,9 @@ import argparse import os from dataclasses import asdict -from typing import Dict, Any from ...commands.base import Command, raise_parse_exception, suppress_exit from ...display import bcolors -from ...error import CommandError from ..config.config_validation import ConfigValidator, ValidationError from ..docker import ( DockerSetupBase, DockerSetupConstants, DockerSetupPrinter, @@ -69,19 +67,17 @@ def get_parser(self): def execute(self, params, **kwargs): """Main execution flow for standalone command""" - self._require_file_based_config(params, 'service-docker-setup') + command_name = self.get_parser().prog + self._require_file_based_config(params, command_name) - # Parse arguments config_path = self._require_commander_config_file( - 'service-docker-setup', + command_name, kwargs.get('config_path'), params, ) - - # Print header + DockerSetupPrinter.print_header("Docker Setup") - - # Run core setup steps (inherited from DockerSetupBase) + setup_result = self.run_setup_steps( params=params, folder_name=kwargs.get('folder_name', DockerSetupConstants.DEFAULT_FOLDER_NAME), @@ -91,19 +87,13 @@ def execute(self, params, **kwargs): timeout=kwargs.get('timeout', DockerSetupConstants.DEFAULT_TIMEOUT), skip_device_setup=kwargs.get('skip_device_setup', False) ) - - # Get service configuration + DockerSetupPrinter.print_completion("Docker Setup Complete!") service_config = self.get_service_configuration(params) - - # Generate docker-compose.yml + self.generate_and_save_docker_compose(setup_result, service_config) DockerSetupPrinter.print_completion("Service Mode Configuration Complete!") - - # Print success message self.print_standalone_success_message(setup_result, service_config, config_path) - - return def get_service_configuration(self, params) -> ServiceConfig: """Interactively get service configuration from user""" @@ -221,134 +211,3 @@ def _get_queue_config(self) -> bool: print(f" Queue mode enables async API (v2) for better performance") queue_input = input(f"{bcolors.OKBLUE}Enable queue mode? [Press Enter for Yes] (y/n):{bcolors.ENDC} ").strip().lower() return queue_input != 'n' - - - def _get_advanced_security_config(self) -> Dict[str, Any]: - """Get advanced security configuration""" - print(f"\n{bcolors.BOLD}Advanced Security (optional):{bcolors.ENDC}") - print(f" Configure IP filtering, rate limiting, and response encryption") - enable_advanced = input(f"{bcolors.OKBLUE}Enable advanced security? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' - - config = { - 'allowed_ip': '0.0.0.0/0,::/0', - 'denied_ip': '', - 'rate_limit': '', - 'encryption_enabled': False, - 'encryption_key': '', - 'token_expiration': '' - } - - if enable_advanced: - # IP Allowed List - config.update(self._get_ip_allowed_config()) - - # IP Denied List - config.update(self._get_ip_denied_config()) - - # Rate Limiting - config.update(self._get_rate_limit_config()) - - # Encryption - config.update(self._get_encryption_config()) - - # Token Expiration - config.update(self._get_token_expiration_config()) - - return config - - def _get_ip_allowed_config(self) -> Dict[str, str]: - """Get allowed IP configuration""" - print(f"\n{bcolors.BOLD}IP Allowed List:{bcolors.ENDC}") - print(f" Comma-separated IPs or CIDR ranges (e.g., 192.168.1.0/24,10.0.0.1)") - - ip_list = input(f"{bcolors.OKBLUE}Allowed IPs [Press Enter for all]:{bcolors.ENDC} ").strip() - - if ip_list: - while True: - try: - return {'allowed_ip': ConfigValidator.validate_ip_list(ip_list)} - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - ip_list = input(f"{bcolors.OKBLUE}Allowed IPs [Press Enter for all]:{bcolors.ENDC} ").strip() - if not ip_list: - break - - return {'allowed_ip': '0.0.0.0/0,::/0'} - - def _get_ip_denied_config(self) -> Dict[str, str]: - """Get denied IP configuration""" - print(f"\n{bcolors.BOLD}IP Denied List:{bcolors.ENDC}") - print(f" Comma-separated IPs or CIDR ranges to block") - - ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() - - if ip_list: - while True: - try: - return {'denied_ip': ConfigValidator.validate_ip_list(ip_list)} - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() - if not ip_list: - break - - return {'denied_ip': ''} - - def _get_rate_limit_config(self) -> Dict[str, str]: - """Get rate limiting configuration""" - print(f"\n{bcolors.BOLD}Rate Limiting:{bcolors.ENDC}") - print(f" Format: / (e.g., 10/minute, 100/hour, 1000/day)") - - rate_limit = input(f"{bcolors.OKBLUE}Rate limit [Press Enter to skip]:{bcolors.ENDC} ").strip() - - if rate_limit: - while True: - try: - return {'rate_limit': ConfigValidator.validate_rate_limit(rate_limit)} - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - rate_limit = input(f"{bcolors.OKBLUE}Rate limit [Press Enter to skip]:{bcolors.ENDC} ").strip() - if not rate_limit: - break - - return {'rate_limit': ''} - - def _get_encryption_config(self) -> Dict[str, Any]: - """Get encryption configuration""" - print(f"\n{bcolors.BOLD}Response Encryption:{bcolors.ENDC}") - print(f" Enable AES-256 encryption for API responses") - enable_encryption = input(f"{bcolors.OKBLUE}Enable encryption? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' - - config = {'encryption_enabled': enable_encryption, 'encryption_key': ''} - - if enable_encryption: - print(f" Encryption key must be exactly 32 alphanumeric characters") - while True: - key = input(f"{bcolors.OKBLUE}Encryption key (32 chars):{bcolors.ENDC} ").strip() - try: - config['encryption_key'] = ConfigValidator.validate_encryption_key(key) - break - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - - return config - - def _get_token_expiration_config(self) -> Dict[str, str]: - """Get token expiration configuration""" - print(f"\n{bcolors.BOLD}API Token Expiration:{bcolors.ENDC}") - print(f" Format: Xm (minutes), Xh (hours), Xd (days) - e.g., 30m, 24h, 7d") - - expiration = input(f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} ").strip() - - if expiration: - while True: - try: - ConfigValidator.parse_expiration_time(expiration) - return {'token_expiration': expiration} - except ValidationError as e: - print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") - expiration = input(f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} ").strip() - if not expiration: - break - - return {'token_expiration': ''} diff --git a/keepercommander/service/commands/terraform_app_setup.py b/keepercommander/service/commands/terraform_app_setup.py new file mode 100644 index 000000000..b802f0965 --- /dev/null +++ b/keepercommander/service/commands/terraform_app_setup.py @@ -0,0 +1,147 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + try: + return RuntimeServiceConfig().validate_command_list( + TerraformSetupConstants.SERVICE_COMMANDS, params + ) + except ValidationError as e: + raise CommandError( + self.get_parser().prog, + f'Terraform command allowlist validation failed: {e}', + ) + + def _get_commands_config(self, params) -> str: + return self._validate_terraform_commands(params) + + def _get_queue_config(self) -> bool: + return True + + def generate_docker_compose_yaml(self, setup_result: SetupResult, config: DockerServiceConfig) -> str: + builder = DockerComposeBuilder( + setup_result, + asdict(config), + commander_service_name=TerraformSetupConstants.COMMANDER_SERVICE_NAME, + commander_container_name=TerraformSetupConstants.COMMANDER_CONTAINER_NAME, + commander_environment={TERRAFORM_DOCKER_ENV: '1'}, + ) + return builder.build() + + def _print_next_steps(self, config: DockerServiceConfig, config_path: str) -> None: + DockerSetupPrinter.print_common_deployment_steps( + str(config.port), + config_path, + container_name=TerraformSetupConstants.COMMANDER_CONTAINER_NAME, + ) + print() diff --git a/keepercommander/service/decorators/min_commander_version.py b/keepercommander/service/decorators/min_commander_version.py new file mode 100644 index 000000000..4d484caf3 --- /dev/null +++ b/keepercommander/service/decorators/min_commander_version.py @@ -0,0 +1,110 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[Version]: + if not version_str or len(version_str) > 64: + return None + try: + return Version(version_str.lstrip('vV')) + except InvalidVersion: + return None + + +_RUNNING_VERSION_RAW = str(commander_version).strip() +_RUNNING_VERSION = _parse_version(_RUNNING_VERSION_RAW) + + +def _is_terraform_docker() -> bool: + """True when this process was started from terraform-app-setup compose.""" + return bool((os.environ.get(TERRAFORM_DOCKER_ENV) or '').strip()) + + +def _read_min_commander_version_header() -> Optional[str]: + if not _is_terraform_docker(): + return None + value = request.headers.get(MIN_COMMANDER_VERSION_HEADER) + if value is None: + return None + value = value.strip() + return value or None + + +def check_min_commander_version() -> Optional[Tuple[dict, int]]: + """ + On Terraform Docker only: if Min-Commander-Version is present, require + running Commander >= that version. + + Non-Terraform service mode: no-op even if the header is sent. + Missing header: no-op. + Invalid header: 400. + Too old: 426 with upgrade guidance. + """ + required_raw = _read_min_commander_version_header() + if required_raw is None: + return None + + required = _parse_version(required_raw) + if required is None: + return { + 'status': 'error', + 'error': ( + f'Invalid {MIN_COMMANDER_VERSION_HEADER} header. ' + 'Expected a dotted version such as 18.1.0.' + ), + }, 400 + + if _RUNNING_VERSION is None: + logger.error('Unable to parse running Commander version') + return { + 'status': 'error', + 'error': 'Unable to determine running Commander version.', + }, 500 + + if _RUNNING_VERSION >= required: + return None + + message = ( + f'Commander version {_RUNNING_VERSION_RAW} is below the required minimum {required_raw}. ' + f'Please update Keeper Commander to >= {required_raw} and retry.' + ) + logger.info(message) + return {'status': 'error', 'error': message}, 426 + + +def min_commander_version_check(fn): + """Run after auth: enforce Min-Commander-Version on Terraform Docker only.""" + + @wraps(fn) + def wrapper(*args, **kwargs): + version_error = check_min_commander_version() + if version_error: + return version_error + return fn(*args, **kwargs) + + return wrapper diff --git a/keepercommander/service/decorators/unified.py b/keepercommander/service/decorators/unified.py index dc87e14d3..873d3cc3c 100644 --- a/keepercommander/service/decorators/unified.py +++ b/keepercommander/service/decorators/unified.py @@ -15,6 +15,7 @@ from .api_logging import api_log_handler from .security import security_check from .auth import auth_check, policy_check +from .min_commander_version import min_commander_version_check def unified_api_decorator() -> Callable: def decorator(f: Callable) -> Callable: @@ -22,10 +23,11 @@ def decorator(f: Callable) -> Callable: @api_log_handler @security_check @auth_check + @min_commander_version_check @policy_check @catch_all @debug_decorator def wrapped_function(*args, **kwargs): return f(*args, **kwargs) return wrapped_function - return decorator \ No newline at end of file + return decorator diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index c30acd0e6..cc03a716c 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -20,8 +20,9 @@ """ from .models import ( - DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, - SailPointConfig, GChatConfig, GChatConstants, SetupStep, ApproverTeam, ApprovalsConfig, + DockerSetupConstants, SetupResult, ServiceConfig, + SlackConfig, TeamsConfig, SailPointConfig, GChatConfig, GChatConstants, + SetupStep, ApproverTeam, ApprovalsConfig, ) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase diff --git a/keepercommander/service/docker/printer.py b/keepercommander/service/docker/printer.py index 224e44c30..564e298f7 100644 --- a/keepercommander/service/docker/printer.py +++ b/keepercommander/service/docker/printer.py @@ -61,7 +61,8 @@ def print_phase1_resources(setup_result: SetupResult, indent: str = " ") -> Non print(f"{indent}• KSM Base64 Config: {bcolors.OKGREEN}✓ Generated{bcolors.ENDC}") @staticmethod - def print_common_deployment_steps(port: str, config_path: str = None) -> None: + def print_common_deployment_steps(port: str, config_path: str = None, + container_name: str = 'keeper-service') -> None: """Print common deployment steps (header + steps 1-5)""" DockerSetupPrinter.print_header("Next Steps to Deploy") @@ -82,6 +83,6 @@ def print_common_deployment_steps(port: str, config_path: str = None) -> None: print(f"\n{bcolors.BOLD}Step 5: Check services health{bcolors.ENDC}") print(f" {bcolors.OKGREEN}docker ps{bcolors.ENDC} - View container status") - print(f" {bcolors.OKGREEN}docker logs keeper-service{bcolors.ENDC} - View Commander logs") + print(f" {bcolors.OKGREEN}docker logs {container_name}{bcolors.ENDC} - View Commander logs") print(f" {bcolors.OKGREEN}curl http://localhost:{port}/health{bcolors.ENDC} - Test health endpoint") diff --git a/keepercommander/service/docker/setup_base.py b/keepercommander/service/docker/setup_base.py index 48fbb07bb..d43eea9a6 100644 --- a/keepercommander/service/docker/setup_base.py +++ b/keepercommander/service/docker/setup_base.py @@ -584,3 +584,138 @@ def _get_cloudflare_config(self) -> Dict[str, Any]: print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") return config + + def _get_advanced_security_config(self) -> Dict[str, Any]: + """Get advanced security configuration (IP filter, rate limit, encryption, token expiry).""" + print(f"\n{bcolors.BOLD}Advanced Security (optional):{bcolors.ENDC}") + print(f" Configure IP filtering, rate limiting, and response encryption") + enable_advanced = input( + f"{bcolors.OKBLUE}Enable advanced security? [Press Enter for No] (y/n):{bcolors.ENDC} " + ).strip().lower() == 'y' + + config = { + 'allowed_ip': '0.0.0.0/0,::/0', + 'denied_ip': '', + 'rate_limit': '', + 'encryption_enabled': False, + 'encryption_key': '', + 'token_expiration': '' + } + + if enable_advanced: + config.update(self._get_ip_allowed_config()) + config.update(self._get_ip_denied_config()) + config.update(self._get_rate_limit_config()) + config.update(self._get_encryption_config()) + config.update(self._get_token_expiration_config()) + + return config + + def _get_ip_allowed_config(self) -> Dict[str, str]: + """Get allowed IP configuration""" + print(f"\n{bcolors.BOLD}IP Allowed List:{bcolors.ENDC}") + print(f" Comma-separated IPs or CIDR ranges (e.g., 192.168.1.0/24,10.0.0.1)") + + ip_list = input(f"{bcolors.OKBLUE}Allowed IPs [Press Enter for all]:{bcolors.ENDC} ").strip() + + if ip_list: + while True: + try: + return {'allowed_ip': ConfigValidator.validate_ip_list(ip_list)} + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + ip_list = input( + f"{bcolors.OKBLUE}Allowed IPs [Press Enter for all]:{bcolors.ENDC} " + ).strip() + if not ip_list: + break + + return {'allowed_ip': '0.0.0.0/0,::/0'} + + def _get_ip_denied_config(self) -> Dict[str, str]: + """Get denied IP configuration""" + print(f"\n{bcolors.BOLD}IP Denied List:{bcolors.ENDC}") + print(f" Comma-separated IPs or CIDR ranges to block") + + ip_list = input(f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} ").strip() + + if ip_list: + while True: + try: + return {'denied_ip': ConfigValidator.validate_ip_list(ip_list)} + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + ip_list = input( + f"{bcolors.OKBLUE}Denied IPs [Press Enter to skip]:{bcolors.ENDC} " + ).strip() + if not ip_list: + break + + return {'denied_ip': ''} + + def _get_rate_limit_config(self) -> Dict[str, str]: + """Get rate limiting configuration""" + print(f"\n{bcolors.BOLD}Rate Limiting:{bcolors.ENDC}") + print(f" Format: / (e.g., 10/minute, 100/hour, 1000/day)") + + rate_limit = input(f"{bcolors.OKBLUE}Rate limit [Press Enter to skip]:{bcolors.ENDC} ").strip() + + if rate_limit: + while True: + try: + return {'rate_limit': ConfigValidator.validate_rate_limit(rate_limit)} + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + rate_limit = input( + f"{bcolors.OKBLUE}Rate limit [Press Enter to skip]:{bcolors.ENDC} " + ).strip() + if not rate_limit: + break + + return {'rate_limit': ''} + + def _get_encryption_config(self) -> Dict[str, Any]: + """Get encryption configuration""" + print(f"\n{bcolors.BOLD}Response Encryption:{bcolors.ENDC}") + print(f" Enable AES-256 encryption for API responses") + enable_encryption = input( + f"{bcolors.OKBLUE}Enable encryption? [Press Enter for No] (y/n):{bcolors.ENDC} " + ).strip().lower() == 'y' + + config = {'encryption_enabled': enable_encryption, 'encryption_key': ''} + + if enable_encryption: + print(f" Encryption key must be exactly 32 alphanumeric characters") + while True: + key = input(f"{bcolors.OKBLUE}Encryption key (32 chars):{bcolors.ENDC} ").strip() + try: + config['encryption_key'] = ConfigValidator.validate_encryption_key(key) + break + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + + return config + + def _get_token_expiration_config(self) -> Dict[str, str]: + """Get token expiration configuration""" + print(f"\n{bcolors.BOLD}API Token Expiration:{bcolors.ENDC}") + print(f" Format: Xm (minutes), Xh (hours), Xd (days) - e.g., 30m, 24h, 7d") + + expiration = input( + f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} " + ).strip() + + if expiration: + while True: + try: + ConfigValidator.parse_expiration_time(expiration) + return {'token_expiration': expiration} + except ValidationError as e: + print(f"{bcolors.FAIL}Error: {str(e)}{bcolors.ENDC}") + expiration = input( + f"{bcolors.OKBLUE}Token expiration [Press Enter for never]:{bcolors.ENDC} " + ).strip() + if not expiration: + break + + return {'token_expiration': ''} diff --git a/setup.cfg b/setup.cfg index 51de347d0..6b0f81bbc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ install_requires = prompt_toolkit protobuf>=5.29.6,<6 googleapis-common-protos + packaging psutil pycryptodomex>=3.20.0 pyngrok diff --git a/unit-tests/service/test_min_commander_version.py b/unit-tests/service/test_min_commander_version.py new file mode 100644 index 000000000..6e0aa90fd --- /dev/null +++ b/unit-tests/service/test_min_commander_version.py @@ -0,0 +1,203 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' = 18.1.0 and retry.', body['error']) + + @mock.patch.dict(os.environ, _TERRAFORM_DOCKER_ENV, clear=False) + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + Version('18.1.0'), + ) + def test_rejects_invalid_header(self): + with self.app.test_request_context( + '/api/v2/executecommand-async', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: 'not-a-version'}, + ): + body, status = check_min_commander_version() + self.assertEqual(status, 400) + self.assertEqual(body['status'], 'error') + self.assertIn('Invalid', body['error']) + + @mock.patch.dict(os.environ, _TERRAFORM_DOCKER_ENV, clear=False) + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + None, + ) + def test_rejects_when_running_version_unparseable(self): + with self.app.test_request_context( + '/api/v2/executecommand-async', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: '18.1.0'}, + ): + body, status = check_min_commander_version() + self.assertEqual(status, 500) + self.assertEqual(body['status'], 'error') + self.assertIn('Unable to determine running Commander version', body['error']) + + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + Version('17.0.0'), + ) + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION_RAW', + '17.0.0', + ) + def test_non_terraform_docker_ignores_min_version_header(self): + env = {k: v for k, v in os.environ.items() if k != TERRAFORM_DOCKER_ENV} + with mock.patch.dict(os.environ, env, clear=True): + with self.app.test_request_context( + '/api/v2/executecommand-async', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: '99.0.0'}, + ): + self.assertIsNone(check_min_commander_version()) + + @mock.patch.dict(os.environ, _TERRAFORM_DOCKER_ENV, clear=False) + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION', + Version('17.0.0'), + ) + @mock.patch( + 'keepercommander.service.decorators.min_commander_version._RUNNING_VERSION_RAW', + '17.0.0', + ) + def test_decorator_blocks_handler(self): + called = {'value': False} + + @min_commander_version_check + def handler(): + called['value'] = True + return {'status': 'success'}, 200 + + with self.app.test_request_context( + '/test', + method='POST', + headers={MIN_COMMANDER_VERSION_HEADER: '99.0.0'}, + ): + body, status = handler() + self.assertEqual(status, 426) + self.assertFalse(called['value']) + self.assertEqual(body['status'], 'error') diff --git a/unit-tests/service/test_terraform_app_setup.py b/unit-tests/service/test_terraform_app_setup.py new file mode 100644 index 000000000..f4e97a446 --- /dev/null +++ b/unit-tests/service/test_terraform_app_setup.py @@ -0,0 +1,164 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Date: Wed, 19 Aug 2026 10:31:55 +0100 Subject: [PATCH 04/14] Fix pykeepass bug when using quotes in record titles The pykeepass add_entry method will unconditionally check for duplicates with xpath, which breaks if there are quotes in the record title. Fixed the issue by replacing the add_entry method with a custom keepass_add_entry function. --- keepercommander/importer/keepass/keepass.py | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/keepercommander/importer/keepass/keepass.py b/keepercommander/importer/keepass/keepass.py index 223635fd7..a93d6d8f6 100644 --- a/keepercommander/importer/keepass/keepass.py +++ b/keepercommander/importer/keepass/keepass.py @@ -23,6 +23,7 @@ from pykeepass.exceptions import CredentialsError from pykeepass.attachment import Attachment as KeepassAttachment from pykeepass.group import Group +from pykeepass.entry import Entry from ..importer import path_components, PathDelimiter, BaseFileImporter, BaseExporter, \ Record, Folder, SharedFolder, BytesAttachment, RecordField @@ -50,13 +51,6 @@ def sanitize_xml_text(value): # type: (any) -> str return '' return ''.join((char for char in value if XmlUtils.is_valid_xml_char(char))) - @staticmethod - def escape_string(plain): # type: (str) -> str - if not plain: - return '' - output = escape(plain) - return output.replace('\'', ''').replace('\"', '"') - class KeepassImporter(BaseFileImporter): @@ -296,7 +290,17 @@ def to_keepass_value(keeper_value): # type: (any) -> str for k, v in keeper_value.items() )) else: - return XmlUtils.sanitize_xml_text(keeper_value) + return XmlUtils.sanitize_xml_text(keeper_value) + + def keepass_add_entry(destination_group, title, username, password, url=None, notes=None, + tags=None, icon=None, kp=None): + """Mirrors pykeepass.PyKeePass.add_entry() but skips its internal + find_entries() duplicate-check, which crashes on values containing + a double quote (upstream bug: libkeepass/pykeepass#254).""" + entry = Entry(title=title, username=username, password=password, + notes=notes, url=url, tags=tags, icon=icon, kp=kp) + destination_group.append(entry) + return entry def do_export(self, filename, records, file_password=None, kbdx_key_file=None, **kwargs): password = file_password or getpass.getpass(prompt='...' + 'Keepass Password'.rjust(20) + ': ', stream=None) or None @@ -349,9 +353,11 @@ def do_export(self, filename, records, file_password=None, kbdx_key_file=None, * break if entry is None: - entry = kdb.add_entry(node, title=entry_title, username=entry_login, - password=entry_password, url=entry_url, - notes=entry_notes) + entry = KeepassExporter.keepass_add_entry(node, title=entry_title, username=entry_login, + password=entry_password, url=entry_url, notes=entry_notes, kp=kdb) + #entry = kdb.add_entry(node, title=entry_title, username=entry_login, + # password=entry_password, url=entry_url, + # notes=entry_notes) if r.uid: entry.UUID = uuid.UUID(bytes=utils.base64_url_decode(r.uid)) if r.type: From f6f3cebbdd20a77e8f46ce773d77f075bce5eaac Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Wed, 19 Aug 2026 17:07:49 +0530 Subject: [PATCH 05/14] KC-1403: Fix NSF record update "This object no longer exists" after PAM tunnel/connection edit (#2298) * Fix NSF nsf-record-update RS_OUT_OF_SYNC after PAM tunnel/connection edits Keep nested_share_records and record_cache revisions aligned after PAM and classic sync so NSF updates no longer send a stale revision. Route PAM connection/RBI/split updates through update_pam_record and retry once on RS_OUT_OF_SYNC. * Fix review issues for NSF PAM record update sync Keep NSF permission caches after classic sync, mark classic PAM edits for resync, and safely retry stale-revision updates. * Addressed review comments: safe RS_OUT_OF_SYNC retry and revision max in sync Pass field overrides to nsf-record-update instead of merged data; do not retry whole-record payloads. Scope sync fallback to ImportError, use max() for classic NSF revision, and add concurrency/retry tests. --- .../nested_share_folder/record_commands.py | 7 +- keepercommander/commands/pam/vault_target.py | 3 + .../commands/tunnel_and_connections.py | 12 +- .../nested_share_folder/__init__.py | 3 +- keepercommander/nested_share_folder/common.py | 30 ++ .../nested_share_folder/record_api.py | 130 +++++--- keepercommander/nested_share_folder/sync.py | 81 +++-- keepercommander/sync_down.py | 20 +- .../test_pam_connection_edit_scrollback.py | 6 +- .../pam/test_pam_connection_edit_security.py | 16 +- unit-tests/pam/test_pam_nsf_config.py | 14 + unit-tests/pam/test_pam_split_nsf.py | 4 +- unit-tests/test_nested_share_folder.py | 295 ++++++++++++++++++ unit-tests/test_sync_down.py | 64 ++++ 14 files changed, 604 insertions(+), 81 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index e5ac2f4fe..c52543be3 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -301,7 +301,12 @@ def execute(self, params, **kwargs): return self.warnings.clear() result = _nsf.update_record_v3( - params=params, record_uid=record_uid, data=merged, + params=params, + record_uid=record_uid, + title=kwargs.get('title'), + record_type=record_type, + fields=fields or None, + notes=kwargs.get('notes'), ) check_result(result, 'nsf-record-update') params.sync_data = True diff --git a/keepercommander/commands/pam/vault_target.py b/keepercommander/commands/pam/vault_target.py index d30377e07..91cd019ad 100644 --- a/keepercommander/commands/pam/vault_target.py +++ b/keepercommander/commands/pam/vault_target.py @@ -589,6 +589,9 @@ def update_pam_record(params, record, command='pam', force_nsf=False): sync_down_preserving_nsf_keys(params) else: record_management.update_record(params, record) + # Defer vault refresh so a second classic edit in the same session does + # not send a stale record_cache revision (no immediate sync_down here). + params.sync_data = True def execute_record_add_in_folder(params, args, folder_uid, command='pam'): diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 9be4d8218..caf45a35f 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -45,7 +45,7 @@ stop_tunnel_process, unregister_tunnel, ) -from .. import api, vault, record_management +from .. import api, vault from ..display import bcolors from ..error import CommandError from ..params import LAST_RECORD_UID @@ -2980,8 +2980,7 @@ def _get_effective_protocol(): logging.debug(f'security is already {target_sec} on record={record_uid}') if dirty: - record_management.update_record(params, record) - api.sync_down(params) + update_pam_record(params, record, command='pam connection edit') traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed') if not traffic_encryption_key: @@ -4058,7 +4057,6 @@ def update_connection_choice(field_name, value): if not traffic_encryption_key: raise CommandError('', f"{bcolors.FAIL}Unable to add Seed to record {record_uid}. " f"Please make sure you have edit rights to record {record_uid} {bcolors.ENDC}") - params.sync_data = True # DAG manipulation options: config, rbi/connections, recording dirty = False @@ -4231,8 +4229,7 @@ def execute(self, params, **kwargs): pam_settings = vault.TypedField.new_field('pamSettings', "", "") record.fields.append(pam_settings) - record_management.update_record(params, record) - params.sync_data = True + update_pam_record(params, record, command='pam-split') print(f"{bcolors.WARNING}Record {record_uid} has no data to split and " "was converted to the new format. Remember to manually add " @@ -4287,8 +4284,7 @@ def execute(self, params, **kwargs): pam_settings = vault.TypedField.new_field('pamSettings', "", "") record.fields.append(pam_settings) - record_management.update_record(params, record) - params.sync_data = True + update_pam_record(params, record, command='pam-split') if pam_config_uid: encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index e9d8ffe00..b2d656c99 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -19,7 +19,8 @@ ], 'common': [ 'get_folder_key', 'get_record_key', 'get_user_public_key', - 'get_record_from_cache', 'parse_sharing_status', 'get_record_key_type', + 'get_record_from_cache', 'get_record_revision', 'patch_record_revision', + 'parse_sharing_status', 'get_record_key_type', 'encrypt_record_key_for_folder', 'encrypt_for_recipient', 'handle_share_invite', 'resolve_user_uid_bytes', 'load_user_public_key', 'parse_folder_access_result', diff --git a/keepercommander/nested_share_folder/common.py b/keepercommander/nested_share_folder/common.py index 4bbdb561e..9b0f4c387 100644 --- a/keepercommander/nested_share_folder/common.py +++ b/keepercommander/nested_share_folder/common.py @@ -65,6 +65,36 @@ def get_record_from_cache(params, record_uid: str) -> Optional[dict]: return None +def get_record_revision(params, record_uid: str, default: int = 0) -> int: + """Return the highest known revision across NSF and classic caches. + + NSF metadata and classic vault sync can diverge; always prefer the + freshest revision when sending optimistic-concurrency updates. + """ + revisions = [] + for attr in ('nested_share_records', 'record_cache'): + cache = getattr(params, attr, None) or {} + if record_uid in cache: + rev = cache[record_uid].get('revision') + if rev is None: + continue + try: + revisions.append(int(rev)) + except (TypeError, ValueError): + continue + return max(revisions) if revisions else default + + +def patch_record_revision(params, record_uid: str, revision: int) -> None: + """Write *revision* into both NSF and classic caches when the UID is present.""" + if not revision: + return + for attr in ('nested_share_records', 'record_cache'): + cache = getattr(params, attr, None) + if cache and record_uid in cache: + cache[record_uid]['revision'] = revision + + def get_record_key_type(params, record_uid: str) -> Optional[int]: """Return the record key type if available (legacy AES-CBC vs AES-GCM).""" meta = getattr(params, 'meta_data_cache', {}).get(record_uid) diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index 1ac17c2fc..66c1dda35 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -15,6 +15,7 @@ from .common import ( get_folder_key, get_record_key, get_record_from_cache, + get_record_revision, patch_record_revision, get_user_public_key, encrypt_for_recipient, handle_share_invite, parse_sharing_status, ) @@ -173,62 +174,121 @@ def _load_existing_record_data(params, record_uid, rec=None): return None +def _apply_record_update_overrides(data, title=None, record_type=None, fields=None, notes=None): + """Apply optional title/type/fields/notes overrides onto a record data dict.""" + if title is not None: + data['title'] = title + if record_type is not None: + data['type'] = record_type + if fields is not None: + by_type = {} + for ef in data.get('fields', []): + by_type.setdefault(ef.get('type'), []).append(ef) + for ft, fv in fields.items(): + fv = fv if isinstance(fv, list) else [fv] + if ft in by_type and by_type[ft]: + by_type[ft][0]['value'] = fv + else: + data.setdefault('fields', []).append({'type': ft, 'value': fv}) + if notes is not None: + data['notes'] = notes + return data + + +def _sync_down_for_nsf_update(params): + """Sync while preserving NSF folder keys when possible.""" + try: + from ..commands.pam_import.nsf_helpers import sync_down_preserving_nsf_keys as _sync + except ImportError: + from .. import sync_down as sync_down_mod + _sync = sync_down_mod.sync_down + _sync(params) + + def update_record_v3(params, record_uid, data=None, title=None, record_type=None, fields=None, notes=None, non_shared_data=None, revision=None): + """Update an NSF/classic-cached record via vault/records/v3/update. + + On ``RS_OUT_OF_SYNC`` with no explicit *revision*, syncs once. When the + update can be rebuilt from *title* / *record_type* / *fields* / *notes* + (or when *data* was omitted), the retry applies those overrides to the + refreshed record. When the caller supplied a full *data* payload only, + the sync runs but no retry is attempted — the caller must re-run. + """ rec = get_record_from_cache(params, record_uid) if not rec: - from .. import sync_down - sync_down.sync_down(params) + _sync_down_for_nsf_update(params) rec = get_record_from_cache(params, record_uid) if not rec: raise ValueError(f"Record {record_uid} not found") rk = rec.get('record_key_unencrypted') or get_record_key(params, record_uid) + can_rebuild_on_retry = ( + data is None or title is not None or record_type is not None + or fields is not None or notes is not None + ) if data is None: existing = _load_existing_record_data(params, record_uid, rec) data = existing.copy() if existing else {'fields': []} - if title is not None: - data['title'] = title - if record_type is not None: - data['type'] = record_type - if fields is not None: - by_type = {} - for ef in data.get('fields', []): - by_type.setdefault(ef.get('type'), []).append(ef) - for ft, fv in fields.items(): - fv = fv if isinstance(fv, list) else [fv] - if ft in by_type and by_type[ft]: - by_type[ft][0]['value'] = fv - else: - data.setdefault('fields', []).append({'type': ft, 'value': fv}) - if notes is not None: - data['notes'] = notes - - ru = record_pb2.RecordUpdate() - ru.record_uid = utils.base64_url_decode(record_uid) - ru.client_modified_time = utils.current_milli_time() - ru.revision = revision if revision is not None else rec.get('revision', 0) - - dj = pad_aes_gcm(json.dumps(data)) - db = dj.encode() if isinstance(dj, str) else dj - ru.data = crypto.encrypt_aes_v2(db, rk) - - if non_shared_data: - nsj = pad_aes_gcm(json.dumps(non_shared_data)) - nsb = nsj.encode() if isinstance(nsj, str) else nsj - ru.non_shared_data = crypto.encrypt_aes_v2(nsb, rk) - + _apply_record_update_overrides(data, title, record_type, fields, notes) + + def _build_update(payload, rev): + ru = record_pb2.RecordUpdate() + ru.record_uid = utils.base64_url_decode(record_uid) + ru.client_modified_time = utils.current_milli_time() + # Prefer the highest known revision: NSF metadata can lag classic sync_down. + ru.revision = (rev if rev is not None + else get_record_revision(params, record_uid, rec.get('revision') or 0)) + dj = pad_aes_gcm(json.dumps(payload)) + db = dj.encode() if isinstance(dj, str) else dj + ru.data = crypto.encrypt_aes_v2(db, rk) + if non_shared_data: + nsj = pad_aes_gcm(json.dumps(non_shared_data)) + nsb = nsj.encode() if isinstance(nsj, str) else nsj + ru.non_shared_data = crypto.encrypt_aes_v2(nsb, rk) + return ru + + ru = _build_update(data, revision) response = record_update_v3(params, [ru]) if response.records: r = response.records[0] + # After PAM/classic edits, sync can leave NSF metadata lagging. Refresh + # once and retry when overrides allow a safe rebuild from fresh caches. + if r.status == record_pb2.RS_OUT_OF_SYNC and revision is None: + _sync_down_for_nsf_update(params) + if not can_rebuild_on_retry: + return { + 'record_uid': record_uid, + 'status': record_pb2.RecordModifyResult.Name(r.status), + 'message': (r.message or + 'Record was modified concurrently; please re-run the command.'), + 'success': False, + 'revision': getattr(response, 'revision', 0), + } + rec = get_record_from_cache(params, record_uid) or rec + rk = rec.get('record_key_unencrypted') or rk + existing = _load_existing_record_data(params, record_uid, rec) + retry_data = existing.copy() if existing else {'fields': []} + _apply_record_update_overrides( + retry_data, title, record_type, fields, notes) + ru = _build_update(retry_data, None) + response = record_update_v3(params, [ru]) + if not response.records: + raise KeeperApiError('no_results', 'No results from record update') + r = response.records[0] + success = r.status == record_pb2.RS_SUCCESS + # For single-record updates the server returns the record revision here. + new_revision = getattr(response, 'revision', 0) + if success: + patch_record_revision(params, record_uid, new_revision) return { 'record_uid': record_uid, 'status': record_pb2.RecordModifyResult.Name(r.status), 'message': r.message, - 'success': r.status == record_pb2.RS_SUCCESS, - 'revision': getattr(response, 'revision', 0), + 'success': success, + 'revision': new_revision, } raise KeeperApiError('no_results', 'No results from record update') diff --git a/keepercommander/nested_share_folder/sync.py b/keepercommander/nested_share_folder/sync.py index 69c884579..a0332717d 100644 --- a/keepercommander/nested_share_folder/sync.py +++ b/keepercommander/nested_share_folder/sync.py @@ -386,9 +386,14 @@ def _process_records(params, records): """Store DriveRecord metadata (no encrypted content).""" for record in records: record_uid = utils.base64_url_encode(record.recordUid) + existing = params.nested_share_records.get(record_uid) or {} + # Classic vault updates can bump revision before NSF drive metadata + # catches up. Never allow a lagging DriveRecord to downgrade the cache. + incoming_rev = record.revision or 0 + existing_rev = existing.get('revision', 0) or 0 record_obj = { 'record_uid': record_uid, - 'revision': record.revision, + 'revision': max(incoming_rev, existing_rev), 'version': record.version, 'shared': record.shared if record.shared else False, 'client_modified_time': record.clientModifiedTime if record.clientModifiedTime else 0, @@ -397,6 +402,9 @@ def _process_records(params, records): record_obj['file_size'] = record.fileSize if record.thumbnailSize: record_obj['thumbnail_size'] = record.thumbnailSize + # Preserve decrypted key material across metadata refreshes. + if 'record_key_unencrypted' in existing: + record_obj['record_key_unencrypted'] = existing['record_key_unencrypted'] params.nested_share_records[record_uid] = record_obj @@ -1136,9 +1144,34 @@ def _reconstruct_nested_share_folder_entities(params): if 'data_json' not in rd_obj: continue + classic = params.record_cache.get(record_uid) or {} + classic_rev = classic.get('revision', 0) or 0 + nsf_rev = record_obj.get('revision', 0) or 0 + # Prefer the freshest revision across classic sync and NSF metadata. + revision = max(classic_rev, nsf_rev) + if revision != nsf_rev: + record_obj['revision'] = revision + + # Classic response.records payloads use encrypted 'data' and are not tagged + # source=nested_share_folder. Prefer that copy when it is at least as fresh + # so lagging keeperDriveData cannot roll content/revision backwards after PAM. + classic_from_vault = ( + 'data' in classic and classic.get('source') != 'nested_share_folder' + ) + if classic_from_vault and classic_rev >= nsf_rev: + if 'record_key_unencrypted' not in classic: + classic['record_key_unencrypted'] = record_obj['record_key_unencrypted'] + # Still backfill meta/owner caches — NSF is often the only source on + # fresh login, and this branch is taken whenever classic sync returns + # the record (e.g. after PAM edits). + _backfill_nsf_record_access_caches(params, record_uid, record_obj, rd_obj) + # Leave classic encrypted payload in place; sync_down decrypts it in a + # later pass (after nested_share_folder_sync.process at ~L783). + continue + record_entry = { 'record_uid': record_uid, - 'revision': record_obj.get('revision', 0), + 'revision': revision, 'version': record_obj.get('version', 0), 'shared': record_obj.get('shared', False), 'record_key_unencrypted': record_obj['record_key_unencrypted'], @@ -1149,24 +1182,28 @@ def _reconstruct_nested_share_folder_entities(params): } params.record_cache[record_uid] = record_entry + _backfill_nsf_record_access_caches(params, record_uid, record_obj, rd_obj) - if record_uid not in params.meta_data_cache: - meta_data = { - 'record_uid': record_uid, - 'record_key_unencrypted': record_obj['record_key_unencrypted'], - 'can_share': True, - 'can_edit': True, - } - if 'user_account_uid' in rd_obj: - meta_data['owner_account_uid'] = rd_obj['user_account_uid'] - if rd_obj['user_account_uid'] in params.user_cache: - meta_data['owner_username'] = params.user_cache[rd_obj['user_account_uid']] - params.meta_data_cache[record_uid] = meta_data - - if record_uid not in params.record_owner_cache: - if 'user_account_uid' in rd_obj: - is_owner = (rd_obj['user_account_uid'] == utils.base64_url_encode(params.account_uid_bytes)) - params.record_owner_cache[record_uid] = RecordOwner( - is_owner, - rd_obj['user_account_uid'] - ) \ No newline at end of file + +def _backfill_nsf_record_access_caches(params, record_uid, record_obj, rd_obj): + """Populate meta_data_cache / record_owner_cache from NSF data when missing.""" + if record_uid not in params.meta_data_cache: + meta_data = { + 'record_uid': record_uid, + 'record_key_unencrypted': record_obj['record_key_unencrypted'], + 'can_share': True, + 'can_edit': True, + } + if 'user_account_uid' in rd_obj: + meta_data['owner_account_uid'] = rd_obj['user_account_uid'] + if rd_obj['user_account_uid'] in params.user_cache: + meta_data['owner_username'] = params.user_cache[rd_obj['user_account_uid']] + params.meta_data_cache[record_uid] = meta_data + + if record_uid not in params.record_owner_cache: + if 'user_account_uid' in rd_obj: + is_owner = (rd_obj['user_account_uid'] == utils.base64_url_encode(params.account_uid_bytes)) + params.record_owner_cache[record_uid] = RecordOwner( + is_owner, + rd_obj['user_account_uid'] + ) diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py index 45fd3a510..35e12876f 100644 --- a/keepercommander/sync_down.py +++ b/keepercommander/sync_down.py @@ -287,7 +287,23 @@ def convert_record(r): for r in response.records: record = convert_record(r) - params.record_cache[record['record_uid']] = record + record_uid = record['record_uid'] + params.record_cache[record_uid] = record + # Classic vault updates bump revision without NSF keeperDriveData. + # Keep nested_share_records.revision aligned so NSF updates do not + # send a stale revision (RS_OUT_OF_SYNC / "This object no longer exists"). + nsf_records = getattr(params, 'nested_share_records', None) + if nsf_records and record_uid in nsf_records: + # Use max(): a deferred classic sync can lag behind a recent + # NSF update that already bumped nested_share_records.revision. + nsf_rec = nsf_records[record_uid] + nsf_rec['revision'] = max( + record['revision'], + nsf_rec.get('revision') or 0, + ) + nsf_rec['version'] = record['version'] + nsf_rec['shared'] = record['shared'] + nsf_rec['client_modified_time'] = record['client_modified_time'] if len(response.nonSharedData) > 0: for nsd in response.nonSharedData: @@ -902,6 +918,8 @@ def convert_user_folder_shared_folder(ufsf): to_delete.clear() logging.debug('Decrypting records') + # NSF reconstruct may leave classic encrypted entries with record_key_unencrypted + # set but data_unencrypted unset; this pass must run after NSF process (~L783). for record_uid, record in params.record_cache.items(): record_key = record['record_key_unencrypted'] if 'data_unencrypted' not in record: diff --git a/unit-tests/pam/test_pam_connection_edit_scrollback.py b/unit-tests/pam/test_pam_connection_edit_scrollback.py index 2ebd61573..090fe1ad5 100644 --- a/unit-tests/pam/test_pam_connection_edit_scrollback.py +++ b/unit-tests/pam/test_pam_connection_edit_scrollback.py @@ -325,7 +325,7 @@ def _mock_record(self, record_type='pamMachine', protocol='ssh'): 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.update_pam_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')) @@ -344,7 +344,7 @@ def test_scrollback_alone_skips_dag(self, mock_tdag, mock_get_config_uid, 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.update_pam_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')) @@ -361,7 +361,7 @@ def test_key_events_alone_skips_dag(self, mock_tdag, mock_get_config_uid, mock_tdag.assert_not_called() @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.update_pam_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')) diff --git a/unit-tests/pam/test_pam_connection_edit_security.py b/unit-tests/pam/test_pam_connection_edit_security.py index 50735c68e..d4dd332d7 100644 --- a/unit-tests/pam/test_pam_connection_edit_security.py +++ b/unit-tests/pam/test_pam_connection_edit_security.py @@ -220,7 +220,7 @@ def _mock_record(self, record_type='pamMachine', protocol='rdp', existing_connec 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.update_pam_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')) @@ -236,7 +236,7 @@ def test_ignore_server_cert_on_writes_true(self, mock_tdag, mock_get_config_uid, 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.update_pam_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')) @@ -251,7 +251,7 @@ def test_ignore_server_cert_off_writes_false(self, mock_tdag, mock_get_config_ui 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.update_pam_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')) @@ -266,7 +266,7 @@ def test_ignore_server_cert_default_removes_key(self, mock_tdag, mock_get_config 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.update_pam_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')) @@ -281,7 +281,7 @@ def test_security_mode_writes_lowercase_value(self, mock_tdag, mock_get_config_u 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.update_pam_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')) @@ -296,7 +296,7 @@ def test_security_mode_default_removes_key(self, mock_tdag, mock_get_config_uid, 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.update_pam_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')) @@ -339,7 +339,7 @@ def _mock_record(self, record_type='pamMachine', protocol='rdp'): 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.update_pam_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')) @@ -356,7 +356,7 @@ def test_ignore_server_cert_alone_skips_dag(self, mock_tdag, mock_get_config_uid 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.update_pam_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')) diff --git a/unit-tests/pam/test_pam_nsf_config.py b/unit-tests/pam/test_pam_nsf_config.py index 30086837a..d3372e1d9 100644 --- a/unit-tests/pam/test_pam_nsf_config.py +++ b/unit-tests/pam/test_pam_nsf_config.py @@ -113,6 +113,20 @@ def test_create_record_in_folder_uses_v3_add_for_nsf_folder(self, _extract, mock self.assertEqual(record.record_uid, 'nsf_record_uid') _sync.assert_called_once_with(params) + @mock.patch('keepercommander.commands.pam.vault_target.record_management.update_record') + def test_update_pam_record_classic_sets_sync_data(self, mock_update): + from keepercommander.commands.pam.vault_target import update_pam_record + + params = _make_params() + params.sync_data = False + record = vault.TypedRecord() + record.record_uid = 'classic_uid' + + update_pam_record(params, record, command='pam connection edit') + + mock_update.assert_called_once_with(params, record) + self.assertTrue(params.sync_data) + def test_resolve_pam_folder_uid_finds_root_nsf_folder_by_name(self): params = _make_params() folder = NestedShareFolderNode() diff --git a/unit-tests/pam/test_pam_split_nsf.py b/unit-tests/pam/test_pam_split_nsf.py index 7ba568c90..a37b74501 100644 --- a/unit-tests/pam/test_pam_split_nsf.py +++ b/unit-tests/pam/test_pam_split_nsf.py @@ -48,7 +48,7 @@ class TestPamSplitNsfPlacement(unittest.TestCase): @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'encrypted_session', b'encrypted_key', b'transmission_key')) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') - @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') @mock.patch('keepercommander.commands.pam.vault_target.create_record_in_folder') @mock.patch('keepercommander.commands.pam.vault_target.resolve_pam_folder_uid', return_value='nsf_folder') @@ -83,7 +83,7 @@ def test_split_resolves_explicit_nsf_folder_name( @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'encrypted_session', b'encrypted_key', b'transmission_key')) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') - @mock.patch('keepercommander.commands.tunnel_and_connections.record_management.update_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') @mock.patch('keepercommander.commands.pam.vault_target.create_record_in_folder') @mock.patch('keepercommander.commands.tunnel_and_connections.vault.KeeperRecord.create') @mock.patch('keepercommander.commands.tunnel_and_connections.vault.KeeperRecord.load') diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index 4d8555041..2779c1796 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -1512,6 +1512,301 @@ def test_update_record_v3_preserves_login_fields_from_nsf_record_data(self, mock self.assertEqual(by_type['login'], ['bob2']) self.assertEqual(by_type['password'], ['KeepMe']) + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + def test_update_record_v3_patches_revision_in_both_caches(self, mock_update): + """Successful NSF update must refresh nested_share_records and record_cache revision.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + robj['revision'] = 3 + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 3, + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'T', 'fields': [], + }).encode('utf-8'), + }}, + ) + mock_rs = Mock() + mock_rec = Mock() + mock_rec.status = record_pb2.RS_SUCCESS + mock_rec.message = '' + mock_rs.records = [mock_rec] + mock_rs.revision = 7 + mock_update.return_value = mock_rs + + result = update_record_v3(params, ruid, title='Updated') + self.assertTrue(result['success']) + self.assertEqual(result['revision'], 7) + self.assertEqual(params.nested_share_records[ruid]['revision'], 7) + self.assertEqual(params.record_cache[ruid]['revision'], 7) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + def test_update_record_v3_uses_max_revision_across_caches(self, mock_update): + """Stale NSF metadata must not win over a newer classic record_cache revision.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + robj['revision'] = 4 # stale NSF cache (preferred by get_record_from_cache) + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 9, # fresher after classic PAM edit / sync_down + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'T', 'fields': [], + }).encode('utf-8'), + }}, + ) + mock_rs = Mock() + mock_rec = Mock() + mock_rec.status = record_pb2.RS_SUCCESS + mock_rec.message = '' + mock_rs.records = [mock_rec] + mock_rs.revision = 10 + mock_update.return_value = mock_rs + + result = update_record_v3(params, ruid, title='Updated') + self.assertTrue(result['success']) + + ru = mock_update.call_args[0][1][0] + self.assertEqual(ru.revision, 9) + + def test_get_record_revision_prefers_max(self): + from keepercommander.nested_share_folder.common import get_record_revision + + ruid = utils.generate_uid() + params = _make_params( + nested_share_records={ruid: {'revision': 2}}, + record_cache={ruid: {'revision': 5}}, + ) + self.assertEqual(get_record_revision(params, ruid), 5) + self.assertEqual(get_record_revision(params, 'missing', default=1), 1) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + @patch('keepercommander.nested_share_folder.record_api._sync_down_for_nsf_update') + def test_update_record_v3_retries_on_out_of_sync(self, mock_sync, mock_update): + """RS_OUT_OF_SYNC should sync once and retry with refreshed revision.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + robj['revision'] = 4 + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 4, + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'T', 'fields': [], + }).encode('utf-8'), + }}, + ) + + def _sync_side_effect(_params): + _params.nested_share_records[ruid]['revision'] = 9 + _params.record_cache[ruid]['revision'] = 9 + _params.record_cache[ruid]['data_unencrypted'] = json.dumps({ + 'type': 'login', 'title': 'FromSync', 'fields': [], + }).encode('utf-8') + + mock_sync.side_effect = _sync_side_effect + + stale = Mock() + stale.status = record_pb2.RS_OUT_OF_SYNC + stale.message = 'This object no longer exists.' + stale_rs = Mock() + stale_rs.records = [stale] + stale_rs.revision = 0 + + ok = Mock() + ok.status = record_pb2.RS_SUCCESS + ok.message = '' + ok_rs = Mock() + ok_rs.records = [ok] + ok_rs.revision = 10 + sent = [] + + def _update_side_effect(params_arg, records): + ru = records[0] + decrypted = json.loads( + crypto.decrypt_aes_v2(ru.data, robj['record_key_unencrypted']) + .decode('utf-8').rstrip('\x00') + ) + sent.append({'revision': ru.revision, 'title': decrypted.get('title')}) + if len(sent) == 1: + return stale_rs + return ok_rs + + mock_update.side_effect = _update_side_effect + + result = update_record_v3(params, ruid, title='Updated') + self.assertTrue(result['success']) + self.assertEqual(sent[0]['revision'], 4) + self.assertEqual(sent[0]['title'], 'Updated') + # Retry rebuilds from refreshed cache then re-applies title override. + self.assertEqual(sent[1]['revision'], 9) + self.assertEqual(sent[1]['title'], 'Updated') + mock_sync.assert_called_once() + self.assertEqual(params.nested_share_records[ruid]['revision'], 10) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + @patch('keepercommander.nested_share_folder.record_api._sync_down_for_nsf_update') + def test_update_record_v3_no_retry_when_revision_explicit(self, mock_sync, mock_update): + """Explicit revision means caller owns concurrency — do not auto-retry.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 4, + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'T', 'fields': [], + }).encode('utf-8'), + }}, + ) + stale = Mock() + stale.status = record_pb2.RS_OUT_OF_SYNC + stale.message = 'This object no longer exists.' + stale_rs = Mock() + stale_rs.records = [stale] + stale_rs.revision = 0 + mock_update.return_value = stale_rs + + result = update_record_v3(params, ruid, title='Updated', revision=4) + self.assertFalse(result['success']) + mock_sync.assert_not_called() + self.assertEqual(mock_update.call_count, 1) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + @patch('keepercommander.nested_share_folder.record_api._sync_down_for_nsf_update') + def test_update_record_v3_caller_data_only_no_retry_on_out_of_sync( + self, mock_sync, mock_update): + """Full data payload without overrides must not retry (avoid clobbering).""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 4, + 'record_key_unencrypted': robj['record_key_unencrypted'], + }}, + ) + payload = {'type': 'login', 'title': 'WholeRecord', 'fields': []} + stale = Mock() + stale.status = record_pb2.RS_OUT_OF_SYNC + stale.message = 'This object no longer exists.' + stale_rs = Mock() + stale_rs.records = [stale] + stale_rs.revision = 0 + mock_update.return_value = stale_rs + + result = update_record_v3(params, ruid, data=payload) + self.assertFalse(result['success']) + mock_sync.assert_called_once() + self.assertEqual(mock_update.call_count, 1) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + @patch('keepercommander.nested_share_folder.record_api._sync_down_for_nsf_update') + def test_update_record_v3_retry_still_out_of_sync_returns_failure( + self, mock_sync, mock_update): + """A second RS_OUT_OF_SYNC after retry must not loop.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 4, + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'T', 'fields': [], + }).encode('utf-8'), + }}, + ) + stale = Mock() + stale.status = record_pb2.RS_OUT_OF_SYNC + stale.message = 'still stale' + stale_rs = Mock() + stale_rs.records = [stale] + stale_rs.revision = 0 + mock_update.return_value = stale_rs + + result = update_record_v3(params, ruid, title='Updated') + self.assertFalse(result['success']) + mock_sync.assert_called_once() + self.assertEqual(mock_update.call_count, 2) + + def test_process_records_does_not_downgrade_revision(self): + from keepercommander.nested_share_folder.sync import _process_records + from types import SimpleNamespace + + ruid, robj = _make_record() + robj['revision'] = 9 + params = _make_params(nested_share_records={ruid: robj}) + drive_rec = SimpleNamespace( + recordUid=utils.base64_url_decode(ruid), + revision=4, + version=3, + shared=False, + clientModifiedTime=0, + fileSize=0, + thumbnailSize=0, + ) + _process_records(params, [drive_rec]) + self.assertEqual(params.nested_share_records[ruid]['revision'], 9) + self.assertIn('record_key_unencrypted', params.nested_share_records[ruid]) + + def test_reconstruct_classic_preferred_still_backfills_meta_owner(self): + """Classic-preferred continue must still populate meta/owner caches.""" + from keepercommander.nested_share_folder.sync import ( + _reconstruct_nested_share_folder_entities, + ) + from keepercommander.params import RecordOwner + + ruid, robj = _make_record() + owner_uid = _ACCOUNT_UID + robj['revision'] = 5 + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: { + 'data_json': {'type': 'login', 'title': 'NSF', 'fields': []}, + 'user_account_uid': owner_uid, + }}, + record_cache={ruid: { + 'record_uid': ruid, + 'revision': 9, + 'data': 'encrypted-classic-payload', + 'version': 3, + }}, + meta_data_cache={}, + record_owner_cache={}, + user_cache={owner_uid: 'owner@example.com'}, + ) + params.account_uid_bytes = utils.base64_url_decode(_ACCOUNT_UID) + + _reconstruct_nested_share_folder_entities(params) + + # Keep classic vault payload; do not overwrite with NSF data_json. + self.assertEqual(params.record_cache[ruid].get('data'), 'encrypted-classic-payload') + self.assertNotIn('source', params.record_cache[ruid]) + self.assertIn(ruid, params.meta_data_cache) + self.assertEqual(params.meta_data_cache[ruid]['can_edit'], True) + self.assertEqual(params.meta_data_cache[ruid]['owner_account_uid'], owner_uid) + self.assertIn(ruid, params.record_owner_cache) + self.assertIsInstance(params.record_owner_cache[ruid], RecordOwner) + self.assertEqual(params.nested_share_records[ruid]['revision'], 9) + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') @patch('keepercommander.nested_share_folder.record_api.get_user_public_key') diff --git a/unit-tests/test_sync_down.py b/unit-tests/test_sync_down.py index 71f52adff..c94f6b68d 100644 --- a/unit-tests/test_sync_down.py +++ b/unit-tests/test_sync_down.py @@ -95,6 +95,70 @@ def test_sync_remove_team_shared_folder(self): self.assertEqual(len(params.team_cache), 0) self.assert_key_unencrypted(params) + def test_classic_records_update_nested_share_revision(self): + """Classic response.records must refresh nested_share_records.revision when present.""" + params = get_synced_params() + record_uid = next(iter(params.record_cache)) + params.nested_share_records = { + record_uid: { + 'record_uid': record_uid, + 'revision': 1, + 'version': 3, + 'shared': False, + 'client_modified_time': 0, + } + } + new_revision = 42 + + with mock.patch('keepercommander.api.communicate_rest') as mock_comm: + rs = SyncDown_pb2.SyncDownResponse() + rs.continuationToken = crypto.get_random_bytes(64) + rec = rs.records.add() + rec.recordUid = utils.base64_url_decode(record_uid) + rec.revision = new_revision + rec.version = 3 + rec.shared = True + rec.clientModifiedTime = 123456 + rec.data = b'\x00' * 16 + mock_comm.return_value = rs + sync_down(params) + + self.assertEqual(params.record_cache[record_uid]['revision'], new_revision) + self.assertEqual(params.nested_share_records[record_uid]['revision'], new_revision) + self.assertEqual(params.nested_share_records[record_uid]['shared'], True) + self.assertEqual(params.nested_share_records[record_uid]['client_modified_time'], 123456) + + def test_classic_records_does_not_downgrade_nsf_revision(self): + """Deferred classic sync must not roll back a fresher NSF revision.""" + params = get_synced_params() + record_uid = next(iter(params.record_cache)) + params.nested_share_records = { + record_uid: { + 'record_uid': record_uid, + 'revision': 99, + 'version': 3, + 'shared': False, + 'client_modified_time': 0, + } + } + stale_revision = 42 + + with mock.patch('keepercommander.api.communicate_rest') as mock_comm: + rs = SyncDown_pb2.SyncDownResponse() + rs.continuationToken = crypto.get_random_bytes(64) + rec = rs.records.add() + rec.recordUid = utils.base64_url_decode(record_uid) + rec.revision = stale_revision + rec.version = 3 + rec.shared = True + rec.clientModifiedTime = 123456 + rec.data = b'\x00' * 16 + mock_comm.return_value = rs + sync_down(params) + + self.assertEqual(params.record_cache[record_uid]['revision'], stale_revision) + self.assertEqual(params.nested_share_records[record_uid]['revision'], 99) + def assert_key_unencrypted(self, params): for r in params.record_cache.values(): self.assertTrue('record_key_unencrypted' in r) From 8343f806e5c87d78b8f7cc14dd549de81b88e491 Mon Sep 17 00:00:00 2001 From: pvagare-ks Date: Wed, 19 Aug 2026 15:26:08 +0530 Subject: [PATCH 06/14] KC-1385, KC-1405: Add Range Support to CyberArk PAM Import and --target-node to import --format=cyberark command --- .../commands/pam_import/cyberark_import.py | 162 +++++++--- keepercommander/commands/pam_service/add.py | 56 ++-- keepercommander/importer/commands.py | 6 + keepercommander/importer/cyberark/cyberark.py | 152 +++++++--- .../importer/cyberark/pam/__init__.py | 2 + .../importer/cyberark/pam/dependents.py | 38 ++- keepercommander/importer/imp_exp.py | 4 +- tests/test_cyberark_pam_import.py | 285 +++++++++++++++++- 8 files changed, 578 insertions(+), 127 deletions(-) diff --git a/keepercommander/commands/pam_import/cyberark_import.py b/keepercommander/commands/pam_import/cyberark_import.py index 494ca5c41..7dbe0728e 100644 --- a/keepercommander/commands/pam_import/cyberark_import.py +++ b/keepercommander/commands/pam_import/cyberark_import.py @@ -47,6 +47,7 @@ exclude_system_safes, resolve_account_dependents, resolve_linked_accounts, + PAM_SERVICE_ADD_TYPES, pick_admin_credentials, pick_launch_credentials, detect_dual_account, @@ -376,7 +377,9 @@ def _maybe_interactive_safe_pick(self, safes: list[dict]) -> list[dict]: selected = self._cmd._interactive_safe_picker(safes) if selected is None: return safes - selected_set = {n.strip() for n in selected.split(',')} + if not selected: + return [] + selected_set = set(selected) picked = [s for s in safes if s.get("safeName", "") in selected_set] if not picked: print_formatted_text(HTML("No safes selected")) @@ -705,7 +708,7 @@ def _collect_dependents(self, account: dict, record: dict, f" • {_esc(dep.get('service_name', '') or '?')} " f"({_esc(dep.get('raw_type', '') or 'unknown')}) " f"on {_esc(dep.get('machine_address', '') or '?')} " - f"→ pam action service {_esc(mapped_to)}")) + f"→ pam action service add --type {_esc(mapped_to)}")) def _apply_folder_paths(self, record: dict, safe_name: str, folder_mapper: SafeFolderMapper): @@ -1504,17 +1507,7 @@ def _apply_service_dependent_mappings( self, mapped: MappedImportResult, project_result: dict, unmapped_items: list[dict], ) -> Optional[dict]: - """Replay CyberArk dependents as KeeperPAM service-account mappings. - - Iterates over ``mapped.dependents`` collected during the mapping phase - and invokes ``PAMActionServiceAddCommand`` once per (machine, user, type) - tuple that resolves to imported records. Categories with no Keeper - equivalent, missing host machines, and non-Windows OS hosts are all - skipped silently and accounted for in the returned summary so the - import report can surface them. - - Returns a summary dict, or ``None`` when nothing to do. - """ + """Replay CyberArk dependents as KeeperPAM service-account mappings.""" opts = self.options if opts.skip_dependents or not mapped.dependents: return None @@ -1595,18 +1588,35 @@ def _apply_service_dependent_mappings( add_cmd = PAMActionServiceAddCommand() for dep in mapped.dependents: + # Match pam action service add --type {service,task,iis_pool} service_type = dep.get("service_type") - if service_type not in ("service", "task", "iis"): + # Legacy persisted project state stored CyberArk IIS dependents as + # "iis". Fresh imports emit "iis_pool" via _DEPENDENT_TYPE_ALIASES. + if service_type == "iis": + service_type = "iis_pool" + if service_type not in PAM_SERVICE_ADD_TYPES: summary["skipped_unsupported"] += 1 continue + service_name = (dep.get("service_name") or "").strip() + if not service_name: + summary["skipped_other"] += 1 + summary["details"].append({ + "service": "", + "host": dep.get("machine_address", ""), + "type": dep.get("raw_type", ""), + "reason": "pam action service add requires --name " + "(Windows service / task / IIS pool name)", + }) + continue + machine_record = self._find_machine_record( dep.get("machine_address", ""), machine_index, ) if machine_record is None: summary["skipped_missing_machine"] += 1 summary["details"].append({ - "service": dep.get("service_name", ""), + "service": service_name, "host": dep.get("machine_address", ""), "type": dep.get("raw_type", ""), "reason": "no PAM Machine record imported for this host", @@ -1617,18 +1627,20 @@ def _apply_service_dependent_mappings( summary["skipped_non_windows"] += 1 unmapped_items.append({ "category": "CyberArk dependent", - "item": (f"{dep.get('service_name') or dep.get('raw_type')} " + "item": (f"{service_name or dep.get('raw_type')} " f"on {dep.get('machine_address')}"), "action": "Host is not Windows — Keeper PAM can only rotate " "Windows service / task / IIS credentials", }) continue - user_record = user_index.get(dep.get("master_user_title", "")) + user_record = user_index.get( + (dep.get("master_user_title") or "").casefold(), + ) if user_record is None: summary["skipped_missing_user"] += 1 summary["details"].append({ - "service": dep.get("service_name", ""), + "service": service_name, "host": dep.get("machine_address", ""), "type": dep.get("raw_type", ""), "reason": "PAM User record not found in vault after import", @@ -1636,27 +1648,49 @@ def _apply_service_dependent_mappings( continue try: + # execute() reads argparse dest names, not CLI flag names: + # --type → service_type, --name → name, --machine-uid → machine_uid add_cmd.execute( self.params, gateway=gateway_uid, configuration_uid=gateway_context.configuration.record_uid, machine_uid=machine_record.record_uid, user_uid=user_record.record_uid, - type=service_type, + service_type=service_type, + name=service_name, ) summary["added"] += 1 - except Exception as e: # noqa: BLE001 — never block reporting + except CommandError as e: summary["skipped_other"] += 1 + err_text = (e.message or str(e)).strip() or type(e).__name__ logging.warning( "Failed to register %s mapping for %s on %s: %s", - service_type, dep.get("service_name", "?"), + service_type, service_name, + dep.get("machine_address", "?"), err_text, + ) + summary["details"].append({ + "service": service_name, + "host": dep.get("machine_address", ""), + "type": dep.get("raw_type", ""), + "reason": f"pam action service add failed: {err_text.splitlines()[0]}", + }) + except Exception as e: # noqa: BLE001 — never block reporting + summary["skipped_other"] += 1 + err_text = str(e).strip() or type(e).__name__ + logging.warning( + "Failed to register %s mapping for %s on %s: %s: %s", + service_type, service_name, dep.get("machine_address", "?"), type(e).__name__, + err_text, ) summary["details"].append({ - "service": dep.get("service_name", ""), + "service": service_name, "host": dep.get("machine_address", ""), "type": dep.get("raw_type", ""), - "reason": f"pam action service add failed: {type(e).__name__}", + "reason": ( + f"pam action service add failed: {type(e).__name__}: " + f"{err_text.splitlines()[0]}" + ), }) return summary @@ -1836,6 +1870,17 @@ def _attach_report_files(self, notes_text: str, report_config_uid: str, pass +# Unicode dashes that users commonly paste instead of ASCII '-'. +_DASH_TRANSLATE = str.maketrans({ + '\u2010': '-', # hyphen + '\u2011': '-', # non-breaking hyphen + '\u2012': '-', # figure dash + '\u2013': '-', # en dash + '\u2014': '-', # em dash + '\u2212': '-', # minus +}) + + class CyberArkPAMImportCommand(Command): parser = argparse.ArgumentParser( prog="pam project cyberark-import", @@ -2202,11 +2247,46 @@ def _list_safes_detailed(safes: list[dict], system_excluded: int): print() @staticmethod - def _interactive_safe_picker(safes: list[dict]) -> Optional[str]: - """Show safes and let user select which to import. + def _parse_index_selection(choice: str, count: int) -> list[int]: + """Parse a 1-based selection string into 0-based indices.""" + selected: list[int] = [] + seen: set[int] = set() + if count <= 0: + return selected + token_re = re.compile(r'^(\d+)(?:-(\d+))?$') + errors: list[str] = [] + for part in choice.split(','): + part = part.strip().translate(_DASH_TRANSLATE) + part = re.sub(r'\s+', '', part) + if not part: + continue + m = token_re.match(part) + if not m: + errors.append(part) + continue + start = int(m.group(1)) + end = int(m.group(2)) if m.group(2) is not None else start + lo, hi = min(start, end), max(start, end) + if lo < 1 or hi > count: + errors.append(part) + continue + for num in range(lo, hi + 1): + idx = num - 1 + if idx not in seen: + selected.append(idx) + seen.add(idx) + if errors: + raise ValueError( + f"invalid or out-of-range index(es): {', '.join(errors)}" + ) + return selected - Returns comma-separated safe names for apply_safe_filter, - or None to import all. + @staticmethod + def _interactive_safe_picker(safes: list[dict]) -> Optional[list[str]]: + """Show safes and let the user select which to import. + + Returns ``None`` to import all, an empty list if the user cancelled + or the selection was invalid, or the list of selected safe names. """ print(f'\n{bcolors.OKBLUE}CyberArk Safes Found:{bcolors.ENDC}') print('─' * 50) @@ -2219,28 +2299,32 @@ def _interactive_safe_picker(safes: list[dict]) -> Optional[str]: print() try: - choice = input(f' Select safes (comma-separated numbers, or A for all) [A]: ').strip() + choice = input( + ' Select safes (numbers/ranges e.g. 1-4,6,8-10, or A for all) [A]: ' + ).strip() except EOFError: return None if not choice or choice.upper() == 'A': return None - selected = [] - for part in choice.split(','): - part = part.strip() - try: - idx = int(part) - 1 - if 0 <= idx < len(numbered): - selected.append(numbered[idx]) - except ValueError: - continue + try: + indices = CyberArkPAMImportCommand._parse_index_selection( + choice, len(numbered) + ) + except ValueError as e: + print(f'{bcolors.FAIL}{e}. Import cancelled.{bcolors.ENDC}') + return [] + selected = [numbered[idx] for idx in indices] if not selected: - return None + print( + f'{bcolors.FAIL}No valid indexes in {choice!r}. Import cancelled.{bcolors.ENDC}' + ) + return [] logging.warning('Selected safes: %s', ', '.join(selected)) - return ','.join(selected) + return selected class CyberArkPAMCleanupCommand(Command): diff --git a/keepercommander/commands/pam_service/add.py b/keepercommander/commands/pam_service/add.py index 05c9ec055..fff64d52f 100644 --- a/keepercommander/commands/pam_service/add.py +++ b/keepercommander/commands/pam_service/add.py @@ -3,7 +3,7 @@ import logging from . import record_lookup from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg -from ...display import bcolors +from ...error import CommandError from ... import vault from ...discovery_common.user_service import UserService from ...discovery_common.record_link import RecordLink @@ -11,7 +11,7 @@ from ...discovery_common.types import UserAcl, ServiceEnum from ...keeper_dag.types import RefType, EdgeType from ... import __version__ -from typing import Optional, TYPE_CHECKING +from typing import NoReturn, Optional, TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -40,6 +40,10 @@ class PAMActionServiceAddCommand(PAMGatewayActionDiscoverCommandBase): def get_parser(self): return PAMActionServiceAddCommand.parser + @staticmethod + def _fail(message: str) -> NoReturn: + raise CommandError('pam action service add', message) + def execute(self, params: KeeperParams, **kwargs): gateway = kwargs.get("gateway", "not_set") @@ -55,15 +59,15 @@ def execute(self, params: KeeperParams, **kwargs): 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 + self._fail(f"Could not find the gateway configuration for {gateway}.") except MultiConfigurationException as err: multi_conf_msg(gateway, err) - return + self._fail( + f"Multiple PAM configurations match gateway {gateway}. Use --configuration-uid." + ) if gateway_context is None: - print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") - return + self._fail("Cannot get gateway information. Gateway may not be up.") record_link = RecordLink(record=gateway_context.configuration, params=params, @@ -82,54 +86,45 @@ def execute(self, params: KeeperParams, **kwargs): # 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 + self._fail("The machine record does not exists.") # 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 + self._fail("The machine record is not a PAM Machine.") # Make sure this machine is linked to the configuration record. machine_rl = record_link.get_record_link(machine_record.record_uid) if machine_rl is None: - print(self._f("The machine record does not exists in the graph.")) - return + self._fail("The machine record does not exists in the graph.") # Edges from provider and machine might be wrong. # Should be a LINK edge, could be an ACL edge. root_vertex = record_link.dag.get_root if root_vertex is None: - print(self._f("Could not get the root of the graph.")) - return + self._fail("Could not get the root of the graph.") 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 + self._fail("The machine record does not belong to this gateway.") ############### # 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 + self._fail("The user record does not exists.") # 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 + self._fail("The user record is not a PAM User.") record_rotation = params.record_rotation_cache.get(user_record.record_uid) if record_rotation is not None: controller_uid = record_rotation.get("configuration_uid") if controller_uid is None or controller_uid != gateway_context.configuration_uid: - print(self._f("The user record does not belong to this gateway. Cannot use this user.")) - return + self._fail("The user record does not belong to this gateway. Cannot use this user.") else: - print(self._f("The user record does not have any rotation settings.")) - return + self._fail("The user record does not have any rotation settings.") ######## @@ -137,18 +132,15 @@ def execute(self, params: KeeperParams, **kwargs): # 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 + self._fail("Cannot find the operating system field in this record.") 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 + self._fail("The operating system field of the machine record is blank.") 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 + self._fail("The operating system is not Windows. " + "PAM can only rotate the services and scheduled task password on Windows.") # Get the machine service vertex. # If it doesn't exist, create one. diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index fef8a3b83..d2a19398a 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -85,6 +85,8 @@ def register_command_info(aliases, command_info): help='Display skipped records') import_parser.add_argument('--secret-ids', dest='secret_ids', action='store', help='Comma separated list of secret IDs to fetch (Thycotic)') +import_parser.add_argument('--target-node', '--node', dest='target_node', action='store', + help='node name or ID for CyberArk-provisioned users, teams, and roles (default: root node)') import_parser.add_argument( 'name', type=str, help='file name (json, csv , keepass, 1password), account name (lastpass), or URL (ManageEngine, Thycotic). ' @@ -285,6 +287,10 @@ def execute(self, params, **kwargs): logging.warning(f'Record type "{record_type}" not found.') return + if kwargs.get('target_node') and import_format != 'cyberark': + logging.warning('--target-node/--node is only used with --format=cyberark; ignoring') + kwargs['target_node'] = None + logging.info('Processing... please wait.') imp_exp._import(params, import_format, import_name, manage_users=manage_users, manage_records=manage_records, can_edit=can_edit, can_share=can_share, **kwargs) diff --git a/keepercommander/importer/cyberark/cyberark.py b/keepercommander/importer/cyberark/cyberark.py index 2620d17ce..cf26e44f0 100644 --- a/keepercommander/importer/cyberark/cyberark.py +++ b/keepercommander/importer/cyberark/cyberark.py @@ -22,6 +22,8 @@ from ... import api, crypto, utils from ...commands.enterprise_common import EnterpriseCommand from ...constants import EMAIL_PATTERN +from ...error import CommandError +from .pam import _esc from ..importer import ( BaseDownloadMembership, BaseImporter, @@ -978,6 +980,16 @@ def _do_import_inner(self, filename, **kwargs): return pvwa_host, authorization_token, query_params = auth + params = kwargs.get("params") + will_teams = environ.get("_CYBERARK_SKIP_TEAMS", "").lower() not in ("1", "true", "yes") + will_create_users = environ.get("_CYBERARK_SKIP_CREATE_USERS", "").lower() not in ("1", "true", "yes") + will_print_users = environ.get("_CYBERARK_SKIP_USERS_LIST", "").lower() not in ("1", "true", "yes") + target_node = kwargs.get("target_node") + + provision_node_id = None + if will_teams: + provision_node_id = self._resolve_provisioning_node_id(params, target_node) + safes = self._resolve_safes(pvwa_host, authorization_token) if not safes: return @@ -1013,11 +1025,6 @@ def _do_import_inner(self, filename, **kwargs): continue safe_accounts[safe] = accounts - params = kwargs.get("params") - will_teams = environ.get("_CYBERARK_SKIP_TEAMS", "").lower() not in ("1", "true", "yes") - will_create_users = environ.get("_CYBERARK_SKIP_CREATE_USERS", "").lower() not in ("1", "true", "yes") - will_print_users = environ.get("_CYBERARK_SKIP_USERS_LIST", "").lower() not in ("1", "true", "yes") - # Gather the CyberArk identities (groups + users) that will become Keeper # teams, roles and users so they can be previewed before the import. The # fetched users are reused after the import (no second fetch). @@ -1090,6 +1097,21 @@ def _do_import_inner(self, filename, **kwargs): summary_lines.append(f" - {len(group_names)} user group(s) as Keeper teams and roles") if eligible_users: summary_lines.append(f" - {len(eligible_users)} user(s) provisioned as Keeper users") + if will_teams and provision_node_id is not None: + if target_node: + summary_lines.append( + f' - provision teams, roles, and users into node {_esc(target_node)} ' + f'(id {provision_node_id})' + ) + else: + summary_lines.append( + f' - provision teams, roles, and users into the default root node ' + f'(id {provision_node_id})' + ) + elif will_teams: + summary_lines.append( + ' - teams/roles/users will be skipped (no provisioning node)' + ) if not self._confirm_import(pvwa_host, summary="\n".join(summary_lines)): print_formatted_text(HTML("\nImport cancelled by user")) return @@ -1210,15 +1232,92 @@ def _do_import_inner(self, filename, **kwargs): # Import CyberArk User Groups as Keeper Enterprise Teams + Roles, then optionally # create Keeper users (using their real CyberArk business emails) and # assign them to the matching Keeper Roles. - if will_teams: + if will_teams and provision_node_id is not None: self.import_user_groups( pvwa_host, authorization_token, params, cyberark_users=cyberark_users, + target_node=target_node, + node_id=provision_node_id, ) print_formatted_text(HTML("\nImport completed")) - def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_users=None): + def _resolve_provisioning_node_id(self, params, target_node=None): + """Resolve the enterprise node for CyberArk teams/roles/users. + + If ``target_node`` is set (name or numeric ID), resolve it via + + Returns the node id, or ``None`` if the default-node path fails + (errors are printed). + """ + if target_node is not None: + target_node = str(target_node).strip() or None + + if not params or not getattr(params, "enterprise", None): + if params is None: + msg = ( + "Cannot create Keeper Teams: Keeper session is not " + "available to the importer (no params)." + ) + html = ( + "Cannot create Keeper Teams: Keeper session is not " + "available to the importer (no params)." + ) + else: + msg = ( + "Cannot create Keeper Teams/users: the logged-in account " + "is not an enterprise admin (no enterprise data loaded)." + ) + html = ( + "Cannot create Keeper Teams/users: the logged-in account " + "is not an enterprise admin (no enterprise data loaded)." + ) + if target_node: + raise CommandError("import", msg) + print_formatted_text(HTML(html)) + return None + + if target_node: + try: + nodes = list(EnterpriseCommand.resolve_nodes(params, target_node)) + except (KeyError, TypeError) as e: + logging.debug("resolve_nodes(%r) failed: %s", target_node, e) + nodes = [] + if len(nodes) == 0: + raise CommandError( + "import", + f'Cannot provision into node: node "{target_node}" was not found.', + ) + if len(nodes) > 1: + raise CommandError( + "import", + f'Cannot provision into node: more than one node matches "{target_node}". ' + "Use the numeric node ID.", + ) + return nodes[0]["node_id"] + + # Default: first user-root node (loads managed nodes if needed), then + # the first tree root (parent_id unset/0). + try: + root_nodes = list(EnterpriseCommand.get_user_root_nodes(params)) + except Exception as e: + logging.debug("Failed to load user root nodes: %s", e) + root_nodes = [] + if root_nodes: + return root_nodes[0] + for n in params.enterprise.get("nodes", []) or []: + if not n.get("parent_id"): + return n["node_id"] + print_formatted_text( + HTML( + "Cannot create Keeper Teams/users: no root node found in the " + "enterprise tree." + ) + ) + return None + + def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_users=None, + target_node=None, node_id=None): """Fetch CyberArk User Groups and create them as Keeper Enterprise Teams. This mirrors the ``enterprise-team --add`` command flow: for each @@ -1304,25 +1403,18 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us existing_team_names.add(team["name"].lower()) # Determine the target node id (same default as enterprise-team --add): - # the first user-root node when no --node was specified. - node_id = None - for nid in params.enterprise.get("user_root_nodes", []) or []: - node_id = nid - break + # --target-node when specified, otherwise the first user-root node. if node_id is None: - # Fall back to the first node in the tree (root has parent_id=0) - for n in params.enterprise.get("nodes", []) or []: - if not n.get("parent_id"): - node_id = n["node_id"] - break + node_id = self._resolve_provisioning_node_id(params, target_node) if node_id is None: + return + if target_node: print_formatted_text( HTML( - "Cannot create Keeper Teams: no root node found in the " - "enterprise tree." + f"Provisioning teams, roles, and users into node " + f"{_esc(target_node)} (id {node_id})" ) ) - return print_formatted_text( HTML(f"Importing {len(groups)} user groups as Keeper Teams (members not provisioned):\n"), @@ -1743,24 +1835,10 @@ def _create_keeper_users_and_assign_roles(self, groups, cyberark_users, params, if uname: existing_user_by_email[uname] = u - # Determine the target node (root node) for new invitations. - invite_node_id = None - for nid in params.enterprise.get("user_root_nodes", []) or []: - invite_node_id = nid - break - if invite_node_id is None: - for n in params.enterprise.get("nodes", []) or []: - if not n.get("parent_id"): - invite_node_id = n["node_id"] - break - if invite_node_id is None: - print_formatted_text( - HTML( - "\nCannot invite Keeper users: no root node found in " - "the enterprise tree." - ) - ) + # Caller already resolved --target-node (or the default root). + if node_id is None: return + invite_node_id = node_id tree_key = params.enterprise.get("unencrypted_tree_key") if not tree_key: diff --git a/keepercommander/importer/cyberark/pam/__init__.py b/keepercommander/importer/cyberark/pam/__init__.py index c34884751..21bbfdf4e 100644 --- a/keepercommander/importer/cyberark/pam/__init__.py +++ b/keepercommander/importer/cyberark/pam/__init__.py @@ -39,6 +39,7 @@ validate_import_data, ) from .dependents import ( + PAM_SERVICE_ADD_TYPES, _normalize_dependent_type, resolve_account_dependents, ) @@ -91,6 +92,7 @@ "RecordDecision", "MAX_FETCH_RECORDS", "MAX_SAFE_NAME_LENGTH", + "PAM_SERVICE_ADD_TYPES", "PermissionMapper", "RecordKind", "SafeFolderMapper", diff --git a/keepercommander/importer/cyberark/pam/dependents.py b/keepercommander/importer/cyberark/pam/dependents.py index 1b03242f2..7259308f2 100644 --- a/keepercommander/importer/cyberark/pam/dependents.py +++ b/keepercommander/importer/cyberark/pam/dependents.py @@ -12,9 +12,9 @@ # scheduled tasks and IIS application pools running on remote hosts. The # ``/Accounts/{id}/Dependents`` endpoint returns one entry per (host, service, # type) tuple. KeeperPAM models the same relationship via -# ``pam action service add`` (machine-uid + user-uid + type), so the importer -# collects dependents during the mapping phase and replays them as service -# mappings after the vault import succeeds. +# ``pam action service add`` (--machine-uid, --user-uid, --type, --name), so +# the importer collects dependents during the mapping phase and replays them +# as service mappings after the vault import succeeds. from __future__ import annotations @@ -30,11 +30,14 @@ from .client import CyberArkPVWAClient -# CyberArk dependent ``type`` / ``platformId`` values → Keeper service-mapping -# verbs accepted by ``PAMActionServiceAddCommand`` (--type service|task|iis). -# Keys are matched case-insensitively after stripping non-alphanumerics so -# spellings like ``Windows Service``, ``Win32Service``, ``WinService``, and -# the Privilege Cloud ``SchedTask`` platformId all resolve. +# Exact ``pam action service add --type`` choices. +PAM_SERVICE_ADD_TYPES = frozenset({"service", "task", "iis_pool"}) + +# CyberArk dependent ``type`` / ``platformId`` values → Keeper ``--type`` +# values (service | task | iis_pool). Keys are matched case-insensitively +# after stripping non-alphanumerics so spellings like ``Windows Service``, +# ``Win32Service``, ``WinService``, and Privilege Cloud ``SchedTask`` / +# ``IISAppPool`` all resolve. _DEPENDENT_TYPE_ALIASES: Dict[str, str] = { # Windows services "windowsservice": "service", @@ -47,11 +50,13 @@ "windowsscheduledtask": "task", "schedtask": "task", "task": "task", - # IIS application pools - "iisapppool": "iis", - "iisapplicationpool": "iis", - "iisapppools": "iis", - "iis": "iis", + # IIS application pools → Keeper --type iis_pool + "iisapppool": "iis_pool", + "iisapplicationpool": "iis_pool", + "iisapppools": "iis_pool", + "iispool": "iis_pool", + "iis_pool": "iis_pool", + "iis": "iis_pool", } @@ -78,10 +83,11 @@ def resolve_account_dependents(client: 'CyberArkPVWAClient', * ``machine_address`` — host where the service runs (used to find the Keeper PAM Machine record). - * ``service_type`` — Keeper verb (service|task|iis) or ``None`` for - unsupported categories. + * ``service_type`` — ``pam action service add --type`` value + (service|task|iis_pool) or ``None`` for unsupported categories. * ``raw_type`` — original CyberArk ``Type`` string (kept for reporting). - * ``service_name`` — informational, surfaced in the report only. + * ``service_name`` — ``pam action service add --name`` (Windows + service / scheduled-task / IIS pool name). * ``master_user_title`` — Keeper title of the pamUser record that holds the rotated credential (i.e. the user the service runs as). * ``master_account_id`` / ``master_account_name`` — CyberArk source IDs diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index 1acb9ed0b..6dcc2cc96 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -750,6 +750,7 @@ def _import(params, file_format, filename, **kwargs): dry_run = kwargs.get('dry_run') is True show_skipped = kwargs.get('show_skipped') is True secret_ids = kwargs.get('secret_ids') + target_node = kwargs.get('target_node') import_into = kwargs.get('import_into') or '' if import_into: @@ -771,7 +772,8 @@ def _import(params, file_format, filename, **kwargs): classic_shared = shared and not use_nsf for x in importer.execute(filename, params=params, users_only=import_users, filter_folder=filter_folder, - old_domain=old_domain, new_domain=new_domain, tmpdir=tmpdir, secret_ids=secret_ids, dry_run=dry_run): + old_domain=old_domain, new_domain=new_domain, tmpdir=tmpdir, secret_ids=secret_ids, + dry_run=dry_run, target_node=target_node): if isinstance(x, ImportRecord): if filter_folder and not importer.support_folder_filter(): if not x.folders: diff --git a/tests/test_cyberark_pam_import.py b/tests/test_cyberark_pam_import.py index 619e7e646..4f969bd9e 100644 --- a/tests/test_cyberark_pam_import.py +++ b/tests/test_cyberark_pam_import.py @@ -1451,7 +1451,42 @@ def test_select_specific(self): safes = [{"safeName": "Alpha"}, {"safeName": "Beta"}, {"safeName": "Gamma"}] with patch("builtins.input", return_value="1,3"): result = CyberArkPAMImportCommand._interactive_safe_picker(safes) - assert result == "Alpha,Gamma" + assert result == ["Alpha", "Gamma"] + + def test_select_range(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": f"S{i}"} for i in range(1, 6)] + with patch("builtins.input", return_value="2-4"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == ["S2", "S3", "S4"] + + def test_select_mixed_ranges_and_indexes(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": f"S{i}"} for i in range(1, 21)] + with patch("builtins.input", return_value="1,2,3,6-9,11,14-18"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == [ + "S1", "S2", "S3", "S6", "S7", "S8", "S9", "S11", + "S14", "S15", "S16", "S17", "S18", + ] + + def test_select_reversed_range(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": f"S{i}"} for i in range(1, 6)] + with patch("builtins.input", return_value="4-1"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == ["S1", "S2", "S3", "S4"] + + def test_select_deduplicates(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": f"S{i}"} for i in range(1, 6)] + with patch("builtins.input", return_value="1,1-3,2"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == ["S1", "S2", "S3"] def test_select_invalid_input(self): from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand @@ -1459,7 +1494,24 @@ def test_select_invalid_input(self): safes = [{"safeName": "Safe1"}] with patch("builtins.input", return_value="abc"): result = CyberArkPAMImportCommand._interactive_safe_picker(safes) - assert result is None # invalid → all + assert result == [] # invalid non-empty → abort, do not import all + + def test_select_invalid_range_syntax_aborts(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": f"S{i}"} for i in range(1, 6)] + with patch("builtins.input", return_value="1..4"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == [] + + def test_partially_invalid_aborts(self, capsys): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": f"S{i}"} for i in range(1, 8)] + with patch("builtins.input", return_value="1,abc,7"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == [] + assert "abc" in capsys.readouterr().out def test_eof_returns_none(self): from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand @@ -1470,6 +1522,235 @@ def test_eof_returns_none(self): assert result is None +class TestMaybeInteractiveSafePick: + """Picker abort must not emit a second 'No safes selected' message.""" + + def _orch(self): + from keepercommander.commands.pam_import.cyberark_import import ( + CyberArkImportOrchestrator, CyberArkPAMImportCommand, + ) + orch = CyberArkImportOrchestrator.__new__(CyberArkImportOrchestrator) + orch.options = MagicMock( + safe_include="", safe_exclude="", skip_confirm=False, + dry_run=False, output_file="", + ) + orch.params = MagicMock(batch_mode=False) + orch._cmd = CyberArkPAMImportCommand() + return orch + + def test_cancelled_does_not_print_no_safes_selected(self, capsys): + orch = self._orch() + safes = [{"safeName": "A"}] + with patch.object(orch._cmd, "_interactive_safe_picker", return_value=[]): + result = orch._maybe_interactive_safe_pick(safes) + assert result == [] + assert "No safes selected" not in capsys.readouterr().out + + def test_all_returns_original(self): + orch = self._orch() + safes = [{"safeName": "A"}] + with patch.object(orch._cmd, "_interactive_safe_picker", return_value=None): + assert orch._maybe_interactive_safe_pick(safes) is safes + + +class TestParseIndexSelection: + """Unit tests for _parse_index_selection.""" + + def test_single_indexes(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + assert CyberArkPAMImportCommand._parse_index_selection("1,3", 5) == [0, 2] + + def test_range(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + assert CyberArkPAMImportCommand._parse_index_selection("1-4", 10) == [0, 1, 2, 3] + + def test_mixed(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + assert CyberArkPAMImportCommand._parse_index_selection("1,2,3,6-9,11,14-18", 20) == [ + 0, 1, 2, 5, 6, 7, 8, 10, 13, 14, 15, 16, 17 + ] + + def test_out_of_range_rejected(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + with pytest.raises(ValueError, match="99"): + CyberArkPAMImportCommand._parse_index_selection("1,99,2-3,50-60", 5) + + def test_zero_start_rejected(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + with pytest.raises(ValueError, match="0-2"): + CyberArkPAMImportCommand._parse_index_selection("0-2", 5) + + def test_partially_invalid_token_rejected(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + with pytest.raises(ValueError, match="abc"): + CyberArkPAMImportCommand._parse_index_selection("1,abc,7", 10) + + def test_whitespace_tolerant(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + assert CyberArkPAMImportCommand._parse_index_selection(" 1 , 3 - 5 , 7 ", 10) == [0, 2, 3, 4, 6] + + def test_huge_range_rejected(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + with pytest.raises(ValueError, match="1-1000000"): + CyberArkPAMImportCommand._parse_index_selection("1-1000000", 5) + + def test_unicode_en_dash(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + assert CyberArkPAMImportCommand._parse_index_selection("2–4", 6) == [1, 2, 3] + + def test_rejects_malformed_tokens(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + for bad in ("1-2-3", "1--5", "1..4", "1:4"): + with pytest.raises(ValueError, match="invalid or out-of-range"): + CyberArkPAMImportCommand._parse_index_selection(bad, 10) + + def test_empty_tokens_ignored(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + assert CyberArkPAMImportCommand._parse_index_selection("1,,3", 5) == [0, 2] + + +def _enterprise_params(nodes=None, user_root_nodes=None, enterprise_name="Acme"): + params = MagicMock() + params.enterprise = { + "nodes": nodes if nodes is not None else [ + {"node_id": 1, "data": {}}, + {"node_id": 2, "parent_id": 1, "data": {"displayname": "Engineering"}}, + {"node_id": 3, "parent_id": 1, "data": {"displayname": "R&D"}}, + {"node_id": 4, "parent_id": 1, "data": {"displayname": "Dup"}}, + {"node_id": 5, "parent_id": 1, "data": {"displayname": "Dup"}}, + ], + "user_root_nodes": user_root_nodes if user_root_nodes is not None else [1], + "enterprise_name": enterprise_name, + } + return params + + +class TestResolveProvisioningNodeId: + """Tests for CyberArkImporter._resolve_provisioning_node_id.""" + + def _importer(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + return CyberArkImporter() + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_resolves_by_name(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id(_enterprise_params(), "Engineering") == 2 + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_resolves_by_numeric_id(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id(_enterprise_params(), "3") == 3 + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_strips_whitespace(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id(_enterprise_params(), " Engineering ") == 2 + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_ampersand_name_found(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id(_enterprise_params(), "R&D") == 3 + + def test_ampersand_name_not_found_raises(self): + from keepercommander.error import CommandError + importer = self._importer() + with pytest.raises(CommandError, match="No&Such"): + importer._resolve_provisioning_node_id(_enterprise_params(), "No&Such") + + def test_not_found_raises(self): + from keepercommander.error import CommandError + importer = self._importer() + with pytest.raises(CommandError, match="was not found"): + importer._resolve_provisioning_node_id(_enterprise_params(), "Missing") + + def test_ambiguous_name_raises(self): + from keepercommander.error import CommandError + importer = self._importer() + with pytest.raises(CommandError, match="more than one node matches"): + importer._resolve_provisioning_node_id(_enterprise_params(), "Dup") + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_default_user_root_node(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id( + _enterprise_params(user_root_nodes=[99]), None + ) == 99 + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_default_falls_back_to_tree_root(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id( + _enterprise_params(user_root_nodes=[]), None + ) == 1 + + def test_no_enterprise_with_target_node_raises(self): + from keepercommander.error import CommandError + importer = self._importer() + params = MagicMock() + params.enterprise = None + with pytest.raises(CommandError, match="not an enterprise admin"): + importer._resolve_provisioning_node_id(params, "Engineering") + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_no_enterprise_default_path_returns_none(self, _print): + importer = self._importer() + params = MagicMock() + params.enterprise = None + assert importer._resolve_provisioning_node_id(params, None) is None + + def test_no_params_with_target_node_raises(self): + from keepercommander.error import CommandError + importer = self._importer() + with pytest.raises(CommandError, match="Keeper session is not available"): + importer._resolve_provisioning_node_id(None, "Engineering") + + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + def test_no_params_default_path_returns_none(self, _print): + importer = self._importer() + assert importer._resolve_provisioning_node_id(None, None) is None + + def test_success_html_escapes_ampersand(self): + from prompt_toolkit import HTML + from keepercommander.importer.cyberark.pam import _esc + HTML( + f"Provisioning teams, roles, and users into node " + f"{_esc('R&D')} (id 3)" + ) + + +class TestImportTargetNodeArgparse: + """--target-node / --node on the vault import parser.""" + + def test_target_node_flag(self): + from keepercommander.importer.commands import import_parser + ns = import_parser.parse_args(["--format", "cyberark", "--target-node", "Eng", "https://pvwa"]) + assert ns.target_node == "Eng" + + def test_node_alias(self): + from keepercommander.importer.commands import import_parser + ns = import_parser.parse_args(["--format", "cyberark", "--node", "Eng", "https://pvwa"]) + assert ns.target_node == "Eng" + + def test_ignored_for_non_cyberark_format(self): + from keepercommander.importer.commands import RecordImportCommand + cmd = RecordImportCommand() + params = MagicMock() + params.enforcements = None + with patch("keepercommander.importer.commands.imp_exp._import") as mock_import: + cmd.execute(params, format="json", name="vault.json", target_node="Eng") + assert mock_import.call_args.kwargs.get("target_node") is None + + def test_kept_for_cyberark_format(self): + from keepercommander.importer.commands import RecordImportCommand + cmd = RecordImportCommand() + params = MagicMock() + params.enforcements = None + with patch("keepercommander.importer.commands.imp_exp._import") as mock_import: + cmd.execute(params, format="cyberark", name="https://pvwa", target_node="Eng") + assert mock_import.call_args.kwargs.get("target_node") == "Eng" + + class TestListSafesDetailed: """Tests for _list_safes_detailed.""" From ab44c55640085c47e49b62c33d4762662c577342 Mon Sep 17 00:00:00 2001 From: pvagare-ks Date: Wed, 19 Aug 2026 15:56:26 +0530 Subject: [PATCH 07/14] updated changes --- keepercommander/commands/pam_service/add.py | 56 ++++++++++++--------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/keepercommander/commands/pam_service/add.py b/keepercommander/commands/pam_service/add.py index fff64d52f..05c9ec055 100644 --- a/keepercommander/commands/pam_service/add.py +++ b/keepercommander/commands/pam_service/add.py @@ -3,7 +3,7 @@ import logging from . import record_lookup from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg -from ...error import CommandError +from ...display import bcolors from ... import vault from ...discovery_common.user_service import UserService from ...discovery_common.record_link import RecordLink @@ -11,7 +11,7 @@ from ...discovery_common.types import UserAcl, ServiceEnum from ...keeper_dag.types import RefType, EdgeType from ... import __version__ -from typing import NoReturn, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -40,10 +40,6 @@ class PAMActionServiceAddCommand(PAMGatewayActionDiscoverCommandBase): def get_parser(self): return PAMActionServiceAddCommand.parser - @staticmethod - def _fail(message: str) -> NoReturn: - raise CommandError('pam action service add', message) - def execute(self, params: KeeperParams, **kwargs): gateway = kwargs.get("gateway", "not_set") @@ -59,15 +55,15 @@ def execute(self, params: KeeperParams, **kwargs): gateway=gateway, configuration_uid=kwargs.get('configuration_uid')) if gateway_context is None: - self._fail(f"Could not find the gateway configuration for {gateway}.") + print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + return except MultiConfigurationException as err: multi_conf_msg(gateway, err) - self._fail( - f"Multiple PAM configurations match gateway {gateway}. Use --configuration-uid." - ) + return if gateway_context is None: - self._fail("Cannot get gateway information. Gateway may not be up.") + print(f" {self._f('Cannot get gateway information. Gateway may not be up.')}") + return record_link = RecordLink(record=gateway_context.configuration, params=params, @@ -86,45 +82,54 @@ def execute(self, params: KeeperParams, **kwargs): # Check to see if the record exists. machine_record = vault.KeeperRecord.load(params, machine_uid) # type: Optional[TypedRecord] if machine_record is None: - self._fail("The machine record does not exists.") + 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: - self._fail("The machine record is not a 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_rl = record_link.get_record_link(machine_record.record_uid) if machine_rl is None: - self._fail("The machine record does not exists in the graph.") + 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. root_vertex = record_link.dag.get_root if root_vertex is None: - self._fail("Could not get the root of the graph.") + 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): - self._fail("The machine record does not belong to this gateway.") + print(self._f("The machine record does not belong to this gateway.")) + return ############### # Check to see if the record exists. user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] if user_record is None: - self._fail("The user record does not exists.") + 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: - self._fail("The user record is not a PAM User.") + print(self._f("The user record is not a PAM User.")) + return record_rotation = params.record_rotation_cache.get(user_record.record_uid) if record_rotation is not None: controller_uid = record_rotation.get("configuration_uid") if controller_uid is None or controller_uid != gateway_context.configuration_uid: - self._fail("The user record does not belong to this gateway. Cannot use this user.") + print(self._f("The user record does not belong to this gateway. Cannot use this user.")) + return else: - self._fail("The user record does not have any rotation settings.") + print(self._f("The user record does not have any rotation settings.")) + return ######## @@ -132,15 +137,18 @@ def execute(self, params: KeeperParams, **kwargs): # 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: - self._fail("Cannot find the operating system field in this record.") + 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: - self._fail("The operating system field of the machine record is blank.") + print(self._f("The operating system field of the machine record is blank.")) + return if os_type.lower() != "windows": - self._fail("The operating system is not Windows. " - "PAM can only rotate the services and scheduled task password on 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. From ac477192ca7f6d7b39c531348708680a7b871b06 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 20 Aug 2026 18:17:56 +0530 Subject: [PATCH 08/14] KC-1412: Fix email-config title lookup so shared-in records cannot redirect --send-email SMTP (#2296) * Require ownership when resolving email-config by title Shared-in records titled like an email config could redirect record-add --send-email SMTP delivery. Only owned configs are eligible now, and missing configs fail with a clear error. * Filter email-config list to owned configs only, clarify the non-owned warning text, and cover record-add / pam rotate not-found paths. * Harden email-config ownership checks and regression coverage * Rename GChat one-time-share command ID to external share and add create-secret. Replace chat_command_one_time_share_id with chat_command_external_share_id (/keeper-external-share, default 3), add chat_command_create_secret_id (/keeper-create-secret, default 4), and update setup prompts, vault fields, README, and unit tests. * Drop unsafe account_uid ownership check in email-config lookup The account_uid equality fallback could treat shared-in records as owned when sync_down stored the current user's UID as a display fallback. Use owner.owner and meta_data_cache only, and add a regression test for RecordOwner(False, current_uid). --- keepercommander/commands/discoveryrotation.py | 4 + keepercommander/commands/email_commands.py | 75 ++++-- keepercommander/commands/record_edit.py | 2 + keepercommander/service/README.md | 3 +- .../commands/integrations/gchat_app_setup.py | 38 ++- keepercommander/service/docker/models.py | 9 +- unit-tests/service/test_gchat_app_setup.py | 6 +- unit-tests/test_email_config_ownership.py | 247 ++++++++++++++++++ 8 files changed, 352 insertions(+), 32 deletions(-) create mode 100644 unit-tests/test_email_config_ownership.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 29a2797d7..c8c3bd13b 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -3819,6 +3819,10 @@ def execute(self, params, **kwargs): # Find and load email config to validate provider and dependencies try: config_uid = find_email_config_record(params, self.email_config) + if not config_uid: + raise CommandError( + 'pam action rotate', + f'Email configuration "{self.email_config}" not found') email_config_obj = load_email_config_from_record(params, config_uid) # Check if required dependencies are installed for this provider diff --git a/keepercommander/commands/email_commands.py b/keepercommander/commands/email_commands.py index 0e195c26d..0858f7ecd 100644 --- a/keepercommander/commands/email_commands.py +++ b/keepercommander/commands/email_commands.py @@ -151,9 +151,28 @@ # Helper Functions # ============================================================================= +def _is_owned_email_config_record(params: KeeperParams, record_uid: str) -> bool: + """True if the current account owns this record. + + Uses ``record_owner_cache`` first, then ``meta_data_cache`` from + recordMetaData (server-asserted). Unknown ownership fails closed. + """ + owner = (params.record_owner_cache or {}).get(record_uid) + if owner and owner.owner: + return True + meta = (getattr(params, 'meta_data_cache', None) or {}).get(record_uid) or {} + return bool(meta.get('owner')) + + def find_email_config_record(params: KeeperParams, name: str) -> Optional[str]: """ - Find email config record by name. + Find an owned email config record by name. + + Only records owned by the current account are eligible. Shared or + non-owned records (even with a matching title and ``__email_config__`` + marker) are ignored so SMTP/provider settings cannot be supplied by + another user. A missing or unknown ownership entry is treated the same + way (not owned by the current account, or ownership unknown). Args: params: KeeperParams session @@ -169,15 +188,26 @@ def find_email_config_record(params: KeeperParams, name: str) -> Optional[str]: if record.record_type != 'login': continue - # Check if this is an email config by looking for custom field try: record_dict = vault_extensions.extract_typed_record_data(record) custom_fields = record_dict.get('custom', []) - for field in custom_fields: - if field.get('type') == 'text' and field.get('label') == '__email_config__': - if record.title == name: - return record_uid - except: + is_email_config = any( + field.get('type') == 'text' and field.get('label') == '__email_config__' + for field in custom_fields + ) + if not is_email_config: + continue + if record.title != name: + continue + if not _is_owned_email_config_record(params, record_uid): + logging.warning( + 'Ignoring email configuration "%s" (%s): ' + 'not owned by the current account (or ownership unknown)', + name, record_uid) + continue + return record_uid + except Exception as e: + logging.debug('Skipping record %s: %s', record_uid, e) continue return None @@ -591,13 +621,17 @@ def execute(self, params: KeeperParams, **kwargs): class EmailConfigListCommand(Command): - """List all email configurations.""" + """List owned email configurations.""" def get_parser(self): return email_config_list_parser def execute(self, params: KeeperParams, **kwargs): - """Execute email-config list command.""" + """Execute email-config list command. + + Only configurations owned by the current account are listed, matching + ``find_email_config_record`` eligibility used by test/delete/--send-email. + """ configs = [] # Find all email config records @@ -629,13 +663,22 @@ def execute(self, params: KeeperParams, **kwargs): if values: from_address = values[0] - if is_email_config: - configs.append({ - 'name': record.title, - 'record_uid': record_uid, - 'provider': provider or 'unknown', - 'from_address': from_address or '' - }) + if not is_email_config: + continue + + if not _is_owned_email_config_record(params, record_uid): + logging.debug( + 'Skipping email configuration "%s" (%s) in list: ' + 'not owned by the current account (or ownership unknown)', + record.title, record_uid) + continue + + configs.append({ + 'name': record.title, + 'record_uid': record_uid, + 'provider': provider or 'unknown', + 'from_address': from_address or '' + }) except Exception as e: logging.debug(f'Error loading record {record_uid}: {e}') continue diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index c0750fdb1..4b6bd2da3 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -881,6 +881,8 @@ def execute(self, params, **kwargs): email_config_name = kwargs.get('email_config') config_uid = find_email_config_record(params, email_config_name) + if not config_uid: + raise CommandError('record-add', f'Email configuration "{email_config_name}" not found') email_config_obj = load_email_config_from_record(params, config_uid) # Check if required dependencies are installed for this provider diff --git a/keepercommander/service/README.md b/keepercommander/service/README.md index 400af6ec9..18b8e4e28 100644 --- a/keepercommander/service/README.md +++ b/keepercommander/service/README.md @@ -540,7 +540,8 @@ The command generates a complete `docker-compose.yml` with both Commander servic | `chat_approvals_space_id` | text | Google Chat space ID (`spaces/...`) | | `chat_command_request_record_id` | text | Slash command ID for `/keeper-request-record` | | `chat_command_request_folder_id` | text | Slash command ID for `/keeper-request-folder` | -| `chat_command_one_time_share_id` | text | Slash command ID for `/keeper-one-time-share` | +| `chat_command_external_share_id` | text | Slash command ID for `/keeper-external-share` | +| `chat_command_create_secret_id` | text | Slash command ID for `/keeper-create-secret` | | `pedm_enabled` | text | `true` / `false` | | `pedm_polling_interval` | text | Seconds | | `device_approval_enabled` | text | `true` / `false` | diff --git a/keepercommander/service/commands/integrations/gchat_app_setup.py b/keepercommander/service/commands/integrations/gchat_app_setup.py index 7d0eb99f0..cdc1f00d4 100644 --- a/keepercommander/service/commands/integrations/gchat_app_setup.py +++ b/keepercommander/service/commands/integrations/gchat_app_setup.py @@ -100,9 +100,13 @@ def collect_integration_config(self, params): '/keeper-request-folder', GChatConstants.DEFAULT_COMMAND_REQUEST_FOLDER_ID, ) - chat_command_one_time_share_id = self._prompt_command_id( - '/keeper-one-time-share', - GChatConstants.DEFAULT_COMMAND_ONE_TIME_SHARE_ID, + chat_command_external_share_id = self._prompt_command_id( + '/keeper-external-share', + GChatConstants.DEFAULT_COMMAND_EXTERNAL_SHARE_ID, + ) + chat_command_create_secret_id = self._prompt_command_id( + '/keeper-create-secret', + GChatConstants.DEFAULT_COMMAND_CREATE_SECRET_ID, ) pedm_enabled, pedm_interval = self._collect_pedm_config() @@ -118,7 +122,8 @@ def collect_integration_config(self, params): chat_approvals_space_id=chat_approvals_space_id, chat_command_request_record_id=chat_command_request_record_id, chat_command_request_folder_id=chat_command_request_folder_id, - chat_command_one_time_share_id=chat_command_one_time_share_id, + chat_command_external_share_id=chat_command_external_share_id, + chat_command_create_secret_id=chat_command_create_secret_id, pedm_enabled=pedm_enabled, pedm_polling_interval=pedm_interval, device_approval_enabled=da_enabled, @@ -158,8 +163,13 @@ def build_record_custom_fields(self, config): ), vault.TypedField.new_field( 'text', - config.chat_command_one_time_share_id, - GChatConstants.FIELD_COMMAND_ONE_TIME_SHARE_ID, + config.chat_command_external_share_id, + GChatConstants.FIELD_COMMAND_EXTERNAL_SHARE_ID, + ), + vault.TypedField.new_field( + 'text', + config.chat_command_create_secret_id, + GChatConstants.FIELD_COMMAND_CREATE_SECRET_ID, ), vault.TypedField.new_field( 'text', @@ -205,8 +215,12 @@ def print_integration_specific_resources(self, config): f"{bcolors.OKBLUE}{config.chat_command_request_folder_id}{bcolors.ENDC}" ) print( - f" • /keeper-one-time-share ID: " - f"{bcolors.OKBLUE}{config.chat_command_one_time_share_id}{bcolors.ENDC}" + f" • /keeper-external-share ID: " + f"{bcolors.OKBLUE}{config.chat_command_external_share_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-create-secret ID: " + f"{bcolors.OKBLUE}{config.chat_command_create_secret_id}{bcolors.ENDC}" ) def print_integration_commands(self): @@ -214,8 +228,12 @@ def print_integration_commands(self): print(f" {bcolors.OKGREEN}• /keeper-request-record{bcolors.ENDC} - Request access to a record") print(f" {bcolors.OKGREEN}• /keeper-request-folder{bcolors.ENDC} - Request access to a folder") print( - f" {bcolors.OKGREEN}• /keeper-one-time-share{bcolors.ENDC} " - f"- Request a one-time share link\n" + f" {bcolors.OKGREEN}• /keeper-external-share{bcolors.ENDC} " + f"- Request an external share link" + ) + print( + f" {bcolors.OKGREEN}• /keeper-create-secret{bcolors.ENDC} " + f"- Request creation of a secret\n" ) # ── Validation helpers ──────────────────────────────────────── diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index c8090e228..c17950fe7 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -149,7 +149,8 @@ class GChatConstants: FIELD_APPROVALS_SPACE_ID = 'chat_approvals_space_id' FIELD_COMMAND_REQUEST_RECORD_ID = 'chat_command_request_record_id' FIELD_COMMAND_REQUEST_FOLDER_ID = 'chat_command_request_folder_id' - FIELD_COMMAND_ONE_TIME_SHARE_ID = 'chat_command_one_time_share_id' + FIELD_COMMAND_EXTERNAL_SHARE_ID = 'chat_command_external_share_id' + FIELD_COMMAND_CREATE_SECRET_ID = 'chat_command_create_secret_id' FIELD_PEDM_ENABLED = 'pedm_enabled' FIELD_PEDM_POLLING_INTERVAL = 'pedm_polling_interval' FIELD_DEVICE_APPROVAL_ENABLED = 'device_approval_enabled' @@ -157,7 +158,8 @@ class GChatConstants: DEFAULT_COMMAND_REQUEST_RECORD_ID = '1' DEFAULT_COMMAND_REQUEST_FOLDER_ID = '2' - DEFAULT_COMMAND_ONE_TIME_SHARE_ID = '3' + DEFAULT_COMMAND_EXTERNAL_SHARE_ID = '3' + DEFAULT_COMMAND_CREATE_SECRET_ID = '4' SPACE_ID_PREFIX = 'spaces/' SERVICE_ACCOUNT_TYPE = 'service_account' @@ -178,7 +180,8 @@ class GChatConfig: chat_approvals_space_id: str chat_command_request_record_id: str = GChatConstants.DEFAULT_COMMAND_REQUEST_RECORD_ID chat_command_request_folder_id: str = GChatConstants.DEFAULT_COMMAND_REQUEST_FOLDER_ID - chat_command_one_time_share_id: str = GChatConstants.DEFAULT_COMMAND_ONE_TIME_SHARE_ID + chat_command_external_share_id: str = GChatConstants.DEFAULT_COMMAND_EXTERNAL_SHARE_ID + chat_command_create_secret_id: str = GChatConstants.DEFAULT_COMMAND_CREATE_SECRET_ID pedm_enabled: bool = False pedm_polling_interval: int = 120 device_approval_enabled: bool = False diff --git a/unit-tests/service/test_gchat_app_setup.py b/unit-tests/service/test_gchat_app_setup.py index 00d812ad0..5f44fbee3 100644 --- a/unit-tests/service/test_gchat_app_setup.py +++ b/unit-tests/service/test_gchat_app_setup.py @@ -164,7 +164,8 @@ def test_build_record_custom_fields(self): chat_approvals_space_id='spaces/AAAA', chat_command_request_record_id='1', chat_command_request_folder_id='2', - chat_command_one_time_share_id='3', + chat_command_external_share_id='3', + chat_command_create_secret_id='4', pedm_enabled=True, pedm_polling_interval=60, device_approval_enabled=False, @@ -184,7 +185,8 @@ def test_build_record_custom_fields(self): self.assertEqual(fields[GChatConstants.FIELD_APPROVALS_SPACE_ID], 'spaces/AAAA') self.assertEqual(fields[GChatConstants.FIELD_COMMAND_REQUEST_RECORD_ID], '1') self.assertEqual(fields[GChatConstants.FIELD_COMMAND_REQUEST_FOLDER_ID], '2') - self.assertEqual(fields[GChatConstants.FIELD_COMMAND_ONE_TIME_SHARE_ID], '3') + self.assertEqual(fields[GChatConstants.FIELD_COMMAND_EXTERNAL_SHARE_ID], '3') + self.assertEqual(fields[GChatConstants.FIELD_COMMAND_CREATE_SECRET_ID], '4') self.assertEqual(fields[GChatConstants.FIELD_PEDM_ENABLED], 'true') self.assertEqual(fields[GChatConstants.FIELD_PEDM_POLLING_INTERVAL], '60') self.assertEqual(fields[GChatConstants.FIELD_DEVICE_APPROVAL_ENABLED], 'false') diff --git a/unit-tests/test_email_config_ownership.py b/unit-tests/test_email_config_ownership.py new file mode 100644 index 000000000..0a53035ec --- /dev/null +++ b/unit-tests/test_email_config_ownership.py @@ -0,0 +1,247 @@ +import unittest +from unittest.mock import MagicMock, patch + +from keepercommander.error import CommandError +from keepercommander.params import KeeperParams, RecordOwner + + +class TestFindEmailConfigRecordOwnership(unittest.TestCase): + """Email config records must be owned by the current account.""" + + TITLE = 'default' + + def _make_typed_record(self, uid, title=None): + from keepercommander import vault + + record = MagicMock(spec=vault.TypedRecord) + record.record_uid = uid + record.title = title or self.TITLE + record.record_type = 'login' + return record + + def _email_config_data(self, extra_fields=None): + custom = [ + {'type': 'text', 'label': '__email_config__', 'value': ['true']}, + ] + if extra_fields: + custom.extend(extra_fields) + return {'custom': custom} + + def _params(self, record_cache, record_owner_cache, account_uid_bytes=None, meta_data_cache=None): + params = MagicMock(spec=KeeperParams) + params.account_uid_bytes = account_uid_bytes + params.record_cache = record_cache + params.record_owner_cache = record_owner_cache + params.meta_data_cache = meta_data_cache or {} + return params + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_skips_non_owned_record(self, mock_load, mock_extract): + from keepercommander.commands.email_commands import find_email_config_record + + shared = self._make_typed_record('SHARED_UID') + owned = self._make_typed_record('OWNED_UID') + + def load_side_effect(_params, uid): + return {'SHARED_UID': shared, 'OWNED_UID': owned}[uid] + + mock_load.side_effect = load_side_effect + mock_extract.return_value = self._email_config_data() + + params = self._params( + {'SHARED_UID': {}, 'OWNED_UID': {}}, + { + 'SHARED_UID': RecordOwner(False, 'attacker'), + 'OWNED_UID': RecordOwner(True, 'operator'), + }, + ) + + self.assertEqual(find_email_config_record(params, self.TITLE), 'OWNED_UID') + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_returns_none_when_only_shared_match(self, mock_load, mock_extract): + from keepercommander.commands.email_commands import find_email_config_record + + mock_load.return_value = self._make_typed_record('SHARED_UID') + mock_extract.return_value = self._email_config_data() + + params = self._params( + {'SHARED_UID': {}}, + {'SHARED_UID': RecordOwner(False, 'attacker')}, + ) + + self.assertIsNone(find_email_config_record(params, self.TITLE)) + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_returns_none_when_owner_cache_missing(self, mock_load, mock_extract): + from keepercommander.commands.email_commands import find_email_config_record + + mock_load.return_value = self._make_typed_record('UNKNOWN_UID') + mock_extract.return_value = self._email_config_data() + + params = self._params({'UNKNOWN_UID': {}}, {}) + + self.assertIsNone(find_email_config_record(params, self.TITLE)) + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_returns_owned_record(self, mock_load, mock_extract): + from keepercommander.commands.email_commands import find_email_config_record + + mock_load.return_value = self._make_typed_record('OWNED_UID') + mock_extract.return_value = self._email_config_data() + + params = self._params( + {'OWNED_UID': {}}, + {'OWNED_UID': RecordOwner(True, 'operator')}, + ) + + self.assertEqual(find_email_config_record(params, self.TITLE), 'OWNED_UID') + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_rejects_shared_record_with_current_account_uid_fallback(self, mock_load, mock_extract): + """Shared-in records must not match via ownerAccountUid display fallback.""" + from keepercommander import utils + from keepercommander.commands.email_commands import find_email_config_record + + mock_load.return_value = self._make_typed_record('SHARED_UID') + mock_extract.return_value = self._email_config_data() + + account_uid_bytes = b'current-account' + current_uid = utils.base64_url_encode(account_uid_bytes) + params = self._params( + {'SHARED_UID': {}}, + {'SHARED_UID': RecordOwner(False, current_uid)}, + account_uid_bytes=account_uid_bytes, + ) + + self.assertIsNone(find_email_config_record(params, self.TITLE)) + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_accepts_owned_record_from_metadata_when_owner_cache_wrong(self, mock_load, mock_extract): + from keepercommander.commands.email_commands import find_email_config_record + + mock_load.return_value = self._make_typed_record('OWNED_UID') + mock_extract.return_value = self._email_config_data() + + params = self._params( + {'OWNED_UID': {}}, + {'OWNED_UID': RecordOwner(False, 'attacker')}, + meta_data_cache={'OWNED_UID': {'owner': True}}, + ) + + self.assertEqual(find_email_config_record(params, self.TITLE), 'OWNED_UID') + + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_matches_when_marker_is_not_last_custom_field(self, mock_load, mock_extract): + from keepercommander.commands.email_commands import find_email_config_record + + mock_load.return_value = self._make_typed_record('OWNED_UID') + mock_extract.return_value = self._email_config_data( + extra_fields=[ + {'type': 'text', 'label': 'smtp_host', 'value': ['smtp.example.com']}, + ] + ) + params = self._params( + {'OWNED_UID': {}}, + {'OWNED_UID': RecordOwner(True, 'operator')}, + ) + + self.assertEqual(find_email_config_record(params, self.TITLE), 'OWNED_UID') + + +class TestEmailConfigListOwnership(unittest.TestCase): + """email-config list must only show owned configurations.""" + + def _make_typed_record(self, uid, title): + from keepercommander import vault + + record = MagicMock(spec=vault.TypedRecord) + record.record_uid = uid + record.title = title + record.record_type = 'login' + return record + + def _email_config_data(self, provider='smtp', from_address='it@corp.example'): + return { + 'custom': [ + {'type': 'text', 'label': '__email_config__', 'value': ['true']}, + {'type': 'text', 'label': 'provider', 'value': [provider]}, + {'type': 'text', 'label': 'from_address', 'value': [from_address]}, + ] + } + + @patch('keepercommander.commands.email_commands.dump_report_data') + @patch('keepercommander.commands.email_commands.vault_extensions.extract_typed_record_data') + @patch('keepercommander.commands.email_commands.vault.KeeperRecord.load') + def test_list_skips_shared_in_config(self, mock_load, mock_extract, mock_dump): + from keepercommander.commands.email_commands import EmailConfigListCommand + + shared = self._make_typed_record('SHARED_UID', 'default') + owned = self._make_typed_record('OWNED_UID', 'corp-smtp') + + def load_side_effect(_params, uid): + return {'SHARED_UID': shared, 'OWNED_UID': owned}[uid] + + mock_load.side_effect = load_side_effect + mock_extract.return_value = self._email_config_data() + + params = MagicMock(spec=KeeperParams) + params.account_uid_bytes = None + params.record_cache = {'SHARED_UID': {}, 'OWNED_UID': {}} + params.record_owner_cache = { + 'SHARED_UID': RecordOwner(False, 'attacker'), + 'OWNED_UID': RecordOwner(True, 'operator'), + } + params.meta_data_cache = {} + + EmailConfigListCommand().execute(params, format='table') + + mock_dump.assert_called_once() + table = mock_dump.call_args[0][0] + self.assertEqual(len(table), 1) + self.assertEqual(table[0][0], 'corp-smtp') + self.assertEqual(table[0][3], 'OWNED_UID') + + +class TestSendEmailValidationOwnership(unittest.TestCase): + """Patched validation paths must raise when only a shared-in config matches.""" + + @patch('keepercommander.commands.email_commands.find_email_config_record', return_value=None) + def test_record_add_raises_when_only_shared_config(self, _mock_find): + from keepercommander.commands.record_edit import RecordAddCommand + + params = MagicMock(spec=KeeperParams) + with self.assertRaises(CommandError) as ctx: + RecordAddCommand().execute( + params, + send_email='newhire@corp.example', + email_config='default', + ) + self.assertIn('Email configuration "default" not found', str(ctx.exception)) + + @patch('keepercommander.commands.discoveryrotation._is_rotation_allowed_by_enforcement', + return_value=True) + @patch('keepercommander.commands.discoveryrotation.find_email_config_record', return_value=None) + def test_pam_rotate_raises_when_only_shared_config(self, _mock_find, _mock_allowed): + from keepercommander.commands.discoveryrotation import PAMGatewayActionRotateCommand + + params = MagicMock(spec=KeeperParams) + with self.assertRaises(CommandError) as ctx: + PAMGatewayActionRotateCommand().execute( + params, + record_uid='REC_UID', + send_email='newhire@corp.example', + email_config='default', + ) + self.assertIn('Email configuration "default" not found', str(ctx.exception)) + + +if __name__ == '__main__': + unittest.main() From 463bff18dc75414f7a0382e1be63fc3c9975f015 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 20 Aug 2026 19:22:22 +0530 Subject: [PATCH 09/14] KC-1417, KC-1418: Handle NSF record fields the same way as classic add/update. * Handle NSF record fields the same way as classic add/update. * Update unit tests * Fix for review comments * Resolve merge conflicts * Fix review comments * Change error message --- .../nested_share_folder/display_commands.py | 28 +- .../commands/nested_share_folder/helpers.py | 11 +- .../nested_share_folder/record_commands.py | 337 ++++++------ keepercommander/enforcement.py | 2 +- unit-tests/test_nested_share_folder.py | 505 ++++++++++++++++++ 5 files changed, 711 insertions(+), 172 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/display_commands.py b/keepercommander/commands/nested_share_folder/display_commands.py index 7f67039c1..037cf5fe5 100644 --- a/keepercommander/commands/nested_share_folder/display_commands.py +++ b/keepercommander/commands/nested_share_folder/display_commands.py @@ -87,7 +87,10 @@ def execute(self, params, **kwargs): class NestedShareGetCommand(Command): """Show details of a Nested Share Record or folder.""" - _MASKED_TYPES = frozenset({'password', 'secret', 'pinCode', 'pin_code'}) + _MASKED_TYPES = frozenset({ + 'password', 'secret', 'pinCode', 'pin_code', 'oneTimeCode', 'otp', + 'note', 'json', + }) def get_parser(self): return nested_share_get_parser @@ -196,10 +199,19 @@ def _record_detail(self, params, record_uid, verbose, unmask): if url_val: print('{0:>20s}: {1:<20s}'.format('URL', url_val)) - shown_types = {'login', 'password', 'url'} - for f in meta['fields']: + self._print_typed_fields(meta['fields'], unmask, skip_types={'login', 'password', 'url'}) + self._print_typed_fields(meta.get('custom') or [], unmask) + + if meta['notes']: + for i, line in enumerate(meta['notes'].split('\n')): + print('{0:>21s} {1}'.format('Notes:' if i == 0 else '', line.strip())) + + self._print_record_permissions(params, record_uid, verbose) + + def _print_typed_fields(self, fields, unmask, skip_types=()): + for f in fields or []: ftype = f.get('type', '') - if ftype in shown_types: + if ftype in skip_types: continue label = f.get('label') or ftype.replace('_', ' ').title() values = f.get('value', []) @@ -216,12 +228,6 @@ def _record_detail(self, params, record_uid, verbose, unmask): dval = str(val) print('{0:>20s}: {1:21s} {1}'.format('Notes:' if i == 0 else '', line.strip())) - - self._print_record_permissions(params, record_uid, verbose) - @staticmethod def _extract_field_value(fields, field_type): """Extract the first non-empty value for a given field type.""" @@ -247,6 +253,8 @@ def _record_json(self, params, record_uid, verbose, _unmask=False, include_dag=F ro['folder'] = meta['folder_location'] if meta['fields']: ro['fields'] = meta['fields'] + if meta.get('custom'): + ro['custom'] = meta['custom'] if meta['notes']: ro['notes'] = meta['notes'] diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index 5b2718375..5fa57a037 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -705,7 +705,7 @@ def load_record_metadata(params, record_uid): """Load record metadata from cache, falling back to the v3 details API. Returns a dict with keys: - ``title``, ``type``, ``fields``, ``notes``, + ``title``, ``type``, ``fields``, ``custom``, ``notes``, ``revision``, ``version``, ``folder_location`` """ from ... import nested_share_folder as _nsf @@ -713,6 +713,7 @@ def load_record_metadata(params, record_uid): title = record_uid rec_type = '' fields = [] + custom = [] notes = '' revision = 0 version = 0 @@ -724,7 +725,12 @@ def load_record_metadata(params, record_uid): dj = data_obj['data_json'] title = dj.get('title', record_uid) rec_type = dj.get('type', '') - fields = dj.get('fields', []) + fields = dj.get('fields') or [] + custom = dj.get('custom') or [] + if not isinstance(fields, list): + fields = [] + if not isinstance(custom, list): + custom = [] notes = dj.get('notes', '') or '' nsf_records = getattr(params, 'nested_share_records', {}) @@ -749,6 +755,7 @@ def load_record_metadata(params, record_uid): 'title': title, 'type': rec_type, 'fields': fields, + 'custom': custom, 'notes': notes, 'revision': revision, 'version': version, diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index c52543be3..faf277ce1 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -16,15 +16,16 @@ (create, update, link/unlink, shortcut management, delete). """ +import copy import json import logging from typing import Any, Dict, List, Optional from ..base import Command, GroupCommand -from ..record_edit import RecordEditMixin, record_fields_description, ParsedFieldValue +from ..record_edit import RecordEditMixin, record_fields_description from ...enforcement import PasswordComplexityEnforcer, RecordTypeEnforcer from ...error import CommandError -from ... import nested_share_folder as _nsf, vault +from ... import nested_share_folder as _nsf, vault, vault_extensions from .helpers import ( resolve_folder_uid, command_error_handler, check_result, check_record_edit_permission, check_record_delete_permission, @@ -41,6 +42,52 @@ ) +def _parse_field_specs(raw_fields, cmd_name): + record_fields = [] + attachments = [] + for spec in [f.strip() for f in (raw_fields or []) if f.strip()]: + try: + parsed = RecordEditMixin.parse_field(spec) + except ValueError as e: + raise CommandError(cmd_name, f'Invalid field specification: {e}') + (attachments if parsed.type == 'file' else record_fields).append(parsed) + return record_fields, attachments + + +def _apply_password_policy(editor, params, source, force): + # TypedRecord (typed add/update) or v3 dict (legacy add). validate_record accepts both. + pw_failures = PasswordComplexityEnforcer.validate_record(params, source) + for failure in pw_failures: + editor.on_warning(failure) + if pw_failures and not force: + editor.on_warning('Use --force to bypass password policy warnings.') + + +def _should_stop_after_warnings(editor, force): + if not editor.warnings: + return False + for w in editor.warnings: + logging.warning(w) + if not force: + return True + editor.warnings.clear() + return False + + +def _unsupported_attachment_warning(attachments, cmd_name): + if not attachments: + return + classic = 'record-add' if cmd_name == 'nsf-record-add' else 'record-update' + if any(not a.value for a in attachments): + logging.warning( + 'Attachment removal (file= with an empty value) is not supported in %s. ' + 'Use record-update to remove attachments.', cmd_name) + if any(a.value for a in attachments): + logging.warning( + 'File attachments are not yet supported in %s. ' + 'Use %s for attachment support.', cmd_name, classic) + + # ══════════════════════════════════════════════════════════════════════════ # nsf-record-add # ══════════════════════════════════════════════════════════════════════════ @@ -73,26 +120,18 @@ def execute(self, params, **kwargs): self._password_policy = PasswordComplexityEnforcer.get_policy(params) notes = kwargs.get('notes') - record_fields, add_attachments = self._parse_fields(kwargs.get('fields', [])) + record_fields, add_attachments = _parse_field_specs(kwargs.get('fields', []), 'nsf-record-add') folder_uid = self._resolve_folder(params, kwargs.get('folder_uid')) - data = self._build_record_data(params, record_type, title, notes, record_fields) + data = self._build_record_data( + params, record_type, title, notes, record_fields, kwargs.get('force')) if self.abort_if_errors(): return - self._check_password_policy(params, data, **kwargs) - - if self.abort_if_errors(): + if _should_stop_after_warnings(self, kwargs.get('force')): return - if self.warnings: - for w in self.warnings: - logging.warning(w) - if not kwargs.get('force'): - return - if add_attachments: - logging.warning('File attachments are not yet supported in nsf-record-add. ' - 'Use record-add for attachment support.') + _unsupported_attachment_warning(add_attachments, 'nsf-record-add') if not kwargs.get('force'): return @@ -107,15 +146,6 @@ def execute(self, params, **kwargs): params.sync_data = True return result['record_uid'] - def _parse_fields(self, raw_fields): - fields = [f.strip() for f in raw_fields if f.strip()] - record_fields = [] - attachments = [] - for field in fields: - parsed = RecordEditMixin.parse_field(field) - (attachments if parsed.type == 'file' else record_fields).append(parsed) - return record_fields, attachments - @staticmethod def _resolve_folder(params, folder_input): if not folder_input: @@ -126,13 +156,15 @@ def _resolve_folder(params, folder_input): ensure_nested_share_folder(params, uid, 'nsf-record-add', identifier=folder_input) return uid - def _build_record_data(self, params, record_type, title, notes, record_fields): + def _build_record_data(self, params, record_type, title, notes, record_fields, force=False): if record_type in ('legacy', 'general'): record = vault.PasswordRecord() self.assign_legacy_fields(record, record_fields) record.title = title record.notes = self.validate_notes(notes or '') - return self._legacy_to_data(record, title, notes) + data = self._legacy_to_data(record, title) + _apply_password_policy(self, params, data, force) + return data rt_fields = self.get_record_type_fields(params, record_type) if not rt_fields: @@ -151,10 +183,11 @@ def _build_record_data(self, params, record_type, title, notes, record_fields): self.assign_typed_fields(record, record_fields) record.title = title record.notes = self.validate_notes(notes or '') - return self._typed_to_data(record, title, notes) + _apply_password_policy(self, params, record, force) + return self._typed_to_data(record, title) @staticmethod - def _typed_to_data(record, title, notes=None): + def _typed_to_data(record, title): data = { 'type': record.type_name, 'title': title, 'fields': [{'type': f.type, 'label': f.label or '', 'value': list(f.value)} @@ -162,12 +195,12 @@ def _typed_to_data(record, title, notes=None): 'custom': [{'type': f.type, 'label': f.label or '', 'value': list(f.value)} for f in record.custom], } - if notes: - data['notes'] = notes + if record.notes: + data['notes'] = record.notes return data @staticmethod - def _legacy_to_data(record, title, notes=None): + def _legacy_to_data(record, title): data = {'type': record.get_record_type(), 'title': title, 'fields': []} for ftype, val in [('login', record.login), ('password', record.password), ('url', record.link), ('oneTimeCode', record.totp)]: @@ -179,24 +212,17 @@ def _legacy_to_data(record, title, notes=None): 'label': cf.name if hasattr(cf, 'name') else '', 'value': [cf.value if hasattr(cf, 'value') else str(cf)], }) - if notes: - data['notes'] = notes + if record.notes: + data['notes'] = record.notes return data - def _check_password_policy(self, params, data, **kwargs): - pw_failures = PasswordComplexityEnforcer.validate_record(params, data) - for failure in pw_failures: - self.on_warning(failure) - if pw_failures and not kwargs.get('force'): - self.on_warning('Use --force to bypass password policy warnings.') - # ══════════════════════════════════════════════════════════════════════════ # nsf-record-update # ══════════════════════════════════════════════════════════════════════════ class NestedShareRecordUpdateCommand(Command, RecordEditMixin): - """Update a Nested Share Record.""" + """Update a Nested Share Record, matching ``record-update`` field handling.""" def __init__(self): super().__init__() @@ -205,32 +231,6 @@ def __init__(self): def get_parser(self): return nested_share_record_update_parser - def _resolve_field_value(self, parsed): - raw = parsed.value - if not raw: - return raw - - action_params = [] - if self.is_json_value(raw, action_params): - return action_params[0] if action_params else None - action_params.clear() - if self.is_generate_value(raw, action_params): - if self.warn_wrong_password_gen_field(parsed): - return None - if parsed.type == 'password': - password, gen_error = self.generate_password(action_params, policy=self._password_policy) - if gen_error: - self.on_error(gen_error) - return None - return password - if parsed.type in ('oneTimeCode', 'otp'): - return self.generate_totp_url() - return raw - action_params.clear() - if self.is_base64_value(raw, action_params): - return action_params[0] if action_params else None - return raw - def execute(self, params, **kwargs): if kwargs.get('syntax_help'): print(record_fields_description) @@ -244,79 +244,112 @@ def execute(self, params, **kwargs): self._password_policy = PasswordComplexityEnforcer.get_policy(params) record_type = kwargs.get('record_type') - if record_type and record_type not in ('legacy', 'general'): + rt_fields = None + if record_type: + if record_type in ('legacy', 'general'): + raise CommandError('nsf-record-update', + 'Record type not valid for NSF records; ' + 'use login or a typed record type.') RecordTypeEnforcer.enforce(params, record_type, 'nsf-record-update') rt_fields = self.get_record_type_fields(params, record_type) if not rt_fields: - raise CommandError('nsf-record-update', f'Record type "{record_type}" cannot be found.') + raise CommandError('nsf-record-update', + f'Record type "{record_type}" cannot be found.') - fields = {} - for spec in [f.strip() for f in kwargs.get('fields', []) if f.strip()]: - try: - parsed = RecordEditMixin.parse_field(spec) - if self.warn_wrong_password_gen_field(parsed): - continue - value = self._resolve_field_value(parsed) - if value is None: - continue - if parsed.type in fields: - existing = fields[parsed.type] - fields[parsed.type] = ([existing] if not isinstance(existing, list) - else existing) + [value] - else: - fields[parsed.type] = value - except ValueError as e: - raise CommandError('nsf-record-update', f'Invalid field specification: {e}') - - if self.abort_if_errors(): - return + record_fields, attachments = _parse_field_specs(kwargs.get('fields', []), 'nsf-record-update') - if self.warnings: - for w in self.warnings: - logging.warning(w) + if attachments: + _unsupported_attachment_warning(attachments, 'nsf-record-update') if not kwargs.get('force'): return - with command_error_handler('nsf-record-update'): - for identifier in record_uids: - record_uid = _nsf.resolve_nested_share_record_uid(params, identifier) - if not record_uid: - raise CommandError('nsf-record-update', - f"Record '{identifier}' not found") - ensure_nested_share_record(params, record_uid, 'nsf-record-update', - identifier=identifier) - check_record_edit_permission(params, record_uid, 'nsf-record-update') - merged = self._merge_update_data( - params, record_uid, - title=kwargs.get('title'), - record_type=record_type, - fields=fields or None, - notes=kwargs.get('notes'), - ) - self._check_password_policy(params, merged, **kwargs) - if self.warnings: - for w in self.warnings: - logging.warning(w) - if not kwargs.get('force'): - return + force = kwargs.get('force') + updated = 0 + try: + with command_error_handler('nsf-record-update'): + for identifier in record_uids: self.warnings.clear() - result = _nsf.update_record_v3( - params=params, - record_uid=record_uid, - title=kwargs.get('title'), - record_type=record_type, - fields=fields or None, - notes=kwargs.get('notes'), - ) - check_result(result, 'nsf-record-update') - params.sync_data = True + self.errors.clear() + record_uid = _nsf.resolve_nested_share_record_uid(params, identifier) + if not record_uid: + raise CommandError('nsf-record-update', + f"Record '{identifier}' not found") + ensure_nested_share_record(params, record_uid, 'nsf-record-update', + identifier=identifier) + check_record_edit_permission(params, record_uid, 'nsf-record-update') + + record = self._apply_update( + self._typed_record_from_uid(params, record_uid), + kwargs.get('title'), kwargs.get('notes'), + record_type, rt_fields, record_fields) + + if self.abort_if_errors(): + continue + + _apply_password_policy(self, params, record, force) + if _should_stop_after_warnings(self, force): + continue + + result = self._send_typed_update(params, record_uid, record) + # API syncs on RS_OUT_OF_SYNC but does not retry a full data + # payload; rebuild from refreshed cache and re-apply once. + if (not result.get('success') + and result.get('status') == 'RS_OUT_OF_SYNC'): + self.warnings.clear() + self.errors.clear() + record = self._apply_update( + self._typed_record_from_uid(params, record_uid), + kwargs.get('title'), kwargs.get('notes'), + record_type, rt_fields, record_fields) + if self.abort_if_errors(): + continue + _apply_password_policy(self, params, record, force) + if _should_stop_after_warnings(self, force): + continue + result = self._send_typed_update(params, record_uid, record) + check_result(result, 'nsf-record-update') + updated += 1 + finally: + if updated: + params.sync_data = True + + def _apply_update(self, record, title, notes, record_type, rt_fields, record_fields): + if title: + record.title = title + self._apply_notes(record, notes) + if record_type: + record.type_name = record_type + self.adjust_typed_record_fields(record, rt_fields) + self.assign_typed_fields(record, record_fields) + return record + + @staticmethod + def _send_typed_update(params, record_uid, record): + return _nsf.update_record_v3( + params=params, record_uid=record_uid, + data=vault_extensions.extract_typed_record_data(record), + ) + + def _apply_notes(self, record, notes): + if not isinstance(notes, str): + return + notes = self.validate_notes(notes) + if notes.startswith('+'): + notes = notes[1:].strip() + if record.notes: + record.notes += '\n' + record.notes += notes + return + record.notes = notes - def _check_password_policy(self, params, data, **kwargs): - pw_failures = PasswordComplexityEnforcer.validate_record(params, data) - for failure in pw_failures: - self.on_warning(failure) - if pw_failures and not kwargs.get('force'): - self.on_warning('Use --force to bypass password policy warnings.') + def _typed_record_from_uid(self, params, record_uid): + existing = self._load_record_data(params, record_uid) + if not existing: + raise CommandError('nsf-record-update', + f"Record data for '{record_uid}' is not available in the local cache") + record = vault.TypedRecord() + record.load_record_data(existing) + return record @staticmethod def _load_record_data(params, record_uid): # type: (Any, str) -> Optional[Dict] @@ -327,35 +360,21 @@ def _load_record_data(params, record_uid): # type: (Any, str) -> Optional[Dict raw = nsf_data.get('data_json') if raw is None: return None - if isinstance(raw, bytes): - return json.loads(raw.decode('utf-8')) - if isinstance(raw, str): - return json.loads(raw) - if isinstance(raw, dict): - return raw.copy() - return None - - @classmethod - def _merge_update_data(cls, params, record_uid, title=None, record_type=None, - fields=None, notes=None): # type: (...) -> Dict - existing = cls._load_record_data(params, record_uid) - data = existing.copy() if existing else {'fields': []} - if title is not None: - data['title'] = title - if record_type is not None: - data['type'] = record_type - if fields is not None: - by_type = {} - for ef in data.get('fields', []): - by_type.setdefault(ef.get('type'), []).append(ef) - for ft, fv in fields.items(): - fv = fv if isinstance(fv, list) else [fv] - if ft in by_type and by_type[ft]: - by_type[ft][0]['value'] = fv - else: - data.setdefault('fields', []).append({'type': ft, 'value': fv}) - if notes is not None: - data['notes'] = notes + try: + if isinstance(raw, bytes): + data = json.loads(raw.decode('utf-8')) + elif isinstance(raw, str): + data = json.loads(raw) + elif isinstance(raw, dict): + data = copy.deepcopy(raw) + else: + return None + except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): + raise CommandError('nsf-record-update', + f"Record data for '{record_uid}' could not be decoded") + if not isinstance(data, dict): + raise CommandError('nsf-record-update', + f"Record data for '{record_uid}' could not be decoded") return data diff --git a/keepercommander/enforcement.py b/keepercommander/enforcement.py index 1923395c3..2e3cd0a43 100644 --- a/keepercommander/enforcement.py +++ b/keepercommander/enforcement.py @@ -586,7 +586,7 @@ def _extract_passwords(source): # type: (Any) -> List[str] return [] if not isinstance(data, dict): return [] - for fld in data.get('fields') or []: + for fld in itertools.chain(data.get('fields') or [], data.get('custom') or []): if isinstance(fld, dict) and fld.get('type') == 'password': val = fld.get('value') if isinstance(val, list): diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index 2779c1796..7c912c6bc 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -235,6 +235,24 @@ def test_load_record_metadata_from_cache(self): result = load_record_metadata(params, ruid) self.assertEqual(result['title'], 'Cached') self.assertEqual(result['revision'], 5) + self.assertEqual(result['custom'], []) + + def test_load_record_metadata_includes_custom(self): + from keepercommander.commands.nested_share_folder.helpers import load_record_metadata + ruid = utils.generate_uid() + custom = [{'type': 'text', 'label': 'AppName', 'value': ['Example']}] + params = _make_params( + nested_share_record_data={ruid: { + 'data_json': { + 'title': 'SaaS', 'type': 'saasConfiguration', + 'fields': [{'type': 'fileRef', 'value': []}], + 'custom': custom, + } + }}, + nested_share_records={ruid: {'revision': 1, 'version': 3}}, + ) + result = load_record_metadata(params, ruid) + self.assertEqual(result['custom'], custom) class TestSync(TestCase): @@ -602,6 +620,442 @@ def test_update_record_rejects_weak_password_without_force(self, mock_perm, mock cmd.execute(params, record_uids=[ruid], fields=['password=abc'], force=False) mock_update.assert_not_called() + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_record_gen_password_uses_password_policy(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: {'revision': 1, 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'Old', + 'fields': [{'type': 'password', 'value': ['ExistingPass123']}], + })}}, + enforcements={ + 'jsons': [{ + 'key': 'generated_password_complexity', + 'value': json.dumps([{ + 'length': 16, + 'lower-use': True, 'lower-min': 2, + 'upper-use': True, 'upper-min': 2, + 'digit-use': True, 'digit-min': 2, + 'special-use': True, 'special-min': 1, + 'special': '!@#$', + }]), + }], + }, + ) + self._stub_update(mock_update, ruid) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], fields=['password=$GEN'], force=True) + data = self._updated_data(mock_update) + password = next( + v[0] for f in data['fields'] + if f.get('type') == 'password' for v in [f.get('value', [])] if v + ) + self.assertGreaterEqual(len(password), 16) + self.assertGreaterEqual(sum(1 for c in password if c.islower()), 2) + self.assertGreaterEqual(sum(1 for c in password if c.isupper()), 2) + self.assertGreaterEqual(sum(1 for c in password if c.isdigit()), 2) + self.assertGreaterEqual(sum(1 for c in password if c in '!@#$'), 1) + + def _nsf_update_params(self, data): + ruid, robj = _make_record() + payload = json.dumps(data) + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 1, + 'data_unencrypted': payload, + }}, + nested_share_record_data={ruid: {'data_json': data}}, + ) + return ruid, params + + @staticmethod + def _stub_update(mock_update, ruid): + mock_update.return_value = { + 'record_uid': ruid, 'status': 'SUCCESS', 'message': '', 'success': True, + } + + @staticmethod + def _updated_data(mock_update): + return mock_update.call_args.kwargs['data'] + + @staticmethod + def _by_label(fields): + return {(f.get('type'), f.get('label') or ''): f for f in fields} + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_matches_labeled_fields_without_merging(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'pamDatabase', + 'title': 'DB', + 'fields': [ + {'type': 'checkbox', 'label': 'useSSL', 'value': [True]}, + {'type': 'text', 'label': 'databaseId', 'value': []}, + {'type': 'text', 'label': 'providerGroup', 'value': []}, + {'type': 'text', 'label': 'providerRegion', 'value': []}, + ], + 'custom': [], + }) + self._stub_update(mock_update, ruid) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], force=True, fields=[ + 'f.checkbox.useSSL=false', + 'f.text.databaseId=abc', + 'f.text.providerGroup=AWS', + 'f.text.providerRegion=us-east-1', + ]) + data = self._updated_data(mock_update) + by_label = self._by_label(data['fields']) + self.assertEqual(by_label[('checkbox', 'useSSL')]['value'], [False]) + self.assertEqual(by_label[('text', 'databaseId')]['value'], ['abc']) + self.assertEqual(by_label[('text', 'providerGroup')]['value'], ['AWS']) + self.assertEqual(by_label[('text', 'providerRegion')]['value'], ['us-east-1']) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_writes_custom_fields_in_place(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'saasConfiguration', + 'title': 'SaaS', + 'fields': [{'type': 'fileRef', 'value': []}], + 'custom': [ + {'type': 'text', 'label': 'SaaS Type', 'value': ['Okta']}, + {'type': 'text', 'label': 'AppName', 'value': ['Example']}, + ], + }) + self._stub_update(mock_update, ruid) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], force=True, fields=[ + 'c.text.SaaS Type=Okta updated', + 'c.text.AppName=Example SaaS App updated', + ]) + data = self._updated_data(mock_update) + custom = self._by_label(data.get('custom') or []) + self.assertEqual(custom[('text', 'SaaS Type')]['value'], ['Okta updated']) + self.assertEqual(custom[('text', 'AppName')]['value'], ['Example SaaS App updated']) + field_types = [f.get('type') for f in data['fields']] + self.assertNotIn('text', field_types) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_converts_pam_hostname(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'pamMachine', + 'title': 'Host', + 'fields': [{ + 'type': 'pamHostname', + 'value': [{'hostName': 'db.example.com', 'port': '5432'}], + }], + 'custom': [], + }) + self._stub_update(mock_update, ruid) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], force=True, + fields=['f.pamHostname=other.example.com:22']) + data = self._updated_data(mock_update) + host = next(f for f in data['fields'] if f.get('type') == 'pamHostname') + self.assertEqual(host['value'], [{'hostName': 'other.example.com', 'port': '22'}]) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_clears_login_and_removes_custom(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'login', + 'title': 'Clear', + 'fields': [ + {'type': 'login', 'value': ['alice']}, + {'type': 'password', 'value': ['Secret123']}, + ], + 'custom': [{'type': 'text', 'label': 'AppName', 'value': ['KeepMe']}], + }) + self._stub_update(mock_update, ruid) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], force=True, + fields=['login=', 'c.text.AppName=']) + data = self._updated_data(mock_update) + login = next(f for f in data['fields'] if f.get('type') == 'login') + self.assertEqual(login['value'], []) + self.assertFalse(data.get('custom')) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_appends_and_unescapes_notes(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'login', + 'title': 'Notes', + 'notes': 'Original notes', + 'fields': [{'type': 'login', 'value': ['user']}], + 'custom': [], + }) + self._stub_update(mock_update, ruid) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], force=True, fields=[], + notes='+Rotated 2026-08-18') + data = self._updated_data(mock_update) + self.assertEqual(data['notes'], 'Original notes\nRotated 2026-08-18') + + mock_update.reset_mock() + self._stub_update(mock_update, ruid) + cmd.execute(params, record_uids=[ruid], force=True, fields=[], + notes='line1\\nline2') + data = self._updated_data(mock_update) + self.assertEqual(data['notes'], 'line1\nline2') + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_rejects_missing_record_data(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, robj = _make_record() + params = _make_params(nested_share_records={ruid: robj}) + cmd = NestedShareRecordUpdateCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, record_uids=[ruid], title='X', fields=[]) + self.assertIn('not available', str(ctx.exception).lower()) + mock_update.assert_not_called() + + def test_update_rejects_invalid_field_spec(self): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + cmd = NestedShareRecordUpdateCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(), record_uids=['x'], fields=['nocolon']) + self.assertIn('invalid field specification', str(ctx.exception).lower()) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_title_only_preserves_custom(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'login', + 'title': 'Old', + 'fields': [{'type': 'login', 'value': ['alice']}], + 'custom': [{'type': 'text', 'label': 'AppName', 'value': ['KeepMe']}], + }) + self._stub_update(mock_update, ruid) + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid], title='New', fields=[], force=True) + data = self._updated_data(mock_update) + self.assertEqual(data['title'], 'New') + custom = self._by_label(data.get('custom') or []) + self.assertEqual(custom[('text', 'AppName')]['value'], ['KeepMe']) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_rejects_legacy_and_general_record_type(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'login', 'title': 'Typed', 'fields': [], 'custom': [], + }) + cmd = NestedShareRecordUpdateCommand() + for record_type in ('legacy', 'general'): + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, record_uids=[ruid], record_type=record_type, fields=[]) + self.assertIn('not valid for nsf', str(ctx.exception).lower()) + mock_update.assert_not_called() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_failed_request_does_not_mutate_cache(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, robj = _make_record() + cache_data = { + 'type': 'login', + 'title': 'Old', + 'fields': [{'type': 'login', 'value': ['alice']}], + 'custom': [], + } + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: {'revision': 1}}, + nested_share_record_data={ruid: {'data_json': cache_data}}, + ) + mock_update.return_value = { + 'success': False, 'status': 'RS_ACCESS_DENIED', 'message': 'denied', + } + with self.assertRaises(CommandError): + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid], fields=['login=bob'], force=True) + self.assertEqual(cache_data['fields'][0]['value'], ['alice']) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_retries_out_of_sync_with_typed_fields(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'pamDatabase', + 'title': 'DB', + 'fields': [ + {'type': 'checkbox', 'label': 'useSSL', 'value': [True]}, + {'type': 'text', 'label': 'databaseId', 'value': []}, + ], + 'custom': [], + }) + + def _side_effect(**kwargs): + if mock_update.call_count == 1: + params.record_cache[ruid]['data_unencrypted'] = json.dumps({ + 'type': 'pamDatabase', + 'title': 'DB', + 'fields': [ + {'type': 'checkbox', 'label': 'useSSL', 'value': [True]}, + {'type': 'text', 'label': 'databaseId', 'value': ['from-sync']}, + ], + 'custom': [], + }) + return { + 'success': False, 'status': 'RS_OUT_OF_SYNC', + 'message': 'This object no longer exists.', + } + return { + 'record_uid': ruid, 'status': 'SUCCESS', 'message': '', 'success': True, + } + + mock_update.side_effect = _side_effect + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid], force=True, + fields=['f.checkbox.useSSL=false']) + self.assertEqual(mock_update.call_count, 2) + data = self._updated_data(mock_update) + by_label = self._by_label(data['fields']) + self.assertEqual(by_label[('checkbox', 'useSSL')]['value'], [False]) + self.assertEqual(by_label[('text', 'databaseId')]['value'], ['from-sync']) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_sets_sync_data_after_partial_batch_failure(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid1, params = self._nsf_update_params({ + 'type': 'login', 'title': 'One', + 'fields': [{'type': 'login', 'value': ['alice']}], 'custom': [], + }) + ruid2, robj2 = _make_record() + params.nested_share_records[ruid2] = robj2 + params.record_cache[ruid2] = { + 'revision': 1, + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'Two', + 'fields': [{'type': 'login', 'value': ['bob']}], 'custom': [], + }), + } + mock_update.side_effect = [ + {'record_uid': ruid1, 'status': 'SUCCESS', 'message': '', 'success': True}, + {'success': False, 'status': 'RS_ACCESS_DENIED', 'message': 'denied'}, + ] + with self.assertRaises(CommandError): + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid1, ruid2], title='Updated', fields=[], force=True) + self.assertEqual(mock_update.call_count, 2) + self.assertTrue(params.sync_data) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_skips_warned_record_and_continues_batch(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + policy = json.dumps([{ + 'length': 12, + 'lower-use': True, 'lower-min': 1, + 'upper-use': True, 'upper-min': 1, + 'digit-use': True, 'digit-min': 1, + }]) + ruid1, params = self._nsf_update_params({ + 'type': 'login', 'title': 'Weak', + 'fields': [{'type': 'password', 'value': ['abc']}], 'custom': [], + }) + ruid2, robj2 = _make_record() + params.nested_share_records[ruid2] = robj2 + params.record_cache[ruid2] = { + 'revision': 1, + 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'Strong', + 'fields': [{'type': 'password', 'value': ['ExistingPass123']}], + 'custom': [], + }), + } + params.enforcements = {'jsons': [{'key': 'generated_password_complexity', 'value': policy}]} + self._stub_update(mock_update, ruid2) + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid1, ruid2], title='Updated', fields=[], force=False) + self.assertEqual(mock_update.call_count, 1) + self.assertEqual(mock_update.call_args.kwargs['record_uid'], ruid2) + self.assertTrue(params.sync_data) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_empty_title_does_not_clear_existing(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'login', 'title': 'Keep Me', + 'fields': [{'type': 'login', 'value': ['alice']}], 'custom': [], + }) + self._stub_update(mock_update, ruid) + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid], title='', fields=['login=bob'], force=True) + data = self._updated_data(mock_update) + self.assertEqual(data['title'], 'Keep Me') + login = next(f for f in data['fields'] if f.get('type') == 'login') + self.assertEqual(login['value'], ['bob']) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_empty_file_spec_warns_as_removal(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, params = self._nsf_update_params({ + 'type': 'login', 'title': 'Files', 'fields': [], 'custom': [], + }) + with self.assertLogs() as cm: + NestedShareRecordUpdateCommand().execute( + params, record_uids=[ruid], fields=['file='], force=False) + self.assertTrue(any('Attachment removal' in line for line in cm.output)) + mock_update.assert_not_called() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.create_record_v3') + def test_add_rejects_weak_custom_password_without_force(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareRecordAddCommand + fuid, fobj = _make_folder() + params = _make_params( + nested_share_folders={fuid: fobj}, + enforcements={ + 'jsons': [{ + 'key': 'generated_password_complexity', + 'value': json.dumps([{ + 'length': 12, + 'lower-use': True, 'lower-min': 1, + 'upper-use': True, 'upper-min': 1, + 'digit-use': True, 'digit-min': 1, + }]), + }], + }, + ) + cmd = NestedShareRecordAddCommand() + with mock.patch.object(cmd, 'get_record_type_fields', + return_value=[{'$ref': 'login'}, {'$ref': 'password'}]): + cmd.execute(params, title='Weak custom', record_type='login', + fields=['c.password.AppSecret=abc'], force=False) + mock_create.assert_not_called() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.create_record_v3') + def test_add_unescapes_notes(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareRecordAddCommand + mock_create.return_value = { + 'record_uid': utils.generate_uid(), 'status': 'SUCCESS', + 'message': '', 'success': True, 'revision': 1, + } + fuid, fobj = _make_folder() + cmd = NestedShareRecordAddCommand() + cmd.execute(_make_params(nested_share_folders={fuid: fobj}), + title='Notes', record_type='general', fields=[], force=True, + notes='line1\\nline2') + record_data = mock_create.call_args.kwargs['record_data'] + self.assertEqual(record_data['notes'], 'line1\nline2') + @patch('keepercommander.nested_share_folder.folder_record_api.add_record_to_folder_v3') def test_add_record_to_folder(self, mock_add): pass @@ -2027,6 +2481,57 @@ def test_folder_json_uses_team_name_in_team_permissions(self, mock_access): payload = json.loads(captured[-1]) self.assertEqual(payload['team_permissions'][0]['accessor'], 'Engineering Team') + @patch('keepercommander.commands.nested_share_folder.display_commands._nsf.get_record_accesses_v3') + def test_record_json_includes_custom_fields(self, mock_access): + from keepercommander.commands.nested_share_folder.display_commands import NestedShareGetCommand + mock_access.return_value = {'record_accesses': []} + ruid, robj = _make_record() + custom = [ + {'type': 'text', 'label': 'SaaS Type', 'value': ['Okta']}, + {'type': 'text', 'label': 'AppName', 'value': ['Example SaaS App']}, + ] + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: { + 'data_json': { + 'title': 'SaaS', + 'type': 'saasConfiguration', + 'fields': [{'type': 'fileRef', 'label': '', 'value': []}], + 'custom': custom, + } + }}, + ) + captured = [] + with mock.patch('builtins.print', side_effect=lambda *a, **k: captured.append(a[0] if a else '')): + NestedShareGetCommand()._record_json(params, ruid, verbose=False) + payload = json.loads(captured[-1]) + self.assertEqual(payload['custom'], custom) + + @patch('keepercommander.commands.nested_share_folder.display_commands._nsf.get_record_accesses_v3') + def test_record_detail_prints_custom_and_masks_totp(self, mock_access): + from keepercommander.commands.nested_share_folder.display_commands import NestedShareGetCommand + mock_access.return_value = {'record_accesses': []} + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: { + 'data_json': { + 'title': 'TOTP', + 'type': 'login', + 'fields': [{'type': 'oneTimeCode', 'value': ['otpauth://totp/secret']}], + 'custom': [{'type': 'text', 'label': 'AppName', 'value': ['Example']}], + } + }}, + ) + captured = [] + with mock.patch('builtins.print', side_effect=lambda *a, **k: captured.append(a[0] if a else '')): + NestedShareGetCommand()._record_detail(params, ruid, verbose=False, unmask=False) + text = '\n'.join(str(x) for x in captured) + self.assertIn('AppName', text) + self.assertIn('Example', text) + self.assertIn('********', text) + self.assertNotIn('otpauth://', text) + class TestCommandRegistration(TestCase): From 71aa40c4361b66409ce590c953769563258f600b Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Mon, 17 Aug 2026 09:25:15 +0100 Subject: [PATCH 10/14] Add support for export --ownled-only flag The Vault UI allows two options for exporting: exporting only owned records or owned+shared records. Adding this choice to Commander's export command. --- keepercommander/importer/commands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index d2a19398a..96ef92fed 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -106,6 +106,8 @@ def register_command_info(aliases, command_info): help='Keepass key file for the exported file') export_parser.add_argument('--zip', dest='zip_archive', action='store_true', help='Create ZIP archive for file attachments. JSON only') +export_parser.add_argument('--owned-only', dest='owned_only', action='store_true', + help='Only export owned records') export_parser.add_argument('--save-in-vault', dest='save_in_vault', action='store_true', help='Stores exports file as a record attachment. KeePass only') export_parser.add_argument('--force', dest='force', action='store_true', help='Suppress user interaction. Assume "yes"') From 4a8ad2b91f558f46fc39d755040a913ecbdf5868 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Mon, 17 Aug 2026 09:26:50 +0100 Subject: [PATCH 11/14] Add support for only exporting owned records The Vault UI allows you to export owned or owned+shared records. The Commander export only allows you to export owned_shared records. Adding support for --owned-only flag, which provides parity between Vault and Commander --- keepercommander/importer/imp_exp.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index 6dcc2cc96..1e08d8069 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -331,12 +331,13 @@ def on_folder(base_folder): # type: (BaseFolderNode) -> None # for record_uid in params.record_cache.keys(): # ext_id += 1 # external_ids[record_uid] = ext_id - for record_uid in params.record_cache: + record_list = params.record_cache if not kwargs.get('owned_only') else {uid:params.record_cache[uid] for uid in params.record_owner_cache if params.record_owner_cache[uid].owner is True} + for record_uid in record_list: if record_filter or folder_path: if record_uid not in record_filter: continue - record = params.record_cache[record_uid] + record = record_list[record_uid] record_version = record.get('version') or 0 if record_version == 2 or record_version == 3: try: From 732fd3151a66943cae06efd595d010d4281455be Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Fri, 21 Aug 2026 22:40:26 +0530 Subject: [PATCH 12/14] KC-1419: Fix pam project export by discovering from project folders (#2312) * Fix pam project export by discovering from project folders * Add warning messages * Fix review comments * Add lazy user title index --- keepercommander/commands/pam_import/export.py | 775 ++++++++++++++---- unit-tests/pam/test_pam_project_export.py | 511 +++++++++++- 2 files changed, 1111 insertions(+), 175 deletions(-) diff --git a/keepercommander/commands/pam_import/export.py b/keepercommander/commands/pam_import/export.py index d847797ee..31ccca872 100644 --- a/keepercommander/commands/pam_import/export.py +++ b/keepercommander/commands/pam_import/export.py @@ -11,22 +11,24 @@ from __future__ import annotations import argparse +import copy +import itertools import json import logging +import os +from typing import Dict, List, Optional, Set, Tuple -from .base import ( - PAM_RESOURCES_RECORD_TYPES, - PAM_CONFIG_TYPES, - PAM_ENVIRONMENT_TYPES, -) +from .base import PAM_CONFIG_TYPES, PAM_RESOURCES_RECORD_TYPES from ..base import Command from ..pam.config_facades import PamConfigurationRecordFacade +from .nsf_helpers import get_folder_record_uids from .record_loader import iter_accessible_record_uids, load_pam_record -from ... import vault +from ... import utils, vault from ...display import bcolors +from ...recordv3 import RecordV3 +from ...subfolder import find_folders as find_record_folders -# Maps v6 record_type -> environment name used by pam project import _RECORD_TYPE_TO_ENV = { "pamNetworkConfiguration": "local", "pamAwsConfiguration": "aws", @@ -36,21 +38,64 @@ "pamOciConfiguration": "oci", } -# Maps DAG allowedSettings keys -> JSON keys used in PROJECT_IMPORT_JSON_TEMPLATE _DAG_KEY_TO_JSON = { - "connections": "connections", - "portForwards": "tunneling", - "rotation": "rotation", + "connections": "connections", + "portForwards": "tunneling", + "rotation": "rotation", "remoteBrowserIsolation": "remote_browser_isolation", - "sessionRecording": "graphical_session_recording", - "typescriptRecording": "text_session_recording", - "aiEnabled": "ai_threat_detection", - "aiSessionTerminate": "ai_terminate_session_on_detection", + "sessionRecording": "graphical_session_recording", + "typescriptRecording": "text_session_recording", + "aiEnabled": "ai_threat_detection", + "aiSessionTerminate": "ai_terminate_session_on_detection", } +_RESOURCE_FIELD_MAP = { + "operatingsystem": "operating_system", + "instancename": "instance_name", + "instanceid": "instance_id", + "providergroup": "provider_group", + "providerregion": "provider_region", + "databaseid": "database_id", + "databasetype": "database_type", + "domainname": "domain_name", + "directoryid": "directory_id", + "directorytype": "directory_type", + "usermatch": "user_match", + "sslverification": "ssl_verification", + "usessl": "use_ssl", +} + +_USER_RECORD_TYPES = ("pamUser", "login") +_PAM_ROOT_FOLDER_NAME = "pam environments" +_BOOL_EXPORT_KEYS = ("ssl_verification", "use_ssl") + +_DEFAULT_ALLOWED_SETTINGS = { + "connections": "on", + "rotation": "on", + "tunneling": "on", + "remote_browser_isolation": "on", + "graphical_session_recording": "off", + "text_session_recording": "off", + "ai_threat_detection": "off", + "ai_terminate_session_on_detection": "off", +} + + +class _LazyUserTitleIndex: + """Build the vault title→UID map only when a non-UID credential ref is resolved.""" + + def __init__(self, build_fn): + self._build_fn = build_fn + self._index = None + + def get(self, key, default=None): + if self._index is None: + self._index = self._build_fn() or {} + return self._index.get(key, default) + class PAMProjectExportCommand(Command): - """Export a PAM project to a JSON document that can be re-imported via pam project import.""" + """Export a PAM project to JSON for re-import via pam project import.""" parser = argparse.ArgumentParser(prog="pam project export") parser.add_argument( @@ -67,68 +112,55 @@ class PAMProjectExportCommand(Command): def get_parser(self): return PAMProjectExportCommand.parser - # ------------------------------------------------------------------ - # Public execute - # ------------------------------------------------------------------ - def execute(self, params, **kwargs): project_uid = (kwargs.get("project_uid") or "").strip() output_file = (kwargs.get("output") or "").strip() + self._nsf_perm_noted = False if not project_uid: logging.warning(f"{bcolors.FAIL}--project-uid is required{bcolors.ENDC}") return - # 1. Load PAM configuration record (v6) config_record = load_pam_record(params, project_uid) if not config_record: logging.warning( f"{bcolors.FAIL}PAM configuration '{project_uid}' not found in vault{bcolors.ENDC}" ) return - if config_record.version != 6: + if not isinstance(config_record, vault.TypedRecord): logging.warning( - f"{bcolors.FAIL}Record '{project_uid}' (version {config_record.version}) " - f"is not a PAM configuration — version 6 required{bcolors.ENDC}" + f"{bcolors.FAIL}Record '{project_uid}' is not a TypedRecord{bcolors.ENDC}" ) return - if not isinstance(config_record, vault.TypedRecord): + if config_record.version != 6 or config_record.record_type not in PAM_CONFIG_TYPES: logging.warning( - f"{bcolors.FAIL}Record '{project_uid}' is not a TypedRecord{bcolors.ENDC}" + f"{bcolors.FAIL}Record '{project_uid}' is not a PAM configuration " + f"(version 6 / known PAM config type required){bcolors.ENDC}" ) return - # 2. Determine environment - environment = _RECORD_TYPE_TO_ENV.get(config_record.record_type, "local") - - # 3. Get resource UIDs from pamResources.resourceRef facade = PamConfigurationRecordFacade() facade.record = config_record - resource_uids = list(facade.resource_ref or []) - - # 4. Try to read connection/rotation/tunneling settings from DAG (best-effort) - allowed_settings = self._get_allowed_settings(params, project_uid) - # 5. Walk resources and gather users - resources_list, top_level_users = self._build_resources_and_users(params, resource_uids) + allowed_settings, dag_resource_uids = self._load_dag_context(params, project_uid) + resource_uids, folder_user_uids, resources_folder_uid, users_folder_uid = ( + self._discover_project_record_uids( + params, facade, project_uid, dag_resource_uids + ) + ) + resources_list, top_level_users = self._build_resources_and_users( + params, resource_uids, extra_user_uids=folder_user_uids + ) - # 6. Assemble result dict result = { "tool_version": "commander-export-1.0", "project": config_record.title, - "shared_folder_users": {}, - "shared_folder_resources": {}, + "shared_folder_users": self._export_folder_permissions(params, users_folder_uid), + "shared_folder_resources": self._export_folder_permissions(params, resources_folder_uid), "pam_configuration": { - "environment": environment, + "environment": _RECORD_TYPE_TO_ENV.get(config_record.record_type, "local"), "title": config_record.title, - "connections": allowed_settings.get("connections", "on"), - "rotation": allowed_settings.get("rotation", "on"), - "tunneling": allowed_settings.get("tunneling", "on"), - "remote_browser_isolation": allowed_settings.get("remote_browser_isolation", "on"), - "graphical_session_recording": allowed_settings.get("graphical_session_recording", "off"), - "text_session_recording": allowed_settings.get("text_session_recording", "off"), - "ai_threat_detection": allowed_settings.get("ai_threat_detection", "off"), - "ai_terminate_session_on_detection": allowed_settings.get("ai_terminate_session_on_detection", "off"), + **{k: allowed_settings.get(k, v) for k, v in _DEFAULT_ALLOWED_SETTINGS.items()}, }, "pam_data": { "resources": resources_list, @@ -137,32 +169,178 @@ def execute(self, params, **kwargs): } output_json = json.dumps(result, indent=2, sort_keys=True) + if self._export_contains_password(resources_list, top_level_users): + logging.warning( + f"{bcolors.WARNING}Export includes passwords; treat output as sensitive.{bcolors.ENDC}" + ) + if not output_file: + return output_json - if output_file: - with open(output_file, "w", encoding="utf-8") as fh: + path = os.path.expanduser(output_file) + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + # O_CREAT mode applies only on create; force private perms on overwrite too. + os.chmod(path, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(output_json) - print(f"{bcolors.OKGREEN}PAM project exported to: {output_file}{bcolors.ENDC}") + except OSError as exc: + logging.warning( + f"{bcolors.FAIL}Failed to write export file '{path}': {exc}{bcolors.ENDC}" + ) return + print(f"{bcolors.OKGREEN}PAM project exported to: {path}{bcolors.ENDC}") + + @staticmethod + def _export_contains_password(resources_list, top_level_users) -> bool: + if any(u.get("password") for u in top_level_users): + return True + for res in resources_list: + for user in res.get("users") or []: + if user.get("password"): + return True + return False + + def _discover_project_record_uids(self, params, facade, config_uid, dag_resource_uids): + """Merge resourceRef + DAG + folder-tree UIDs under the project wrapper.""" + resource_uids: List[str] = [] + seen_resources: Set[str] = set() + + def add_resource(uid: str) -> None: + if not uid or uid == config_uid or uid in seen_resources: + return + seen_resources.add(uid) + resource_uids.append(uid) + + for uid in facade.resource_ref or []: + add_resource(uid) + for uid in dag_resource_uids or []: + add_resource(uid) + + resources_folder_uid, users_folder_uid, scan_roots = self._resolve_project_folders( + params, facade.folder_uid, config_uid + ) + + folder_user_uids: List[str] = [] + seen_users: Set[str] = set() + skipped_loads = 0 + seen_folders: Set[str] = set() + for root_uid in scan_roots: + rows, skipped = self._collect_folder_record_types(params, root_uid, seen_folders) + skipped_loads += skipped + for rec_uid, rtype in rows: + if not rec_uid or rec_uid == config_uid: + continue + if rtype in PAM_RESOURCES_RECORD_TYPES: + add_resource(rec_uid) + elif rtype in _USER_RECORD_TYPES and rec_uid not in seen_users: + seen_users.add(rec_uid) + folder_user_uids.append(rec_uid) - return output_json - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _get_allowed_settings(self, params, config_uid): - """Return on/off dict for tunneling config, falling back to safe defaults.""" - defaults = { - "connections": "on", - "rotation": "on", - "tunneling": "on", - "remote_browser_isolation": "on", - "graphical_session_recording": "off", - "text_session_recording": "off", - "ai_threat_detection": "off", - "ai_terminate_session_on_detection": "off", - } + if skipped_loads: + logging.warning( + f"{bcolors.WARNING}Export: skipped {skipped_loads} unloadable record(s) during folder scan{bcolors.ENDC}" + ) + + return resource_uids, folder_user_uids, resources_folder_uid, users_folder_uid + + @staticmethod + def _is_pam_content_folder_name(name: str) -> bool: + n = (name or "").casefold() + return n.endswith(" - resources") or n.endswith(" - users") + + @staticmethod + def _is_resources_folder_name(name: str) -> bool: + return (name or "").casefold().endswith(" - resources") + + @staticmethod + def _is_users_folder_name(name: str) -> bool: + return (name or "").casefold().endswith(" - users") + + def _project_wrapper_uid(self, params, folder_uid): + """Project folder for folder_uid; never returns the PAM Environments root.""" + if not folder_uid: + return "" + parent_uid = self._folder_parent_uid(params, folder_uid) + if not parent_uid: + return folder_uid + parent_name = (self._folder_name(params, parent_uid) or "").casefold() + if parent_name == _PAM_ROOT_FOLDER_NAME: + return folder_uid + return parent_uid + + def _resolve_project_folders(self, params, folder_uid, config_uid): + """Return (resources_folder_uid, users_folder_uid, scan_roots).""" + folder_uid = (folder_uid or "").strip() + anchors: List[str] = [] + if folder_uid: + anchors.append(folder_uid) + else: + anchors.extend(uid for uid in (find_record_folders(params, config_uid) or []) if uid) + + resources_candidates: List[str] = [] + users_candidates: List[str] = [] + scan_roots: List[str] = [] + seen_roots: Set[str] = set() + + def add_scan_root(uid: str) -> None: + if uid and uid not in seen_roots: + seen_roots.add(uid) + scan_roots.append(uid) + + for anchor in anchors: + add_scan_root(anchor) + project_uid = self._project_wrapper_uid(params, anchor) + if not project_uid: + continue + + for fuid in self._iter_descendant_folders(params, project_uid): + name = self._folder_name(params, fuid) or "" + if not self._is_pam_content_folder_name(name): + continue + add_scan_root(fuid) + if self._is_resources_folder_name(name): + if fuid not in resources_candidates: + resources_candidates.append(fuid) + elif self._is_users_folder_name(name): + if fuid not in users_candidates: + users_candidates.append(fuid) + + if len(resources_candidates) > 1: + logging.warning( + f"{bcolors.WARNING}Export: multiple Resources folders found; " + f"using first for shared_folder_resources ACL{bcolors.ENDC}" + ) + if len(users_candidates) > 1: + logging.warning( + f"{bcolors.WARNING}Export: multiple Users folders found; " + f"using first for shared_folder_users ACL{bcolors.ENDC}" + ) + + # Prefer real matches; never invent resources ACL from the users folder. + resources_folder_uid = resources_candidates[0] if resources_candidates else "" + users_folder_uid = users_candidates[0] if users_candidates else (folder_uid or "") + return resources_folder_uid, users_folder_uid, scan_roots + + def _iter_descendant_folders(self, params, folder_uid): + """Yield every folder UID under folder_uid (not including folder_uid).""" + if not folder_uid: + return + seen: Set[str] = set() + stack = list(self._child_folder_uids(params, folder_uid)) + while stack: + fuid = stack.pop() + if not fuid or fuid in seen: + continue + seen.add(fuid) + yield fuid + stack.extend(self._child_folder_uids(params, fuid)) + + def _load_dag_context(self, params, config_uid): + """Load allowed settings and linked resource UIDs from TunnelDAG (best-effort).""" + allowed = dict(_DEFAULT_ALLOWED_SETTINGS) + resource_uids: List[str] = [] try: + from ...keeper_dag import EdgeType from ..tunnel.port_forward.tunnel_helpers import get_keeper_tokens from ..tunnel.port_forward.TunnelGraph import TunnelDAG, get_vertex_content @@ -173,36 +351,161 @@ def _get_allowed_settings(self, params, config_uid): ) tmp_dag.linking_dag.load() vertex = tmp_dag.linking_dag.get_vertex(config_uid) - content = get_vertex_content(vertex) if vertex else None - dag_allowed = (content or {}).get("allowedSettings") or {} + if not vertex: + logging.debug("PAMProjectExportCommand: no DAG vertex for config %s", config_uid) + return allowed, resource_uids + + content = get_vertex_content(vertex) or {} + dag_allowed = content.get("allowedSettings") or {} for dag_key, json_key in _DAG_KEY_TO_JSON.items(): if dag_key in dag_allowed: - defaults[json_key] = "on" if dag_allowed[dag_key] else "off" - except Exception as exc: - logging.debug("PAMProjectExportCommand: could not load DAG allowed settings: %s", exc) - return defaults - - def _build_resources_and_users(self, params, resource_uids): - """Walk resource UIDs and collect resources + deduplicated top-level users. - - Two linking strategies are supported: - - 1. Standard: ``pam_settings.connection.userRecords[]`` and - top-level ``adminRef`` / ``adminCredentialRef`` carry user UIDs. - 2. Title-based (e.g. KCM imports — see PR #1942): the resource - record references users by **title** in - ``pam_settings.connection.{launch,administrative}_credentials`` - (e.g. ``"KCM User - prod-db"``) without a userRecords list. We - resolve those by scanning the project's vault for pamUser / - login records with matching titles. - """ - resources_list = [] - top_level_users = [] - seen_user_uids = set() + allowed[json_key] = "on" if dag_allowed[dag_key] else "off" - # Pre-build a lookup of (record_type, title.lower()) -> uid for fallback resolution - title_to_uid = self._build_user_title_index(params) + for child in vertex.has_vertices(EdgeType.LINK): + uid = getattr(child, "uid", None) + if uid: + resource_uids.append(uid) + except ImportError as exc: + logging.warning( + f"{bcolors.WARNING}Export: DAG dependencies unavailable; " + f"using default pam_configuration flags ({exc}){bcolors.ENDC}" + ) + except Exception as exc: + logging.warning( + f"{bcolors.WARNING}Export: could not load DAG context; " + f"using default pam_configuration flags ({exc}){bcolors.ENDC}" + ) + return allowed, resource_uids + + def _collect_folder_record_types(self, params, folder_uid, seen_folders: Optional[Set[str]] = None): + """Return ([(uid, record_type), ...], skipped_unloadable_count).""" + rows: List[Tuple[str, str]] = [] + skipped = 0 + if not folder_uid: + return rows, skipped + if seen_folders is None: + seen_folders = set() + stack = [folder_uid] + while stack: + fuid = stack.pop() + if not fuid or fuid in seen_folders: + continue + seen_folders.add(fuid) + for rec_uid in get_folder_record_uids(params, fuid): + rec = load_pam_record(params, rec_uid) + if not rec: + skipped += 1 + continue + rows.append((rec_uid, getattr(rec, "record_type", None) or "")) + stack.extend(self._child_folder_uids(params, fuid)) + return rows, skipped + + @staticmethod + def _folder_parent_uid(params, folder_uid): + folder = (getattr(params, "folder_cache", None) or {}).get(folder_uid) + if folder and getattr(folder, "parent_uid", None): + return folder.parent_uid + nsf = getattr(params, "nested_share_folders", None) or {} + info = nsf.get(folder_uid) + if isinstance(info, dict): + return info.get("parent_uid") or "" + return "" + + @staticmethod + def _folder_name(params, folder_uid): + folder = (getattr(params, "folder_cache", None) or {}).get(folder_uid) + if folder and getattr(folder, "name", None): + return folder.name + nsf = getattr(params, "nested_share_folders", None) or {} + info = nsf.get(folder_uid) + if isinstance(info, dict) and info.get("name"): + return info["name"] + sf = (getattr(params, "shared_folder_cache", None) or {}).get(folder_uid) or {} + return sf.get("name_unencrypted") or "" + + @staticmethod + def _child_folder_uids(params, folder_uid): + children: List[str] = [] + folder = (getattr(params, "folder_cache", None) or {}).get(folder_uid) + if folder: + children.extend(getattr(folder, "subfolders", None) or []) + for fuid, info in (getattr(params, "nested_share_folders", None) or {}).items(): + if not isinstance(info, dict): + continue + if (info.get("parent_uid") or "") == folder_uid: + children.append(fuid) + seen: Set[str] = set() + out: List[str] = [] + for uid in children: + if uid and uid not in seen: + seen.add(uid) + out.append(uid) + return out + + def _export_folder_permissions(self, params, folder_uid): + """Import-compatible shared_folder_* dict, or {} when permissions are unknown.""" + if not folder_uid: + return {} + sf = (getattr(params, "shared_folder_cache", None) or {}).get(folder_uid) + if isinstance(sf, dict): + return self._permissions_from_classic_sf(sf) + + nsf = getattr(params, "nested_share_folders", None) or {} + if folder_uid in nsf: + if not getattr(self, "_nsf_perm_noted", False): + logging.info( + f"{bcolors.OKBLUE}Export: NSF folder permissions are not exported " + f"(shared_folder_* left empty){bcolors.ENDC}" + ) + self._nsf_perm_noted = True + return {} + return {} + @staticmethod + def _permissions_from_classic_sf(sf): + result = { + "manage_users": bool(sf.get("default_manage_users")), + "manage_records": bool(sf.get("default_manage_records")), + "can_edit": bool(sf.get("default_can_edit")), + "can_share": bool(sf.get("default_can_share")), + "permissions": [], + } + for user in sf.get("users") or []: + if not isinstance(user, dict): + continue + name = (user.get("username") or "").strip() + if not name: + continue + result["permissions"].append({ + "name": name, + "manage_users": bool(user.get("manage_users")), + "manage_records": bool(user.get("manage_records")), + }) + for team in sf.get("teams") or []: + if not isinstance(team, dict): + continue + name = (team.get("name") or "").strip() + uid = (team.get("team_uid") or "").strip() + if not name and not uid: + continue + entry = { + "manage_users": bool(team.get("manage_users")), + "manage_records": bool(team.get("manage_records")), + } + if name: + entry["name"] = name + if uid: + entry["uid"] = uid + result["permissions"].append(entry) + return result + + def _build_resources_and_users(self, params, resource_uids, extra_user_uids=None): + """Build pam_data for re-import: one full user object per user, credentials by title.""" + title_index = _LazyUserTitleIndex(lambda: self._build_user_title_index(params)) + + # Pass 1: load resources and linked user UIDs + resource_rows = [] # (res_record, pam_settings, linked_uids) + user_link_count: Dict[str, int] = {} for res_uid in resource_uids: res_record = load_pam_record(params, res_uid) if not res_record or not isinstance(res_record, vault.TypedRecord): @@ -214,86 +517,237 @@ def _build_resources_and_users(self, params, resource_uids): res_uid, res_record.record_type, ) continue + pam_settings_dict = self._extract_pam_settings(res_record) + linked_uids = self._extract_user_uids(pam_settings_dict, title_index) + resource_rows.append((res_record, pam_settings_dict, linked_uids)) + for uid in linked_uids: + user_link_count[uid] = user_link_count.get(uid, 0) + 1 + + user_cache: Dict[str, dict] = {} - # Extract raw pamSettings payload (keep as-is for round-trip fidelity) - pam_settings_dict = {} - pam_settings_field = res_record.get_typed_field("pamSettings") - if ( - pam_settings_field - and isinstance(pam_settings_field.value, list) - and pam_settings_field.value - and isinstance(pam_settings_field.value[0], dict) - ): - pam_settings_dict = dict(pam_settings_field.value[0]) - - # Gather user UIDs referenced by this resource - resource_user_entries = [] - user_uids_for_resource = self._extract_user_uids(pam_settings_dict, title_to_uid) - - for usr_uid in user_uids_for_resource: - user_obj = self._load_user_obj(params, usr_uid) + def cached_user(uid: str): + if uid not in user_cache: + user_cache[uid] = self._load_user_obj(params, uid) + return user_cache[uid] + + resources_list = [] + top_level_users = [] + seen_top: Set[str] = set() + + for res_record, pam_settings_dict, linked_uids in resource_rows: + uid_to_user = {} + nested_users = [] + for usr_uid in linked_uids: + user_obj = cached_user(usr_uid) if user_obj is None: continue - resource_user_entries.append({"uid": usr_uid, "type": user_obj["type"], "title": user_obj["title"], "login": user_obj["login"]}) - if usr_uid not in seen_user_uids: - seen_user_uids.add(usr_uid) - top_level_users.append(user_obj) - - resources_list.append({ - "uid": res_uid, - "type": res_record.record_type, - "title": res_record.title, - "pam_settings": pam_settings_dict, - "users": resource_user_entries, - }) + uid_to_user[usr_uid] = user_obj + # Single-resource users live fully under the resource; shared → top-level only. + if user_link_count.get(usr_uid, 0) == 1: + nested_users.append(dict(user_obj)) + elif usr_uid not in seen_top: + seen_top.add(usr_uid) + top_level_users.append(dict(user_obj)) + + import_settings = self._rewrite_pam_settings_for_import( + pam_settings_dict, uid_to_user, resource_title=res_record.title or res_record.record_uid + ) + resources_list.append( + self._load_resource_obj(res_record, import_settings, nested_users) + ) + + for usr_uid in extra_user_uids or []: + # Orphans only: already-linked users are nested (count==1) or top-level (count>=2). + if usr_uid in seen_top or usr_uid in user_link_count: + continue + user_obj = cached_user(usr_uid) + if user_obj is None: + continue + seen_top.add(usr_uid) + top_level_users.append(dict(user_obj)) return resources_list, top_level_users + @staticmethod + def _user_ref_name(user_obj: dict) -> str: + return (user_obj.get("title") or user_obj.get("login") or user_obj.get("uid") or "").strip() + + def _rewrite_pam_settings_for_import( + self, pam_settings_dict, uid_to_user: Dict[str, dict], resource_title: str = "" + ): + """Convert vault wire keys (userRecords UIDs) to import JSON credential titles.""" + settings = copy.deepcopy(pam_settings_dict) if pam_settings_dict else {} + conn = settings.get("connection") + if not isinstance(conn, dict): + return settings + res_label = resource_title or "(unknown resource)" + + def warn_unresolved(kind: str, ref: str) -> None: + logging.warning( + f"{bcolors.WARNING}Export: could not resolve {kind} credential " + f"'{ref}' for resource '{res_label}'; omitting from export{bcolors.ENDC}" + ) + + def resolve_ref(ref, kind: str = "credential") -> str: + if not isinstance(ref, str) or not ref.strip(): + return "" + ref = ref.strip() + if RecordV3.is_valid_ref_uid(ref): + user_obj = uid_to_user.get(ref) + if user_obj: + return self._user_ref_name(user_obj) + warn_unresolved(kind, ref) + return "" + return ref + + admin_names: List[str] = [] + for uid in conn.get("userRecords") or []: + if not isinstance(uid, str) or not uid: + continue + user_obj = uid_to_user.get(uid) + if not user_obj: + warn_unresolved("administrative", uid) + continue + name = self._user_ref_name(user_obj) + if name and name not in admin_names: + admin_names.append(name) + + existing_admin = conn.get("administrative_credentials") + if isinstance(existing_admin, str) and existing_admin.strip(): + name = resolve_ref(existing_admin, "administrative") + if name and name not in admin_names: + admin_names.append(name) + elif isinstance(existing_admin, list): + for item in existing_admin: + name = resolve_ref(item, "administrative") if isinstance(item, str) else "" + if name and name not in admin_names: + admin_names.append(name) + + for key in ("adminRef", "adminCredentialRef"): + uid = settings.pop(key, None) + if not isinstance(uid, str) or not uid: + continue + if uid in uid_to_user: + name = self._user_ref_name(uid_to_user[uid]) + if name and name not in admin_names: + admin_names.append(name) + else: + warn_unresolved("administrative", uid) + + launch_name = "" + launch_ref = conn.get("launch_credentials") + if isinstance(launch_ref, list): + # Import's get_launch_credential uses the first non-empty entry only. + launch_ref = next( + (item for item in launch_ref if isinstance(item, str) and item.strip()), + "", + ) + if isinstance(launch_ref, str) and launch_ref.strip(): + launch_name = resolve_ref(launch_ref, "launch") + + conn.pop("userRecords", None) + if admin_names: + conn["administrative_credentials"] = admin_names if len(admin_names) > 1 else admin_names[0] + else: + conn.pop("administrative_credentials", None) + if launch_name: + conn["launch_credentials"] = launch_name + else: + conn.pop("launch_credentials", None) + + settings["connection"] = conn + return settings + + @staticmethod + def _extract_pam_settings(res_record): + field = res_record.get_typed_field("pamSettings") + if not field or not isinstance(field.value, list) or not field.value: + return {} + first = field.value[0] + return dict(first) if isinstance(first, dict) else {} + + def _load_resource_obj(self, res_record, pam_settings_dict, resource_user_entries): + obj = { + "uid": res_record.record_uid, + "type": res_record.record_type, + "title": res_record.title, + "pam_settings": pam_settings_dict, + "users": resource_user_entries, + } + if res_record.notes: + obj["notes"] = res_record.notes + + host_field = res_record.get_typed_field("pamHostname") + if host_field: + raw = host_field.get_default_value() + if isinstance(raw, dict): + host = raw.get("hostName") + port = raw.get("port") + if host: + obj["host"] = str(host) + if port is not None and str(port) != "": + obj["port"] = str(port) + + for field in itertools.chain( + getattr(res_record, "fields", None) or [], + getattr(res_record, "custom", None) or [], + ): + label = (getattr(field, "label", None) or "").strip() + ftype = (getattr(field, "type", None) or "").strip() + key = ( + _RESOURCE_FIELD_MAP.get(label.casefold()) + or _RESOURCE_FIELD_MAP.get(ftype.casefold()) + ) + if not key or key in obj: + continue + raw = field.get_default_value() if hasattr(field, "get_default_value") else None + if raw is None or raw == "": + continue + if ftype == "checkbox" or key in _BOOL_EXPORT_KEYS: + coerced = utils.value_to_boolean(raw) + if coerced is not None: + obj[key] = coerced + else: + obj[key] = str(raw) + + return obj + def _build_user_title_index(self, params): - """Index every pamUser / login record by lowercased title for title-based linking.""" index = {} for uid in iter_accessible_record_uids(params): try: rec = load_pam_record(params, uid) - except Exception: + except Exception as exc: + logging.debug("Export: title index skip UID %s: %s", uid, exc) continue if not rec or not isinstance(rec, vault.TypedRecord): continue - if rec.record_type not in ("pamUser", "login"): + if rec.record_type not in _USER_RECORD_TYPES or not rec.title: continue - if rec.title: - index.setdefault(rec.title.strip().lower(), uid) + index.setdefault(rec.title.strip().lower(), uid) return index def _extract_user_uids(self, pam_settings_dict, title_to_uid=None): - """Return all user record UIDs referenced inside a pamSettings dict. - - Falls back to title-based resolution against ``title_to_uid`` when - the record stores a title (e.g. KCM-imported records, PR #1942) - instead of a UID in launch_credentials / administrative_credentials. - """ - user_uids = [] + user_uids: List[str] = [] title_to_uid = title_to_uid or {} conn = pam_settings_dict.get("connection") or {} if isinstance(conn, dict): - for uid in (conn.get("userRecords") or []): + for uid in conn.get("userRecords") or []: if uid and uid not in user_uids: user_uids.append(uid) - # KCM-style title references (PR #1942 schema) for key in ("launch_credentials", "administrative_credentials"): ref = conn.get(key) - if not isinstance(ref, str) or not ref: - continue - # If it already looks like a UID, accept as-is - if len(ref) == 22 and "/" not in ref and " " not in ref: - if ref not in user_uids: - user_uids.append(ref) - continue - # Otherwise treat as a title and resolve against the index - resolved = title_to_uid.get(ref.strip().lower()) - if resolved and resolved not in user_uids: - user_uids.append(resolved) - # Some record types also reference admin via adminRef / adminCredentialRef at top level + refs = ref if isinstance(ref, list) else ([ref] if isinstance(ref, str) else []) + for item in refs: + if not isinstance(item, str) or not item: + continue + if RecordV3.is_valid_ref_uid(item): + if item not in user_uids: + user_uids.append(item) + continue + resolved = title_to_uid.get(item.strip().lower()) + if resolved and resolved not in user_uids: + user_uids.append(resolved) for key in ("adminRef", "adminCredentialRef"): uid = pam_settings_dict.get(key) if uid and uid not in user_uids: @@ -301,19 +755,34 @@ def _extract_user_uids(self, pam_settings_dict, title_to_uid=None): return user_uids def _load_user_obj(self, params, usr_uid): - """Load a pamUser/login record and return a plain dict, or None on failure.""" usr_record = load_pam_record(params, usr_uid) if not usr_record or not isinstance(usr_record, vault.TypedRecord): logging.debug("Export: user UID %s not found or not TypedRecord", usr_uid) return None - login_field = usr_record.get_typed_field("login") + if usr_record.record_type not in _USER_RECORD_TYPES: + logging.debug( + "Export: skipping UID %s with type '%s' (not a PAM user type)", + usr_uid, usr_record.record_type, + ) + return None + login = "" + login_field = usr_record.get_typed_field("login") if login_field: raw = login_field.get_default_value() login = str(raw) if raw is not None else "" - return { + + obj = { "uid": usr_uid, "type": usr_record.record_type, "title": usr_record.title, "login": login, } + if usr_record.notes: + obj["notes"] = usr_record.notes + pwd_field = usr_record.get_typed_field("password") + if pwd_field: + raw = pwd_field.get_default_value() + if raw is not None and str(raw) != "": + obj["password"] = str(raw) + return obj diff --git a/unit-tests/pam/test_pam_project_export.py b/unit-tests/pam/test_pam_project_export.py index f12a37784..88ce54917 100644 --- a/unit-tests/pam/test_pam_project_export.py +++ b/unit-tests/pam/test_pam_project_export.py @@ -20,7 +20,7 @@ import os import tempfile import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch import keepercommander.commands.record # noqa: F401 @@ -113,8 +113,6 @@ def _fake_load(_params, uid): # ── tests ────────────────────────────────────────────────────────────────── -from unittest.mock import MagicMock - class TestPAMProjectExportCommand(unittest.TestCase): def setUp(self): @@ -127,8 +125,8 @@ def _execute(self, project_uid=CONFIG_UID, output=None): """Run execute() with load_pam_record mocked.""" with patch("keepercommander.commands.pam_import.export.load_pam_record", side_effect=_fake_load): - with patch.object(self.cmd, "_get_allowed_settings", - return_value=dict(_DEFAULT_ALLOWED)): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): kwargs = {"project_uid": project_uid} if output: kwargs["output"] = output @@ -213,9 +211,35 @@ def test_top_level_users_deduplication(self): "top-level user UIDs must be unique (de-duplicated)") def test_top_level_users_count(self): - # USER1 shared across both resources, USER2 only in DB → 2 unique users + # USER1 shared → top-level only; USER2 single-resource → nested under DB parsed = json.loads(self._execute()) - self.assertEqual(len(parsed["pam_data"]["users"]), 2) + self.assertEqual(len(parsed["pam_data"]["users"]), 1) + self.assertEqual(parsed["pam_data"]["users"][0]["uid"], USER1_UID) + + def test_single_resource_user_nested_with_password_keys(self): + parsed = json.loads(self._execute()) + by_uid = {r["uid"]: r for r in parsed["pam_data"]["resources"]} + machine_uids = {u["uid"] for u in by_uid[MACHINE_UID]["users"]} + db_uids = {u["uid"] for u in by_uid[DB_UID]["users"]} + self.assertNotIn(USER1_UID, machine_uids) + self.assertNotIn(USER1_UID, db_uids) + self.assertEqual(db_uids, {USER2_UID}) + db_user = by_uid[DB_UID]["users"][0] + for key in ("uid", "type", "title", "login"): + self.assertIn(key, db_user) + + def test_user_records_rewritten_to_administrative_credentials(self): + parsed = json.loads(self._execute()) + by_uid = {r["uid"]: r for r in parsed["pam_data"]["resources"]} + machine_conn = by_uid[MACHINE_UID]["pam_settings"]["connection"] + db_conn = by_uid[DB_UID]["pam_settings"]["connection"] + self.assertNotIn("userRecords", machine_conn) + self.assertNotIn("userRecords", db_conn) + self.assertEqual(machine_conn["administrative_credentials"], "Admin User") + self.assertEqual( + db_conn["administrative_credentials"], + ["Admin User", "DB User"], + ) def test_user_has_required_keys(self): parsed = json.loads(self._execute()) @@ -223,6 +247,42 @@ def test_user_has_required_keys(self): for key in ("uid", "type", "title", "login"): self.assertIn(key, usr, f"user missing key: {key}") + def test_output_file_mode_is_private(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + tmp_path = tmp.name + try: + os.unlink(tmp_path) + self._execute(output=tmp_path) + mode = os.stat(tmp_path).st_mode & 0o777 + self.assertEqual(mode, 0o600) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + def test_output_file_mode_overwrites_existing_world_readable(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + tmp.write("{}") + tmp_path = tmp.name + try: + os.chmod(tmp_path, 0o644) + self._execute(output=tmp_path) + mode = os.stat(tmp_path).st_mode & 0o777 + self.assertEqual(mode, 0o600) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + def test_title_index_not_built_when_all_refs_are_uids(self): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=_fake_load): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): + with patch.object( + self.cmd, "_build_user_title_index", wraps=self.cmd._build_user_title_index + ) as mock_index: + self.cmd.execute(self.params, project_uid=CONFIG_UID) + mock_index.assert_not_called() + # ── --output flag ──────────────────────────────────────────── def test_output_flag_writes_file(self): @@ -331,7 +391,6 @@ def _make_kcm_records(self): def setUp(self): from keepercommander.commands.pam_import.export import PAMProjectExportCommand - from unittest.mock import MagicMock self.cmd = PAMProjectExportCommand() self.records = self._make_kcm_records() self.params = MagicMock() @@ -342,8 +401,8 @@ def _load(_p, uid): return self.records.get(uid) with patch("keepercommander.commands.pam_import.export.load_pam_record", side_effect=_load): - with patch.object(self.cmd, "_get_allowed_settings", - return_value=dict(_DEFAULT_ALLOWED)): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): return self.cmd.execute(self.params, project_uid=self.KCM_CFG) def test_title_based_user_link_resolved(self): @@ -357,23 +416,44 @@ def test_title_based_user_link_resolved(self): self.assertEqual(res["users"][0]["uid"], self.KCM_USR) self.assertEqual(res["users"][0]["title"], "KCM User - prod-db") - def test_top_level_users_includes_resolved_user(self): + def test_title_index_built_for_non_uid_credential_ref(self): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=lambda _p, uid: self.records.get(uid)): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): + with patch.object( + self.cmd, "_build_user_title_index", wraps=self.cmd._build_user_title_index + ) as mock_index: + self.cmd.execute(self.params, project_uid=self.KCM_CFG) + mock_index.assert_called_once() + + def test_list_launch_credentials_exports_first_entry(self): + res = self.records[self.KCM_RES] + ps = res.get_typed_field("pamSettings").value[0] + ps["connection"]["launch_credentials"] = ["KCM User - prod-db", "ignored"] parsed = json.loads(self._execute()) - top_users = parsed["pam_data"]["users"] - self.assertEqual(len(top_users), 1) - self.assertEqual(top_users[0]["uid"], self.KCM_USR) + conn = parsed["pam_data"]["resources"][0]["pam_settings"]["connection"] + self.assertEqual(conn["launch_credentials"], "KCM User - prod-db") + + def test_single_resource_user_not_duplicated_at_top_level(self): + parsed = json.loads(self._execute()) + self.assertEqual(parsed["pam_data"]["users"], []) + self.assertEqual( + parsed["pam_data"]["resources"][0]["users"][0]["uid"], self.KCM_USR + ) def test_pam_settings_preserved_for_round_trip(self): - """Round-trip safety: KCM-specific pam_settings keys preserved verbatim.""" + """Round-trip safety: KCM launch_credentials title preserved for import.""" parsed = json.loads(self._execute()) res = parsed["pam_data"]["resources"][0] conn = res["pam_settings"]["connection"] self.assertEqual(conn["protocol"], "ssh") self.assertEqual(conn["port"], "22") self.assertEqual(conn["launch_credentials"], "KCM User - prod-db") + self.assertNotIn("userRecords", conn) - def test_uid_in_launch_credentials_accepted(self): - """If launch_credentials already holds a 22-char UID (non-KCM path), keep it as-is.""" + def test_uid_in_launch_credentials_rewritten_to_title(self): + """Vault UID in launch_credentials must export as title for re-import.""" uid_22 = "AAAAAAAAAAAAAAAAAAAAAA" # 22 chars, no slash, no space usr = vault.TypedRecord(version=3) usr.type_name = "pamUser" @@ -387,9 +467,14 @@ def test_uid_in_launch_credentials_accepted(self): ps = res.get_typed_field("pamSettings").value[0] ps["connection"]["launch_credentials"] = uid_22 parsed = json.loads(self._execute()) - users = parsed["pam_data"]["resources"][0]["users"] + resource = parsed["pam_data"]["resources"][0] + users = resource["users"] self.assertEqual(len(users), 1) self.assertEqual(users[0]["uid"], uid_22) + self.assertEqual( + resource["pam_settings"]["connection"]["launch_credentials"], + "Direct UID User", + ) class TestPAMProjectExportNSF(unittest.TestCase): @@ -452,8 +537,8 @@ def setUp(self): } def _execute(self): - with patch.object(self.cmd, "_get_allowed_settings", - return_value=dict(_DEFAULT_ALLOWED)): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): return self.cmd.execute(self.params, project_uid=self.NSF_CFG) def test_nsf_config_export_succeeds(self): @@ -469,8 +554,390 @@ def test_nsf_resources_and_users_exported(self): self.assertEqual(res["type"], "pamMachine") self.assertEqual(len(res["users"]), 1) self.assertEqual(res["users"][0]["uid"], self.NSF_USER) - self.assertEqual(len(parsed["pam_data"]["users"]), 1) - self.assertEqual(parsed["pam_data"]["users"][0]["login"], "root") + self.assertEqual(res["users"][0]["login"], "root") + self.assertEqual(parsed["pam_data"]["users"], []) + conn = res["pam_settings"]["connection"] + self.assertNotIn("userRecords", conn) + self.assertEqual(conn["administrative_credentials"], "NSF Admin") + + +class TestPAMProjectExportFolderDiscovery(unittest.TestCase): + """Modern projects leave resourceRef empty; export must walk folderUid.""" + + CFG = "fold-cfg-001" + MACHINE = "fold-res-machine" + USER = "fold-usr-admin" + ORPHAN_USER = "fold-usr-orphan" + PROJECT_FOLDER = "fold-project" + RES_FOLDER = "fold-resources" + USR_FOLDER = "fold-users" + + def setUp(self): + from keepercommander.commands.pam_import.export import PAMProjectExportCommand + from types import SimpleNamespace + + self.cmd = PAMProjectExportCommand() + self.params = MagicMock() + + # Config with empty resourceRef (modern import layout) + cfg = vault.TypedRecord(version=6) + cfg.type_name = "pamNetworkConfiguration" + cfg.title = "Folder Project" + cfg.record_uid = self.CFG + cfg.fields.append(_make_typed_field("pamResources", [{ + "controllerUid": "gw-1", + "folderUid": self.USR_FOLDER, + "resourceRef": [], + }])) + + machine = vault.TypedRecord(version=3) + machine.type_name = "pamMachine" + machine.title = "App Server" + machine.record_uid = self.MACHINE + machine.fields.append(_make_typed_field("pamHostname", [{ + "hostName": "10.0.0.5", + "port": "22", + }])) + machine.fields.append(_make_typed_field("pamSettings", [{ + "connection": { + "userRecords": [self.USER], + "protocol": "ssh", + "port": "22", + }, + }])) + machine.fields.append(_make_typed_field("text", ["Linux"], label="operatingSystem")) + + user = _make_user_record(self.USER, "Admin", "root") + user.fields.append(_make_typed_field("password", ["s3cret"])) + + orphan = _make_user_record(self.ORPHAN_USER, "Orphan User", "orphan") + + self.records = { + self.CFG: cfg, + self.MACHINE: machine, + self.USER: user, + self.ORPHAN_USER: orphan, + } + self.params.record_cache = {uid: {} for uid in self.records} + self.params.nested_share_records = {} + self.params.nested_share_record_data = {} + self.params.nested_share_folders = {} + self.params.shared_folder_cache = { + self.RES_FOLDER: { + "default_manage_users": True, + "default_manage_records": True, + "default_can_edit": True, + "default_can_share": False, + "users": [{ + "username": "admin@example.com", + "manage_users": True, + "manage_records": True, + }], + "teams": [{ + "name": "Ops Team", + "team_uid": "team-uid-1", + "manage_users": False, + "manage_records": True, + }], + }, + self.USR_FOLDER: { + "default_manage_users": False, + "default_manage_records": True, + "default_can_edit": True, + "default_can_share": True, + "users": [], + "teams": [], + }, + } + self.params.subfolder_record_cache = { + self.RES_FOLDER: {self.MACHINE}, + self.USR_FOLDER: {self.USER, self.ORPHAN_USER, self.CFG}, + } + self.params.nested_share_folder_records = {} + self.params.folder_cache = { + self.PROJECT_FOLDER: SimpleNamespace( + uid=self.PROJECT_FOLDER, name="Folder Project", + parent_uid="", subfolders=[self.RES_FOLDER, self.USR_FOLDER], + ), + self.RES_FOLDER: SimpleNamespace( + uid=self.RES_FOLDER, name="Folder Project - Resources", + parent_uid=self.PROJECT_FOLDER, subfolders=[], + ), + self.USR_FOLDER: SimpleNamespace( + uid=self.USR_FOLDER, name="Folder Project - Users", + parent_uid=self.PROJECT_FOLDER, subfolders=[], + ), + } + + def _execute(self): + def _load(_p, uid): + return self.records.get(uid) + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=_load): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): + return self.cmd.execute(self.params, project_uid=self.CFG) + + def test_empty_resource_ref_discovers_folder_resources(self): + parsed = json.loads(self._execute()) + self.assertEqual(len(parsed["pam_data"]["resources"]), 1) + res = parsed["pam_data"]["resources"][0] + self.assertEqual(res["uid"], self.MACHINE) + self.assertEqual(res["host"], "10.0.0.5") + self.assertEqual(res["port"], "22") + self.assertEqual(res["operating_system"], "Linux") + + def test_folder_users_exported_including_orphan(self): + parsed = json.loads(self._execute()) + # Linked admin lives under the resource; orphan stays top-level only. + self.assertEqual( + {u["login"] for u in parsed["pam_data"]["users"]}, + {"orphan"}, + ) + res = parsed["pam_data"]["resources"][0] + self.assertEqual(len(res["users"]), 1) + root = res["users"][0] + self.assertEqual(root["login"], "root") + self.assertEqual(root.get("password"), "s3cret") + conn = res["pam_settings"]["connection"] + self.assertNotIn("userRecords", conn) + self.assertEqual(conn["administrative_credentials"], "Admin") + + def test_shared_folder_permissions_exported(self): + parsed = json.loads(self._execute()) + sfr = parsed["shared_folder_resources"] + self.assertTrue(sfr.get("manage_users")) + self.assertTrue(sfr.get("manage_records")) + self.assertFalse(sfr.get("can_share")) + names = {p.get("name") for p in sfr.get("permissions") or []} + self.assertIn("admin@example.com", names) + self.assertIn("Ops Team", names) + + sfu = parsed["shared_folder_users"] + self.assertFalse(sfu.get("manage_users")) + self.assertTrue(sfu.get("can_share")) + + def test_does_not_scan_sibling_projects_under_pam_root(self): + """Sibling projects under PAM Environments must not be exported.""" + from types import SimpleNamespace + + pam_root = "fold-pam-root" + sibling_project = "fold-sibling-project" + sibling_res = "fold-sibling-resources" + sibling_machine = "fold-sibling-machine" + + other = vault.TypedRecord(version=3) + other.type_name = "pamMachine" + other.title = "Other Project Machine" + other.record_uid = sibling_machine + other.fields.append(_make_typed_field("pamSettings", [{"connection": {}}])) + self.records[sibling_machine] = other + self.params.record_cache[sibling_machine] = {} + self.params.subfolder_record_cache[sibling_res] = {sibling_machine} + + self.params.folder_cache[self.PROJECT_FOLDER].parent_uid = pam_root + self.params.folder_cache[pam_root] = SimpleNamespace( + uid=pam_root, name="PAM Environments", + parent_uid="", subfolders=[self.PROJECT_FOLDER, sibling_project], + ) + self.params.folder_cache[sibling_project] = SimpleNamespace( + uid=sibling_project, name="Other Project", + parent_uid=pam_root, subfolders=[sibling_res], + ) + self.params.folder_cache[sibling_res] = SimpleNamespace( + uid=sibling_res, name="Other Project - Resources", + parent_uid=sibling_project, subfolders=[], + ) + + parsed = json.loads(self._execute()) + uids = {r["uid"] for r in parsed["pam_data"]["resources"]} + self.assertEqual(uids, {self.MACHINE}) + self.assertNotIn(sibling_machine, uids) + + def test_nested_safe_mode_resources_discovered(self): + """Safe-mode: Resources/Users nested under per-safe folders.""" + from types import SimpleNamespace + + config_folder = "fold-config" + safe_folder = "fold-safe-a" + safe_res = "fold-safe-a-resources" + safe_usr = "fold-safe-a-users" + safe_machine = "fold-safe-machine" + safe_user = "fold-safe-user" + + cfg = self.records[self.CFG] + pam = cfg.get_typed_field("pamResources").value[0] + pam["folderUid"] = config_folder + + machine = vault.TypedRecord(version=3) + machine.type_name = "pamMachine" + machine.title = "Safe Machine" + machine.record_uid = safe_machine + machine.fields.append(_make_typed_field("pamHostname", [{"hostName": "1.2.3.4", "port": "22"}])) + machine.fields.append(_make_typed_field("pamSettings", [{ + "connection": {"userRecords": [safe_user], "protocol": "ssh"}, + }])) + user = _make_user_record(safe_user, "Safe Admin", "safeadmin") + + self.records[safe_machine] = machine + self.records[safe_user] = user + self.params.record_cache[safe_machine] = {} + self.params.record_cache[safe_user] = {} + self.params.subfolder_record_cache = { + config_folder: {self.CFG}, + safe_res: {safe_machine}, + safe_usr: {safe_user}, + } + self.params.folder_cache = { + self.PROJECT_FOLDER: SimpleNamespace( + uid=self.PROJECT_FOLDER, name="Folder Project", + parent_uid="", subfolders=[config_folder, safe_folder], + ), + config_folder: SimpleNamespace( + uid=config_folder, name="Folder Project - Config", + parent_uid=self.PROJECT_FOLDER, subfolders=[], + ), + safe_folder: SimpleNamespace( + uid=safe_folder, name="SafeA", + parent_uid=self.PROJECT_FOLDER, subfolders=[safe_res, safe_usr], + ), + safe_res: SimpleNamespace( + uid=safe_res, name="SafeA - Resources", + parent_uid=safe_folder, subfolders=[], + ), + safe_usr: SimpleNamespace( + uid=safe_usr, name="SafeA - Users", + parent_uid=safe_folder, subfolders=[], + ), + } + + parsed = json.loads(self._execute()) + uids = {r["uid"] for r in parsed["pam_data"]["resources"]} + self.assertEqual(uids, {safe_machine}) + res = next(r for r in parsed["pam_data"]["resources"] if r["uid"] == safe_machine) + self.assertEqual(res["users"][0]["login"], "safeadmin") + self.assertEqual(parsed["pam_data"]["users"], []) + + def test_unloadable_folder_records_warn_once(self): + missing_uid = "fold-missing-rec" + self.params.subfolder_record_cache[self.RES_FOLDER].add(missing_uid) + with self.assertLogs(level="WARNING") as logs: + self._execute() + self.assertTrue(any("skipped 1 unloadable" in m for m in logs.output)) + + def test_password_warning_on_file_output(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + path = tmp.name + try: + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=lambda _p, uid: self.records.get(uid)): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): + with self.assertLogs(level="WARNING") as logs: + self.cmd.execute(self.params, project_uid=self.CFG, output=path) + self.assertTrue(any("passwords" in m.lower() for m in logs.output)) + finally: + if os.path.exists(path): + os.unlink(path) + + def test_unresolvable_admin_uid_warns_with_resource_name(self): + missing = "AAAAAAAAAAAAAAAAAAAAAA" + machine = self.records[self.MACHINE] + ps = machine.get_typed_field("pamSettings").value[0] + ps["connection"]["userRecords"] = [missing] + with self.assertLogs(level="WARNING") as logs: + parsed = json.loads(self._execute()) + self.assertTrue( + any("could not resolve administrative" in m and "App Server" in m + for m in logs.output) + ) + conn = parsed["pam_data"]["resources"][0]["pam_settings"]["connection"] + self.assertNotIn("administrative_credentials", conn) + self.assertNotIn("userRecords", conn) + + +class TestExportImportParseContract(unittest.TestCase): + """Parse exported JSON through import loaders (no vault dry-run).""" + + def setUp(self): + from keepercommander.commands.pam_import.export import PAMProjectExportCommand + + self.cmd = PAMProjectExportCommand() + self.params = MagicMock() + # Clone fixture records and attach passwords so import sees them. + self.records = { + CONFIG_UID: _make_config_record(CONFIG_UID, "Test Project", + resource_uids=[MACHINE_UID, DB_UID]), + MACHINE_UID: _make_resource_record(MACHINE_UID, "Linux Server", "pamMachine", + user_uids=[USER1_UID]), + DB_UID: _make_resource_record(DB_UID, "Postgres DB", "pamDatabase", + user_uids=[USER1_UID, USER2_UID]), + USER1_UID: _make_user_record(USER1_UID, "Admin User", "root"), + USER2_UID: _make_user_record(USER2_UID, "DB User", "dbuser"), + } + for uid in (USER1_UID, USER2_UID): + self.records[uid].fields.append(_make_typed_field("password", [f"pw-{uid}"])) + self.params.record_cache = {uid: {} for uid in self.records} + + def _export(self): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=lambda _p, uid: self.records.get(uid)): + with patch.object(self.cmd, "_load_dag_context", + return_value=(dict(_DEFAULT_ALLOWED), [])): + return json.loads(self.cmd.execute(self.params, project_uid=CONFIG_UID)) + + def test_exported_json_loads_via_import_object_loaders(self): + from keepercommander.commands.pam_import.base import ( + PamDatabaseObject, + PamMachineObject, + PamUserObject, + get_admin_credential, + ) + + parsed = self._export() + loaders = { + "pamMachine": PamMachineObject.load, + "pamDatabase": PamDatabaseObject.load, + } + + for res in parsed["pam_data"]["resources"]: + # Import regenerates UIDs; drop export UIDs so validators don't fire on fixtures. + payload = dict(res) + payload.pop("uid", None) + nested = [] + for user in payload.get("users") or []: + u = dict(user) + u.pop("uid", None) + nested.append(u) + payload["users"] = nested + + obj = loaders[res["type"]](payload) + admin = get_admin_credential(obj) + self.assertTrue( + admin, + f"{res['title']}: administrative_credentials must populate import userRecords", + ) + self.assertNotIn( + "userRecords", + (res.get("pam_settings") or {}).get("connection") or {}, + ) + for nested_user in obj.users or []: + self.assertTrue( + nested_user.password, + f"nested user {nested_user.title} missing password", + ) + + for user in parsed["pam_data"]["users"]: + payload = dict(user) + payload.pop("uid", None) + loaded = PamUserObject.load(payload) + self.assertEqual(loaded.title, user["title"]) + self.assertTrue(loaded.password, f"top-level user {loaded.title} missing password") + + # Shared admin is top-level only; single-resource DB user is nested only. + self.assertEqual({u["uid"] for u in parsed["pam_data"]["users"]}, {USER1_UID}) + db = next(r for r in parsed["pam_data"]["resources"] if r["uid"] == DB_UID) + self.assertEqual({u["uid"] for u in db["users"]}, {USER2_UID}) if __name__ == "__main__": From cf0694da6e3a086460cd91a30af519d09a3198ed Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:18:24 -0500 Subject: [PATCH 13/14] NSF fixes for pam project import/extend: KSM app grants and root folder reuse (#2308) * Grant NSF project folders to the KSM app on pam project import/extend * Reuse pre-existing NSF PAM root folder instead of creating a duplicate * Refresh cached record revision after update to avoid RS_OUT_OF_SYNC --- keepercommander/commands/pam_import/edit.py | 65 +++- keepercommander/commands/pam_import/extend.py | 82 +++-- .../commands/pam_import/nsf_helpers.py | 89 +++++ keepercommander/record_management.py | 50 ++- unit-tests/pam/test_pam_project_extend_nsf.py | 150 +++++++++ unit-tests/pam/test_pam_project_import_nsf.py | 305 +++++++++++++++++- .../test_record_update_revision_refresh.py | 114 +++++++ 7 files changed, 811 insertions(+), 44 deletions(-) create mode 100644 unit-tests/test_record_update_revision_refresh.py diff --git a/keepercommander/commands/pam_import/edit.py b/keepercommander/commands/pam_import/edit.py index 0087b1ae9..e2c6420ef 100644 --- a/keepercommander/commands/pam_import/edit.py +++ b/keepercommander/commands/pam_import/edit.py @@ -49,6 +49,7 @@ ) from ..base import Command from ..ksm import KSMCommand +from ..nested_share_folder.helpers import ROOT_FOLDER_UID as NSF_ROOT_FOLDER_UID from ..pam import gateway_helper from ..pam.config_helper import pam_configurations_get_all from ..pam.vault_target import ( @@ -546,7 +547,9 @@ def process_ksm_app(self, params, project: dict) -> dict: # Create KSM App and share Resources/Users folders (classic SF or NSF). # KSMCommand routes NSF folder UIDs through grant_folder_access_to_application_v3. use_nsf = project["options"].get("use_nsf", False) is True - from .nsf_helpers import restore_nsf_folder_keys, snapshot_nsf_folder_keys, sync_down_preserving_nsf_keys + from .nsf_helpers import (collect_nsf_subtree_uids, grant_nsf_folders_to_ksm_app, + restore_nsf_folder_keys, snapshot_nsf_folder_keys, + sync_down_preserving_nsf_keys) preserved = snapshot_nsf_folder_keys(params) if use_nsf else None res["app_uid"] = self.create_ksm_app(params, res["app_name"]) if preserved is not None: @@ -570,18 +573,35 @@ def _add_sf(sf_uid: str): sf_uids_to_grant.append(sf_uid) folders = project["folders"] + # NSF folder keys are per-folder: a child does not inherit the parent's + # AT_APPLICATION grant the way a classic shared_folder_folder does. Grant + # the whole project subtree so records in any descendant (safe-mode + # "{safe} - Resources"/"- Users" subfolders included) stay GW-visible. + # The global "PAM Environments" root is deliberately excluded - it is a + # container shared across projects and holds no records. + if use_nsf and folders.get("project_folder_uid", ""): + for nsf_uid in collect_nsf_subtree_uids(params, folders["project_folder_uid"]): + _add_sf(nsf_uid) _add_sf(folders.get("resources_folder_uid", "")) _add_sf(folders.get("users_folder_uid", "")) _add_sf(folders.get("config_folder_uid", "")) for entry in folders.get("safe_folders", []) or []: if isinstance(entry, dict): _add_sf(entry.get("uid", "")) + if use_nsf: + # Classic safe subfolders are shared_folder_folder children and + # inherit the safe's key; NSF ones need their own grant. + _add_sf(entry.get("resources_subfolder_uid", "")) + _add_sf(entry.get("users_subfolder_uid", "")) - for sf_uid in sf_uids_to_grant: - KSMCommand().execute(params, - command=("secret", "add"), - app=res["app_uid"], - secret=[sf_uid], editable=True) + if use_nsf: + grant_nsf_folders_to_ksm_app(params, res["app_uid"], sf_uids_to_grant, editable=True) + else: + for sf_uid in sf_uids_to_grant: + KSMCommand().execute(params, + command=("secret", "add"), + app=res["app_uid"], + secret=[sf_uid], editable=True) if use_nsf or any(is_nested_share_folder(params, uid) for uid in (project["folders"].get("resources_folder_uid", ""), @@ -1105,16 +1125,43 @@ def find_folders(self, params, parent_uid:str, folder:str, is_shared_folder:bool (is_shared_folder and v.type == BaseFolderNode.SharedFolderType) or (not is_shared_folder and v.type == BaseFolderNode.UserFolderType)] if not is_shared_folder: - for uid, nsf in getattr(params, 'nested_share_folders', {}).items(): - nsf_parent = nsf.get('parent_uid') or None - if nsf_parent == puid and nsf.get('name') == folder: + seen = {v.uid for v in result} + nsf_folders = getattr(params, 'nested_share_folders', None) or {} + for uid, nsf in nsf_folders.items(): + if uid in seen or nsf.get('name') != folder: + continue + nsf_parent = self._nsf_effective_parent_uid(nsf, nsf_folders, folders) + if nsf_parent == puid: nsf_folder = NestedShareFolderNode() nsf_folder.uid = uid nsf_folder.name = nsf.get('name') nsf_folder.parent_uid = nsf_parent result.append(nsf_folder) + seen.add(uid) return result + @staticmethod + def _nsf_effective_parent_uid(nsf: dict, nsf_folders: dict, folder_cache: dict) -> Optional[str]: + """Normalize an NSF folder's parent to ``None`` when the folder sits at the root. + + A root-level NSF can be reported three ways: ``None`` (no ``parentUid`` was + ever sent - both Commander and the Web Vault omit it on create), the server's + drive-root sentinel UID, or - after ``normalize_parent_uid`` - the string + ``'root'``. ``sync_down.prepare_folder_tree`` reconciles this on the + ``folder_cache`` node but leaves ``params.nested_share_folders`` untouched, so + matching on the raw value there misses roots and duplicates them instead. + Mirrors the same predicate used by ``tree``/``ls`` in commands/folder.py. + """ + nsf_parent = nsf.get('parent_uid') or None + if not nsf_parent: + return None + if nsf_parent in (NSF_ROOT_FOLDER_UID, 'root'): + return None + if nsf_parent not in nsf_folders and nsf_parent not in folder_cache: + # Parent resolves to no known folder, so it cannot be a real parent. + return None + return nsf_parent + def create_ksm_app(self, params, app_name) -> str: app_record_data = { "title": app_name, diff --git a/keepercommander/commands/pam_import/extend.py b/keepercommander/commands/pam_import/extend.py index b2dfa371f..ecc846e04 100644 --- a/keepercommander/commands/pam_import/extend.py +++ b/keepercommander/commands/pam_import/extend.py @@ -62,6 +62,8 @@ get_folder_record_uids, get_ksm_app_folders, get_records_in_folder, + grant_nsf_folders_to_ksm_app, + sync_down_preserving_nsf_keys, ) from .record_loader import load_pam_record from ...keeper_dag import EdgeType @@ -464,6 +466,7 @@ def execute(self, params, **kwargs): project = { "data": {"pam_data": pam_data}, "options": {"dry_run": dry_run}, + "ksm_app_uid": ksmapp_uid, "ksm_shared_folders": ksm_shared_folders, "folders": {}, "pam_config": {"pam_config_uid": configuration.record_uid, "pam_config_object": None}, @@ -651,6 +654,7 @@ def process_folders(self, params, project: dict) -> dict: if not dry_run and not step1_errors and new_nodes_list: sf_name_map = {shf["name"]: shf for shf in ksm_shared_folders} + new_nsf_uids = [] for full_path, parent_path, name, node in new_nodes_list: parent_uid = path_to_folder_uid.get(parent_path, "") if not parent_uid and parent_path in sf_name_map: @@ -658,7 +662,29 @@ def process_folders(self, params, project: dict) -> dict: new_uid = self.create_subfolder(params, name, parent_uid, folder_uid=node.get("uid")) node["uid"] = new_uid path_to_folder_uid[full_path] = new_uid - api.sync_down(params) + if is_nested_share_folder(params, new_uid): + new_nsf_uids.append(new_uid) + + if new_nsf_uids: + # Plain sync_down drops the NSF folder keys the grant below needs. + sync_down_preserving_nsf_keys(params) + # NSF children do not inherit the parent's AT_APPLICATION grant, + # so the Gateway cannot see records in them until each new folder + # is registered on the KSM application. + app_uid = project.get("ksm_app_uid") or "" + if app_uid: + granted = grant_nsf_folders_to_ksm_app(params, app_uid, new_nsf_uids, editable=True) + print(f"Registered {len(granted)} of {len(new_nsf_uids)} new Nested Share Folder(s) " + f"on KSM Application {app_uid}") + if len(granted) != len(new_nsf_uids): + logging.warning("Records in unregistered Nested Share Folders will not be " + "visible to the Gateway - add them with " + "`secrets-manager share add --app %s --secret `", app_uid) + else: + logging.warning("KSM Application UID unavailable - new Nested Share Folders " + "were not registered on the application") + else: + api.sync_down(params) existing_msg = f"{x_count} existing folders (skipped)" if x_count else "0 existing folders" if dry_run: @@ -666,6 +692,16 @@ def process_folders(self, params, project: dict) -> dict: else: print(f"{existing_msg}, {y_count} new folders created") + if dry_run and not step1_errors and new_nodes_list: + nsf_paths = self._nsf_paths_to_be_created(params, new_nodes_list, + path_to_folder_uid, ksm_shared_folders) + if nsf_paths: + app_uid = project.get("ksm_app_uid") or "?" + print(f"[DRY RUN] {len(nsf_paths)} new Nested Share Folder(s) would be registered " + f"on KSM Application {app_uid}") + for path in nsf_paths: + print(f"[DRY RUN] • {path}") + if logging.getLogger().getEffectiveLevel() <= logging.DEBUG: for path, _ in good_paths: tag = "existing" if path in existing_paths_set else "new" @@ -678,6 +714,25 @@ def process_folders(self, params, project: dict) -> dict: folders_out["folder_stats_y"] = y_count return folders_out + @staticmethod + def _nsf_paths_to_be_created(params, new_nodes_list, path_to_folder_uid, ksm_shared_folders) -> list: + """Dry-run helper: which of the folders about to be created land under an NSF parent. + + New folders have pre-generated UIDs that do not exist in the vault yet, so a + new child of a new folder cannot be classified by UID - shallow paths are + resolved first and deeper ones inherit from their parent path. + """ + sf_name_map = {shf["name"]: shf for shf in ksm_shared_folders} + nsf_paths = set() + for full_path, parent_path, _name, _node in sorted(new_nodes_list, + key=lambda x: str(x[0]).count("/")): + parent_uid = path_to_folder_uid.get(parent_path, "") + if not parent_uid and parent_path in sf_name_map: + parent_uid = sf_name_map[parent_path]["uid"] + if parent_path in nsf_paths or is_nested_share_folder(params, parent_uid): + nsf_paths.add(full_path) + return sorted(nsf_paths) + def map_records(self, params, project: dict) -> tuple: """Step 2: Parse resources/users, tag existing vs new, set obj.uid; collect errors. Returns (resources, users, step2_errors, new_record_count). Updates project['error_count'].""" @@ -1245,31 +1300,6 @@ def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissio params.environment_variables[LAST_SHARED_FOLDER_UID] = folder_uid return folder_uid - def find_folders(self, params, parent_uid:str, folder:str, is_shared_folder:bool) -> List[BaseFolderNode]: - result: List[BaseFolderNode] = [] - folders = params.folder_cache if params and params.folder_cache else {} - if not isinstance(folders, dict): - return result - - puid = parent_uid if parent_uid else None # root folder parent uid is set to None - matches = {k: v for k, v in folders.items() if v.parent_uid == puid and v.name == folder} - result = [v for k, v in matches.items() if - (is_shared_folder and v.type == BaseFolderNode.SharedFolderType) or - (not is_shared_folder and v.type == BaseFolderNode.UserFolderType)] - if not is_shared_folder: - for uid, nsf in getattr(params, 'nested_share_folders', {}).items(): - nsf_parent = nsf.get('parent_uid') or None - if nsf_parent == puid and nsf.get('name') == folder: - result.append(SimpleNamespace( - uid=uid, - name=nsf.get('name'), - parent_uid=nsf_parent, - type=BaseFolderNode.UserFolderType, - UserFolderType=BaseFolderNode.UserFolderType, - SharedFolderType=BaseFolderNode.SharedFolderType, - )) - return result - def create_ksm_app(self, params, app_name) -> str: app_record_data = { "title": app_name, diff --git a/keepercommander/commands/pam_import/nsf_helpers.py b/keepercommander/commands/pam_import/nsf_helpers.py index 48963cf58..4088daa93 100644 --- a/keepercommander/commands/pam_import/nsf_helpers.py +++ b/keepercommander/commands/pam_import/nsf_helpers.py @@ -208,6 +208,95 @@ def sync_down_preserving_nsf_keys(params) -> None: restore_nsf_folder_keys(params, preserved) +def collect_nsf_subtree_uids(params, root_uid: str) -> List[str]: + """Return *root_uid* plus every NSF folder nested underneath it. + + Breadth-first so parents are granted before their children; dedup'd and + cycle-safe. + """ + root_uid = (root_uid or '').strip() + if not root_uid: + return [] + + children = {} + for folder_uid, info in (getattr(params, 'nested_share_folders', None) or {}).items(): + parent_uid = (info.get('parent_uid') if isinstance(info, dict) else None) or '' + if parent_uid: + children.setdefault(parent_uid, []).append(folder_uid) + + ordered = [root_uid] + seen = {root_uid} + queue = [root_uid] + while queue: + current = queue.pop(0) + for child_uid in children.get(current, []): + if child_uid in seen: + continue + seen.add(child_uid) + ordered.append(child_uid) + queue.append(child_uid) + return ordered + + +def grant_nsf_folders_to_ksm_app(params, app_uid: str, folder_uids, editable: bool = True) -> List[str]: + """Register NSF folders on a KSM application. + + KSM and the Gateway only see records in folders carrying a direct + ``AT_APPLICATION`` grant - unlike classic shared-folder children, NSF + children have their own folder key and inherit nothing from the parent. + + One batched ``secret add`` is attempted first; on failure the UIDs are + retried one at a time so a single unshareable folder does not lose the + rest. Returns the UIDs that were granted. + """ + from ..ksm import KSMCommand + + app_uid = (app_uid or '').strip() + if not app_uid: + return [] + + uids: List[str] = [] + seen = set() + for folder_uid in folder_uids or []: + folder_uid = (folder_uid or '').strip() + if not folder_uid or folder_uid in seen: + continue + seen.add(folder_uid) + uids.append(folder_uid) + if not uids: + return [] + + # add_app_share ends in a sync_down that drops the NSF folder keys, so + # restore them before every call - not just once at the end. + preserved = snapshot_nsf_folder_keys(params) + try: + try: + KSMCommand().execute(params, command=('secret', 'add'), app=app_uid, + secret=list(uids), editable=editable) + return uids + except Exception as exc: + if len(uids) == 1: + logging.warning('Could not register folder %s on KSM application %s: %s', + uids[0], app_uid, exc) + return [] + logging.debug('Batch KSM share of %d folders failed (%s) - retrying one at a time', + len(uids), exc) + + granted: List[str] = [] + for folder_uid in uids: + restore_nsf_folder_keys(params, preserved) + try: + KSMCommand().execute(params, command=('secret', 'add'), app=app_uid, + secret=[folder_uid], editable=editable) + granted.append(folder_uid) + except Exception as exc: + logging.warning('Could not register folder %s on KSM application %s: %s', + folder_uid, app_uid, exc) + return granted + finally: + restore_nsf_folder_keys(params, preserved) + + def seed_nsf_folder_cache(params, folder_uid: str, name: str, parent_uid: Optional[str] = None, folder_key: Optional[bytes] = None) -> None: diff --git a/keepercommander/record_management.py b/keepercommander/record_management.py index 2074bfe26..e645a7cc8 100644 --- a/keepercommander/record_management.py +++ b/keepercommander/record_management.py @@ -188,6 +188,37 @@ def compare_records(record1, record2): return status +def _sync_cached_record(params, record_uid, revision, client_modified_time, + encrypted_data, decrypted_data, + encrypted_extra=None, decrypted_extra=None, udata=None): + # type: (KeeperParams, str, int, int, Optional[bytes], Optional[bytes], Optional[bytes], Optional[bytes], Optional[dict]) -> None + """Fold a just-persisted record back into ``params.record_cache``. + + ``update_record`` reads the revision it puts on the wire from the cache, not + from the passed-in record object, so leaving the cache at the pre-update + revision makes a second update of the same record within one command fail + with ``RS_OUT_OF_SYNC``. The payload is written alongside the revision to keep + the entry self-consistent - ``KeeperRecord.load`` reads ``data_unencrypted``, + so a bumped revision paired with stale data would surface pre-update fields. + """ + storage_record = params.record_cache.get(record_uid) + if not isinstance(storage_record, dict) or not revision: + return + storage_record['revision'] = revision + if client_modified_time: + storage_record['client_modified_time'] = client_modified_time + if encrypted_data is not None: + storage_record['data'] = utils.base64_url_encode(encrypted_data) + if decrypted_data is not None: + storage_record['data_unencrypted'] = decrypted_data + if encrypted_extra is not None: + storage_record['extra'] = utils.base64_url_encode(encrypted_extra) + if decrypted_extra is not None: + storage_record['extra_unencrypted'] = decrypted_extra + if udata is not None: + storage_record['udata'] = udata + + def update_record(params, record, skip_extra=False): # type: (KeeperParams, vault.KeeperRecord, bool) -> None storage_record = params.record_cache.get(record.record_uid) @@ -212,9 +243,12 @@ def update_record(params, record, skip_extra=False): record_object.update(path) data = vault_extensions.extract_password_record_data(record) - record_object['data'] = utils.base64_url_encode( - crypto.encrypt_aes_v1(json.dumps(data).encode(), record.record_key)) + decrypted_data = json.dumps(data).encode() + encrypted_data = crypto.encrypt_aes_v1(decrypted_data, record.record_key) + record_object['data'] = utils.base64_url_encode(encrypted_data) + decrypted_extra = None + encrypted_extra = None if not skip_extra: existing_extra = None try: @@ -225,8 +259,9 @@ def update_record(params, record, skip_extra=False): extra = vault_extensions.extract_password_record_extras(record, existing_extra) extra_str = json.dumps(extra) - record_object['extra'] = utils.base64_url_encode( - crypto.encrypt_aes_v1(extra_str.encode('utf-8'), record.record_key)) + decrypted_extra = extra_str.encode('utf-8') + encrypted_extra = crypto.encrypt_aes_v1(decrypted_extra, record.record_key) + record_object['extra'] = utils.base64_url_encode(encrypted_extra) if 'udata' in storage_record: u = storage_record['udata'] @@ -262,6 +297,11 @@ def update_record(params, record, skip_extra=False): raise KeeperApiError(record_status, status.get('message', '')) record.revision = rs.get('revision', record.revision) + _sync_cached_record(params, record.record_uid, record.revision, + record_object.get('client_modified_time'), + encrypted_data, decrypted_data, + encrypted_extra, decrypted_extra, + record_object.get('udata')) add_record_audit_data(params, record) prev_file_refs = set((x.id for x in existing_record.attachments or [])) new_file_refs = set((x.id for x in record.attachments or [])) @@ -317,6 +357,8 @@ def update_record(params, record, skip_extra=False): if rs_status and rs_status.status != record_pb2.RS_SUCCESS: raise KeeperApiError(record_pb2.RecordModifyResult.keys()[rs_status.status], rs_status.message) record.revision = rs.revision + _sync_cached_record(params, record.record_uid, rs.revision, + ru.client_modified_time, ru.data, json_data) for file_id in refs.difference(existing_refs): params.queue_audit_event( 'file_attachment_uploaded', record_uid=record.record_uid, attachment_id=file_id) diff --git a/unit-tests/pam/test_pam_project_extend_nsf.py b/unit-tests/pam/test_pam_project_extend_nsf.py index 86dfa7478..998f845ea 100644 --- a/unit-tests/pam/test_pam_project_extend_nsf.py +++ b/unit-tests/pam/test_pam_project_extend_nsf.py @@ -110,3 +110,153 @@ def test_process_folders_creates_new_nsf_folder_paths(): create_sub.assert_called_once() assert folders['path_to_folder_uid']['NSF Project - Users/Admins'] == 'admins_nsf' assert project['error_count'] == 0 + + +def _extend_project(dry_run=False, ksm_app_uid='ksm_app_uid', folder_path='NSF Project - Users/Admins', + shared_folders=None): + return { + 'data': { + 'pam_data': { + 'users': [{'type': 'pamUser', 'title': 'Admin', 'login': 'admin', + 'folder_path': folder_path}], + 'resources': [], + } + }, + 'options': {'dry_run': dry_run}, + 'ksm_app_uid': ksm_app_uid, + 'ksm_shared_folders': shared_folders if shared_folders is not None else [ + {'uid': 'users_nsf', 'name': 'NSF Project - Users', 'folder_tree': {}}, + {'uid': 'resources_nsf', 'name': 'NSF Project - Resources', 'folder_tree': {}}, + ], + 'folders': {}, + 'error_count': 0, + } + + +def _seeding_nsf_create(params, new_uid, parent_uid): + """Stand-in for create_nsf_subfolder that also seeds the NSF cache, as the real one does.""" + def _create(_params, name, _parent_uid='', folder_uid=None): + uid = folder_uid or new_uid + params.nested_share_folders[uid] = { + 'name': name, 'parent_uid': parent_uid, 'folder_key_unencrypted': b'k' * 32, + } + return uid + return _create + + +def test_process_folders_registers_new_nsf_child_on_ksm_app(capsys): + params = _params() + project = _extend_project() + + with patch('keepercommander.commands.pam_import.extend.create_nsf_subfolder', + side_effect=_seeding_nsf_create(params, 'admins_nsf', 'users_nsf')), \ + patch('keepercommander.commands.pam_import.extend.grant_nsf_folders_to_ksm_app', + side_effect=lambda _p, _a, uids, **_kw: list(uids)) as grant, \ + patch('keepercommander.commands.pam_import.extend.sync_down_preserving_nsf_keys') as nsf_sync, \ + patch('keepercommander.commands.pam_import.extend.api.sync_down') as plain_sync: + folders = PAMProjectExtendCommand().process_folders(params, project) + + # The folder UID is pre-generated before creation, so grant the UID that was used. + new_uid = folders['path_to_folder_uid']['NSF Project - Users/Admins'] + grant.assert_called_once() + args, kwargs = grant.call_args + assert args[1] == 'ksm_app_uid' + assert args[2] == [new_uid] + assert kwargs['editable'] is True + # NSF keys must survive the post-create sync so the grant can use them. + nsf_sync.assert_called_once_with(params) + plain_sync.assert_not_called() + assert 'Registered 1 of 1 new Nested Share Folder(s)' in capsys.readouterr().out + + +def test_process_folders_warns_when_a_folder_could_not_be_registered(capsys): + params = _params() + project = _extend_project() + + with patch('keepercommander.commands.pam_import.extend.create_nsf_subfolder', + side_effect=_seeding_nsf_create(params, 'admins_nsf', 'users_nsf')), \ + patch('keepercommander.commands.pam_import.extend.grant_nsf_folders_to_ksm_app', + return_value=[]), \ + patch('keepercommander.commands.pam_import.extend.sync_down_preserving_nsf_keys'), \ + patch('keepercommander.commands.pam_import.extend.api.sync_down'), \ + patch('keepercommander.commands.pam_import.extend.logging.warning') as warn: + PAMProjectExtendCommand().process_folders(params, project) + + assert 'Registered 0 of 1 new Nested Share Folder(s)' in capsys.readouterr().out + assert any('not be visible to the Gateway' in str(c.args[0]) for c in warn.call_args_list) + + +def test_process_folders_classic_child_is_not_registered(): + params = _params() + # Classic shared folder root - children inherit the folder key, nothing to grant. + params.folder_cache['classic_sf'] = _folder('classic_sf', 'Classic Project') + params.folder_cache['classic_sf'].type = BaseFolderNode.SharedFolderType + params.shared_folder_cache['classic_sf'] = {'name_unencrypted': 'Classic Project'} + project = _extend_project(folder_path='Classic Project/Admins', + shared_folders=[{'uid': 'classic_sf', 'name': 'Classic Project', + 'folder_tree': {}}]) + + with patch.object(PAMProjectExtendCommand, 'create_subfolder', return_value='admins_sf'), \ + patch('keepercommander.commands.pam_import.extend.grant_nsf_folders_to_ksm_app') as grant, \ + patch('keepercommander.commands.pam_import.extend.sync_down_preserving_nsf_keys') as nsf_sync, \ + patch('keepercommander.commands.pam_import.extend.api.sync_down') as plain_sync: + PAMProjectExtendCommand().process_folders(params, project) + + grant.assert_not_called() + nsf_sync.assert_not_called() + plain_sync.assert_called_once_with(params) + + +def test_process_folders_warns_when_ksm_app_uid_missing(): + params = _params() + project = _extend_project(ksm_app_uid='') + + with patch('keepercommander.commands.pam_import.extend.create_nsf_subfolder', + side_effect=_seeding_nsf_create(params, 'admins_nsf', 'users_nsf')), \ + patch('keepercommander.commands.pam_import.extend.grant_nsf_folders_to_ksm_app') as grant, \ + patch('keepercommander.commands.pam_import.extend.sync_down_preserving_nsf_keys'), \ + patch('keepercommander.commands.pam_import.extend.api.sync_down'), \ + patch('keepercommander.commands.pam_import.extend.logging.warning') as warn: + PAMProjectExtendCommand().process_folders(params, project) + + grant.assert_not_called() + assert any('not registered' in str(c.args[0]) for c in warn.call_args_list) + + +def test_process_folders_dry_run_reports_nsf_registration_without_calling_api(capsys): + params = _params() + project = _extend_project(dry_run=True) + + with patch('keepercommander.commands.pam_import.extend.create_nsf_subfolder') as create_sub, \ + patch('keepercommander.commands.pam_import.extend.grant_nsf_folders_to_ksm_app') as grant, \ + patch('keepercommander.commands.pam_import.extend.api.sync_down'): + PAMProjectExtendCommand().process_folders(params, project) + + create_sub.assert_not_called() + grant.assert_not_called() + out = capsys.readouterr().out + assert 'would be registered on KSM Application ksm_app_uid' in out + assert 'NSF Project - Users/Admins' in out + + +def test_nsf_paths_to_be_created_classifies_nested_new_paths(): + params = _params() + new_nodes = [ + ('NSF Project - Users/Admins/Tier2', 'NSF Project - Users/Admins', 'Tier2', {'uid': 'b'}), + ('NSF Project - Users/Admins', 'NSF Project - Users', 'Admins', {'uid': 'a'}), + ('Classic Project/Ops', 'Classic Project', 'Ops', {'uid': 'c'}), + ] + path_to_uid = { + 'NSF Project - Users': 'users_nsf', + 'NSF Project - Users/Admins': 'a', + 'NSF Project - Users/Admins/Tier2': 'b', + 'Classic Project': 'classic_sf', + } + shared_folders = [{'uid': 'users_nsf', 'name': 'NSF Project - Users'}, + {'uid': 'classic_sf', 'name': 'Classic Project'}] + + paths = PAMProjectExtendCommand._nsf_paths_to_be_created( + params, new_nodes, path_to_uid, shared_folders) + + # A new child of a new NSF folder is still NSF even though its UID does not exist yet. + assert paths == ['NSF Project - Users/Admins', 'NSF Project - Users/Admins/Tier2'] diff --git a/unit-tests/pam/test_pam_project_import_nsf.py b/unit-tests/pam/test_pam_project_import_nsf.py index 42ab218da..02a6a6be1 100644 --- a/unit-tests/pam/test_pam_project_import_nsf.py +++ b/unit-tests/pam/test_pam_project_import_nsf.py @@ -6,8 +6,9 @@ from keepercommander.commands.pam.vault_target import ( execute_record_add_in_folder, execute_record_v3_add_in_folder, grant_pam_folder_permissions, is_nested_share_folder) from keepercommander.commands.pam_import.base import PamUserObject +from keepercommander.commands.nested_share_folder.helpers import ROOT_FOLDER_UID as NSF_ROOT_FOLDER_UID from keepercommander.commands.pam_import.edit import PAMProjectImportCommand -from keepercommander.subfolder import BaseFolderNode +from keepercommander.subfolder import BaseFolderNode, NestedShareFolderNode def _params(): @@ -97,6 +98,140 @@ def test_import_record_objects_use_nsf_aware_record_add_helper(): assert add_record.call_args.kwargs == {'command': 'pam-project-import'} +ROOT_NAME = PAMProjectImportCommand.PAM_ROOT_FOLDER_NAME + + +def _synced_root_params(root_parent_uid): + """Vault state after sync: nested_share_folders keeps the raw parent from the server, + while prepare_folder_tree clears it on the folder_cache node for root-level NSF.""" + params = _params() + params.nested_share_folders = { + 'wv_root': {'name': ROOT_NAME, 'parent_uid': root_parent_uid}, + } + node = NestedShareFolderNode() + node.uid = 'wv_root' + node.name = ROOT_NAME + node.parent_uid = None # prepare_folder_tree normalized it + node.subfolders = [] + params.folder_cache = {'wv_root': node} + params.subfolder_cache = { + 'wv_root': {'folder_uid': 'wv_root', 'type': 'user_folder', 'name': ROOT_NAME, + 'parent_uid': root_parent_uid, 'source': 'nested_share_folder'}, + } + return params + + +def test_find_folders_matches_root_nsf_reported_with_drive_root_sentinel(): + # The server reports root-level NSF folders with the drive-root sentinel UID, which + # is not a vault folder. Such a root must still be found - otherwise --nsf creates a + # duplicate "PAM Environments" on every import. + params = _synced_root_params(NSF_ROOT_FOLDER_UID) + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root'] + assert found[0].parent_uid is None + + +def test_find_folders_matches_root_nsf_when_sentinel_is_a_listed_folder(): + # Defensive: the sentinel is recognized by value, so a root is still matched even if + # the drive root ever shows up as a real entry in the NSF caches. + params = _synced_root_params(NSF_ROOT_FOLDER_UID) + params.nested_share_folders[NSF_ROOT_FOLDER_UID] = {'name': 'Drive Root', 'parent_uid': None} + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root'] + + +def test_find_folders_matches_root_nsf_normalized_to_root_string(): + params = _synced_root_params('root') + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root'] + + +def test_find_folders_matches_root_nsf_whose_synced_parent_is_not_a_folder(): + # Any parent that resolves to no known folder is also treated as root level. + params = _synced_root_params('SomeUnknownParentUid__') + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root'] + + +def test_find_folders_matches_root_nsf_created_by_commander(): + # Commander omits parentUid on create, so the same root can also come back as None. + params = _synced_root_params(None) + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root'] + + +def test_find_folders_returns_both_root_shapes_first_wins(): + params = _synced_root_params(NSF_ROOT_FOLDER_UID) + params.nested_share_folders['kc_root'] = {'name': ROOT_NAME, 'parent_uid': None} + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root', 'kc_root'] + + +def test_find_folders_does_not_promote_a_real_nested_child_to_root(): + params = _synced_root_params(None) + params.nested_share_folders['child'] = {'name': 'Project', 'parent_uid': 'wv_root'} + + assert PAMProjectImportCommand().find_folders(params, '', 'Project', False) == [] + child = PAMProjectImportCommand().find_folders(params, 'wv_root', 'Project', False) + assert [f.uid for f in child] == ['child'] + + +def test_find_folders_does_not_duplicate_a_uid_present_in_both_caches(): + params = _synced_root_params(NSF_ROOT_FOLDER_UID) + # A user_folder node with the same UID must not yield two results. + plain = BaseFolderNode(BaseFolderNode.UserFolderType) + plain.uid = 'wv_root' + plain.name = ROOT_NAME + plain.parent_uid = None + plain.subfolders = [] + params.folder_cache['wv_root'] = plain + + found = PAMProjectImportCommand().find_folders(params, '', ROOT_NAME, False) + + assert [f.uid for f in found] == ['wv_root'] + + +def test_process_folders_reuses_synced_nsf_root_instead_of_creating_duplicate(): + params = _synced_root_params(NSF_ROOT_FOLDER_UID) + project = { + 'options': {'project_name': 'BatchSmokeS', 'dry_run': False, 'use_nsf': True}, + 'data': {}, + 'folders': {}, + } + created = [] + + def create_folder(_params, folder_name, parent_uid=None, permissions=None, use_nsf=False): + uid = f'new_{len(created)}' + created.append((folder_name, parent_uid)) + _params.nested_share_folders[uid] = {'name': folder_name, 'parent_uid': parent_uid} + return uid + + with patch.object(PAMProjectImportCommand, 'create_subfolder', side_effect=create_folder), \ + patch.object(PAMProjectImportCommand, 'get_folder_permissions', return_value=({}, [])), \ + patch.object(PAMProjectImportCommand, 'verify_users_and_teams'), \ + patch.object(PAMProjectImportCommand, 'add_folder_permissions'), \ + patch('keepercommander.commands.pam_import.nsf_helpers.api.sync_down'): + res = PAMProjectImportCommand().process_folders(params, project) + + assert res['root_folder_uid'] == 'wv_root' + # Only the project folder and its two leaves are created - no second PAM root. + assert [name for name, _ in created] == [ + 'BatchSmokeS', 'BatchSmokeS - Resources', 'BatchSmokeS - Users'] + assert ROOT_NAME not in [name for name, _ in created] + + def test_process_folders_uses_existing_nsf_root_and_creates_nsf_children(): params = _params() project = { @@ -316,15 +451,175 @@ def wipe_then_return(_params, _name): with patch('keepercommander.commands.pam_import.edit.api.communicate_rest') as communicate, \ patch.object(PAMProjectImportCommand, 'create_ksm_app', side_effect=wipe_then_return), \ - patch('keepercommander.commands.pam_import.edit.KSMCommand') as ksm_cmd, \ + patch('keepercommander.commands.ksm.KSMCommand') as ksm_cmd, \ patch('keepercommander.commands.pam_import.nsf_helpers.api.sync_down'), \ patch('keepercommander.commands.pam_import.edit.api.sync_down'): communicate.return_value = MagicMock(applicationSummary=[]) result = PAMProjectImportCommand().process_ksm_app(params, project) assert result['app_uid'] == 'app_uid' - assert ksm_cmd.return_value.execute.call_count == 2 + # Without a project_folder_uid the legacy resources/users fallback applies, + # batched into a single share call. + assert ksm_cmd.return_value.execute.call_count == 1 + assert ksm_cmd.return_value.execute.call_args.kwargs['secret'] == ['res_nsf', 'usr_nsf'] + assert params.nested_share_folders['res_nsf']['folder_key_unencrypted'] == b'k' * 32 + + +def _nsf_project_params(): + """NSF project tree: root > project > {Resources, Users, Safe > {Safe-Res, Safe-Usr}}.""" + params = _params() + tree = { + 'proj_nsf': ('Project', 'root_nsf'), + 'res_nsf': ('Project - Resources', 'proj_nsf'), + 'usr_nsf': ('Project - Users', 'proj_nsf'), + 'safe_nsf': ('Safe A', 'proj_nsf'), + 'safe_res_nsf': ('Safe A - Resources', 'safe_nsf'), + 'safe_usr_nsf': ('Safe A - Users', 'safe_nsf'), + } + for uid, (name, parent) in tree.items(): + params.nested_share_folders[uid] = { + 'name': name, 'parent_uid': parent, 'folder_key_unencrypted': b'k' * 32, + } + return params + + +def test_collect_nsf_subtree_uids_walks_descendants_breadth_first(): + from keepercommander.commands.pam_import.nsf_helpers import collect_nsf_subtree_uids + + params = _nsf_project_params() + uids = collect_nsf_subtree_uids(params, 'proj_nsf') + + assert uids[0] == 'proj_nsf' + assert set(uids) == {'proj_nsf', 'res_nsf', 'usr_nsf', 'safe_nsf', + 'safe_res_nsf', 'safe_usr_nsf'} + # The global PAM root is a parent, never a descendant. + assert 'root_nsf' not in uids + # Parents are granted before their children. + assert uids.index('safe_nsf') < uids.index('safe_res_nsf') + + +def test_collect_nsf_subtree_uids_survives_parent_cycle(): + from keepercommander.commands.pam_import.nsf_helpers import collect_nsf_subtree_uids + + params = _params() + params.nested_share_folders['a'] = {'name': 'A', 'parent_uid': 'b'} + params.nested_share_folders['b'] = {'name': 'B', 'parent_uid': 'a'} + + assert collect_nsf_subtree_uids(params, 'a') == ['a', 'b'] + assert collect_nsf_subtree_uids(params, '') == [] + + +def test_process_ksm_app_nsf_grants_whole_project_subtree(): + from unittest.mock import MagicMock + + params = _nsf_project_params() + project = { + 'options': {'project_name': 'NSF App', 'dry_run': False, 'use_nsf': True}, + 'data': {}, + 'folders': { + 'project_folder_uid': 'proj_nsf', + 'resources_folder_uid': 'res_nsf', + 'users_folder_uid': 'usr_nsf', + 'safe_folders': [{ + 'name': 'Safe A', + 'uid': 'safe_nsf', + 'resources_subfolder_uid': 'safe_res_nsf', + 'users_subfolder_uid': 'safe_usr_nsf', + }], + }, + } + + with patch('keepercommander.commands.pam_import.edit.api.communicate_rest') as communicate, \ + patch.object(PAMProjectImportCommand, 'create_ksm_app', return_value='app_uid'), \ + patch('keepercommander.commands.ksm.KSMCommand') as ksm_cmd, \ + patch('keepercommander.commands.pam_import.nsf_helpers.api.sync_down'), \ + patch('keepercommander.commands.pam_import.edit.api.sync_down'): + communicate.return_value = MagicMock(applicationSummary=[]) + PAMProjectImportCommand().process_ksm_app(params, project) + + assert ksm_cmd.return_value.execute.call_count == 1 + kwargs = ksm_cmd.return_value.execute.call_args.kwargs + assert kwargs['app'] == 'app_uid' + assert kwargs['editable'] is True + # Project wrapper + both leaves + the safe folder and its two record subfolders. + assert set(kwargs['secret']) == {'proj_nsf', 'res_nsf', 'usr_nsf', 'safe_nsf', + 'safe_res_nsf', 'safe_usr_nsf'} + assert 'root_nsf' not in kwargs['secret'] + + +def test_process_ksm_app_classic_grant_list_unchanged(): + from unittest.mock import MagicMock + + params = _params() + project = { + 'options': {'project_name': 'Classic App', 'dry_run': False, 'use_nsf': False}, + 'data': {}, + 'folders': { + 'project_folder_uid': 'proj_sf', + 'resources_folder_uid': 'res_sf', + 'users_folder_uid': 'usr_sf', + 'safe_folders': [{ + 'name': 'Safe A', + 'uid': 'safe_sf', + 'resources_subfolder_uid': 'safe_res_sf', + 'users_subfolder_uid': 'safe_usr_sf', + }], + }, + } + + with patch('keepercommander.commands.pam_import.edit.api.communicate_rest') as communicate, \ + patch.object(PAMProjectImportCommand, 'create_ksm_app', return_value='app_uid'), \ + patch('keepercommander.commands.pam_import.edit.KSMCommand') as ksm_cmd, \ + patch('keepercommander.commands.pam_import.edit.api.sync_down'): + communicate.return_value = MagicMock(applicationSummary=[]) + PAMProjectImportCommand().process_ksm_app(params, project) + + # One call per UID, no project wrapper, no shared_folder_folder children. shared = [c.kwargs.get('secret') for c in ksm_cmd.return_value.execute.call_args_list] - assert ['res_nsf'] in shared - assert ['usr_nsf'] in shared + assert shared == [['res_sf'], ['usr_sf'], ['safe_sf']] + + +def test_grant_nsf_folders_falls_back_to_single_uid_calls(): + from keepercommander.commands.pam_import.nsf_helpers import grant_nsf_folders_to_ksm_app + + params = _nsf_project_params() + calls = [] + + class FakeKSM: + def execute(self, _params, **kwargs): + secret = kwargs.get('secret') + calls.append(list(secret)) + if len(secret) > 1: + raise Exception('batch rejected') + if secret == ['usr_nsf']: + raise Exception('already shared') + return None + + with patch('keepercommander.commands.ksm.KSMCommand', FakeKSM): + granted = grant_nsf_folders_to_ksm_app(params, 'app_uid', ['res_nsf', 'usr_nsf', 'safe_nsf']) + + assert calls[0] == ['res_nsf', 'usr_nsf', 'safe_nsf'] + assert calls[1:] == [['res_nsf'], ['usr_nsf'], ['safe_nsf']] + # The one folder that failed is reported as not granted; the rest still land. + assert granted == ['res_nsf', 'safe_nsf'] + # Folder keys survive the sync_down inside each share call. assert params.nested_share_folders['res_nsf']['folder_key_unencrypted'] == b'k' * 32 + + +def test_grant_nsf_folders_dedups_and_ignores_empty(): + from keepercommander.commands.pam_import.nsf_helpers import grant_nsf_folders_to_ksm_app + + params = _nsf_project_params() + calls = [] + + class FakeKSM: + def execute(self, _params, **kwargs): + calls.append(list(kwargs.get('secret'))) + + with patch('keepercommander.commands.ksm.KSMCommand', FakeKSM): + granted = grant_nsf_folders_to_ksm_app(params, 'app_uid', ['res_nsf', '', 'res_nsf', None, 'usr_nsf']) + assert grant_nsf_folders_to_ksm_app(params, '', ['res_nsf']) == [] + assert grant_nsf_folders_to_ksm_app(params, 'app_uid', []) == [] + + assert calls == [['res_nsf', 'usr_nsf']] + assert granted == ['res_nsf', 'usr_nsf'] diff --git a/unit-tests/test_record_update_revision_refresh.py b/unit-tests/test_record_update_revision_refresh.py new file mode 100644 index 000000000..a629fece8 --- /dev/null +++ b/unit-tests/test_record_update_revision_refresh.py @@ -0,0 +1,114 @@ +"""Regression tests: update_record must leave params.record_cache at the revision +the server just returned. + +update_record puts the revision from the cache (not from the record object) on the +wire, so a second update of the same record inside one command used to resend the +pre-update revision and fail with RS_OUT_OF_SYNC ("This object no longer exists"). +""" +import json +from types import SimpleNamespace +from unittest.mock import patch + +from keepercommander import crypto, record_management, utils, vault +from keepercommander.proto import record_pb2 + +RECORD_UID = 'wKpRON0rxJfq6qefq43y7g' +RECORD_KEY = b'k' * 32 + + +def _params(): + data = {'type': 'pamMachine', 'title': 'Machine', 'fields': [], 'custom': []} + plain = json.dumps(data).encode() + return SimpleNamespace( + record_cache={ + RECORD_UID: { + 'record_uid': RECORD_UID, + 'version': 3, + 'revision': 5380934, + 'client_modified_time': 1787187441498, + 'shared': True, + 'data': utils.base64_url_encode(crypto.encrypt_aes_v2(plain, RECORD_KEY)), + 'data_unencrypted': plain, + 'record_key_unencrypted': RECORD_KEY, + } + }, + enterprise_ec_key=None, + enterprise_rsa_key=None, + breach_watch=None, + sync_data=False, + forbid_rsa=False, + ) + + +def _record(params, title='Machine'): + record = vault.KeeperRecord.load(params, RECORD_UID) + record.title = title + return record + + +def _run_update(params, record, new_revision): + """Drive update_record with a stubbed transport; return the revision sent.""" + sent = {} + + def fake_communicate_rest(_params, rq, endpoint, rs_type=None): + assert endpoint == 'vault/records_update' + sent['revision'] = rq.records[0].revision + sent['data'] = rq.records[0].data + rs = record_pb2.RecordsModifyResponse() + rs.revision = new_revision + status = rs.records.add() + status.record_uid = utils.base64_url_decode(RECORD_UID) + status.status = record_pb2.RS_SUCCESS + return rs + + with patch('keepercommander.record_management.api.communicate_rest', + side_effect=fake_communicate_rest), \ + patch('keepercommander.record_management.attach_security_data', + side_effect=lambda _p, _r, ru: ru), \ + patch('keepercommander.record_management.add_record_audit_data'), \ + patch('keepercommander.record_management.BreachWatch.scan_and_update_security_data'): + record_management.update_record(params, record) + return sent + + +def test_update_record_refreshes_cached_revision(): + params = _params() + sent = _run_update(params, _record(params), 5380938) + + assert sent['revision'] == 5380934, 'first update sends the cached revision' + assert params.record_cache[RECORD_UID]['revision'] == 5380938 + + +def test_second_update_in_same_run_sends_fresh_revision(): + # The failure mode from the log: two writes on one record with no sync between. + params = _params() + first = _run_update(params, _record(params, 'First'), 5380938) + second = _run_update(params, _record(params, 'Second'), 5380942) + + assert first['revision'] == 5380934 + assert second['revision'] == 5380938, 'stale revision would be rejected as RS_OUT_OF_SYNC' + assert params.record_cache[RECORD_UID]['revision'] == 5380942 + + +def test_update_record_keeps_cached_payload_consistent_with_revision(): + # A bumped revision paired with stale data would make the next load return + # pre-update fields; the cache must carry what was actually persisted. + params = _params() + record = _record(params, 'Renamed') + sent = _run_update(params, record, 5380938) + + cached = params.record_cache[RECORD_UID] + assert cached['data'] == utils.base64_url_encode(sent['data']) + assert json.loads(cached['data_unencrypted'])['title'] == 'Renamed' + assert vault.KeeperRecord.load(params, RECORD_UID).title == 'Renamed' + assert crypto.decrypt_aes_v2(utils.base64_url_decode(cached['data']), RECORD_KEY) \ + == cached['data_unencrypted'] + + +def test_update_record_leaves_cache_untouched_when_revision_missing(): + params = _params() + before = dict(params.record_cache[RECORD_UID]) + _run_update(params, _record(params), 0) + + assert params.record_cache[RECORD_UID]['revision'] == before['revision'] + assert params.record_cache[RECORD_UID]['data'] == before['data'] From 6afa78c47453168d291ff1e1370dc993760351cc Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 21 Aug 2026 11:11:06 -0700 Subject: [PATCH 14/14] Release 18.1.2 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 5c2cff33d..090609d3e 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.1.1' +__version__ = '18.1.2'