From 45e2c0eb9b27aa8f61fab49909608ce4cd941333 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Thu, 27 Aug 2026 13:58:19 +0100 Subject: [PATCH 1/3] Add unsafe flag to ApplyMembership Add unsafe flag and manual documentation. Unsafe flag allows the apply-membership to remove yourself from the folders if set. --- keepercommander/importer/commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index 32a54f7f5..c0280b9aa 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -135,6 +135,7 @@ def register_command_info(aliases, command_info): apply_membership_parser = argparse.ArgumentParser(prog='apply-membership', description='Loads shared folder membership from JSON file') apply_membership_parser.add_argument('--full-sync', dest='full_sync', action='store_true', help='Update and remove membership also.') +apply_membership_parser.add_argument('--unsafe', dest='unsafe', action='store_true', help='Combined with --full-sync. Will remove yourself from folders where you have no permission.') apply_membership_parser.add_argument('name', type=str, nargs='?', help='Input file name. "shared_folder_membership.json" if omitted.') apply_membership_parser.error = raise_parse_exception apply_membership_parser.exit = suppress_exit @@ -523,8 +524,9 @@ def execute(self, params, **kwargs): teams.append(obj) full_sync = kwargs.get('full_sync') is True + unsafe = kwargs.get('unsafe') is True if len(shared_folders) > 0: - imp_exp.import_user_permissions(params, shared_folders, full_sync) + imp_exp.import_user_permissions(params, shared_folders, full_sync, unsafe) if len(teams) > 0: imp_exp.import_teams(params, teams, full_sync) From fc9b3cc28480cfdcb851f09b0a990cb6a383d8ca Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Thu, 27 Aug 2026 14:12:04 +0100 Subject: [PATCH 2/3] Support unsafe flag for ApplyMembership Currently, apply-membership --full-sync will remove folder permissions so that it matches the JSON file, however it won't remove yourself. Added --unsafe flag, which will allow removing yourself if you're not set on the folder permissions. This can be useful: - If the folder is only meant to be shared to your team and not you. - To reproduce enterprise-push behavior with shared folders at scale --- keepercommander/importer/imp_exp.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index fcaa869dd..c42082f18 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -617,7 +617,7 @@ def import_teams(params, teams, full_sync=False): # type: (KeeperParams, List[ def import_user_permissions(params, shared_folders, - full_sync=False): # type: (KeeperParams, List[ImportSharedFolder], bool) -> None + full_sync=False, unsafe=False): # type: (KeeperParams, List[ImportSharedFolder], bool) -> None if not shared_folders: return @@ -654,7 +654,7 @@ def import_user_permissions(params, folders = [x for x in folders if x.uid in params.shared_folder_cache] if folders: - permissions = prepare_folder_permission(params, folders, full_sync) + permissions = prepare_folder_permission(params, folders, full_sync, unsafe) if permissions: teams_added = 0 users_added = 0 @@ -2536,7 +2536,7 @@ def prepare_record_link(params, records): return record_links -def prepare_folder_permission(params, folders, full_sync): +def prepare_folder_permission(params, folders, full_sync, unsafe=False): # type: (KeeperParams, List[ImportSharedFolder], bool) -> list """Prepare a list of API interactions for changes to folder permissions.""" shared_folder_lookup = {} @@ -2632,6 +2632,9 @@ def prepare_folder_permission(params, folders, full_sync): existing_users.update((x['username'] for x in shared_folder['users'])) if params.user in existing_users: existing_users.remove(params.user) + if unsafe: + # Set user to end of array to be removed last + existing_users.add(params.user) keep_teams = set() keep_users = set() From e47b6272d8020da530fe9daad1a4dd4ca6dd1bd3 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Mon, 31 Aug 2026 10:04:14 +0100 Subject: [PATCH 3/3] Import Command - Match Shared Folders by UID In import (e.g. JSON), shared folders are matched by path, not UID. If a vault has a shared folder with the same UID, but different path (e.g. the shared folder is nested in a personal folder), the import will create a new shared folder. This changes the import process so that if the import includes a UID which matches that of a shared folder in the vault, the folder will not be duplicated. If the path is different, the existing folder will be moved to the new path. Currently only wired up for Classic folders, awaiting the upcoming NSF folder-move API (https://github.com/Keeper-Security/Commander/pull/2324) to apply this to NSF. --- keepercommander/importer/imp_exp.py | 134 ++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index c42082f18..68bd2d525 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -1045,6 +1045,8 @@ def _import(params, file_format, filename, **kwargs): if not dry_run: prepare_nsf_folders(params, folders, records, nsf_base_parent) else: + if not dry_run: + resolve_shared_folder_uid_relocation(params, folders) folder_add = prepare_folder_add(params, folders, records, manage_users, manage_records, can_edit, can_share) if folder_add: if not dry_run: @@ -1847,6 +1849,138 @@ def upload_attachment(params, attachments): logging.debug(e) +def _ensure_user_folder_path(params, comps): + # type: (KeeperParams, List[str]) -> str + """Ensure a chain of plain (non-shared) folders exists under root, creating + any missing folders along the way, and return the uid of the deepest + folder in the chain ('' means root itself). + + Shared folders can only be nested under plain user folders (never under + another shared folder), so this is sufficient for building the parent + chain of a shared folder's destination path. + """ + parent_uid = '' + for comp in comps: + if not comp: + continue + existing_uid = None + for f_uid, f in params.folder_cache.items(): + if f.type != BaseFolderNode.UserFolderType: + continue + if (f.parent_uid or '') != parent_uid: + continue + if (f.name or '').casefold() == comp.casefold(): + existing_uid = f_uid + break + + if not existing_uid: + folder_uid = api.generate_record_uid() + fol_req = folder_pb2.FolderRequest() + fol_req.folderUid = base64.urlsafe_b64decode(folder_uid + '==') + fol_req.folderType = 1 # user_folder + if parent_uid: + fol_req.parentFolderUid = base64.urlsafe_b64decode(parent_uid + '==') + folder_key = utils.generate_aes_key() + fol_req.encryptedFolderKey = crypto.encrypt_aes_v1(folder_key, params.data_key) + data = {'name': comp} + fol_req.folderData = crypto.encrypt_aes_v1(json.dumps(data).encode('utf-8'), folder_key) + + execute_import_folder_record(params, [fol_req], None) + sync_down.sync_down(params) + existing_uid = folder_uid + + parent_uid = existing_uid + + return parent_uid + + +def resolve_shared_folder_uid_relocation(params, folders): + # type: (KeeperParams, List[ImportSharedFolder]) -> None + """ + If an imported shared folder record carries a `uid` that matches a shared + folder that already exists in the vault, but the vault's copy currently + lives at a different path than the one requested in the import file, move + the *existing* shared folder to the requested path instead of letting + prepare_folder_add() create a brand-new (duplicate) shared folder there. + + Any ImportSharedFolder entries that are resolved this way (moved, or + already sitting at the correct path) are removed from `folders` in place, + so prepare_folder_add() does not try to create them again. + """ + if not folders: + return + + resolved = [] + for fol in folders: + uid = getattr(fol, 'uid', None) + if not uid: + continue + if uid not in params.shared_folder_cache or uid not in params.folder_cache: + # A uid was supplied but no such shared folder exists in this + # vault (deleted, wrong vault, etc.) - fall back to normal + # path-based creation for it. + continue + + desired_path = (fol.path or '').strip(PathDelimiter) + current_path = get_folder_path(params, uid).strip(PathDelimiter) + + if desired_path.casefold() == current_path.casefold(): + resolved.append(fol) + continue + + comps = list(path_components(fol.path)) if fol.path else [] + if not comps: + continue + parent_comps = comps[:-1] + + try: + parent_uid = _ensure_user_folder_path(params, parent_comps) + except Exception as e: + logging.warning('Unable to prepare destination path for shared folder "%s": %s', + desired_path, e) + continue + + folder_node = params.folder_cache[uid] + if (folder_node.parent_uid or '') == (parent_uid or ''): + # Already directly under the correct parent folder. + resolved.append(fol) + continue + + rq = { + 'command': 'move', + 'link': False, + 'move': [{ + 'uid': uid, + 'type': BaseFolderNode.SharedFolderType, + 'cascade': True, + }], + } + if folder_node.parent_uid: + parent_folder = params.folder_cache.get(folder_node.parent_uid) + rq['move'][0]['from_type'] = parent_folder.type if parent_folder else BaseFolderNode.UserFolderType + rq['move'][0]['from_uid'] = folder_node.parent_uid + else: + rq['move'][0]['from_type'] = BaseFolderNode.UserFolderType + + if parent_uid: + dst_folder = params.folder_cache.get(parent_uid) + rq['to_type'] = dst_folder.type if dst_folder else BaseFolderNode.UserFolderType + rq['to_uid'] = parent_uid + else: + rq['to_type'] = BaseFolderNode.UserFolderType + + try: + api.communicate(params, rq) + logging.debug('Shared folder "%s" (%s) moved to "%s"', current_path, uid, desired_path) + resolved.append(fol) + except Exception as e: + logging.warning('Failed to move shared folder "%s" to "%s": %s', uid, desired_path, e) + + if resolved: + sync_down.sync_down(params) + folders[:] = [f for f in folders if f not in resolved] + + def prepare_folder_add(params, folders, records, manage_users, manage_records, can_edit, can_share): """Find what folders to import (?).""" folder_hash = {}