diff --git a/keepercommander/api.py b/keepercommander/api.py index 4d611e5fb..5c73130b2 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -108,7 +108,7 @@ def accept_account_transfer_consent(params): # type: (KeeperParams) -> bool responses = execute_batch(params, requests) if isinstance(responses, list): for response in responses: - if response['result_code'] != 'success': + if response['result'] != 'success': logging.warning('Account Transfer policy acceptance error: %s', response.get('message') or response['result_code']) ok = False diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index 78482639f..a6ddecee7 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -2022,6 +2022,9 @@ def _nsf_folder_share_data(params, folder_uid, *, include_uids=False): abbrev = _nsf_role_abbrev(a) auid = a.get('accessor_uid') or '' + if a.get('denied_access'): + continue + if at == 'AT_OWNER': if not owner: email = _resolve_nsf_user_email(params, a) diff --git a/keepercommander/commands/nested_share_folder/display_commands.py b/keepercommander/commands/nested_share_folder/display_commands.py index 54df4ef10..12362a88d 100644 --- a/keepercommander/commands/nested_share_folder/display_commands.py +++ b/keepercommander/commands/nested_share_folder/display_commands.py @@ -460,7 +460,10 @@ def _nsf_folder_path(uid): for fr in result.get('results', []): if not fr.get('success'): continue - accessors = fr.get('accessors', []) + accessors = [ + a for a in fr.get('accessors', []) + if not a.get('denied_access') + ] if not accessors: continue user_perms = [] @@ -541,7 +544,10 @@ def _print_folder_permissions(params, folder_uid, verbose): logging.warning(" Access error: %s — %s", err.get('status'), err.get('message')) continue - accessors = fr.get('accessors', []) + accessors = [ + a for a in fr.get('accessors', []) + if not a.get('denied_access') + ] if not accessors: continue diff --git a/keepercommander/commands/pam_import/README.md b/keepercommander/commands/pam_import/README.md index 9370cae03..630711379 100644 --- a/keepercommander/commands/pam_import/README.md +++ b/keepercommander/commands/pam_import/README.md @@ -39,13 +39,16 @@ Notes: Adding new PAM resources and users to an existing PAM configuration from an import file. The command validates folders and records, then creates only new items (match by title, existing records are skipped). The import JSON format is the same. -`pam project extend --config= --filename=/path/to/import.json [--dry-run]` +`pam project extend --config= --filename=/path/to/import.json [--resources-folder=] [--users-folder=] [--dry-run]` - `--config`, `-c` → PAM Configuration record UID or title. - `--filename`, `-f` → JSON file to load import data from. +- `--resources-folder` → Existing folder (UID or folder path) for new resources. Overrides `folder_path` of resources in the JSON (a warning is printed). +- `--users-folder` → Existing folder (UID or folder path) for new users, incl. users nested in resources. Overrides `folder_path` of users in the JSON (a warning is printed). - `--dry-run`, `-d` → Test import without modifying vault. > **Notes:** +- Folder for new records: 1) `--resources-folder`/`--users-folder`, 2) `folder_path` in the JSON, 3) autodetect — folders named ` - Resources`/` - Users`, otherwise the folder where existing resources (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) or users (pamUser, login) live. If autodetect is ambiguous or finds nothing the command fails; add `folder_path` or use the options above. - Use **`--dry-run`** to preview what would be created and to see detailed validation output without changing the vault. - If the command reports errors, run it again with **`--dry-run`** for more detailed error messages. diff --git a/keepercommander/commands/pam_import/edit.py b/keepercommander/commands/pam_import/edit.py index cd2ca4b90..5da111aca 100644 --- a/keepercommander/commands/pam_import/edit.py +++ b/keepercommander/commands/pam_import/edit.py @@ -1759,9 +1759,11 @@ def _resolve_folder_uid(obj, default_uid: str) -> str: args["pwd_complexity"]=user.rotation_settings.password_complexity prc.execute(params, silent=True, **args) # Launch credentials: link for pamMachine, pamDatabase, pamDirectory (not RBI) + # Also runs for admin-only (no launch): re-asserts the admin as the last write, + # after the JIT/AI/meta writes and after nested users are created. launch_uid = get_launch_credential(mach, True) - if launch_uid and not isinstance(mach, PamRemoteBrowserObject): - tdag.set_launch_credentials(mach.uid, launch_uid=launch_uid, admin_uid=(admin_uid or None)) + if (launch_uid or admin_uid) and not isinstance(mach, PamRemoteBrowserObject): + tdag.set_launch_credentials(mach.uid, launch_uid=(launch_uid or None), admin_uid=(admin_uid or None)) if resources: print(f"{len(resources)}/{len(resources)}\n") # link machine -> pamDirectory (LINK, path=domain) for jit_settings.pam_directory_uid diff --git a/keepercommander/commands/pam_import/extend.py b/keepercommander/commands/pam_import/extend.py index 504292e72..865a6ce59 100644 --- a/keepercommander/commands/pam_import/extend.py +++ b/keepercommander/commands/pam_import/extend.py @@ -328,10 +328,67 @@ def walk(t): return out +_RESOURCE_RECORD_TYPES = ("pammachine", "pamdatabase", "pamdirectory", "pamremotebrowser") +_USER_RECORD_TYPES = ("pamuser", "login") + + def _is_resource_type(obj) -> bool: """True if object is a PAM resource (machine, database, directory, remote browser).""" t = (getattr(obj, "type", None) or "").lower() - return t in ("pammachine", "pamdatabase", "pamdirectory", "pamremotebrowser") + return t in _RESOURCE_RECORD_TYPES + + +def _resolve_folder_option(value: str, ksm_shared_folders: list) -> str: + """Resolve a --resources-folder/--users-folder value (folder UID or folder path) to the UID + of an existing folder under the KSM app. Returns "" if it cannot be resolved.""" + value = (value or "").strip() + if not value: + return "" + if value in _collect_all_folder_uids_under_ksm(ksm_shared_folders): + return value + path_to_uid = {} + for shf in ksm_shared_folders: + name = shf.get("name") or "" + if name: + path_to_uid[name] = shf["uid"] + _collect_path_to_uid_from_tree(name, shf.get("folder_tree") or {}, path_to_uid, only_existing=True) + return path_to_uid.get(value, "") + + +def _drop_folder_paths(pam_data: dict, drop_resources: bool, drop_users: bool) -> int: + """Remove folder_path from resources and/or users (incl. users nested in resources) in raw + pam_data so the folder given on the command line wins. Returns the number of folder_paths dropped.""" + dropped = 0 + + def drop(item): + nonlocal dropped + if isinstance(item, dict) and item.get("folder_path"): + del item["folder_path"] + dropped += 1 + + for res in pam_data.get("resources") or []: + if not isinstance(res, dict): + continue + if drop_resources: + drop(res) + if drop_users: + for nested in res.get("users") or []: + drop(nested) + if drop_users: + for usr in pam_data.get("users") or []: + drop(usr) + return dropped + + +def _shf_uids_holding_record_types(params, ksm_shared_folders: list, record_types: tuple) -> list: + """Return UIDs of the KSM app shared folders that hold (at any depth) a record of one of record_types.""" + found = [] + for shf in ksm_shared_folders: + for fuid in _folder_uids_under_shf(shf): + if any((rec[2] or "").lower() in record_types for rec in _get_records_in_folder(params, fuid)): + found.append(shf["uid"]) + break + return found def _record_identifier(obj, fallback_login: str = "") -> str: @@ -378,6 +435,10 @@ class PAMProjectExtendCommand(Command): parser = argparse.ArgumentParser(prog="pam project extend") parser.add_argument("--config", "-c", required=True, dest="config", action="store", help="PAM Configuration UID or Title") parser.add_argument("--filename", "-f", required=True, dest="file_name", action="store", help="File to load import data from.") + parser.add_argument("--resources-folder", required=False, dest="resources_folder", action="store", default="", + help="Existing folder (UID or folder path) for new resources. Overrides folder_path of resources in the JSON.") + parser.add_argument("--users-folder", required=False, dest="users_folder", action="store", default="", + help="Existing folder (UID or folder path) for new users. Overrides folder_path of users in the JSON.") parser.add_argument("--dry-run", "-d", required=False, dest="dry_run", action="store_true", default=False, help="Test import without modifying vault.") def get_parser(self): @@ -463,12 +524,33 @@ def execute(self, params, **kwargs): for shf in ksm_shared_folders: shf["folder_tree"] = build_tree_recursive(params, shf["uid"]) + # Folder precedence: 1) command line options, 2) folder_path in JSON, 3) autodetect + folders = {} + drop_paths = {} + for opt_name, key in (("resources_folder", "resources_folder_uid"), ("users_folder", "users_folder_uid")): + opt_value = str(kwargs.get(opt_name) or "").strip() + if not opt_value: + continue + folder_uid = _resolve_folder_option(opt_value, ksm_shared_folders) + if not folder_uid: + raise CommandError("pam project extend", f"""--{opt_name.replace("_", "-")} "{opt_value}" is not an existing """ + "folder shared to the KSM Application (use folder UID or folder path)") + folders[key] = folder_uid + drop_paths[opt_name] = True + if dry_run: + print(f"[DRY RUN] Will use --{opt_name.replace('_', '-')}: {folder_uid}") + if drop_paths: + dropped = _drop_folder_paths(pam_data, "resources_folder" in drop_paths, "users_folder" in drop_paths) + if dropped: + logging.warning(f"{bcolors.WARNING}WARNING: {dropped} folder_path value(s) in the import JSON are " + f"ignored - overridden by --resources-folder/--users-folder{bcolors.ENDC}") + project = { "data": {"pam_data": pam_data}, "options": {"dry_run": dry_run}, "ksm_app_uid": ksmapp_uid, "ksm_shared_folders": ksm_shared_folders, - "folders": {}, + "folders": folders, "pam_config": {"pam_config_uid": configuration.record_uid, "pam_config_object": None}, "error_count": 0, } @@ -476,22 +558,7 @@ def execute(self, params, **kwargs): self.process_folders(params, project) self.map_records(params, project) if project.get("error_count", 0) == 0: - has_new_no_path = False - for o in chain(project.get("mapped_resources", []), project.get("mapped_users", [])): - if getattr(o, "_extend_tag", None) == "new" and not (getattr(o, "folder_path", None) or "").strip(): - has_new_no_path = True - break - if not has_new_no_path: - for mach in project.get("mapped_resources", []): - if hasattr(mach, "users") and isinstance(mach.users, list): - for u in mach.users: - if getattr(u, "_extend_tag", None) == "new" and not (getattr(u, "folder_path", None) or "").strip(): - has_new_no_path = True - break - if has_new_no_path: - break - if has_new_no_path: - self.autodetect_folders(params, project) + self.autodetect_folders(params, project) err_count = project.get("error_count", 0) new_count = project.get("new_record_count", 0) @@ -1140,11 +1207,13 @@ def resolve_one(obj, parent_machine=None): return (resources, users, step2_errors, y_count) def autodetect_folders(self, params, project: dict) -> list: - """Step 3: Autodetect resources_folder_uid and users_folder_uid when new records have no folder_path. - Call only when error_count==0 and there are records with no uid and no folder_path (tagged new). + """Step 3: Autodetect resources_folder_uid and users_folder_uid for new records with no folder_path. + Roles already set (--resources-folder/--users-folder) are left alone. Order: folders named + " - Resources"/" - Users", then where existing resource/user records live. + Never falls back to one folder for both roles - ambiguity is an error asking for folder_path. Returns list of step3 errors; updates project['folders'] with resources_folder_uid/users_folder_uid on success.""" step3_errors = [] - folders_out = project.get("folders") or {} + folders_out = project.setdefault("folders", {}) ksm_shared_folders = project.get("ksm_shared_folders") or [] new_no_path = [] @@ -1157,74 +1226,55 @@ def autodetect_folders(self, params, project: dict) -> list: for u in mach.users: if getattr(u, "_extend_tag", None) == "new" and not (getattr(u, "folder_path", None) or "").strip(): new_no_path.append(u) - if not new_no_path: - return step3_errors - shf_list = [(shf["uid"], shf.get("name") or "") for shf in ksm_shared_folders] - if len(shf_list) == 1: - folders_out["resources_folder_uid"] = shf_list[0][0] - folders_out["users_folder_uid"] = shf_list[0][0] - print("Warning: Using single shared folder for both resources and users (best practice: separate).") + need_res = not folders_out.get("resources_folder_uid") and any(_is_resource_type(o) for o in new_no_path) + need_usr = not folders_out.get("users_folder_uid") and any(not _is_resource_type(o) for o in new_no_path) + if not (need_res or need_usr): return step3_errors - if len(shf_list) == 2: - names = [n for _, n in shf_list] + shf_names = {shf["uid"]: (shf.get("name") or shf["uid"]) for shf in ksm_shared_folders} + res_uid = usr_uid = None + + if len(ksm_shared_folders) == 2: + names = [shf.get("name") or "" for shf in ksm_shared_folders] r_idx = next((i for i, n in enumerate(names) if n.endswith(" - Resources") or n.endswith("- Resources")), -1) u_idx = next((i for i, n in enumerate(names) if n.endswith(" - Users") or n.endswith("- Users")), -1) if r_idx >= 0 and u_idx >= 0 and r_idx != u_idx: - folders_out["resources_folder_uid"] = shf_list[r_idx][0] - folders_out["users_folder_uid"] = shf_list[u_idx][0] - return step3_errors + res_uid = ksm_shared_folders[r_idx]["uid"] + usr_uid = ksm_shared_folders[u_idx]["uid"] + + def detect(role, label, option, record_types): + candidates = _shf_uids_holding_record_types(params, ksm_shared_folders, record_types) + if len(candidates) == 1: + return candidates[0] + if not candidates: + step3_errors.append(f"Autodetect: no existing {label} records found in the folders shared to the KSM " + f"Application - cannot determine the {role} folder. " + f"Add folder_path to the {role} or use {option}.") + else: + found = ", ".join(f'"{shf_names[u]}"' for u in candidates) + step3_errors.append(f"Autodetect: existing {label} records found in multiple folders ({found}) - " + f"cannot determine the {role} folder. Add folder_path to the {role} or use {option}.") + return None + + if need_res: + res_uid = res_uid or detect("resources", "resource", "--resources-folder", _RESOURCE_RECORD_TYPES) + if res_uid: + folders_out["resources_folder_uid"] = res_uid + if need_usr: + usr_uid = usr_uid or detect("users", "user", "--users-folder", _USER_RECORD_TYPES) + if usr_uid: + folders_out["users_folder_uid"] = usr_uid - non_empty = [] - for shf in ksm_shared_folders: - uids = _folder_uids_under_shf(shf) - if any(get_folder_record_uids(params, fuid) for fuid in uids): - non_empty.append(shf) - if len(non_empty) == 0: - step3_errors.append("Autodetect: no folders contain records; cannot assign resources/users folders.") + if need_res and need_usr and res_uid and res_uid == usr_uid: + print(f'Warning: Using the same folder "{shf_names[res_uid]}" for both resources and users ' + "(best practice: separate).") + + if step3_errors: project["error_count"] = project.get("error_count", 0) + len(step3_errors) for e in step3_errors: print(f" {e}") print(f"Total: {len(step3_errors)} errors") - return step3_errors - if len(non_empty) == 1: - folders_out["resources_folder_uid"] = non_empty[0]["uid"] - folders_out["users_folder_uid"] = non_empty[0]["uid"] - print("Warning: Using single non-empty folder for both resources and users.") - return step3_errors - if len(non_empty) == 2: - res_uid = users_uid = None - for shf in non_empty: - uids = _folder_uids_under_shf(shf) - for fuid in uids: - recs = _get_records_in_folder(params, fuid) - if not recs: - continue - for ruid, _title, rtype in recs: - rtype = (rtype or "").lower() - if rtype in ("pamuser", "login"): - users_uid = shf["uid"] - break - if rtype in ("pammachine", "pamdatabase", "pamdirectory", "pamremotebrowser"): - res_uid = shf["uid"] - break - if users_uid is not None or res_uid is not None: - break - if users_uid is not None and res_uid is not None: - break - if res_uid is not None and users_uid is not None: - folders_out["resources_folder_uid"] = res_uid - folders_out["users_folder_uid"] = users_uid - return step3_errors - step3_errors.append("Autodetect: could not determine which folder is resources vs users.") - else: - step3_errors.append("Autodetect: three or more non-empty folders; add folder_path to disambiguate.") - project["error_count"] = project.get("error_count", 0) + len(step3_errors) - for e in step3_errors: - print(f" {e}") - if step3_errors: - print(f"Total: {len(step3_errors)} errors") return step3_errors def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissions:Optional[Dict]=None, folder_uid:Optional[str]=None): @@ -1539,9 +1589,11 @@ def process_data(self, params, project): if getattr(rs, "password_complexity", None): args["pwd_complexity"] = rs.password_complexity prc.execute(params, silent=True, **args) + # Also runs for admin-only (no launch): re-asserts the admin as the last write, + # after the JIT/AI/meta writes and after nested users are created. launch_uid = get_launch_credential(mach, True) - if launch_uid and not isinstance(mach, PamRemoteBrowserObject): - tdag.set_launch_credentials(mach.uid, launch_uid=launch_uid, admin_uid=(admin_uid or None)) + if (launch_uid or admin_uid) and not isinstance(mach, PamRemoteBrowserObject): + tdag.set_launch_credentials(mach.uid, launch_uid=(launch_uid or None), admin_uid=(admin_uid or None)) if new_resources: print(f"{len(new_resources)}/{len(new_resources)}\n") @@ -1581,9 +1633,11 @@ def process_data(self, params, project): if getattr(rs, "password_complexity", None): args["pwd_complexity"] = rs.password_complexity prc.execute(params, silent=True, **args) + # Launch only (no admin-only re-assert): an empty connectUsers would clear + # launch credentials already set on this existing resource. launch_uid = get_launch_credential(mach, True) if launch_uid and not isinstance(mach, PamRemoteBrowserObject): - tdag.link_user_to_resource(launch_uid, mach.uid, is_launch_credential=True, belongs_to=True) + tdag.set_launch_credentials(mach.uid, launch_uid=launch_uid, admin_uid=(admin_uid or None)) # link machine -> pamDirectory (LINK, path=domain) for jit_settings.pam_directory_uid # RBI has rbi_settings only (no pam_settings.jit_settings) diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index ddd124166..0b16aa237 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -1509,6 +1509,8 @@ def _get_nested_folder_report_rows(params, show_team_users=False): if not folder_result.get('success'): continue for accessor in folder_result.get('accessors', []): + if accessor.get('denied_access'): + continue if ShareReportCommand._is_nested_folder_owner(folder_info, accessor): continue permissions = ShareReportCommand._get_nested_folder_permission_label(accessor) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index fe8440e6f..b183a39e1 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -519,8 +519,8 @@ class PAMTunnelEditCommand(Command): help='Enable/disable Keeper Proxy, auto-selecting the backend from the record\'s ' 'connection protocol: KeeperDB Proxy for database protocols ' '(pamDatabase, or pamMachine set to a database protocol), KeeperRDP Proxy ' - 'for RDP (pamMachine). SSH is not yet supported. Records/protocols that ' - 'don\'t map to a proxy backend are ignored with a warning (on/off/default)') + 'for RDP (pamMachine), KeeperSSH Proxy for SSH (pamMachine). Records/protocols ' + 'that don\'t map to a proxy backend are ignored with a warning (on/off/default)') def get_parser(self): return PAMTunnelEditCommand.pam_cmd_parser @@ -4142,6 +4142,15 @@ def _do_apply(self, params, set_values, unset_values, record_uid, config_uid): print(f'{bcolors.OKGREEN}KeeperAI settings saved successfully.{bcolors.ENDC}') +# Clipboard data size limits offered by the Vault UI (ClipboardSettings.tsx), name -> bytes +RBI_CLIPBOARD_BUFFER_SIZES = { + '256kb': 256 * 1024, + '1mb': 1024 * 1024, + '10mb': 10 * 1024 * 1024, + '50mb': 50 * 1024 * 1024, +} + + class PAMRbiEditCommand(Command): choices = ['on', 'off', 'default'] parser = argparse.ArgumentParser(prog='pam rbi edit') @@ -4180,6 +4189,8 @@ class PAMRbiEditCommand(Command): # Autofill Settings parser.add_argument('--autofill-credentials', '-a', type=str, required=False, dest='autofill', action='store', help='The record UID or path of the RBI Autofill Credentials record.') + parser.add_argument('--allow-supply-user', '-asu', dest='allow_supply_user', choices=choices, + help='Allow the user to supply credentials at launch (on/off/default)') parser.add_argument('--autofill-targets', '-at', dest='autofill_targets', action='append', help='Autofill target selectors (can specify multiple times)') @@ -4188,6 +4199,9 @@ class PAMRbiEditCommand(Command): help='Allow copying to clipboard (on/off/default)') parser.add_argument('--allow-paste', '-p', dest='allow_paste', choices=choices, help='Allow pasting from clipboard (on/off/default)') + parser.add_argument('--clipboard-buffer-size', '-cbs', dest='clipboard_buffer_size', type=str.lower, + choices=['default'] + list(RBI_CLIPBOARD_BUFFER_SIZES), + help='Clipboard data size limit (256kb/1mb/10mb/50mb; default = unset)') # Audio Settings parser.add_argument('--disable-audio', '-da', dest='disable_audio', choices=choices, @@ -4233,6 +4247,14 @@ def execute(self, params, **kwargs): autofill_targets = kwargs.get('autofill_targets') # list or None allow_copy = kwargs.get('allow_copy') # on/off/default/None allow_paste = kwargs.get('allow_paste') # on/off/default/None + allow_supply_user = kwargs.get('allow_supply_user') # on/off/default/None + clipboard_buffer_size = kwargs.get('clipboard_buffer_size') # 256kb/1mb/10mb/50mb/default/None + if clipboard_buffer_size is not None: + clipboard_buffer_size = str(clipboard_buffer_size).lower() + if clipboard_buffer_size != 'default' and clipboard_buffer_size not in RBI_CLIPBOARD_BUFFER_SIZES: + allowed = ', '.join(['default'] + list(RBI_CLIPBOARD_BUFFER_SIZES)) + raise CommandError('pam rbi edit', f'{bcolors.FAIL}Invalid --clipboard-buffer-size ' + f'"{clipboard_buffer_size}". Allowed values: {allowed}{bcolors.ENDC}') disable_audio = kwargs.get('disable_audio') # on/off/default/None audio_channels = kwargs.get('audio_channels') # int or None audio_bit_depth = kwargs.get('audio_bit_depth') # int or None @@ -4253,6 +4275,8 @@ def execute(self, params, **kwargs): autofill_targets is not None, allow_copy is not None, allow_paste is not None, + allow_supply_user is not None, + clipboard_buffer_size is not None, disable_audio is not None, audio_channels is not None, audio_bit_depth is not None, @@ -4485,6 +4509,16 @@ def update_connection_choice(field_name, value): if allow_paste: update_connection_toggle('disablePaste', allow_paste, invert=True) + # Clipboard Settings - clipboardBufferSize (stored in bytes; default removes the key) + if clipboard_buffer_size == 'default': + update_connection_choice('clipboardBufferSize', 'default') + elif clipboard_buffer_size: + update_connection_int('clipboardBufferSize', RBI_CLIPBOARD_BUFFER_SIZES[clipboard_buffer_size]) + + # Autofill Settings - allowSupplyUser (on/off/default) + if allow_supply_user: + update_connection_toggle('allowSupplyUser', allow_supply_user) + # Audio Settings - disableAudio (on -> disableAudio=True, off -> disableAudio=False) if disable_audio: update_connection_toggle('disableAudio', disable_audio) diff --git a/keepercommander/commands/universalsecretsync.py b/keepercommander/commands/universalsecretsync.py index 1a1388079..5eb90b996 100644 --- a/keepercommander/commands/universalsecretsync.py +++ b/keepercommander/commands/universalsecretsync.py @@ -143,6 +143,19 @@ def print_uss_configurations_list(params, format_type='table'): logging.debug(f"Failed to decrypt vault_name for record {record.record_uid}: {e}") vault_name = 'N/A' + # Decrypt object_storage_bucket if present. The router stores it under + # the 'objectStorageBucket' key as a base64-url string of the encrypted bytes. + object_storage_bucket = 'N/A' + object_storage_bucket_encrypted = config_data.get('objectStorageBucket') + if object_storage_bucket_encrypted: + try: + object_storage_bucket_bytes = crypto.decrypt_aes_v2( + utils.base64_url_decode(object_storage_bucket_encrypted), record.record_key) + object_storage_bucket = object_storage_bucket_bytes.decode('utf-8') + except Exception as e: + logging.debug(f"Failed to decrypt object_storage_bucket for record {record.record_uid}: {e}") + object_storage_bucket = 'N/A' + # Decrypt sync_identity if present. The router stores it under the # 'syncIdentity' key as a base64-url string of the encrypted bytes; the # decrypted value is the UID of the Identity record used for syncing. @@ -216,6 +229,7 @@ def print_uss_configurations_list(params, format_type='table'): 'dry_run_enabled': config_data.get('dryRunEnabled', False), 'folder_count': folder_count, 'vault_name': vault_name, + 'object_storage_bucket': object_storage_bucket, 'sync_identity': sync_identity, 'scope': scope_str, 'owner': owner, @@ -242,8 +256,9 @@ 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', - 'Scope', 'Owner', 'Org Visibility', 'Repos', 'Vault Base URL'] + headers = ['Network UID', 'Title', 'Type', 'Enabled', 'Dry Run', 'Folders', 'Vault Name', + 'Object Storage Bucket', 'Sync Identity', 'Scope', 'Owner', 'Org Visibility', 'Repos', + 'Vault Base URL'] for config in configs_data: enabled_str = f"{bcolors.OKGREEN}Yes{bcolors.ENDC}" if config['enabled'] else f"{bcolors.FAIL}No{bcolors.ENDC}" @@ -261,6 +276,7 @@ def print_uss_configurations_list(params, format_type='table'): dry_run_str, folders_str, config['vault_name'], + config.get('object_storage_bucket', 'N/A'), config['sync_identity'], config.get('scope', 'N/A'), config.get('owner', 'N/A'), @@ -355,6 +371,19 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): logging.debug(f"Failed to decrypt vault_name for network {network.record_uid}: {e}") vault_name = 'N/A' + # Decrypt object_storage_bucket if present. The router stores it under + # the 'objectStorageBucket' key as a base64-url string of the encrypted bytes. + object_storage_bucket = 'N/A' + object_storage_bucket_encrypted = config_data.get('objectStorageBucket') + if object_storage_bucket_encrypted: + try: + object_storage_bucket_bytes = crypto.decrypt_aes_v2( + utils.base64_url_decode(object_storage_bucket_encrypted), network.record_key) + object_storage_bucket = object_storage_bucket_bytes.decode('utf-8') + except Exception as e: + logging.debug(f"Failed to decrypt object_storage_bucket for network {network.record_uid}: {e}") + object_storage_bucket = 'N/A' + # Decrypt sync_identity if present. The router stores it under the # 'syncIdentity' key as a base64-url string of the encrypted bytes; the # decrypted value is the UID of the Identity record used for syncing. @@ -465,6 +494,7 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): 'enabled': config_data.get('enabled', False), 'dry_run_enabled': config_data.get('dryRunEnabled', False), 'vault_name': vault_name, + 'object_storage_bucket': object_storage_bucket, 'sync_identity': sync_identity, 'scope': scope_str, 'owner': owner, @@ -501,6 +531,7 @@ def print_uss_configuration_details(params, network_uid, format_type='table'): table.append(['Enabled', 'Yes' if config_data.get('enabled', False) else 'No']) table.append(['Dry Run', 'Yes' if config_data.get('dryRunEnabled', False) else 'No']) table.append(['Vault Name', vault_name]) + table.append(['Object Storage Bucket', object_storage_bucket]) table.append(['Sync Identity', sync_identity]) table.append(['Scope', scope_str]) table.append(['Owner', owner]) @@ -568,6 +599,8 @@ 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('--object-storage-bucket', '-osb', dest='object_storage_bucket', action='store', + help='Object storage bucket for storing file reference content') parser.add_argument('--scope', '-sc', dest='scope', action='store', choices=['repository', 'organization'], help='GitHub sync scope: a single repository or an entire organization') @@ -636,6 +669,12 @@ def execute(self, params, **kwargs): encrypted_vault_name = crypto.encrypt_aes_v2(vault_name_bytes, network.record_key) rq.vaultName = encrypted_vault_name + object_storage_bucket = kwargs.get('object_storage_bucket') + if object_storage_bucket: + object_storage_bucket_bytes = string_to_bytes(object_storage_bucket) + encrypted_object_storage_bucket = crypto.encrypt_aes_v2(object_storage_bucket_bytes, network.record_key) + rq.objectStorageBucket = encrypted_object_storage_bucket + scope = kwargs.get('scope') if scope is not None: rq.github.scope = pam_pb2.ORGANIZATION if scope == 'organization' else pam_pb2.REPOSITORY @@ -708,6 +747,8 @@ 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('--object-storage-bucket', '-osb', dest='object_storage_bucket', action='store', + help='Object storage bucket for storing file reference content') parser.add_argument('--scope', '-sc', dest='scope', action='store', choices=['repository', 'organization'], help='GitHub sync scope: a single repository or an entire organization') @@ -794,7 +835,7 @@ def execute(self, params, **kwargs): else: # Keep existing folders by loading them from DAG try: - for vertex in root.has_vertices(None, True): + for vertex in root.has_vertices(): for edge in vertex.edges: if edge.path == 'universal_sync_folder' and edge.head_uid == root.uid: folder_obj = pam_pb2.PAMUniversalSyncFolder() @@ -822,6 +863,14 @@ def execute(self, params, **kwargs): elif existing_config.get('vaultName'): rq.vaultName = utils.base64_url_decode(existing_config['vaultName']) + object_storage_bucket = kwargs.get('object_storage_bucket') + if object_storage_bucket: + object_storage_bucket_bytes = string_to_bytes(object_storage_bucket) + encrypted_object_storage_bucket = crypto.encrypt_aes_v2(object_storage_bucket_bytes, network.record_key) + rq.objectStorageBucket = encrypted_object_storage_bucket + elif existing_config.get('objectStorageBucket'): + rq.objectStorageBucket = utils.base64_url_decode(existing_config['objectStorageBucket']) + # 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 {} diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index 3be980c95..80b6f0869 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -1913,9 +1913,9 @@ def execute(self, params, **kwargs): del params.settings['must_perform_account_share_by'] if 'share_account_to' in params.settings: del params.settings['share_account_to'] - logging.info('Account transfer accepted.') + logging.info('Account Transfer policy accepted.') else: - logging.info('Account transfer canceled.') + logging.info('Account Transfer policy acceptance canceled.') else: logging.info('There is no account transfer to accept.') diff --git a/keepercommander/commands/workflow/approver_commands.py b/keepercommander/commands/workflow/approver_commands.py index ff0b5d796..b2915327a 100644 --- a/keepercommander/commands/workflow/approver_commands.py +++ b/keepercommander/commands/workflow/approver_commands.py @@ -266,6 +266,12 @@ def execute(self, params: KeeperParams, **kwargs): @staticmethod def _encrypt_denial_reason(params, flow_uid_bytes, reason_bytes): + """Encrypt the denial reason with the requested record's key. + + Workflow request reasons are encrypted with the resource record key, + so denial reasons must use the same encryption contract for the Vault + notification to decrypt and display them. + """ try: response = _post_request_to_router( params, 'get_approval_requests', @@ -274,27 +280,24 @@ def _encrypt_denial_reason(params, flow_uid_bytes, reason_bytes): if not response or not response.workflows: return None - requester_email = None + record_uid = None for wf in response.workflows: if wf.flowUid == flow_uid_bytes: - requester_email = wf.user or RecordResolver.resolve_user(params, wf.userId) + if wf.resource and wf.resource.value: + record_uid = utils.base64_url_encode(wf.resource.value) break - if not requester_email or requester_email.startswith('User ID '): - logging.debug('Could not resolve requester email for flow UID') + + if not record_uid: + logging.debug('Could not resolve record UID for workflow denial reason') return None - api.load_user_public_keys(params, [requester_email]) - public_keys = params.key_cache.get(requester_email) - if not public_keys: - logging.debug('Public key not available for %s', requester_email) + from ...nested_share_folder.common import get_record_key + record_key = get_record_key(params, record_uid, raise_on_missing=False) + if not record_key: + logging.debug('Record key not available for workflow resource %s', record_uid) return None - if public_keys.ec: - ec_key = crypto.load_ec_public_key(public_keys.ec) - return crypto.encrypt_ec(reason_bytes, ec_key) - elif public_keys.rsa: - rsa_key = crypto.load_rsa_public_key(public_keys.rsa) - return crypto.encrypt_rsa(reason_bytes, rsa_key) + return crypto.encrypt_aes_v2(reason_bytes, record_key) except Exception: - logging.debug('Failed to encrypt denial reason with requester public key', exc_info=True) + logging.debug('Failed to encrypt denial reason with record key', exc_info=True) return None diff --git a/keepercommander/nested_share_folder/acl_cache.py b/keepercommander/nested_share_folder/acl_cache.py index 4d30c3d19..9e85cf612 100644 --- a/keepercommander/nested_share_folder/acl_cache.py +++ b/keepercommander/nested_share_folder/acl_cache.py @@ -29,7 +29,7 @@ from .. import api -_FOLDER_BATCH = 100 +_FOLDER_BATCH = 20 _RECORD_BATCH = 10 _RECORD_BATCH_MIN = 1 diff --git a/keepercommander/proto/pam_pb2.py b/keepercommander/proto/pam_pb2.py index b773f6c8e..6c0f68645 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\"\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') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10enterprise.proto\x1a\x0crecord.proto"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10configurationUid\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(\x0b2\x18.PAM.PAMRotationSchedule"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0bconnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0bconnections\x18\x05 \x03(\x0b2\x18.PAM.PAMWebRtcConnection"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e2\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\x10configurationUid\x18\x06 \x01(\x0c"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0bcontrollers\x18\x02 \x03(\x0b2\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\x0bcontrollers\x18\x01 \x03(\x0b2\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\x0bcontrollers\x18\x01 \x03(\x0b2\x18.PAM.PAMRemoveController"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b2\x15.PAM.PAMDataOperation"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e2\x15.PAM.PAMOperationType\x120\n\rconfiguration\x18\x02 \x01(\x0b2\x19.PAM.PAMConfigurationData\x12$\n\x07element\x18\x03 \x01(\x0b2\x13.PAM.PAMElementData"e\n\x14PAMConfigurationData\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04data\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\x04data\x18\x03 \x01(\x0c"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e2\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b2\x1e.PAM.PAMElementOperationResult"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04data\x18\x02 \x01(\x0c\x12\x0f\n\x07created\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08children\x18\x05 \x03(\x0b2\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\x10configurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04data\x18\x04 \x01(\x0c\x12\x0f\n\x07created\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08children\x18\x07 \x03(\x0b2\x0f.PAM.PAMElement"B\n\x11PAMConfigurations\x12-\n\x0econfigurations\x18\x01 \x03(\x0b2\x15.PAM.PAMConfiguration"\xab\x02\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0econtrollerName\x18\x02 \x01(\t\x12\x13\n\x0bdeviceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07created\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0eapplicationUid\x18\x08 \x01(\x0c\x120\n\rappClientType\x18\t \x01(\x0e2\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\x12ControllerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t"M\n\x1aPAMConfigurationController\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c"\xa3\x01\n\x17ConfigurationAddRequest\x12\x18\n\x10configurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04data\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b2\x13.Records.RecordLink\x12#\n\x05audit\x18\x05 \x01(\x0b2\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\x10cert_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(\x0e2\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e2\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0ccloseReasons\x18\x08 \x03(\x05B\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(\x0e2\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e2\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0ccloseReasons\x18\x08 \x03(\x05B\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(\x0e2\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\x08fileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0bcloseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x126\n\x12aiOverallRiskLevel\x18\x0c \x01(\x0e2\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10aiOverallSummary\x18\r \x01(\x0c"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b2\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07content\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\x08adminUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12connectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0cconnectUsers\x18\x06 \x01(\x0b2\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\x01B\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(\x0e2\x10.PAM.GitHubScopeH\x00\x88\x01\x01\x12\r\n\x05owner\x18\x02 \x01(\x0c\x12F\n\x16organizationVisibility\x18\x03 \x01(\x0e2!.PAM.GitHubOrganizationVisibilityH\x01\x88\x01\x01\x12$\n\x05repos\x18\x04 \x03(\x0b2\x15.PAM.GitHubRepositoryB\x08\n\x06_scopeB\x19\n\x17_organizationVisibility"\xe9\x02\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07enabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07folders\x18\x04 \x03(\x0b2\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(\x0b2\x11.PAM.GitHubConfigH\x04\x88\x01\x01\x12 \n\x13objectStorageBucket\x18\x08 \x01(\x0cH\x05\x88\x01\x01B\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultNameB\t\n\x07_githubB\x16\n\x14_objectStorageBucket"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07endTime\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\x13discoveryJobStarted\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\x0bconnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0centerpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07endTime\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(\x0b2\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(\x0b2\x13.PAM.NhiUsageByUser"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b2\x17.PAM.NhiMetricsResponse"^\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12"\n\x08category\x18\x02 \x01(\x0e2\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\x12\x0e\n\x06appUid\x18\x04 \x01(\t"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07endTime\x18\x02 \x01(\x03"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b2\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\x0bdefaultDate\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\x124\n\x07results\x18\x01 \x03(\x0b2#.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\x03ADD\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06DELETE\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\x15ControllerMessageType\x12\x0f\n\x0bCMT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0bCMT_CONNECT\x10\x03\x12\x19\n\x15CMT_ANALYZE_RECORDING\x10\x04\x12!\n\x1dCMT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07CMT_USS\x10\x06\x12\x0c\n\x08CMT_INFO\x10\x07\x12\x12\n\x0eCMT_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\x03ALL\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\x06DEVICE\x10\x04B\x1f\n\x18com.keepersecurity.protoB\x03PAMb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,24 +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=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['_WEBRTCCONNECTIONTYPE']._serialized_start=6325 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=6483 + _globals['_PAMOPERATIONTYPE']._serialized_start=6485 + _globals['_PAMOPERATIONTYPE']._serialized_end=6549 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=6551 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=6663 + _globals['_CONTROLLERMESSAGETYPE']._serialized_start=6666 + _globals['_CONTROLLERMESSAGETYPE']._serialized_end=6867 + _globals['_PAMRECORDINGTYPE']._serialized_start=6869 + _globals['_PAMRECORDINGTYPE']._serialized_end=6955 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=6957 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=7062 + _globals['_GITHUBSCOPE']._serialized_start=7064 + _globals['_GITHUBSCOPE']._serialized_end=7111 + _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_start=7113 + _globals['_GITHUBORGANIZATIONVISIBILITY']._serialized_end=7179 + _globals['_NHICATEGORY']._serialized_start=7181 + _globals['_NHICATEGORY']._serialized_end=7277 _globals['_PAMROTATIONSCHEDULE']._serialized_start=51 _globals['_PAMROTATIONSCHEDULE']._serialized_end=182 _globals['_PAMROTATIONSCHEDULESRESPONSE']._serialized_start=184 @@ -131,31 +131,31 @@ _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 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4956 + _globals['_NHIMETRICSREQUEST']._serialized_start=4958 + _globals['_NHIMETRICSREQUEST']._serialized_end=5013 + _globals['_PAMUSAGEBYUSER']._serialized_start=5016 + _globals['_PAMUSAGEBYUSER']._serialized_end=5300 + _globals['_NHIUSAGEBYUSER']._serialized_start=5302 + _globals['_NHIUSAGEBYUSER']._serialized_end=5414 + _globals['_NHIMETRICSRESPONSE']._serialized_start=5417 + _globals['_NHIMETRICSRESPONSE']._serialized_end=5677 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5679 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5747 + _globals['_NHIUIDENTRY']._serialized_start=5749 + _globals['_NHIUIDENTRY']._serialized_end=5843 + _globals['_GETNHIUIDSREQUEST']._serialized_start=5845 + _globals['_GETNHIUIDSREQUEST']._serialized_end=5900 + _globals['_GETNHIUIDSRESPONSE']._serialized_start=5902 + _globals['_GETNHIUIDSRESPONSE']._serialized_end=5954 + _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_start=5956 + _globals['_SETNHIKSMEFFECTIVEDATEREQUEST']._serialized_end=6010 + _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_start=6012 + _globals['_GETNHIKSMEFFECTIVEDATERESPONSE']._serialized_end=6088 + _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_start=6090 + _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_end=6163 + _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_start=6165 + _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_end=6232 + _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_start=6234 + _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_end=6322 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/pam_pb2.pyi b/keepercommander/proto/pam_pb2.pyi index b83fdc735..5b5a29ce3 100644 --- a/keepercommander/proto/pam_pb2.pyi +++ b/keepercommander/proto/pam_pb2.pyi @@ -555,7 +555,7 @@ class GitHubConfig(_message.Message): 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", "github") + __slots__ = ("networkUid", "enabled", "dryRunEnabled", "folders", "syncIdentity", "vaultName", "github", "objectStorageBucket") NETWORKUID_FIELD_NUMBER: _ClassVar[int] ENABLED_FIELD_NUMBER: _ClassVar[int] DRYRUNENABLED_FIELD_NUMBER: _ClassVar[int] @@ -563,6 +563,7 @@ class PAMUniversalSyncConfig(_message.Message): SYNCIDENTITY_FIELD_NUMBER: _ClassVar[int] VAULTNAME_FIELD_NUMBER: _ClassVar[int] GITHUB_FIELD_NUMBER: _ClassVar[int] + OBJECTSTORAGEBUCKET_FIELD_NUMBER: _ClassVar[int] networkUid: bytes enabled: bool dryRunEnabled: bool @@ -570,7 +571,8 @@ class PAMUniversalSyncConfig(_message.Message): syncIdentity: bytes vaultName: bytes 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: ... + objectStorageBucket: bytes + 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]] = ..., objectStorageBucket: _Optional[bytes] = ...) -> None: ... class NhiMetricsRequest(_message.Message): __slots__ = ("startTime", "endTime") diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index c05ed1415..f882f112a 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -71,10 +71,13 @@ def _find_parser_method(command: str) -> str: Returns: str: Method name to call for parsing """ + if KeeperResponseParser._is_pam_project_export(command): + return '_parse_pam_project_export_command' + # Check for JSON format first (highest priority) if '--format=json' in command or '--format json' in command: return '_parse_json_format_command' - + # Check for other substring matches substring_patterns = { 'pam project import': '_parse_pam_project_import_command', @@ -153,8 +156,8 @@ def parse_response(command: str, response: Any, log_output: str = None) -> Dict[ # Call the parser method with appropriate arguments if parser_method_name in ['_parse_generate_command', '_parse_json_format_command', - '_parse_pam_project_import_command', '_parse_enterprise_push_command', - '_parse_epm_policy_add_command']: + '_parse_pam_project_import_command', '_parse_pam_project_export_command', + '_parse_enterprise_push_command', '_parse_epm_policy_add_command']: return parser_method(command, response_str) elif parser_method_name in ['_parse_nsf_mkdir_command', '_parse_nsf_rmdir_command', '_parse_nsf_rndir_command']: return parser_method(response_str, parser_method_name.replace('_parse_', '').replace('_command', '')) @@ -888,6 +891,31 @@ def _parse_pam_project_import_command(command: str, response: str) -> Dict[str, return result + @staticmethod + def _is_pam_project_export(command: str) -> bool: + """Whether command is 'pam project export' (alias-aware).""" + from .verified_command import Verifycommand + try: + tokens = Verifycommand.tokenize_service_command(command) + except ValueError: + return False + return Verifycommand._is_pam_project_export(tokens) + + @staticmethod + def _parse_pam_project_export_command(command: str, response: str) -> Dict[str, Any]: + """Parse 'pam project export' output; the project JSON is returned in 'data'.""" + result = { + "status": "success", + "command": "pam project export", + "data": None, + } + try: + result["data"] = json.loads(response) + except (TypeError, json.JSONDecodeError): + result["status"] = "error" + result["error"] = response.strip() or "Invalid JSON response" + return result + @staticmethod def _parse_json_format_command(command: str, response: str) -> Dict[str, Any]: """ diff --git a/unit-tests/pam/test_pam_project_extend_nsf.py b/unit-tests/pam/test_pam_project_extend_nsf.py index 998f845ea..a8da9eecf 100644 --- a/unit-tests/pam/test_pam_project_extend_nsf.py +++ b/unit-tests/pam/test_pam_project_extend_nsf.py @@ -3,7 +3,11 @@ import keepercommander.commands.record # noqa: F401 -from keepercommander.commands.pam_import.extend import PAMProjectExtendCommand +from keepercommander.commands.pam_import.extend import ( + PAMProjectExtendCommand, + _drop_folder_paths, + _resolve_folder_option, +) from keepercommander.subfolder import BaseFolderNode, NestedShareFolderNode, RootFolderNode @@ -260,3 +264,202 @@ def test_nsf_paths_to_be_created_classifies_nested_new_paths(): # 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'] + + +# --- autodetect_folders (KC-1491) --- + +def _shf(uid, name, folder_tree=None): + return {'uid': uid, 'name': name, 'folder_tree': folder_tree or {}} + + +def _new_project(shared_folders, resources=1, users=1, folders=None): + mapped_resources = [SimpleNamespace(type='pamDatabase', title=f'DB{i}', folder_path='', + _extend_tag='new', users=[]) for i in range(resources)] + mapped_users = [SimpleNamespace(type='pamUser', title=f'U{i}', folder_path='', + _extend_tag='new') for i in range(users)] + return { + 'ksm_shared_folders': shared_folders, + 'mapped_resources': mapped_resources, + 'mapped_users': mapped_users, + 'folders': folders if folders is not None else {}, + 'error_count': 0, + } + + +def _autodetect(project, types_by_folder): + """Run autodetect_folders with records faked as (uid, title, type, login) 4-tuples per folder uid.""" + def fake_records(_params, folder_uid): + return [(f'{folder_uid}-{i}', 'title', rtype, '') for i, rtype in enumerate(types_by_folder.get(folder_uid, []))] + + with patch('keepercommander.commands.pam_import.extend._get_records_in_folder', side_effect=fake_records): + return PAMProjectExtendCommand().autodetect_folders(SimpleNamespace(), project) + + +def test_autodetect_uses_resources_and_users_folder_names(): + project = _new_project([_shf('u', 'Proj - Users'), _shf('r', 'Proj - Resources')]) + + assert _autodetect(project, {}) == [] + assert project['folders'] == {'resources_folder_uid': 'r', 'users_folder_uid': 'u'} + + +def test_autodetect_finds_folders_from_existing_records(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta')]) + + assert _autodetect(project, {'a': ['pamMachine'], 'b': ['pamUser']}) == [] + assert project['folders'] == {'resources_folder_uid': 'a', 'users_folder_uid': 'b'} + + +def test_autodetect_looks_into_subfolders_of_shared_folder(): + tree = {'Prod': {'uid': 'a_prod', 'name': 'Prod', 'subfolders': {}}} + project = _new_project([_shf('a', 'Alpha', tree), _shf('b', 'Beta')]) + + assert _autodetect(project, {'a_prod': ['pamDirectory'], 'b': ['login']}) == [] + assert project['folders'] == {'resources_folder_uid': 'a', 'users_folder_uid': 'b'} + + +def test_autodetect_does_not_put_resources_into_the_only_non_empty_users_folder(capsys): + # KC-1491: Resources folder is empty, Users folder holds the admin pamUser. + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta')], users=0) + + errors = _autodetect(project, {'b': ['pamUser']}) + + assert len(errors) == 1 + assert 'resources folder' in errors[0] and '--resources-folder' in errors[0] + assert project['folders'] == {} + assert project['error_count'] == 1 + assert 'Total: 1 errors' in capsys.readouterr().out + + +def test_autodetect_errors_on_single_shared_folder_without_resource_records(): + project = _new_project([_shf('u', 'Only Users')], users=0) + + assert len(_autodetect(project, {'u': ['pamUser']})) == 1 + assert project['folders'] == {} + + +def test_autodetect_errors_when_resources_live_in_several_folders(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')], users=0) + + errors = _autodetect(project, {'a': ['pamMachine'], 'b': ['pamDatabase']}) + + assert len(errors) == 1 + assert 'multiple folders' in errors[0] and '"Alpha"' in errors[0] and '"Beta"' in errors[0] + assert project['folders'] == {} + + +def test_autodetect_reports_both_roles_when_both_are_unknown(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')]) + + assert len(_autodetect(project, {})) == 2 + assert project['error_count'] == 2 + + +def test_autodetect_keeps_roles_already_set_by_command_line(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')], + folders={'resources_folder_uid': 'a', 'users_folder_uid': 'b'}) + + assert _autodetect(project, {}) == [] + assert project['folders'] == {'resources_folder_uid': 'a', 'users_folder_uid': 'b'} + + +def test_autodetect_only_detects_the_missing_role(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')], + folders={'resources_folder_uid': 'a'}) + + assert _autodetect(project, {'c': ['pamUser']}) == [] + assert project['folders'] == {'resources_folder_uid': 'a', 'users_folder_uid': 'c'} + + +def test_autodetect_ignores_roles_with_no_new_records(): + # Only resources are new, so an undetectable users folder is not an error. + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')], users=0) + + assert _autodetect(project, {'a': ['pamMachine']}) == [] + assert project['folders'] == {'resources_folder_uid': 'a'} + + +def test_autodetect_skips_records_with_folder_path_and_existing_records(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')]) + for o in project['mapped_resources']: + o.folder_path = 'Alpha/Sub' + for o in project['mapped_users']: + o._extend_tag = 'existing' + + assert _autodetect(project, {}) == [] + assert project['folders'] == {} + + +def test_autodetect_nested_new_users_need_users_folder(): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta'), _shf('c', 'Gamma')], users=0) + project['mapped_resources'][0].users = [SimpleNamespace(type='pamUser', title='N', folder_path='', + _extend_tag='new')] + + assert _autodetect(project, {'a': ['pamMachine'], 'b': ['pamUser']}) == [] + assert project['folders'] == {'resources_folder_uid': 'a', 'users_folder_uid': 'b'} + + +def test_autodetect_warns_when_both_roles_share_one_folder(capsys): + project = _new_project([_shf('a', 'Alpha'), _shf('b', 'Beta')]) + + assert _autodetect(project, {'a': ['pamMachine', 'pamUser']}) == [] + assert project['folders'] == {'resources_folder_uid': 'a', 'users_folder_uid': 'a'} + assert 'same folder "Alpha"' in capsys.readouterr().out + + +# --- --resources-folder / --users-folder (KC-1491) --- + +def test_resolve_folder_option_accepts_uid_and_path(): + tree = {'Prod': {'uid': 'prod_uid', 'name': 'Prod', 'subfolders': { + 'Db': {'uid': 'db_uid', 'name': 'Db', 'subfolders': {}}}}} + shared = [_shf('root_uid', 'Proj - Resources', tree)] + + assert _resolve_folder_option('root_uid', shared) == 'root_uid' + assert _resolve_folder_option('prod_uid', shared) == 'prod_uid' + assert _resolve_folder_option('Proj - Resources', shared) == 'root_uid' + assert _resolve_folder_option('Proj - Resources/Prod/Db', shared) == 'db_uid' + assert _resolve_folder_option(' Proj - Resources/Prod ', shared) == 'prod_uid' + + +def test_resolve_folder_option_rejects_unknown_or_empty(): + shared = [_shf('root_uid', 'Proj - Resources')] + + assert _resolve_folder_option('', shared) == '' + assert _resolve_folder_option('nope', shared) == '' + assert _resolve_folder_option('Proj - Resources/New', shared) == '' + + +def _pam_data(): + return { + 'resources': [ + {'title': 'R1', 'folder_path': 'A/res', + 'users': [{'title': 'N1', 'folder_path': 'A/usr'}, {'title': 'N2'}]}, + {'title': 'R2'}, + ], + 'users': [{'title': 'U1', 'folder_path': 'A/usr'}, {'title': 'U2'}], + } + + +def test_drop_folder_paths_resources_only(): + pam_data = _pam_data() + + assert _drop_folder_paths(pam_data, True, False) == 1 + assert 'folder_path' not in pam_data['resources'][0] + assert pam_data['resources'][0]['users'][0]['folder_path'] == 'A/usr' + assert pam_data['users'][0]['folder_path'] == 'A/usr' + + +def test_drop_folder_paths_users_only_covers_nested_users(): + pam_data = _pam_data() + + assert _drop_folder_paths(pam_data, False, True) == 2 + assert pam_data['resources'][0]['folder_path'] == 'A/res' + assert 'folder_path' not in pam_data['resources'][0]['users'][0] + assert 'folder_path' not in pam_data['users'][0] + + +def test_extend_parser_accepts_folder_overrides(): + args = PAMProjectExtendCommand.parser.parse_args( + ['--config', 'c', '--filename', 'f', '--resources-folder', 'R', '--users-folder', 'U']) + + assert (args.resources_folder, args.users_folder) == ('R', 'U') + assert PAMProjectExtendCommand.parser.parse_args(['-c', 'c', '-f', 'f']).resources_folder == '' diff --git a/unit-tests/pam/test_pam_rbi_edit.py b/unit-tests/pam/test_pam_rbi_edit.py index 1d8631efa..4c055ac3f 100644 --- a/unit-tests/pam/test_pam_rbi_edit.py +++ b/unit-tests/pam/test_pam_rbi_edit.py @@ -6,6 +6,7 @@ - URL Filtering: --allowed-urls, --allowed-resource-urls (multi-value) - Autofill: --autofill-targets (multi-value) - Clipboard: --allow-copy, --allow-paste (on/off/default) +- Supply user / clipboard size: --allow-supply-user (on/off/default), --clipboard-buffer-size (256kb/1mb/10mb/50mb/default) - Audio: --disable-audio (on/off/default), --audio-channels, --audio-bit-depth, --audio-sample-rate Note: Tests require Python 3.8+ due to pydantic dependency in tunnel_and_connections imports. @@ -224,6 +225,42 @@ def test_combine_new_args_with_existing(self): self.assertEqual(args.allow_url_navigation, 'on') self.assertEqual(args.allowed_urls, ['*.example.com']) + def test_allow_supply_user_choices(self): + for v in ('on', 'off', 'default'): + args = self.parser.parse_args(['--record', 'test-record', '--allow-supply-user', v]) + self.assertEqual(args.allow_supply_user, v) + + def test_allow_supply_user_invalid(self): + with self.assertRaises(SystemExit): + self.parser.parse_args(['--record', 'test-record', '--allow-supply-user', 'invalid']) + + def test_allow_supply_user_not_provided(self): + args = self.parser.parse_args(['--record', 'test-record', '--key-events', 'on']) + self.assertIsNone(args.allow_supply_user) + + def test_clipboard_buffer_size_allowed_values(self): + for v in ('256kb', '1mb', '10mb', '50mb'): + args = self.parser.parse_args(['--record', 'test-record', '--clipboard-buffer-size', v]) + self.assertEqual(args.clipboard_buffer_size, v) + + def test_clipboard_buffer_size_case_insensitive(self): + for v, expected in (('256KB', '256kb'), ('1Mb', '1mb'), ('10MB', '10mb'), ('DEFAULT', 'default')): + args = self.parser.parse_args(['--record', 'test-record', '--clipboard-buffer-size', v]) + self.assertEqual(args.clipboard_buffer_size, expected) + + def test_clipboard_buffer_size_default(self): + args = self.parser.parse_args(['--record', 'test-record', '--clipboard-buffer-size', 'default']) + self.assertEqual(args.clipboard_buffer_size, 'default') + + def test_clipboard_buffer_size_rejects_other_values(self): + for v in ('12345', '10485760', '0', '-1', '2mb', '100mb', 'abc'): + with self.assertRaises(SystemExit, msg=v): + self.parser.parse_args(['--record', 'test-record', '--clipboard-buffer-size', v]) + + def test_clipboard_buffer_size_not_provided(self): + args = self.parser.parse_args(['--record', 'test-record', '--key-events', 'on']) + self.assertIsNone(args.clipboard_buffer_size) + def test_session_persistence_none(self): args = self.parser.parse_args(['--record', 'test-record', '--session-persistence', 'none']) self.assertEqual(args.session_persistence, 'none') @@ -405,6 +442,61 @@ def test_session_persistence_default_removes_present_but_null(self, mock_update, self.command.execute(self.mock_params, record='test-record', session_persistence='default') self.assertNotIn('sessionPersistence', self.pam_settings['connection']) + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_allow_supply_user_on_sets_true(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + self.command.execute(self.mock_params, record='test-record', allow_supply_user='on') + self.assertIs(self.pam_settings['connection'].get('allowSupplyUser'), True) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_allow_supply_user_off_sets_false(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + self.command.execute(self.mock_params, record='test-record', allow_supply_user='off') + self.assertIs(self.pam_settings['connection'].get('allowSupplyUser'), False) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_allow_supply_user_default_removes_field(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + self.pam_settings['connection']['allowSupplyUser'] = True + self.command.execute(self.mock_params, record='test-record', allow_supply_user='default') + self.assertNotIn('allowSupplyUser', self.pam_settings['connection']) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_clipboard_buffer_size_sets_value(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + self.command.execute(self.mock_params, record='test-record', clipboard_buffer_size='10mb') + self.assertEqual(self.pam_settings['connection'].get('clipboardBufferSize'), 10485760) + mock_update.assert_called_once() + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_clipboard_buffer_size_converts_all_names_to_bytes(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + for name, size in (('256KB', 262144), ('1MB', 1048576), ('10mb', 10485760), ('50Mb', 52428800)): + self.command.execute(self.mock_params, record='test-record', clipboard_buffer_size=name) + self.assertEqual(self.pam_settings['connection'].get('clipboardBufferSize'), size) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_clipboard_buffer_size_default_removes_field(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + self.pam_settings['connection']['clipboardBufferSize'] = 1048576 + self.command.execute(self.mock_params, record='test-record', clipboard_buffer_size='default') + self.assertNotIn('clipboardBufferSize', self.pam_settings['connection']) + + @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + def test_clipboard_buffer_size_invalid_raises(self, mock_update, mock_resolve): + mock_resolve.return_value = self.mock_record + with self.assertRaises(CommandError): + self.command.execute(self.mock_params, record='test-record', clipboard_buffer_size='12345') + self.assertNotIn('clipboardBufferSize', self.pam_settings['connection']) + mock_update.assert_not_called() + @mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) diff --git a/unit-tests/pam/test_pam_same_admin_launch_credential.py b/unit-tests/pam/test_pam_same_admin_launch_credential.py index a77253e55..6694e6459 100644 --- a/unit-tests/pam/test_pam_same_admin_launch_credential.py +++ b/unit-tests/pam/test_pam_same_admin_launch_credential.py @@ -91,3 +91,29 @@ def test_set_launch_credentials_without_admin_leaves_admin_unset(): tdag.set_launch_credentials(RESOURCE_UID, launch_uid=SHARED_USER_UID, admin_uid=("" or None)) assert not captured['rq'].HasField('adminUid') assert 'is_admin' not in _written_acl_content(user_vertex) + + +def test_set_launch_credentials_admin_only_sends_admin_with_empty_connect_users(): + """KC-1525: admin-only (no launch) must still be asserted in one request: + adminUid + empty connectUsers, and no local launch ACL write.""" + tdag, _, user_vertex = _make_stale_tdag() + captured = {} + with patch('keepercommander.commands.pam._layer_b.is_layer_b_feature_disabled', return_value=False), patch('keepercommander.commands.pam.router_helper.get_router_url', return_value='krouter.test'), patch('keepercommander.commands.pam.router_helper.router_configure_resource', + side_effect=lambda p, rq: captured.setdefault('rq', rq)): + tdag.set_launch_credentials(RESOURCE_UID, launch_uid=None, admin_uid=SHARED_USER_UID) + + rq = captured['rq'] + assert rq.HasField('connectUsers') and len(rq.connectUsers.uids) == 0 + assert rq.HasField('adminUid') + user_vertex.belongs_to.assert_not_called() + + +def test_extend_existing_resources_loop_uses_set_launch_credentials(): + """The existing-resources loop must not use the legacy launch link (drops is_admin + when admin == launch == newly created user).""" + import inspect + from keepercommander.commands.pam_import import extend + src = inspect.getsource(extend.PAMProjectExtendCommand) + tail = src.split("for mach in existing_resources:", 1)[1].split("link machine -> pamDirectory", 1)[0] + assert "tdag.set_launch_credentials(mach.uid, launch_uid=launch_uid, admin_uid=(admin_uid or None))" in tail + assert "is_launch_credential=True" not in tail diff --git a/unit-tests/service/test_response_parser.py b/unit-tests/service/test_response_parser.py index 086fdfbcd..b1f4e9d47 100644 --- a/unit-tests/service/test_response_parser.py +++ b/unit-tests/service/test_response_parser.py @@ -276,3 +276,16 @@ def test_parse_get_command(self): self.assertEqual(result['data']['username'], 'testuser') self.assertEqual(result['data']['password'], 'testpass') self.assertEqual(result['data']['url'], 'https://example.com') + + def test_parse_pam_project_export_returns_json_data(self): + """'pam project export' output is parsed into structured data, including aliases""" + payload = {"pam_configuration": {"title": "cfg"}, "pam_data": {"resources": [{"host": "db"}]}} + for command in ('pam project export -p UID', 'pam p export --project-uid UID'): + result = KeeperResponseParser.parse_response(command, json.dumps(payload, indent=2)) + self.assertEqual(result['status'], 'success') + self.assertEqual(result['command'], 'pam project export') + self.assertEqual(result['data'], payload) + + def test_parse_pam_project_export_non_json(self): + result = KeeperResponseParser.parse_response('pam project export -p UID', 'not json') + self.assertEqual(result['status'], 'error')