From 25b8e1e5b02cd16a1de1f498965017ff3506d53f Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 23 Jul 2026 18:32:19 +0530 Subject: [PATCH] NSF Support to pam action commands --- keepercommander/commands/discover/__init__.py | 97 +++++--- .../commands/discover/result_process.py | 221 ++++++++++++------ keepercommander/commands/discoveryrotation.py | 126 ++++++---- keepercommander/commands/pam/vault_target.py | 12 +- .../commands/pam_debug/__init__.py | 4 +- keepercommander/commands/pam_debug/acl.py | 8 +- keepercommander/commands/pam_debug/dump.py | 125 +++++++--- keepercommander/commands/pam_debug/graph.py | 30 ++- keepercommander/commands/pam_debug/info.py | 26 +-- keepercommander/commands/pam_debug/krouter.py | 1 - keepercommander/commands/pam_debug/link.py | 6 +- .../commands/pam_debug/rotation_setting.py | 22 +- keepercommander/commands/pam_debug/verify.py | 4 +- keepercommander/commands/pam_launch/launch.py | 150 +++++++----- keepercommander/commands/pam_saas/config.py | 137 +++++------ unit-tests/pam/test_pam_debug_nsf.py | 182 +++++++++++++++ 16 files changed, 786 insertions(+), 365 deletions(-) create mode 100644 unit-tests/pam/test_pam_debug_nsf.py diff --git a/keepercommander/commands/discover/__init__.py b/keepercommander/commands/discover/__init__.py index dd07290cb..6ef3c4b10 100644 --- a/keepercommander/commands/discover/__init__.py +++ b/keepercommander/commands/discover/__init__.py @@ -7,6 +7,7 @@ from ..pam.router_helper import get_response_payload from ..pam.gateway_helper import get_all_gateways from ..pam.router_helper import router_send_action_to_gateway +from ..pam.vault_target import pam_folder_exists, get_pam_folder_path from ..ksm import KSMCommand from ... import utils, vault_extensions from ... import vault @@ -21,7 +22,10 @@ import re from packaging import version as packaging_version -from typing import List, Optional, Union, Callable, Tuple, Any, Dict, TYPE_CHECKING +from collections.abc import Callable +from typing import Any, TYPE_CHECKING + +from ..pam_import.record_loader import iter_accessible_record_uids, load_pam_record if TYPE_CHECKING: from ...params import KeeperParams @@ -33,7 +37,7 @@ class MultiConfigurationException(Exception): """ If the gateway has multiple configuration """ - def __init__(self, items: List[Dict]): + def __init__(self, items: list[dict]): super().__init__() self.items = items @@ -66,14 +70,14 @@ def __init__(self, configuration: KeeperRecord, facade: PamConfigurationRecordFa self.gateway = gateway self.application = application self._shared_folders = None - self._info: Optional[Dict] = None + self._info: dict | None = None @staticmethod def all_gateways(params: KeeperParams): return get_all_gateways(params) @staticmethod - def get_configuration_records(params) -> List[KeeperRecord]: + def get_configuration_records(params) -> list[KeeperRecord]: """ Get PAM configuration records. @@ -84,17 +88,37 @@ def get_configuration_records(params) -> List[KeeperRecord]: """ configuration_list = [] - if value_to_boolean(os.environ.get("PAM_RECORD_TYPE_MATCH")): + seen = set() + type_match = value_to_boolean(os.environ.get("PAM_RECORD_TYPE_MATCH")) + if type_match: for record in list(vault_extensions.find_records(params, record_version=iter([3, 6]))): if re.search(r"pam.+Configuration", record.record_type): configuration_list.append(record) + seen.add(record.record_uid) else: - configuration_list = list(vault_extensions.find_records(params, record_version=6)) + for record in list(vault_extensions.find_records(params, record_version=6)): + configuration_list.append(record) + seen.add(record.record_uid) + + # Include Nested Share Folder PAM configs that are not mirrored into record_cache yet. + for uid in iter_accessible_record_uids(params): + if uid in seen: + continue + record = load_pam_record(params, uid) + if not record or not isinstance(record, vault.TypedRecord): + continue + if type_match: + if not re.search(r"pam.+Configuration", record.record_type or ''): + continue + elif record.version != 6: + continue + configuration_list.append(record) + seen.add(uid) return configuration_list @classmethod - def find_gateway(cls, params: KeeperParams, find_func: Callable, gateways: Optional[List] = None) \ - -> Tuple[Optional[GatewayContext], Any]: + def find_gateway(cls, params: KeeperParams, find_func: Callable, gateways: list | None = None) \ + -> tuple[GatewayContext | None, Any]: """ Populate the context from matching using the function passed in. @@ -121,8 +145,8 @@ def find_gateway(cls, params: KeeperParams, find_func: Callable, gateways: Optio return None, None @staticmethod - def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateways: Optional[List] = None) \ - -> Optional[GatewayContext]: + def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateways: list | None = None) \ + -> GatewayContext | None: """ Populate context using the configuration UID. @@ -134,7 +158,7 @@ def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateway if gateways is None: gateways = GatewayContext.all_gateways(params) - configuration_record = vault.KeeperRecord.load(params, configuration_uid) + configuration_record = load_pam_record(params, configuration_uid) if not isinstance(configuration_record, vault.TypedRecord): print(f'{bcolors.FAIL}PAM Configuration [{configuration_uid}] is not available.{bcolors.ENDC}') return None @@ -161,8 +185,8 @@ def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateway ) @staticmethod - def from_gateway(params: KeeperParams, gateway: str, configuration_uid: Optional[str] = None) \ - -> Optional[GatewayContext]: + def from_gateway(params: KeeperParams, gateway: str, configuration_uid: str | None = None) \ + -> GatewayContext | None: """ Populate context use the gateway, and optional configuration UID. @@ -190,7 +214,10 @@ def from_gateway(params: KeeperParams, gateway: str, configuration_uid: Optional logging.debug(f"checking configuration record {configuration_record.title}") # Load the configuration record and get the gateway_uid from the facade. - configuration_record = vault.KeeperRecord.load(params, configuration_record.record_uid) + configuration_record = load_pam_record(params, configuration_record.record_uid) + if not isinstance(configuration_record, vault.TypedRecord): + logging.debug(f" * configuration record could not be loaded, skipping.") + continue configuration_facade = PamConfigurationRecordFacade() configuration_facade.record = configuration_record @@ -276,7 +303,7 @@ def is_gateway(self, request_gateway: str) -> bool: return (request_gateway == utils.base64_url_encode(self.gateway.controllerUid) or request_gateway.lower() == self.gateway_name.lower()) - def get_shared_folders(self, params: KeeperParams) -> List[dict]: + def get_shared_folders(self, params: KeeperParams) -> list[dict]: if self._shared_folders is None: self._shared_folders = [] application_uid = utils.base64_url_encode(self.gateway.applicationUid) @@ -288,17 +315,35 @@ def get_shared_folders(self, params: KeeperParams) -> List[dict]: uid_str = utils.base64_url_encode(shared.secretUid) shared_type = APIRequest_pb2.ApplicationShareType.Name(shared.shareType) if shared_type == 'SHARE_TYPE_FOLDER': - if uid_str not in params.shared_folder_cache: + if uid_str in params.shared_folder_cache: + cached_shared_folder = params.shared_folder_cache[uid_str] + self._shared_folders.append({ + "uid": uid_str, + "name": cached_shared_folder.get('name_unencrypted'), + "folder": cached_shared_folder + }) continue - cached_shared_folder = params.shared_folder_cache[uid_str] - self._shared_folders.append({ - "uid": uid_str, - "name": cached_shared_folder.get('name_unencrypted'), - "folder": cached_shared_folder - }) + nsf = getattr(params, 'nested_share_folders', {}).get(uid_str) + if nsf: + self._shared_folders.append({ + "uid": uid_str, + "name": nsf.get('name', uid_str), + "folder": nsf + }) + # PAM config application folder (often NSF) may not appear in app shares listing. + app_folder_uid = self.default_shared_folder_uid + if app_folder_uid and not any(f.get('uid') == app_folder_uid for f in self._shared_folders): + if pam_folder_exists(params, app_folder_uid): + nsf = getattr(params, 'nested_share_folders', {}).get(app_folder_uid) + name = (nsf.get('name') if nsf else None) or get_pam_folder_path(params, app_folder_uid) or app_folder_uid + self._shared_folders.append({ + "uid": app_folder_uid, + "name": name, + "folder": nsf or params.shared_folder_cache.get(app_folder_uid) or {}, + }) return self._shared_folders - def info(self, params: KeeperParams) -> Optional[Dict]: + def info(self, params: KeeperParams) -> dict | None: if self._info is None: from ..pam.pam_dto import GatewayActionGatewayInfo @@ -333,7 +378,7 @@ def info(self, params: KeeperParams) -> Optional[Dict]: return self._info - def _gateway_version(self, params: KeeperParams) -> Optional[packaging_version.Version]: + def _gateway_version(self, params: KeeperParams) -> packaging_version.Version | None: try: info = self.info(params) @@ -367,7 +412,7 @@ def encrypt(self, data: dict) -> str: ciphertext = encrypt_aes_v2(json_data.encode(), self.configuration.record_key) return base64.b64encode(ciphertext).decode() - def encrypt_str(self, data: Union[bytes, str]) -> str: + def encrypt_str(self, data: bytes | str) -> str: if isinstance(data, str): data = data.encode() ciphertext = encrypt_aes_v2(data, self.configuration.record_key) @@ -419,7 +464,7 @@ class PAMGatewayActionDiscoverCommandBase(Command): } @staticmethod - def get_response_data(router_response: dict) -> Optional[dict]: + def get_response_data(router_response: dict) -> dict | None: if router_response is None: return None diff --git a/keepercommander/commands/discover/result_process.py b/keepercommander/commands/discover/result_process.py index f00a672be..d221aab20 100644 --- a/keepercommander/commands/discover/result_process.py +++ b/keepercommander/commands/discover/result_process.py @@ -8,6 +8,10 @@ from . import PAMGatewayActionDiscoverCommandBase, GatewayContext from ..pam.router_helper import (router_get_connected_gateways, router_set_record_rotation_information, router_configure_resource) +from ..pam.vault_target import records_in_folder, is_nested_share_folder +from ...nested_share_folder.record_api import create_record_data_v3, record_add_v3 +from ...nested_share_folder.common import get_folder_key +from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys from ... import api, subfolder, utils, crypto, vault, vault_extensions from ...display import bcolors from ...proto import router_pb2, record_pb2, pam_pb2 @@ -23,7 +27,7 @@ from ...discovery_common.constants import PAM_USER from ...discovery_common.constants import VERTICES_SORT_MAP from pydantic import BaseModel -from typing import Optional, List, Any, Tuple, Dict, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from ...api import get_records_add_request if TYPE_CHECKING: @@ -101,21 +105,21 @@ def _is_directory_user(record_type: str) -> bool: record_type == "pamAzureConfiguration") @staticmethod - def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayContext) -> Optional[str]: + def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayContext) -> str | None: while True: shared_folders = gateway_context.get_shared_folders(params) index = 0 for folder in shared_folders: print(f"{pad}* {_h(str(index+1))} - {folder.get('uid')} {folder.get('name')}") index += 1 - selected = input(f"{pad}Enter number of the shared folder>") + selected = input(f"{pad}Enter number of the shared folder or Nested Share Folder>") try: return shared_folders[int(selected) - 1].get("uid") except ValueError: print(f"{pad}{_f('Input was not a number.')}") @staticmethod - def get_field_values(record: TypedRecord, field_type: str) -> Optional[List[Any]]: + def get_field_values(record: TypedRecord, field_type: str) -> list[Any] | None: return next( (f.value for f in record.fields @@ -124,7 +128,7 @@ def get_field_values(record: TypedRecord, field_type: str) -> Optional[List[Any] ) def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayContext, - record: TypedRecord) -> List[str]: + record: TypedRecord) -> list[str]: """ For the record, get the values of fields that are key for this record type. @@ -137,7 +141,7 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte key_field = Process.get_key_field(record.record_type) keys = [] if key_field == "host_port": - values = self.get_field_values(record, "pamHostname") # type: List[dict] + values = self.get_field_values(record, "pamHostname") # type: list[dict] if len(values) == 0: return [] @@ -182,8 +186,8 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte @staticmethod def _record_lookup(record_uid: str, - context: Optional[Any] = None, - allow_sm: bool = False) -> Optional[NormalizedRecord]: + context: Any | None = None, + allow_sm: bool = False) -> NormalizedRecord | None: """ Get the record from the Vault, normalize it, and return it. @@ -192,7 +196,7 @@ def _record_lookup(record_uid: str, """ params = context.get("params") - record = vault.TypedRecord.load(params, record_uid) # type: Optional[TypedRecord] + record = vault.TypedRecord.load(params, record_uid) # type: TypedRecord | None if record is None: return None @@ -232,6 +236,7 @@ def _build_record_cache(self, params: KeeperParams, gateway_context: GatewayCont logging.debug(f"building the PAM record cache") # Make a cache of existing record by the criteria per record type + # (includes Nested Share Folder records) cache = { "pamUser": {}, "pamMachine": {}, @@ -241,28 +246,31 @@ def _build_record_cache(self, params: KeeperParams, gateway_context: GatewayCont # Set all the PAM Records records = list(vault_extensions.find_records(params, "pam*", use_regex=True)) + seen_uids = {getattr(r, 'record_uid', None) for r in records} + # Include NSF PAM records that may not appear in find_records edge cases + for record_uid in getattr(params, 'nested_share_records', {}) or {}: + if record_uid not in seen_uids: + rec = vault.TypedRecord.load(params, record_uid) + if rec is not None: + records.append(rec) + seen_uids.add(record_uid) for record in records: - # If the record type is not part of the cache, skip the record - if record.record_type not in cache: - continue - - # Load the full record - record = vault.TypedRecord.load(params, record.record_uid) # type: Optional[TypedRecord] - - cache_keys = self.get_keys_by_record( - params=params, - gateway_context=gateway_context, - record=record - ) - if len(cache_keys) == 0: - continue + if record.record_type in cache: + # Load the full record + record = vault.TypedRecord.load(params, record.record_uid) # type: TypedRecord | None - for cache_key in cache_keys: - cache[record.record_type][cache_key] = record.record_uid + cache_keys = self.get_keys_by_record( + params=params, + gateway_context=gateway_context, + record=record + ) + if cache_keys: + for cache_key in cache_keys: + cache[record.record_type][cache_key] = record.record_uid return cache - def _edit_record(self, content: DiscoveryObject, pad: str, editable: List[str]) -> bool: + def _edit_record(self, content: DiscoveryObject, pad: str, editable: list[str]) -> bool: edit_label = input(f"{pad}Enter 'title' or the name of the {_ok('Label')} to edit, RETURN to cancel> ") @@ -354,7 +362,7 @@ def _edit_record(self, content: DiscoveryObject, pad: str, editable: List[str]) @staticmethod def _add_all_preprocess(vertex: DAGVertex, content: DiscoveryObject, parent_vertex: DAGVertex, - acl: Optional[UserAcl] = None) -> Optional[PromptResult]: + acl: UserAcl | None = None) -> PromptResult | None: """ This is client side check if we should skip prompting the user. @@ -377,7 +385,7 @@ def _add_all_preprocess(vertex: DAGVertex, content: DiscoveryObject, parent_vert return PromptResult(action=PromptActionEnum.SKIP) return None - def _prompt_display_fields(self, content: DiscoveryObject, pad: str) -> List[str]: + def _prompt_display_fields(self, content: DiscoveryObject, pad: str) -> list[str]: editable = [] for section in ["fields", "custom"]: @@ -454,13 +462,13 @@ def _prompt(self, content: DiscoveryObject, acl: UserAcl, parent_vertex: DAGVertex, - vertex: Optional[DAGVertex] = None, + vertex: DAGVertex | None = None, resource_has_admin: bool = True, item_count: int = 0, items_left: int = 0, indent: int = 0, block_auto_add: bool = False, - context: Optional[Any] = None) -> PromptResult: + context: Any | None = None) -> PromptResult: if context is None: raise Exception("Context not set for processing the discovery results") @@ -614,8 +622,8 @@ def _prompt(self, def _find_user_record(self, params: KeeperParams, - bulk_convert_records: List[BulkRecordConvert], - context: Optional[Any] = None) -> Tuple[Optional[TypedRecord], bool]: + bulk_convert_records: list[BulkRecordConvert], + context: Any | None = None) -> tuple[TypedRecord | None, bool]: gateway_context = context.get("gateway_context") # type: GatewayContext record_link = context.get("record_link") # type: RecordLink @@ -623,12 +631,16 @@ def _find_user_record(self, # Get the latest records params.sync_data = True - # Make a list of all records in the shared folders. + # Make a list of all records in the shared folders / Nested Share Folders. # We will use this to check if a selected user is in the shared folders. + shared_record_uids = [] for shared_folder in gateway_context.get_shared_folders(params): - folder = shared_folder.get("folder") - if "records" in folder: + folder = shared_folder.get("folder") or {} + folder_uid = shared_folder.get("uid") + if folder_uid and is_nested_share_folder(params, folder_uid): + shared_record_uids.extend(records_in_folder(params, folder_uid)) + elif isinstance(folder, dict) and "records" in folder: for record in folder["records"]: shared_record_uids.append(record.get("record_uid")) @@ -656,7 +668,7 @@ def _find_user_record(self, return None, False # Find usable admin records. - admin_search_results = [] # type: List[AdminSearchResult] + admin_search_results = [] # type: list[AdminSearchResult] for record in user_record: user_record = vault.KeeperRecord.load(params, record.record_uid) @@ -692,7 +704,7 @@ def _find_user_record(self, is_directory_user = False if record_vertex is not None: parent_record_uid = record_link.get_parent_record_uid(user_record.record_uid) - parent_record = vault.TypedRecord.load(params, parent_record_uid) # type: Optional[TypedRecord] + parent_record = vault.TypedRecord.load(params, parent_record_uid) # type: TypedRecord | None if parent_record is not None: is_directory_user = self._is_directory_user(parent_record.record_type) if not is_directory_user: @@ -801,7 +813,7 @@ def _find_user_record(self, @staticmethod def _handle_admin_record_from_record(record: TypedRecord, content: DiscoveryObject, - context: Optional[Any] = None) -> Optional[PromptResult]: + context: Any | None = None) -> PromptResult | None: params = context.get("param") # type: KeeperParams gateway_context = context.get("gateway_context") # type: GatewayContext @@ -892,9 +904,9 @@ def _prompt_admin(self, parent_vertex: DAGVertex, content: DiscoveryObject, acl: UserAcl, - bulk_convert_records: List[BulkRecordConvert], + bulk_convert_records: list[BulkRecordConvert], indent: int = 0, - context: Optional[Any] = None) -> Optional[PromptResult]: + context: Any | None = None) -> PromptResult | None: if content is None: raise Exception("The admin content was not passed in to prompt the user.") @@ -961,7 +973,7 @@ def _prompt_admin(self, print("") @staticmethod - def _display_auto_add_results(bulk_add_records: List[BulkRecordAdd]): + def _display_auto_add_results(bulk_add_records: list[BulkRecordAdd]): """ Display the number of record created from rule engine ADD results and smart add function. @@ -974,7 +986,7 @@ def _display_auto_add_results(bulk_add_records: List[BulkRecordAdd]): f"record{'' if add_count == 1 else 's'} to be added.{bcolors.ENDC}") @staticmethod - def _prompt_confirm_add(bulk_add_records: List[BulkRecordAdd]): + def _prompt_confirm_add(bulk_add_records: list[BulkRecordAdd]): """ If we quit, we want to ask the user if they want to add record for discovery objects that they selected @@ -1000,7 +1012,7 @@ def _prompt_confirm_add(bulk_add_records: List[BulkRecordAdd]): print(f"{bcolors.FAIL}Did not get 'Y' or 'N'{bcolors.ENDC}") @staticmethod - def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> Tuple[Any, str]: + def _prepare_record(content: DiscoveryObject, context: Any | None = None) -> tuple[Any, str]: """ Prepare the Vault record side. @@ -1009,12 +1021,16 @@ def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> It will be created at the end of the processing run in bulk. We to build a record to get a record UID. + For Nested Share Folders, returns a TypedRecord (created via NSF v3 APIs). + For legacy shared folders, returns a RecordAdd protobuf. + :params content: The discovery object instance. :params context: Optionally, it will contain information set from the run() method. :returns: Returns an unsaved Keeper record instance. """ params = context.get("params") + from ..pam.vault_target import is_nested_share_folder # DEFINE V3 RECORD @@ -1050,8 +1066,12 @@ def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> record_field.required = field.required record.custom.append(record_field) + # Nested Share Folder: keep TypedRecord; created later via NSF v3 APIs. + if content.shared_folder_uid and is_nested_share_folder(params, content.shared_folder_uid): + return record, record.record_uid + folder = params.folder_cache.get(content.shared_folder_uid) - folder_key = None # type: Optional[bytes] + folder_key = None # type: bytes | None if isinstance(folder, subfolder.SharedFolderFolderNode): shared_folder_uid = folder.shared_folder_uid elif isinstance(folder, subfolder.SharedFolderNode): @@ -1092,7 +1112,7 @@ def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> return record_add_protobuf, record.record_uid @classmethod - def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optional[Any] = None) -> ( + def _create_records(cls, bulk_add_records: list[BulkRecordAdd], context: Any | None = None) -> ( BulkProcessResults): """ @@ -1109,31 +1129,88 @@ def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optiona build_process_results = BulkProcessResults() + nsf_bulk = [r for r in bulk_add_records + if r.shared_folder_uid and is_nested_share_folder(params, r.shared_folder_uid)] + legacy_bulk = [r for r in bulk_add_records + if not (r.shared_folder_uid and is_nested_share_folder(params, r.shared_folder_uid))] + ############################################################################################################## # # STEP 1 - Batch add new records - # Generate a list of RecordAdd instance. - # In BulkRecordAdd they will be the record instance. - record_add_list = [r.record for r in bulk_add_records] # type: List[record_pb2.RecordAdd] - records_per_request = 999 + add_results = [] # type: list[record_pb2.RecordModifyResult] + created_nsf = False + skipped_uids = set() + + # NSF path: vault/records/v3/add with folder key encryption + if nsf_bulk: + logging.debug("adding NSF records in batches") + print("batch NSF record create: ", end="") + sys.stdout.flush() + nsf_adds = [] + for bulk_record in nsf_bulk: + record = bulk_record.record + if not isinstance(record, vault.TypedRecord): + build_process_results.failure.append( + BulkRecordFail( + title=bulk_record.title, + error="NSF create expected a TypedRecord preparation payload.", + ) + ) + skipped_uids.add(bulk_record.record_uid) + continue + folder_key = get_folder_key( + params, bulk_record.shared_folder_uid, raise_on_missing=False, + ) + if not folder_key: + build_process_results.failure.append( + BulkRecordFail( + title=bulk_record.title, + error=f"NSF folder key not available for {bulk_record.shared_folder_uid}.", + ) + ) + skipped_uids.add(bulk_record.record_uid) + continue + data = vault_extensions.extract_typed_record_data(record) + nsf_adds.append(create_record_data_v3( + record_uid=bulk_record.record_uid, + record_key=record.record_key or utils.generate_aes_key(), + data=data, + folder_uid=bulk_record.shared_folder_uid, + folder_key=folder_key, + data_key=params.data_key, + client_modified_time=utils.current_milli_time(), + )) + + while nsf_adds: + print(".", end="") + sys.stdout.flush() + chunk = nsf_adds[:records_per_request] + nsf_adds = nsf_adds[records_per_request:] + rs = record_add_v3(params, chunk) + add_results.extend(rs.records) + created_nsf = True + print("") + sys.stdout.flush() - add_results = [] # type: List[record_pb2.RecordModifyResult] - logging.debug("adding record in batches") - print("batch record create: ", end="") - sys.stdout.flush() - while record_add_list: - print(".", end="") + # Legacy path: vault/records_add + if legacy_bulk: + record_add_list = [r.record for r in legacy_bulk] # type: list[record_pb2.RecordAdd] + logging.debug("adding record in batches") + print("batch record create: ", end="") + sys.stdout.flush() + while record_add_list: + print(".", end="") + sys.stdout.flush() + logging.debug(f"* adding batch") + rq = get_records_add_request(params) + rq.records.extend(record_add_list[:records_per_request]) + record_add_list = record_add_list[records_per_request:] + rs = api.communicate_rest(params, rq, 'vault/records_add', rs_type=record_pb2.RecordsModifyResponse) + add_results.extend(rs.records) + print("") sys.stdout.flush() - logging.debug(f"* adding batch") - rq = get_records_add_request(params) - rq.records.extend(record_add_list[:records_per_request]) - record_add_list = record_add_list[records_per_request:] - rs = api.communicate_rest(params, rq, 'vault/records_add', rs_type=record_pb2.RecordsModifyResponse) - add_results.extend(rs.records) - print("") - sys.stdout.flush() logging.debug(f"add_result: {add_results}") @@ -1159,20 +1236,22 @@ def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optiona if bulk_record.record_uid in created_cache: logging.debug(f"found a duplicate of record uid: {bulk_record.record_uid}") continue + if bulk_record.record_uid in skipped_uids: + continue print(".", end="") sys.stdout.flush() # Grab the type Keeper record instance, and title from that record. - pb_add_record = bulk_record.record title = bulk_record.title + rec_uid_bytes = utils.base64_url_decode(bulk_record.record_uid) rotation_disabled = False # Find the result for this record. result = None for x in add_results: - logging.debug(f"{pb_add_record.record_uid} vs {x.record_uid}") - if pb_add_record.record_uid == x.record_uid: + logging.debug(f"{rec_uid_bytes} vs {x.record_uid}") + if rec_uid_bytes == x.record_uid: result = x break @@ -1246,12 +1325,14 @@ def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optiona print("") sys.stdout.flush() + if created_nsf: + sync_down_preserving_nsf_keys(params) params.sync_data = True return build_process_results @classmethod - def _convert_records(cls, bulk_convert_records: List[BulkRecordConvert], context: Optional[Any] = None): + def _convert_records(cls, bulk_convert_records: list[BulkRecordConvert], context: Any | None = None): params = context.get("params") gateway_context = context.get("gateway_context") @@ -1291,7 +1372,7 @@ def _convert_records(cls, bulk_convert_records: List[BulkRecordConvert], context @staticmethod def _get_directory_info(domain: str, skip_users: bool = False, - context: Optional[Any] = None) -> Optional[DirectoryInfo]: + context: Any | None = None) -> DirectoryInfo | None: """ Get information about this record from the vault records. @@ -1305,7 +1386,7 @@ def _get_directory_info(domain: str, # Find the all directory records, in for this gateway, that have a domain that matches what we are looking for. for directory_record in vault_extensions.find_records(params, record_type="pamDirectory"): directory_record = vault.TypedRecord.load(params, - directory_record.record_uid) # type: Optional[TypedRecord] + directory_record.record_uid) # type: TypedRecord | None info = params.record_rotation_cache.get(directory_record.record_uid) if info is None: @@ -1391,7 +1472,7 @@ def _print_resource(rt: str, rule_result: str): "pamDirectory": "Directories", "pamMachine": "Machines", "pamDatabase": "Databases" - } # type: Dict[str, Optional[str]] + } # type: dict[str, str | None] for rv in record_type_to_vertices_map[rt]: # type: DAGVertex if not rv.active or not rv.has_data: diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 3d5b4a6bf..569c9e0bd 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -18,7 +18,7 @@ import time from datetime import datetime from urllib.parse import urlparse, urlunparse -from typing import Optional, List +from typing import Any import requests from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -29,7 +29,7 @@ from .pam import gateway_helper, router_helper from .pam.config_facades import PamConfigurationRecordFacade from .pam.vault_target import ( - format_pam_folder_display, resolve_pam_folder_uid, + format_pam_folder_display, resolve_pam_folder_uid, is_nested_share_folder, resolve_pam_record, record_exists_in_vault, collect_pam_folder_uids, get_vault_record_title_type, find_pam_records_by_search, resolve_pam_config_folder_info, pam_folder_json_payload, place_record_in_folder, @@ -105,6 +105,7 @@ PAMUniversalSyncRunCommand ) from ..discovery_common.types import UserAcl, UserAclRotationSettings +from ..vault import TypedRecord # These characters are based on the Vault PAM_DEFAULT_SPECIAL_CHAR = '''!@#$%^?();',.=+[]<>{}-_/\\*&:"`~|''' @@ -170,7 +171,7 @@ def parse_schedule_data(kwargs): schedule_json_data = kwargs.get('schedule_json_data') schedule_cron_data = kwargs.get('schedule_cron_data') schedule_on_demand = kwargs.get('on_demand') is True - schedule_data = None # type: Optional[List] + schedule_data = None # type: list | None if isinstance(schedule_json_data, str): schedule_json_data = [schedule_json_data] if isinstance(schedule_json_data, list): @@ -221,7 +222,7 @@ def resolve_record_rotation_revision(params, record_uid): def schedule_from_pam_config(record_pam_config): - # type: (Optional[vault.TypedRecord]) -> Optional[List] + # type: (TypedRecord | None) -> list | None """Return rotation schedule list from a PAM configuration defaultRotationSchedule field.""" if not record_pam_config: return None @@ -233,7 +234,7 @@ def schedule_from_pam_config(record_pam_config): def resolve_record_schedule_data(schedule_data, current_record_rotation, schedule_config, record_pam_config): - # type: (Optional[List], Optional[dict], bool, Optional[vault.TypedRecord]) -> Optional[List] + # type: (list | None, dict | None, bool, TypedRecord | None) -> list | None """Resolve rotation schedule for pam rotation edit (Web Vault use-default-schedule parity).""" if schedule_data is not None: return schedule_data @@ -612,7 +613,7 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): def config_saas_user(_dag, target_record, saas_config_uid: str): - saas_config_record = vault.KeeperRecord.load(params, saas_config_uid) # type: Optional[TypedRecord] + saas_config_record = vault.KeeperRecord.load(params, saas_config_uid) # type: TypedRecord | None if saas_config_record is None: raise CommandError('', 'The SaaS configuration record does not exists.') @@ -1418,7 +1419,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None r_requests.append(rq) # Main execute() logic starts here - record_uids = set() # type: Set[str] + record_uids = set() # type: set[str] folder_uids = set() record_pattern = '' @@ -1475,7 +1476,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None continue record_uids.add(record_uid) - pam_records = [] # type: List[vault.TypedRecord] + pam_records = [] # type: list[vault.TypedRecord] valid_record_types = ['pamDatabase', 'pamDirectory', 'pamMachine', 'pamUser', 'pamRemoteBrowser'] for record_uid in record_uids: record = vault.KeeperRecord.load(params, record_uid) @@ -1502,7 +1503,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None if cfg_rec and cfg_rec.version == 6 and cfg_rec.record_type in PamConfigurationEditMixin.PAM_CONFIG_RECORD_TYPES: config_uid = cfg_rec.record_uid - pam_config = None # type: Optional[vault.TypedRecord] + pam_config = None # type: vault.TypedRecord | None if config_uid: if config_uid in pam_configurations: pam_config = pam_configurations[config_uid] @@ -1519,7 +1520,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None schedule_data = parse_schedule_data(kwargs) pwd_complexity = kwargs.get("pwd_complexity") - pwd_complexity_rule_list = None # type: Optional[dict] + pwd_complexity_rule_list = None # type: dict | None if pwd_complexity is not None: if pwd_complexity: pwd_complexity_list = [s.strip() for s in pwd_complexity.split(',', maxsplit=5)] @@ -1560,7 +1561,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None 'complexity'] valid_records = [] - r_requests = [] # type: List[router_pb2.RouterRecordRotationRequest] + r_requests = [] # type: list[router_pb2.RouterRecordRotationRequest] # Note: --folder, -fd FOLDER_NAME sets up General rotation # use --schedule-only, -so to preserve individual setups (General, IAM, NOOP) @@ -1618,7 +1619,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None config_user(tmp_dag, _record, resource_uid, config_uid, silent=kwargs.get('silent')) elif rotation_profile == 'saas': - saas_config_uid = kwargs.get("saas_config_uid") # type: Optional[str] + saas_config_uid = kwargs.get("saas_config_uid") # type: str | None if saas_config_uid is None: raise CommandError('', 'SaaS rotation profile requires ' '--saas-config-uid to be specified.') @@ -2248,7 +2249,7 @@ def _allowed_settings_dag_to_json(allowed): @staticmethod def _domain_administrative_credential_uid(configuration): - # type: (vault.KeeperRecord) -> Optional[str] + # type: (vault.KeeperRecord) -> str | None if not isinstance(configuration, vault.TypedRecord) or \ configuration.record_type != 'pamDomainConfiguration': return None @@ -2532,7 +2533,7 @@ def get_pam_record_types(params): return PamConfigurationEditMixin.pam_record_types def parse_pam_configuration(self, params, record, **kwargs): - # type: (KeeperParams, vault.TypedRecord, Dict[str, Any]) -> None + # type: (KeeperParams, vault.TypedRecord, dict[str, Any]) -> None field = record.get_typed_field('pamResources') if not field: value = {} @@ -2543,8 +2544,8 @@ def parse_pam_configuration(self, params, record, **kwargs): field.value.append({}) value = field.value[0] - gateway_uid = None # type: Optional[str] - gateway = kwargs.get('gateway_uid') # type: Optional[str] + gateway_uid = None # type: str | None + gateway = kwargs.get('gateway_uid') # type: str | None if gateway: gateways = gateway_helper.get_all_gateways(params) gateway_uid = next((utils.base64_url_encode(x.controllerUid) for x in gateways @@ -2561,8 +2562,8 @@ def parse_pam_configuration(self, params, record, **kwargs): # if len(shares) == 0: # raise Exception(f'Gateway %s has no shared folders', gateway.controllerName) - shared_folder_uid = None # type: Optional[str] - folder_name = kwargs.get('shared_folder_uid') # type: Optional[str] + shared_folder_uid = None # type: str | None + folder_name = kwargs.get('shared_folder_uid') # type: str | None if folder_name: shared_folder_uid = resolve_pam_folder_uid(params, folder_name) if not shared_folder_uid: @@ -2621,7 +2622,7 @@ def parse_pam_configuration(self, params, record, **kwargs): @staticmethod def resolve_single_record(params, record_name, - rec_type=''): # type: (KeeperParams, str, str) -> Optional[vault.KeeperRecord] + rec_type=''): # type: (KeeperParams, str, str) -> vault.KeeperRecord | None return resolve_pam_record(params, record_name, rec_type=rec_type or None) @staticmethod @@ -3125,7 +3126,7 @@ def get_parser(self): @classmethod def _resolve_pam_config_uid(cls, params, identifier): - # type: (KeeperParams, str) -> Optional[str] + # type: (KeeperParams, str) -> str | None if identifier in params.record_cache: rec = vault.KeeperRecord.load(params, identifier) if isinstance(rec, vault.TypedRecord) and rec.version == 6: @@ -3473,7 +3474,7 @@ def execute(self, params, **kwargs): if not record_name: raise CommandError('rotate script', '"record" argument is required') - script_name = kwargs.get('script') # type: Optional[str] + script_name = kwargs.get('script') # type: str | None if not script_name: raise CommandError('rotate script', '"script" argument is required') @@ -3558,7 +3559,7 @@ def execute(self, params, **kwargs): if not record_name: raise CommandError('rotate script', '"record" argument is required') - script_name = kwargs.get('script') # type: Optional[str] + script_name = kwargs.get('script') # type: str | None if not script_name: raise CommandError('rotate script', '"script" argument is required') @@ -3696,9 +3697,10 @@ def _is_rotation_allowed_by_enforcement(params): class PAMGatewayActionRotateCommand(Command): parser = argparse.ArgumentParser(prog='pam action rotate') - parser.add_argument('--record-uid', '-r', dest='record_uid', action='store', help='Record UID to rotate') + parser.add_argument('--record-uid', '-r', dest='record_uid', action='store', + help='Record UID, path, or title to rotate (includes Nested Share Records)') parser.add_argument('--folder', '-f', dest='folder', action='store', - help='Shared folder UID or title pattern to rotate') + help='Shared folder / Nested Share Folder UID or title pattern to rotate') # parser.add_argument('--recursive', '-a', dest='recursive', default=False, action='store', help='Enable recursion to rotate sub-folders too') # parser.add_argument('--record-pattern', '-p', dest='pattern', action='store', help='Record title match pattern') parser.add_argument('--dry-run', '-n', dest='dry_run', default=False, action='store_true', @@ -3716,6 +3718,12 @@ class PAMGatewayActionRotateCommand(Command): parser.add_argument('--email-message', dest='email_message', action='store', help='Custom message to include in email') + _ROTATABLE_FOLDER_TYPES = ( + BaseFolderNode.SharedFolderType, + BaseFolderNode.SharedFolderFolderType, + BaseFolderNode.NestedShareFolderType, + ) + def get_parser(self): return PAMGatewayActionRotateCommand.parser @@ -3768,39 +3776,55 @@ def execute(self, params, **kwargs): f'the following arguments are required: {bcolors.OKBLUE}--record-uid/-r{bcolors.ENDC} or {bcolors.OKBLUE}--folder/-f{bcolors.ENDC}') return - # single record UID - ignore all folder options + # single record - ignore all folder options (NSF-aware UID/path/title resolution) if not folder: - self.record_rotate(params, record_uid) + rec = resolve_pam_record(params, record_uid) + self.record_rotate(params, rec.record_uid if rec else record_uid) return # folder UID or pattern (ignore --record-uid/-r option) folders = [] # root folders matching UID or title pattern records = [] # record UIDs of all v3/pamUser records + rotatable_types = PAMGatewayActionRotateCommand._ROTATABLE_FOLDER_TYPES - # 1. find all shared_folder/shared_folder_folder matching --folder=UID/pattern + # 1. find shared_folder / shared_folder_folder / nested_share_folder matching --folder if folder in params.folder_cache: # folder UID fldr = params.folder_cache.get(folder) - # only shared_folder can be shared to KSM App/Gateway for rotation - # but its children shared_folder_folder can contain rotation records too - if fldr.type in (BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType): + # shared_folder (and NSF) can be shared to KSM App/Gateway for rotation; + # children shared_folder_folder / NSF subfolders can contain rotation records too + if fldr.type in rotatable_types or is_nested_share_folder(params, folder): folders.append(folder) else: - logging.debug(f'Folder skipped (not a shared folder/subfolder) - {folder} {fldr.name}') + logging.debug(f'Folder skipped (not a shared folder/subfolder/NSF) - {folder} {fldr.name}') + elif is_nested_share_folder(params, folder): + folders.append(folder) else: - rx_name = self.str_to_regex(folder) - for fuid in params.folder_cache: - fldr = params.folder_cache.get(fuid) - # requirement - shared folder only (not for user_folder containing shf w/ recursion) - if fldr.type in (BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType): - if fldr.name and rx_name.search(fldr.name): - folders.append(fldr.uid) + resolved = resolve_pam_folder_uid(params, folder) + if resolved: + folders.append(resolved) + else: + rx_name = self.str_to_regex(folder) + for fuid in params.folder_cache: + fldr = params.folder_cache.get(fuid) + # shared folder / NSF only (not user_folder containing shf w/ recursion) + if fldr.type in rotatable_types or is_nested_share_folder(params, fuid): + if fldr.name and rx_name.search(fldr.name): + folders.append(fldr.uid) + for fuid, nsf in getattr(params, 'nested_share_folders', {}).items(): + if fuid in folders: + continue + name = nsf.get('name', '') if isinstance(nsf, dict) else '' + if name and rx_name.search(name): + folders.append(fuid) folders = list(set(folders)) # Remove duplicate UIDs # 2. pattern could match both parent and child - drop all children (w/ a matching parent) if recursive and len(folders) > 1: - roots: dict[str, list] = {} # group by shared_folder_uid + roots: dict[str, list] = {} # group by shared_folder_uid (or NSF root) for fuid in folders: # no shf inside shf yet - roots.setdefault(params.folder_cache.get(fuid).shared_folder_uid, []).append(fuid) + fobj = params.folder_cache.get(fuid) + root_uid = getattr(fobj, 'shared_folder_uid', None) or fuid + roots.setdefault(root_uid, []).append(fuid) uniq = [] for fuid in roots: fldrs = list(set(roots[fuid])) @@ -3813,9 +3837,12 @@ def execute(self, params, **kwargs): for fldr in fldrs: path = [] child = fldr - while params.folder_cache[child].uid != fuid: + while child in params.folder_cache and params.folder_cache[child].uid != fuid: path.append(child) - child = params.folder_cache[child].parent_uid + parent = params.folder_cache[child].parent_uid + if not parent: + break + child = parent path.append(child) # add root shf path = path[1:] if path else [] # skip child uid if not set(path) & fldrset: # no intersect @@ -3827,12 +3854,13 @@ def execute(self, params, **kwargs): if recursive: logging.warning('--recursive/-a option not implemented (ignored)') # params.folder_cache: type=shared_folder_folder, uid=shffUID, shared_folder_uid ='shfUID' - # params.subfolder_cache/subfolder_record_cache + # params.subfolder_cache/subfolder_record_cache / nested_share_folder_records - if fldr not in params.subfolder_record_cache: - logging.debug(f"folder {fldr} empty - not in subfolder_record_cache (skipped)") + folder_records = records_in_folder(params, fldr) + if not folder_records: + logging.debug(f"folder {fldr} empty - no records in folder caches (skipped)") continue - for ruid in params.subfolder_record_cache[fldr]: + for ruid in folder_records: if ruid in params.record_cache: if params.record_cache[ruid].get('version') == 3: data = params.record_cache[ruid].get('data_unencrypted', '') @@ -3849,9 +3877,13 @@ def execute(self, params, **kwargs): for fldr in folders: fobj = params.folder_cache.get(fldr, None) title = fobj.name if isinstance(fobj, BaseFolderNode) else '' + if not title: + nsf = getattr(params, 'nested_share_folders', {}).get(fldr) + if isinstance(nsf, dict): + title = nsf.get('name', '') logging.debug(f'Rotation Folder UID: {fldr} {title}') for rec in records: - title = json.loads(params.record_cache.get(rec, {}).get('data_unencrypted', '')).get('title', '') + title, _ = get_vault_record_title_type(params, rec) logging.debug(f'Rotation Record UID: {rec} {title}') # 6. exit if --dry-run diff --git a/keepercommander/commands/pam/vault_target.py b/keepercommander/commands/pam/vault_target.py index 45d28e589..dae5e96cb 100644 --- a/keepercommander/commands/pam/vault_target.py +++ b/keepercommander/commands/pam/vault_target.py @@ -340,13 +340,11 @@ def resolve_pam_record(params, identifier, rec_type=None): folder, record_title = rs if folder is not None and record_title is not None: folder_uid = folder.uid or '' - subfolder_cache = getattr(params, 'subfolder_record_cache', None) or {} - if folder_uid in subfolder_cache: - for uid in subfolder_cache[folder_uid]: - record = vault.KeeperRecord.load(params, uid) - if record and record.title.casefold() == record_title.casefold(): - if _record_matches_type(record, rec_type): - return record + for uid in records_in_folder(params, folder_uid): + record = vault.KeeperRecord.load(params, uid) + if record and record.title.casefold() == record_title.casefold(): + if _record_matches_type(record, rec_type): + return record l_name = identifier.casefold() matches = [] diff --git a/keepercommander/commands/pam_debug/__init__.py b/keepercommander/commands/pam_debug/__init__.py index eaa0276d3..14e1e17e7 100644 --- a/keepercommander/commands/pam_debug/__init__.py +++ b/keepercommander/commands/pam_debug/__init__.py @@ -1,7 +1,9 @@ from __future__ import annotations from ...utils import value_to_boolean import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING + +from ..pam_import.record_loader import load_pam_record # noqa: F401 — re-export for pam_debug modules if TYPE_CHECKING: from ...params import KeeperParams diff --git a/keepercommander/commands/pam_debug/acl.py b/keepercommander/commands/pam_debug/acl.py index a85f49286..3b1430646 100644 --- a/keepercommander/commands/pam_debug/acl.py +++ b/keepercommander/commands/pam_debug/acl.py @@ -4,10 +4,10 @@ from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_USER, MultiConfigurationException, multi_conf_msg) from ...display import bcolors -from ... import vault +from . import load_pam_record from ...discovery_common.record_link import RecordLink from ...discovery_common.types import UserAcl -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -59,7 +59,7 @@ def execute(self, params: KeeperParams, **kwargs): logger=logging, debug_level=debug_level, use_per_graph_endpoints=False) - user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_uid) # type: TypedRecord | None if user_record is None: print(f"{bcolors.FAIL}The user record does not exists.{bcolors.ENDC}") return @@ -70,7 +70,7 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.FAIL}The user record is not a PAM User record.{bcolors.ENDC}") return - parent_record = vault.KeeperRecord.load(params, parent_uid) # type: Optional[TypedRecord] + parent_record = load_pam_record(params, parent_uid) # type: TypedRecord | None if parent_record is None: print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") return diff --git a/keepercommander/commands/pam_debug/dump.py b/keepercommander/commands/pam_debug/dump.py index 5231a8049..ed6a202de 100644 --- a/keepercommander/commands/pam_debug/dump.py +++ b/keepercommander/commands/pam_debug/dump.py @@ -5,16 +5,19 @@ import json import logging import pathlib -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from ..base import Command, FolderMixin from ...subfolder import get_folder_uids -from ... import vault, api +from ... import vault from ...keeper_dag import DAG, EdgeType from ...keeper_dag.types import GRAPH_ID_TO_ENDPOINT, PamGraphId +from ...nested_share_folder.common import get_record_from_cache, get_record_key +from ..pam.vault_target import resolve_pam_folder_uid from ..pam_import.keeper_ai_settings import get_resource_settings +from ..pam_import.nsf_helpers import get_folder_record_uids from ...keeper_dag.crypto import decrypt_aes -from . import get_connection +from . import get_connection, load_pam_record if TYPE_CHECKING: from ...params import KeeperParams @@ -58,8 +61,14 @@ def _write_result(data: list) -> None: fh.write(json.dumps(data, indent=2)) logging.info('Saved %d record(s) to %s', len(data), p) - # 1. Resolve folder UID(s) from UID or path + # 1. Resolve folder UID(s) from UID or path (classic + NSF) folder_uids = get_folder_uids(params, folder_uid_arg) + if not folder_uids: + resolved = resolve_pam_folder_uid(params, folder_uid_arg) + if resolved: + folder_uids = {resolved} + elif folder_uid_arg in getattr(params, 'nested_share_folders', {}): + folder_uids = {folder_uid_arg} if not folder_uids: logging.warning('Cannot resolve folder: %r', folder_uid_arg) _write_result([]) @@ -67,27 +76,48 @@ def _write_result(data: list) -> None: # 2. Collect records with folder context # record_uid → (folder_uid, folder_parent_uid) - record_folder_map: Dict[str, Tuple[str, str]] = {} + record_folder_map: dict[str, tuple[str, str]] = {} + + def _folder_parent_uid(f_uid: str) -> str: + if not f_uid: + return '' + folder_node = params.folder_cache.get(f_uid) + if folder_node is not None: + return getattr(folder_node, 'parent_uid', None) or '' + nsf = getattr(params, 'nested_share_folders', {}).get(f_uid) or {} + return nsf.get('parent_uid') or '' if recursive: def _on_folder(f): f_uid = f.uid or '' f_parent_uid = getattr(f, 'parent_uid', None) or '' - for rec_uid in params.subfolder_record_cache.get(f_uid, set()): + for rec_uid in get_folder_record_uids(params, f_uid): if rec_uid not in record_folder_map: record_folder_map[rec_uid] = (f_uid, f_parent_uid) for fuid in folder_uids: FolderMixin.traverse_folder_tree(params, fuid, _on_folder) + # NSF folders may not yet be reconstructed into folder_cache nodes; + # walk nested_share_folders children as a fallback. + nsf_folders = getattr(params, 'nested_share_folders', {}) or {} + if fuid in nsf_folders or any(v.get('parent_uid') == fuid for v in nsf_folders.values()): + stack = [fuid] + seen = set() + while stack: + current = stack.pop() + if not current or current in seen: + continue + seen.add(current) + for rec_uid in get_folder_record_uids(params, current): + if rec_uid not in record_folder_map: + record_folder_map[rec_uid] = (current, _folder_parent_uid(current)) + for child_uid, child in nsf_folders.items(): + if child.get('parent_uid') == current and child_uid not in seen: + stack.append(child_uid) else: for fuid in folder_uids: - if fuid: - folder_node = params.folder_cache.get(fuid) - f_parent_uid = getattr(folder_node, 'parent_uid', None) or '' if folder_node else '' - else: - # root folder has no parent - f_parent_uid = '' - for rec_uid in params.subfolder_record_cache.get(fuid, set()): + f_parent_uid = _folder_parent_uid(fuid) + for rec_uid in get_folder_record_uids(params, fuid): if rec_uid not in record_folder_map: record_folder_map[rec_uid] = (fuid, f_parent_uid) @@ -98,17 +128,22 @@ def _on_folder(f): # 3. Filter by version, then group valid records by config_uid. # Supported versions: 3 (typed), 5 (KSM App/Gateway), 6 (PAM Configuration). # Versions 1–2/4 are legacy/attachment records; skip with a warning. - config_to_records: Dict[str, List[str]] = {} - record_config_map: Dict[str, Optional[str]] = {} - valid_uids: List[str] = [] # passed version filter, in discovery order + config_to_records: dict[str, list[str]] = {} + record_config_map: dict[str, str | None] = {} + valid_uids: list[str] = [] # passed version filter, in discovery order for rec_uid in record_folder_map: - rec = params.record_cache.get(rec_uid) + rec = get_record_from_cache(params, rec_uid) if rec is None: - logging.warning('skipping record %s version unknown - not in record cache', rec_uid) - continue + loaded = load_pam_record(params, rec_uid) + if loaded is None: + logging.warning('skipping record %s version unknown - not in record cache', rec_uid) + continue + version = getattr(loaded, 'version', None) + rec = {'version': version, 'revision': 0, 'shared': False} + else: + version = rec.get('version') - version = rec.get('version') if version is None or version <= 2: logging.warning( 'skipping record %s version %s - PAM records have version >= 3', @@ -157,11 +192,11 @@ def _on_folder(f): # 4. Load all 5 DAGs once per config_uid # keyed by (config_uid, graph_id) - dag_cache: Dict[Tuple[str, int], Optional['DAGType']] = {} + dag_cache: dict[tuple[str, int], 'DAGType' | None] = {} conn = get_connection(params) for config_uid in config_to_records: - config_record = vault.KeeperRecord.load(params, config_uid) + config_record = load_pam_record(params, config_uid) if config_record is None: logging.error('Configuration record %s not found; skipping graph load.', config_uid) for graph_id in ALL_GRAPH_IDS: @@ -185,10 +220,11 @@ def _on_folder(f): for rec_uid in valid_uids: folder_uid, folder_parent_uid = record_folder_map[rec_uid] - rec = params.record_cache[rec_uid] # guaranteed present after step 3 - version = rec.get('version') - shared = rec.get('shared', False) - revision = rec.get('revision', 0) + rec = get_record_from_cache(params, rec_uid) or {} + nsf_meta = getattr(params, 'nested_share_records', {}).get(rec_uid) or {} + version = rec.get('version') or nsf_meta.get('version') + shared = rec.get('shared', nsf_meta.get('shared', False)) + revision = rec.get('revision', nsf_meta.get('revision', 0)) client_modified_time = None cmt = rec.get('client_modified_time') @@ -205,15 +241,29 @@ def _on_folder(f): 'revision': revision, } - # data - same structure as `get --format=json` + # data - same structure as `get --format=json` (classic + NSF) data = {} try: - r = api.get_record(params, rec_uid) - if r: - raw = rec.get('data_unencrypted', b'{}') + raw = rec.get('data_unencrypted') + if raw: data = json.loads(raw.decode() if isinstance(raw, bytes) else raw) - if r.notes: - data['notes'] = r.notes + else: + nsf_data = getattr(params, 'nested_share_record_data', {}).get(rec_uid) or {} + data = dict(nsf_data.get('data_json') or {}) + if not data: + loaded = load_pam_record(params, rec_uid) + if loaded is not None: + from ... import vault_extensions + if isinstance(loaded, vault.TypedRecord): + data = vault_extensions.extract_typed_record_data(loaded) + notes = getattr(loaded, 'notes', None) + if notes: + data['notes'] = notes + else: + loaded = load_pam_record(params, rec_uid) + notes = getattr(loaded, 'notes', None) if loaded else None + if notes: + data['notes'] = notes except Exception as err: logging.warning('Could not build data for record %s: %s', rec_uid, err) @@ -224,7 +274,7 @@ def _on_folder(f): # "vertex_active": bool - present when the record UID is a vertex in that graph # "edges": [...] - present only when there are active, non-deleted edges # Config/graph keys are omitted when the record has no presence there. - graph_sync: Dict[str, Dict[str, dict]] = {} + graph_sync: dict[str, dict[str, dict]] = {} for (c_uid, graph_id), dag in dag_cache.items(): if dag is None: continue @@ -273,7 +323,7 @@ def _collect_graph_entry(dag: 'DAGType', record_uid: str, params: 'KeeperParams' def _collect_edges_for_record(dag: 'DAGType', record_uid: str, params: 'KeeperParams', - config_uid: str) -> List[dict]: + config_uid: str) -> list[dict]: """Return all non-deleted edges that reference record_uid as head or tail. Inactive edges (active=False) are included - they may represent settings @@ -304,8 +354,13 @@ def _collect_edges_for_record(dag: 'DAGType', record_uid: str, params: 'KeeperPa pwd_complexity_enc = rotation_settings.get('pwd_complexity') if pwd_complexity_enc and isinstance(pwd_complexity_enc, str): for uid in (head_uid, tail_uid): - raw_rec = params.record_cache.get(uid) or {} + raw_rec = get_record_from_cache(params, uid) or {} rec_key = raw_rec.get('record_key_unencrypted') + if not rec_key: + try: + rec_key = get_record_key(params, uid, raise_on_missing=False) + except Exception: + rec_key = None if not rec_key: continue try: diff --git a/keepercommander/commands/pam_debug/graph.py b/keepercommander/commands/pam_debug/graph.py index b19003010..9b6e56f33 100644 --- a/keepercommander/commands/pam_debug/graph.py +++ b/keepercommander/commands/pam_debug/graph.py @@ -4,7 +4,7 @@ import logging from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors -from ... import vault +from . import load_pam_record from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink from ...discovery_common.jobs import Jobs @@ -14,16 +14,14 @@ DiscoveryDatabase, JobContent, ServiceAcl) from ...discovery_common.dag_sort import sort_infra_vertices from ...keeper_dag import DAG -from ...keeper_dag.connection.commander import Connection as CommanderConnection -from ...keeper_dag.connection.local import Connection as LocalConnection from ...keeper_dag.types import GRAPH_ID_TO_ENDPOINT, PamEndpoints from ...keeper_dag.vertex import DAGVertex -from ...keeper_dag.edge import DAGEdge, EdgeType -from typing import Optional, Union, TYPE_CHECKING +from ...keeper_dag.edge import EdgeType +from typing import TYPE_CHECKING -Connection = Union[CommanderConnection, LocalConnection] if TYPE_CHECKING: from ...params import KeeperParams + from ...vault import TypedRecord class PAMDebugGraphCommand(PAMGatewayActionDiscoverCommandBase): @@ -100,7 +98,7 @@ def _do_text_list_infra(self, params: KeeperParams, gateway_context: GatewayCont 3: self._b } - def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: Optional[str] = None): + def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: str | None = None): if not current_vertex.active: return @@ -120,7 +118,7 @@ def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: Option if current_content.record_uid is None: text += f"{pad}{ls}{current_vertex.uid}; {current_content.title} does not have a record." else: - record = vault.KeeperRecord.load(params, current_content.record_uid) # type: Optional[TypedRecord] + record = load_pam_record(params, current_content.record_uid) # type: TypedRecord | None if record is not None: text += f"{pad}{ls}" + cf(f"{current_vertex.uid}; {record.title}; {record.record_uid}") else: @@ -165,7 +163,7 @@ def _do_text_list_rl(self, params: KeeperParams, gateway_context: GatewayContext debug_level=debug_level, use_per_graph_endpoints=False) configuration = record_link.dag.get_root - record = vault.KeeperRecord.load(params, configuration.uid) # type: Optional[TypedRecord] + record = load_pam_record(params, configuration.uid) # type: TypedRecord | None if record is None: print(self._f("Configuration record does not exists.")) return @@ -193,7 +191,7 @@ def _group(configuration_vertex: DAGVertex) -> dict: } for vertex in configuration_vertex.has_vertices(): - record = vault.KeeperRecord.load(params, vertex.uid) # type: Optional[TypedRecord] + record = load_pam_record(params, vertex.uid) # type: TypedRecord | None if record is None: group[PAMDebugGraphCommand.NO_RECORD].append({ "v": vertex @@ -263,7 +261,7 @@ def _group(configuration_vertex: DAGVertex) -> dict: if len(children) > 0: bad = [] for child in children: - child_record = vault.KeeperRecord.load(params, child.uid) # type: Optional[TypedRecord] + child_record = load_pam_record(params, child.uid) # type: TypedRecord | None if child_record is None: if child.active: bad.append(self._f(f"- Record UID {child.uid} does not exists.")) @@ -329,7 +327,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if not resource_vertex.active: continue - machine_record = vault.KeeperRecord.load(params, resource_vertex.uid) # type: Optional[TypedRecord] + machine_record = load_pam_record(params, resource_vertex.uid) # type: TypedRecord | None if machine_record is None or machine_record.record_type != PAM_MACHINE: continue @@ -342,7 +340,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if not user_vertex.active: continue - user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_vertex.uid) # type: TypedRecord | None acl = record_link.get_acl(parent_record_uid=resource_vertex.uid, record_uid=user_vertex.uid) if acl is not None and acl.controls_services: if resource_vertex.uid not in machine_dict: @@ -365,7 +363,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if not us_machine_vertex.active: continue - machine_record = vault.KeeperRecord.load(params, us_machine_vertex.uid) # type: Optional[TypedRecord] + machine_record = load_pam_record(params, us_machine_vertex.uid) # type: TypedRecord | None if machine_record is not None: machine_name = f"{machine_record.title}, {machine_record.record_uid}" else: @@ -381,7 +379,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if service_acl is None: continue - user_record = vault.KeeperRecord.load(params, us_user_vertex.uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, us_user_vertex.uid) # type: TypedRecord | None if us_machine_vertex.uid not in machine_dict: machine_dict[us_machine_vertex.uid] = { @@ -613,7 +611,7 @@ def _do_raw_text_list(self, params: KeeperParams, gateway_context: GatewayContex 3: self._p } - def _handle(current_vertex: DAGVertex, last_vertex: Optional[DAGVertex] = None, indent: int = 0): + def _handle(current_vertex: DAGVertex, last_vertex: DAGVertex | None = None, indent: int = 0): pad = "" if indent > 0: diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index 1707a0a9d..a89ddbae7 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -2,7 +2,7 @@ import argparse from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext from ...display import bcolors -from ... import vault, vault_extensions +from . import load_pam_record from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink from ...discovery_common.types import UserAcl, DiscoveryObject @@ -11,7 +11,7 @@ import time import re import json -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -38,7 +38,7 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): record_uid = kwargs.get("record_uid") - record = vault.KeeperRecord.load(params, record_uid) # type: Optional[TypedRecord] + record = load_pam_record(params, record_uid) # type: TypedRecord | None if record is None: print(f"{bcolors.FAIL}Record does not exists.{bcolors.ENDC}") return @@ -85,7 +85,7 @@ def execute(self, params: KeeperParams, **kwargs): resource_uid = record_rotation.get("resource_uid") - configuration_record = vault.KeeperRecord.load(params, controller_uid) # type: Optional[TypedRecord] + configuration_record = load_pam_record(params, controller_uid) # type: TypedRecord | None if configuration_record is None: print(f"{bcolors.FAIL}The configuration record {controller_uid} does not exist.{bcolors.ENDC}") return @@ -170,8 +170,8 @@ def _print_field(f): if len(record_parent_vertices) > 0: for record_parent_vertex in record_parent_vertices: - parent_record = vault.KeeperRecord.load(params, - record_parent_vertex.uid) # type: Optional[TypedRecord] + parent_record = load_pam_record(params, + record_parent_vertex.uid) # type: TypedRecord | None if parent_record is None: print(f"{bcolors.FAIL} * Parent record {record_parent_vertex.uid} " f"does not exists.{bcolors.ENDC}") @@ -214,9 +214,9 @@ def _print_field(f): if len(acl_content.rotation_settings.saas_record_uid_list) > 0: if acl_content.rotation_settings.noop: saas_config_uid = acl_content.rotation_settings.saas_record_uid_list[0] - saas_config = vault.KeeperRecord.load( + saas_config = load_pam_record( params, - saas_config_uid) # type: Optional[TypedRecord] + saas_config_uid) # type: TypedRecord | None print(f" . SaaS configuration record is {saas_config.title}") else: @@ -239,8 +239,8 @@ def _print_field(f): print(self._b(" Child Records")) if len(record_child_vertices) > 0: for record_child_vertex in record_child_vertices: - child_record = vault.KeeperRecord.load(params, - record_child_vertex.uid) # type: Optional[TypedRecord] + child_record = load_pam_record(params, + record_child_vertex.uid) # type: TypedRecord | None if child_record is None: print(f"{bcolors.FAIL} * Child record {record_child_vertex.uid} " @@ -297,8 +297,8 @@ def _print_field(f): and acl.controls_services): # Get the resource record - machine_record = vault.KeeperRecord.load(params, - machine_vertex.uid) # type: Optional[TypedRecord] + machine_record = load_pam_record(params, + machine_vertex.uid) # type: TypedRecord | None # If the resource record does not exist. if machine_record is None: @@ -333,7 +333,7 @@ def _print_field(f): # Get the users that are used for tasks/services on this machine. for user_vertex in record_vertex.has_vertices(): - user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_vertex.uid) # type: TypedRecord | None acl = record_link.get_acl(record_vertex.uid, user_vertex.uid) if acl is not None and acl.controls_services: # If the user record does not exist. diff --git a/keepercommander/commands/pam_debug/krouter.py b/keepercommander/commands/pam_debug/krouter.py index a8afaf644..1f4409916 100644 --- a/keepercommander/commands/pam_debug/krouter.py +++ b/keepercommander/commands/pam_debug/krouter.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse import json -import os from typing import TYPE_CHECKING import requests diff --git a/keepercommander/commands/pam_debug/link.py b/keepercommander/commands/pam_debug/link.py index b8bddba8d..deae2697a 100644 --- a/keepercommander/commands/pam_debug/link.py +++ b/keepercommander/commands/pam_debug/link.py @@ -4,9 +4,9 @@ from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY, MultiConfigurationException, multi_conf_msg) from ...display import bcolors -from ... import vault +from . import load_pam_record from ...discovery_common.record_link import RecordLink -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -55,7 +55,7 @@ def execute(self, params: KeeperParams, **kwargs): logger=logging, debug_level=debug_level, use_per_graph_endpoints=False) - resource_record = vault.KeeperRecord.load(params, resource_uid) # type: Optional[TypedRecord] + resource_record = load_pam_record(params, resource_uid) # type: TypedRecord | None if resource_record is None: print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") return diff --git a/keepercommander/commands/pam_debug/rotation_setting.py b/keepercommander/commands/pam_debug/rotation_setting.py index 2557f8a15..0a4c44462 100644 --- a/keepercommander/commands/pam_debug/rotation_setting.py +++ b/keepercommander/commands/pam_debug/rotation_setting.py @@ -2,7 +2,7 @@ import argparse from ..discover import PAMGatewayActionDiscoverCommandBase from ...display import bcolors -from ... import vault +from . import load_pam_record from ...proto import router_pb2 from ...sync_down import sync_down from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -11,7 +11,7 @@ from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...discovery_common.types import UserAcl, UserAclRotationSettings import re -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -49,7 +49,7 @@ def execute(self, params: KeeperParams, **kwargs): print("") - user_record = vault.KeeperRecord.load(params, user_record_uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_record_uid) # type: TypedRecord | None if user_record is None: print(f"{bcolors.FAIL}The PAM user record does not exists.{bcolors.ENDC}") return @@ -68,8 +68,8 @@ def execute(self, params: KeeperParams, **kwargs): f"-c, --configuration-record-uid parameter for this command.{bcolors.ENDC}") return - configuration_record = vault.KeeperRecord.load(params, - configuration_record_uid) # type: Optional[TypedRecord] + configuration_record = load_pam_record(params, + configuration_record_uid) # type: TypedRecord | None if configuration_record is None: print(f"{bcolors.FAIL}Configuration record does not exists.{bcolors.ENDC}") return @@ -94,8 +94,8 @@ def execute(self, params: KeeperParams, **kwargs): if resource_record_uid is not None: - resource_record = vault.KeeperRecord.load(params, - resource_record_uid) # type: Optional[TypedRecord] + resource_record = load_pam_record(params, + resource_record_uid) # type: TypedRecord | None if resource_record is None: print(f"{bcolors.FAIL}The resource record does not exists.{bcolors.ENDC}") return @@ -142,8 +142,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.BOLD}Configuration Record UID{bcolors.ENDC}: {configuration_record_uid}") - configuration_record = vault.KeeperRecord.load(params, - configuration_record_uid) # type: Optional[TypedRecord] + configuration_record = load_pam_record(params, + configuration_record_uid) # type: TypedRecord | None if configuration_record is None: print(f"{bcolors.FAIL}Configuration record does not exists.{bcolors.ENDC}") return @@ -153,8 +153,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.BOLD}Resource Record UID{bcolors.ENDC}: {resource_record_uid}") - resource_record = vault.KeeperRecord.load(params, - resource_record_uid) # type: Optional[TypedRecord] + resource_record = load_pam_record(params, + resource_record_uid) # type: TypedRecord | None if resource_record is None: print(f"{bcolors.FAIL}The resource record does not exists.{bcolors.ENDC}") return diff --git a/keepercommander/commands/pam_debug/verify.py b/keepercommander/commands/pam_debug/verify.py index cabac24a3..8c1b27c40 100644 --- a/keepercommander/commands/pam_debug/verify.py +++ b/keepercommander/commands/pam_debug/verify.py @@ -3,7 +3,7 @@ import argparse from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors -from ...vault import TypedRecord +from . import load_pam_record from ...discovery_common.verify import Verify import sys from typing import TYPE_CHECKING @@ -53,7 +53,7 @@ def execute(self, params: KeeperParams, **kwargs): return def _record_lookup(record_uid: str) -> KeeperRecord: - return TypedRecord.load(params, record_uid) + return load_pam_record(params, record_uid) colors = { Verify.OK: bcolors.OKGREEN, diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index 62ecb9ab4..4736bf61c 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -11,7 +11,6 @@ from __future__ import annotations import argparse -import os import ipaddress import logging import re @@ -21,7 +20,7 @@ import time from colorama import Fore, Style -from typing import TYPE_CHECKING, Dict, Any, Optional, Tuple +from typing import TYPE_CHECKING, Any from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -72,7 +71,7 @@ from ..pam.gateway_helper import get_all_gateways from ..pam.router_helper import router_get_connected_gateways from ..ssh_agent import try_extract_private_key -from ... import api, vault +from ... import vault from ...subfolder import try_resolve_path from ...error import CommandError from ...utils import value_to_boolean @@ -91,7 +90,7 @@ def _pam_connection_clipboard_bool(v: Any) -> bool: return b is True -def _pam_connection_font_size_int(raw: Any) -> Optional[int]: +def _pam_connection_font_size_int(raw: Any) -> int | None: """Parse pamSettings.connection.fontSize to int, or None if unset or not parseable as an integer size.""" if raw is None: return None @@ -115,7 +114,7 @@ def _pam_connection_font_size_int(raw: Any) -> Optional[int]: return None -def _parse_host_port(value: str) -> Tuple[str, int]: +def _parse_host_port(value: str) -> tuple[str, int]: """ Parse a 'host:port' or '[ipv6]:port' string into (host, port). @@ -177,7 +176,7 @@ def _iter_record_fields(record: Any): yield field -def _get_host_port_from_record(record: Any) -> Tuple[Optional[str], Optional[int]]: +def _get_host_port_from_record(record: Any) -> tuple[str | None, int | None]: """ Extract (hostName, port) from a record's pamHostname or host typed fields. @@ -223,7 +222,7 @@ def _get_host_port_from_record(record: Any) -> Tuple[Optional[str], Optional[int return candidates[0] -def _record_has_credentials(record: Any, params: Optional['KeeperParams'] = None) -> bool: +def _record_has_credentials(record: Any, params: 'KeeperParams' | None = None) -> bool: """ Return True if the record has exactly one non-empty login field and at least one of: - exactly one non-empty password field (fields[] and custom[]), or @@ -283,11 +282,11 @@ def _record_has_host_port(record: Any) -> bool: def _print_close_reason_notice( - reason: Optional[str], + reason: str | None, *, - pending_exit_code: Optional[int], + pending_exit_code: int | None, session_established: bool = False, -) -> Optional[int]: +) -> int | None: """Show a user-facing notice for an involuntary remote close. Stays silent for ``normal`` / ``client`` (user-initiated). Called from the @@ -490,18 +489,21 @@ def _is_valid_pam_record(self, params: KeeperParams, record_uid: str) -> bool: """ try: record = vault.KeeperRecord.load(params, record_uid) - if not isinstance(record, vault.TypedRecord): - return False - if record.version != 3: - return False - return record.record_type in self.VALID_PAM_RECORD_TYPES + if isinstance(record, vault.TypedRecord): + if record.version != 3: + return False + return record.record_type in self.VALID_PAM_RECORD_TYPES + # NSF fallback when the typed record is not in record_cache + from ..pam.vault_target import get_vault_record_title_type + _, rec_type = get_vault_record_title_type(params, record_uid) + return rec_type in self.VALID_PAM_RECORD_TYPES except Exception as e: logging.debug(f"Error checking record type for {record_uid}: {e}") return False - def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: + def find_record(self, params: KeeperParams, record_token: str) -> str | None: """ - Find a record by UID, path, or title. + Find a record by UID, path, or title (classic + Nested Share Folder records). Args: params: KeeperParams instance @@ -515,10 +517,12 @@ def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: record_token = record_token.strip() - # Step 1: Try UID lookup + from ..pam.vault_target import record_exists_in_vault + + # Step 1: Try UID lookup (classic + Nested Share Folder) uid_pattern = re.compile(r'^[A-Za-z0-9_-]{22}$') if uid_pattern.match(record_token): - if record_token in params.record_cache: + if record_exists_in_vault(params, record_token): logging.debug(f"Found record by UID: {record_token}") return record_token @@ -541,9 +545,9 @@ def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: return None - def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: + def _find_by_path(self, params: KeeperParams, path: str) -> str | None: """ - Find record by path resolution. + Find record by path resolution (classic folders + Nested Share Folders). If exactly one record matches (any type), returns its UID. If two or more match, filters to PAM types only: returns the single PAM UID if one, @@ -552,24 +556,44 @@ def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: Returns: Record UID if found, None otherwise """ - rs = try_resolve_path(params, path) - if rs is None: - return None + from ..pam.vault_target import records_in_folder, resolve_pam_folder_uid - folder, name = rs - if folder is None or name is None: + folder_uid = None + name = None + + rs = try_resolve_path(params, path) + if rs is not None: + folder, name = rs + if folder is not None and name is not None: + folder_uid = folder.uid or '' + + # NSF / unresolved path fallback: "FolderName/RecordTitle" or nested path + if (not folder_uid or name is None or name == '') and '/' in path.strip('/'): + parent, _, title = path.rstrip('/').rpartition('/') + if parent and title: + resolved_folder = resolve_pam_folder_uid(params, parent, allow_legacy_user=True) + if resolved_folder: + folder_uid = resolved_folder + name = title + + if not folder_uid or name is None or name == '': return None - folder_uid = folder.uid or '' - if folder_uid not in params.subfolder_record_cache: + folder_records = records_in_folder(params, folder_uid) + if not folder_records: return None # All records in folder with matching title (any type) all_matched = [] - for uid in params.subfolder_record_cache[folder_uid]: - r = api.get_record(params, uid) - if r and r.title and r.title.lower() == name.lower(): + for uid in folder_records: + record = vault.KeeperRecord.load(params, uid) + if record and record.title and record.title.lower() == name.lower(): all_matched.append(uid) + elif not record: + from ..pam.vault_target import get_vault_record_title_type + title, _ = get_vault_record_title_type(params, uid) + if title and title.lower() == name.lower() and title != '[record inaccessible]': + all_matched.append(uid) if len(all_matched) == 1: logging.debug(f"Found record by path: {path} -> {all_matched[0]}") @@ -594,9 +618,9 @@ def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: return None - def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: + def _find_by_title(self, params: KeeperParams, title: str) -> str | None: """ - Find record by exact title match. + Find record by exact title match (classic + Nested Share Folder records). If exactly one record matches (any type), returns its UID. If two or more match, filters to PAM types only: returns the single PAM UID if one, @@ -606,10 +630,27 @@ def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: Record UID if found, None otherwise """ all_matched = [] + seen = set() + title_lower = title.lower() + for record_uid in params.record_cache: record = vault.KeeperRecord.load(params, record_uid) - if record and record.title and record.title.lower() == title.lower(): + if record and record.title and record.title.lower() == title_lower: all_matched.append(record_uid) + seen.add(record_uid) + + # NSF titles that may only be present in nested_share_record_data + nsf_data = getattr(params, 'nested_share_record_data', {}) or {} + for record_uid, rd in nsf_data.items(): + if record_uid in seen: + continue + data_json = rd.get('data_json', {}) if isinstance(rd, dict) else {} + if not isinstance(data_json, dict): + continue + nsf_title = data_json.get('title') or '' + if isinstance(nsf_title, str) and nsf_title.lower() == title_lower: + all_matched.append(record_uid) + seen.add(record_uid) if len(all_matched) == 1: logging.debug(f"Found record by title: {title} -> {all_matched[0]}") @@ -634,7 +675,7 @@ def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: return None - def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: + def _find_by_substring(self, params: KeeperParams, token: str) -> str | None: """Substring fallback for ``find_record`` — case-insensitive contains match across PAM record titles and any ``host`` / ``pamHostname`` field. @@ -650,7 +691,12 @@ def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: token_lower = token.lower() # candidate tuple: (uid, title, [(hostName, port), ...]) candidates: list = [] - for record_uid in params.record_cache: + seen = set() + for record_uid in list(getattr(params, 'record_cache', {})) + list( + getattr(params, 'nested_share_records', {}) or {}): + if record_uid in seen: + continue + seen.add(record_uid) try: record = vault.KeeperRecord.load(params, record_uid) except Exception: @@ -686,7 +732,7 @@ def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: return self._pick_candidate(candidates, token) @staticmethod - def _pick_candidate(candidates: list, token: str) -> Optional[str]: + def _pick_candidate(candidates: list, token: str) -> str | None: """Render a numbered list of candidates and prompt for selection. On non-TTY stdin, prints the list once and returns None — caller @@ -734,8 +780,8 @@ def find_gateway( self, params: KeeperParams, record_uid: str, - tdag: Optional[Any] = None, - ) -> Optional[Dict]: + tdag: Any | None = None, + ) -> dict | None: """ Find the gateway associated with a PAM record. @@ -1002,7 +1048,7 @@ def execute(self, params: KeeperParams, **kwargs): # the cache contract. _cache_entry = launch_cache.get(record_uid) _launch_tdag = None # populated only on cache miss - _cached_gateway_info: Optional[Dict[str, Any]] = None + _cached_gateway_info: dict[str, Any] | None = None if _cache_entry is not None: # CACHE HIT: skip DAG build + find_gateway + online probe @@ -1407,7 +1453,7 @@ def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): _debug_connect_ui = bool(getattr(params, 'debug', False)) or logging.getLogger().isEnabledFor( logging.DEBUG ) - pre_connect_spinner: Optional[PamLaunchSpinner] = None + pre_connect_spinner: PamLaunchSpinner | None = None _banner_name_connect = (getattr(record, 'title', None) or record_token or record_uid or '').strip() or 'PAM resource' if not _debug_connect_ui: print(f'Launching connection to {_banner_name_connect}...', flush=True) @@ -1498,17 +1544,17 @@ def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): def _start_cli_session( self, - tunnel_result: Dict[str, Any], + tunnel_result: dict[str, Any], params: KeeperParams, - launch_credential_uid: Optional[str] = None, + launch_credential_uid: str | None = None, use_stdin: bool = False, - cli_scale: Optional[int] = None, - connect_banner_title: Optional[str] = None, - pre_connect_spinner: Optional[PamLaunchSpinner] = None, + cli_scale: int | None = None, + connect_banner_title: str | None = None, + pre_connect_spinner: PamLaunchSpinner | None = None, preserve_crlf: bool = True, - pam_total_tc: Optional[PamConnectTiming] = None, + pam_total_tc: PamConnectTiming | None = None, workflow_expires_on_ms: int = 0, - workflow_flow_uid: Optional[bytes] = None, + workflow_flow_uid: bytes | None = None, workflow_started_by_launch: bool = False, ): """ @@ -1569,7 +1615,7 @@ def _start_cli_session( # Latest close reason from the rust webrtc layer (snake_case name from # PyCloseConnectionReason). Set asynchronously by _on_session_disconnect # below; consumed in the inner finally to print a user-facing notice. - closure_reason: Optional[str] = None + closure_reason: str | None = None # Whether the guac session was live (≥1 sync) at the moment of remote # close. Captured in _on_session_disconnect; lets the notice treat a # guacd_error after an established session as a normal logout. @@ -1577,7 +1623,7 @@ def _start_cli_session( # Distinct exit code for involuntary terminations (KeeperAI, admin). # Raised as SystemExit at the end of the method so the inner/outer # finally cleanup blocks run first. - pending_exit_code: Optional[int] = None + pending_exit_code: int | None = None def signal_handler_fn(signum, frame): nonlocal shutdown_requested @@ -1679,7 +1725,7 @@ def _on_session_disconnect(reason: str) -> None: _debug_connect_ui = bool(getattr(params, 'debug', False)) or logging.getLogger().isEnabledFor( logging.DEBUG ) - _connect_spinner: Optional[PamLaunchSpinner] = pre_connect_spinner + _connect_spinner: PamLaunchSpinner | None = pre_connect_spinner if _connect_spinner is None and not _debug_connect_ui: _banner_name = (connect_banner_title or '').strip() or 'PAM resource' print(f'Launching connection to {_banner_name}...', flush=True) @@ -1954,7 +2000,7 @@ def _remote_key_ctrl_c() -> None: ) # No PasteOrchestrator when PAM disables paste — only Guacamole key chords (no pyperclip). - paste_orch: Optional[PasteOrchestrator] = None + paste_orch: PasteOrchestrator | None = None if not disable_paste: paste_orch = PasteOrchestrator( send_clipboard_fn=python_handler.send_clipboard_stream, diff --git a/keepercommander/commands/pam_saas/config.py b/keepercommander/commands/pam_saas/config.py index 1ecb51d1b..b27eff20e 100644 --- a/keepercommander/commands/pam_saas/config.py +++ b/keepercommander/commands/pam_saas/config.py @@ -4,15 +4,13 @@ from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors from . import get_plugins_map, make_script_signature, SaasCatalog, get_field_input -from ... import api, subfolder, utils, crypto, vault, vault_extensions, attachment, record_management -from ...proto import record_pb2 -from ...api import get_records_add_request, sync_down -from ...error import KeeperApiError +from ... import utils, vault, attachment +from ...api import sync_down from tempfile import TemporaryDirectory import os import json import requests -from typing import Optional, List, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...params import KeeperParams @@ -25,7 +23,7 @@ def __init__(self, configuration_uid: str, plugin_code: str, gateway_context: GatewayContext, - languages: Optional[List[str]] = None, + languages: list[str] | None = None, ): if languages is None: @@ -74,7 +72,8 @@ class PAMActionSaasConfigCommand(PAMGatewayActionDiscoverCommandBase): help='Update an existing SaaS configuration.') parser.add_argument('--shared-folder-uid', '-s', required=False, dest='shared_folder_uid', - action='store', help='Shared folder to store SaaS configuration.') + action='store', + help='Shared folder or Nested Share Folder UID/name to store SaaS configuration.') def get_parser(self): return PAMActionSaasConfigCommand.parser @@ -141,11 +140,49 @@ def _show_plugin_info(plugin: SaasCatalog): print(item) print("") + @staticmethod + def _resolve_target_folder(params: KeeperParams, gateway_context: GatewayContext, + shared_folder_uid: str | None) -> str | None: + from ..pam.vault_target import resolve_pam_folder_uid, pam_folder_exists + + shared_folders = gateway_context.get_shared_folders(params) + allowed_uids = {x.get('uid') for x in shared_folders if x.get('uid')} + + if shared_folder_uid is None: + if len(shared_folders) == 1: + return shared_folders[0].get('uid') + print("") + print(f"{bcolors.FAIL}Multiple shared folders found. " + f"Please use '-s' to select a shared folder.{bcolors.ENDC}") + if shared_folders: + print("Available folders:") + for sf in shared_folders: + print(f" * {sf.get('name') or sf.get('uid')} ({sf.get('uid')})") + return None + + resolved = resolve_pam_folder_uid(params, shared_folder_uid, allow_legacy_user=True) + if not resolved and pam_folder_exists(params, shared_folder_uid): + resolved = shared_folder_uid + if not resolved: + print("") + print(f"{bcolors.FAIL}Folder not found: {shared_folder_uid}{bcolors.ENDC}") + return None + + if resolved not in allowed_uids: + print("") + print(f"{bcolors.FAIL}The shared folder is not part of the gateway application.{bcolors.ENDC}") + return None + return resolved + @staticmethod def _create_config(params: KeeperParams, plugin: SaasCatalog, shared_folder_uid: str, - plugin_code_bytes: Optional[bytes] = None): + plugin_code_bytes: bytes | None = None): + from ..pam.vault_target import ( + create_record_in_folder, update_pam_record, is_nested_share_folder, + ) + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys custom_fields = [ vault.TypedField.new_field( @@ -198,59 +235,18 @@ def _create_config(params: KeeperParams, for item in custom_fields: record.custom.append(item) - folder = params.folder_cache.get(shared_folder_uid) - folder_key = None # type: Optional[bytes] - if isinstance(folder, subfolder.SharedFolderFolderNode): - shared_folder_uid = folder.shared_folder_uid - elif isinstance(folder, subfolder.SharedFolderNode): - shared_folder_uid = folder.uid - else: - shared_folder_uid = None - if shared_folder_uid and shared_folder_uid in params.shared_folder_cache: - shared_folder = params.shared_folder_cache.get(shared_folder_uid) - folder_key = shared_folder.get('shared_folder_key_unencrypted') - - add_record = record_pb2.RecordAdd() - add_record.record_uid = utils.base64_url_decode(record.record_uid) - add_record.record_key = crypto.encrypt_aes_v2(record.record_key, params.data_key) - add_record.client_modified_time = utils.current_milli_time() - add_record.folder_type = record_pb2.user_folder - if folder: - add_record.folder_uid = utils.base64_url_decode(folder.uid) - if folder.type == 'shared_folder': - add_record.folder_type = record_pb2.shared_folder - elif folder.type == 'shared_folder_folder': - add_record.folder_type = record_pb2.shared_folder_folder - if folder_key: - add_record.folder_key = crypto.encrypt_aes_v2(record.record_key, folder_key) - - data = vault_extensions.extract_typed_record_data(record) - json_data = api.get_record_data_json_bytes(data) - add_record.data = crypto.encrypt_aes_v2(json_data, record.record_key) - - if params.enterprise_ec_key: - audit_data = vault_extensions.extract_audit_data(record) - if audit_data: - add_record.audit.version = 0 - add_record.audit.data = crypto.encrypt_ec( - json.dumps(audit_data).encode('utf-8'), params.enterprise_ec_key) - - rq = get_records_add_request(params) - rq.records.append(add_record) - rs = api.communicate_rest(params, rq, 'vault/records_add', rs_type=record_pb2.RecordsModifyResponse) - record_rs = next((x for x in rs.records if utils.base64_url_encode(x.record_uid) == record.record_uid), None) - if record_rs: - if record_rs.status != record_pb2.RS_SUCCESS: - raise KeeperApiError(record_rs.status, rs.message) - record.revision = rs.revision - - params.sync_data = True + create_record_in_folder( + params, record, folder_uid=shared_folder_uid, command='pam action saas config', + ) # If this is not a built-in or custom script, we need to attach it to the config record. if plugin_code_bytes is not None and plugin.file_name: with TemporaryDirectory() as temp_dir: - sync_down(params) + if is_nested_share_folder(params, shared_folder_uid): + sync_down_preserving_nsf_keys(params) + else: + sync_down(params) existing_record = vault.TypedRecord.load(params, record.record_uid) # type: TypedRecord if existing_record is None: @@ -272,14 +268,8 @@ def _create_config(params: KeeperParams, attachment.upload_attachments(params, existing_record, [task]) - record.fields = [ - vault.TypedField.new_field( - field_type="fileRef", - field_value=list(existing_record.linked_keys.keys())) - ] - - record_management.update_record(params, existing_record) - params.sync_data = True + # upload_attachments updates fileRef on existing_record via facade + update_pam_record(params, existing_record, command='pam action saas config') print("") print(f"{bcolors.OKGREEN}Created SaaS configuration record with UID of {record.record_uid}{bcolors.ENDC}") @@ -296,9 +286,9 @@ def execute(self, params: KeeperParams, **kwargs): do_update = kwargs.get("do_update", False) # type: bool shared_folder_uid = kwargs.get("shared_folder_uid") # type: str - use_plugin = kwargs.get("plugin") # type: Optional[str] + use_plugin = kwargs.get("plugin") # type: str | None gateway = kwargs.get("gateway") # type: str - configuration_uid = kwargs.get('configuration_uid') # type Optional[str] + configuration_uid = kwargs.get('configuration_uid') # type: str | None try: gateway_context = GatewayContext.from_gateway(params=params, @@ -332,17 +322,10 @@ def execute(self, params: KeeperParams, **kwargs): elif do_create: - shared_folders = gateway_context.get_shared_folders(params) - if shared_folder_uid is None: - if len(shared_folders) == 1: - shared_folder_uid = shared_folders[0].get("uid") - else: - print("") - print(f"{bcolors.FAIL}Multiple shared folders found. " - f"Please use '-s' to select a shared folder.{bcolors.ENDC}") - if next((x for x in shared_folders if x.get("uid") == shared_folder_uid), None) is None: - print("") - print(f"{bcolors.FAIL}The shared folder is not part of the gateway application.{bcolors.ENDC}") + shared_folder_uid = self._resolve_target_folder( + params, gateway_context, shared_folder_uid, + ) + if not shared_folder_uid: return # For catalog plugins, we need to download the python file from GitHub. diff --git a/unit-tests/pam/test_pam_debug_nsf.py b/unit-tests/pam/test_pam_debug_nsf.py new file mode 100644 index 000000000..b68666192 --- /dev/null +++ b/unit-tests/pam/test_pam_debug_nsf.py @@ -0,0 +1,182 @@ +import json +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import keepercommander.commands.record # noqa: F401 + +from keepercommander import utils, vault +from keepercommander.commands.discover import GatewayContext +from keepercommander.commands.pam_debug import load_pam_record +from keepercommander.commands.pam_debug.acl import PAMDebugACLCommand +from keepercommander.commands.pam_debug.dump import PAMDebugDumpCommand +from keepercommander.commands.pam_debug.link import PAMDebugLinkCommand +from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode + + +def _typed(uid, title, record_type='pamUser', version=3): + rec = vault.TypedRecord(version=version) + rec.record_uid = uid + rec.title = title + rec.type_name = record_type + return rec + + +def _params(): + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + folder.name = 'pamFolder - Resources' + folder.parent_uid = None + folder.subfolders = [] + return SimpleNamespace( + folder_cache={'nsf_folder': folder}, + shared_folder_cache={}, + nested_share_folders={ + 'nsf_folder': {'name': 'pamFolder - Resources', 'parent_uid': None}, + }, + nested_share_folder_records={'nsf_folder': {'machine_uid', 'user_uid'}}, + nested_share_records={ + 'machine_uid': {'version': 3, 'revision': 1, 'shared': False}, + 'user_uid': {'version': 3, 'revision': 1, 'shared': False}, + 'config_uid': {'version': 6, 'revision': 1, 'shared': False}, + }, + nested_share_record_data={ + 'machine_uid': { + 'data_json': {'type': 'pamMachine', 'title': 'NSF Machine', 'fields': []}, + }, + 'user_uid': { + 'data_json': {'type': 'pamUser', 'title': 'NSF User', 'fields': []}, + }, + 'config_uid': { + 'data_json': { + 'type': 'pamNetworkConfiguration', + 'title': 'NSF Config', + 'fields': [{'type': 'pamResources', 'value': [{'folderUid': 'nsf_folder'}]}], + }, + }, + }, + record_cache={}, + subfolder_record_cache={}, + record_rotation_cache={}, + root_folder=RootFolderNode(), + environment_variables={}, + ) + + +class TestPamDebugNsf(unittest.TestCase): + + def test_load_pam_record_resolves_nsf_machine(self): + params = _params() + rec = load_pam_record(params, 'machine_uid') + self.assertIsNotNone(rec) + self.assertEqual(rec.record_uid, 'machine_uid') + self.assertEqual(rec.title, 'NSF Machine') + self.assertEqual(rec.record_type, 'pamMachine') + + def test_acl_uses_load_pam_record_for_nsf_uids(self): + params = _params() + user = _typed('user_uid', 'NSF User', 'pamUser') + parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') + gw = MagicMock() + gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) + gw.configuration_uid = 'config_uid' + + with patch('keepercommander.commands.pam_debug.acl.GatewayContext.from_gateway', return_value=gw), \ + patch('keepercommander.commands.pam_debug.acl.RecordLink') as rl_cls, \ + patch('keepercommander.commands.pam_debug.acl.load_pam_record', + side_effect=[user, parent]) as load, \ + patch('builtins.input', side_effect=['n', 'n']): + rl = rl_cls.return_value + rl.get_acl.return_value = None + rl.get_admin_record_uid.return_value = None + rl.acl_has_belong_to_record_uid.return_value = None + rl.dag.get_vertex.return_value = MagicMock() + PAMDebugACLCommand().execute( + params, gateway='gw', user_uid='user_uid', parent_uid='machine_uid') + + self.assertEqual(load.call_count, 2) + self.assertEqual(load.call_args_list[0].args[1], 'user_uid') + self.assertEqual(load.call_args_list[1].args[1], 'machine_uid') + + def test_link_uses_load_pam_record_for_nsf_resource(self): + params = _params() + parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') + gw = MagicMock() + gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) + gw.configuration_uid = 'config_uid' + + with patch('keepercommander.commands.pam_debug.link.GatewayContext.from_gateway', return_value=gw), \ + patch('keepercommander.commands.pam_debug.link.RecordLink') as rl_cls, \ + patch('keepercommander.commands.pam_debug.link.load_pam_record', return_value=parent) as load: + rl = rl_cls.return_value + PAMDebugLinkCommand().execute(params, gateway='gw', resource_uid='machine_uid') + rl.belongs_to.assert_called_once() + rl.save.assert_called_once() + self.assertEqual(load.call_args.args[1], 'machine_uid') + + def test_dump_collects_nsf_folder_records(self): + params = _params() + with tempfile.TemporaryDirectory() as tmp: + out = f'{tmp}/dump.json' + with patch('keepercommander.commands.pam_debug.dump.get_connection'), \ + patch('keepercommander.commands.pam_debug.dump.DAG'): + PAMDebugDumpCommand().execute( + params, + folder_uid='nsf_folder', + recursive=False, + save_as=out, + ) + with open(out, encoding='utf-8') as fh: + data = json.loads(fh.read()) + + uids = {row['uid'] for row in data} + self.assertEqual(uids, {'machine_uid', 'user_uid'}) + titles = {row['data'].get('title') for row in data} + self.assertEqual(titles, {'NSF Machine', 'NSF User'}) + + def test_gateway_context_includes_nsf_shared_folders(self): + folder_uid = 'zytjEAw5RTUJsF-PPx9wqA' + params = _params() + params.nested_share_folders = { + folder_uid: {'name': 'pamFolder - Resources', 'parent_uid': None}, + } + facade = MagicMock() + facade.folder_uid = folder_uid + gateway = MagicMock() + gateway.applicationUid = b'\x01' * 16 + gateway.controllerUid = b'\x02' * 16 + gateway.controllerName = 'gw' + ctx = GatewayContext( + configuration=_typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6), + facade=facade, + gateway=gateway, + application=MagicMock(), + ) + + share = MagicMock() + share.secretUid = utils.base64_url_decode(folder_uid) + share.shareType = 1 + app_info = MagicMock() + app_info.shares = [share] + + with patch('keepercommander.commands.discover.KSMCommand.get_app_info', return_value=[app_info]), \ + patch('keepercommander.commands.discover.APIRequest_pb2.ApplicationShareType.Name', + return_value='SHARE_TYPE_FOLDER'): + folders = ctx.get_shared_folders(params) + + self.assertEqual(len(folders), 1) + self.assertEqual(folders[0]['uid'], folder_uid) + self.assertEqual(folders[0]['name'], 'pamFolder - Resources') + + def test_gateway_context_loads_nsf_configuration_records(self): + params = _params() + with patch('keepercommander.commands.discover.vault_extensions.find_records', return_value=[]): + configs = GatewayContext.get_configuration_records(params) + + self.assertTrue(any(c.record_uid == 'config_uid' for c in configs)) + self.assertTrue(any(c.record_type == 'pamNetworkConfiguration' for c in configs)) + + +if __name__ == '__main__': + unittest.main()