Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions keepercommander/commands/pam_import/cyberark_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -2201,12 +2201,56 @@ def _list_safes_detailed(safes: list[dict], system_excluded: int):
print(' Wildcards supported: --safes "Windows*,Unix*"')
print()

@staticmethod
def _parse_index_selection(choice: str, count: int) -> list[int]:
"""Parse a selection string into 0-based indices.

Supports single indexes and inclusive ranges, mixed freely::

"1,3" -> [0, 2]
"1-4" -> [0, 1, 2, 3]
"4-1" -> [0, 1, 2, 3] (reversed ranges accepted)
"1,3,6-9" -> [0, 2, 5, 6, 7, 8]

Out-of-range and non-numeric tokens are skipped. Duplicates are
removed while preserving first-seen order.
"""
selected: list[int] = []
seen: set[int] = set()
for part in choice.split(','):
part = part.strip()
if not part:
continue
try:
if '-' in part:
left, right = part.split('-', 1)
start = int(left.strip())
end = int(right.strip())
if start > end:
start, end = end, start
for num in range(start, end + 1):
idx = num - 1
if 0 <= idx < count and idx not in seen:
selected.append(idx)
seen.add(idx)
else:
idx = int(part) - 1
if 0 <= idx < count and idx not in seen:
selected.append(idx)
seen.add(idx)
except ValueError:
continue
return selected

@staticmethod
def _interactive_safe_picker(safes: list[dict]) -> Optional[str]:
"""Show safes and let user select which to import.

Returns comma-separated safe names for apply_safe_filter,
or None to import all.

Selection accepts single indexes and inclusive ranges, e.g.
``1-4``, ``1,2,3,6-9,11,14-18``, or ``A`` for all.
"""
print(f'\n{bcolors.OKBLUE}CyberArk Safes Found:{bcolors.ENDC}')
print('─' * 50)
Expand All @@ -2219,22 +2263,19 @@ def _interactive_safe_picker(safes: list[dict]) -> Optional[str]:
print()

try:
choice = input(f' Select safes (comma-separated numbers, or A for all) [A]: ').strip()
choice = input(
' Select safes (numbers/ranges e.g. 1-4,6,8-10, or A for all) [A]: '
).strip()
except EOFError:
return None

if not choice or choice.upper() == 'A':
return None

selected = []
for part in choice.split(','):
part = part.strip()
try:
idx = int(part) - 1
if 0 <= idx < len(numbered):
selected.append(numbered[idx])
except ValueError:
continue
selected = [
numbered[idx]
for idx in CyberArkPAMImportCommand._parse_index_selection(choice, len(numbered))
]

if not selected:
return None
Expand Down
2 changes: 2 additions & 0 deletions keepercommander/importer/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def register_command_info(aliases, command_info):
help='Display skipped records')
import_parser.add_argument('--secret-ids', dest='secret_ids', action='store',
help='Comma separated list of secret IDs to fetch (Thycotic)')
import_parser.add_argument('--target-node', dest='target_node', action='store',
help='node name or ID for CyberArk-provisioned users, teams, and roles (default: root node)')
import_parser.add_argument(
'name', type=str,
help='file name (json, csv , keepass, 1password), account name (lastpass), or URL (ManageEngine, Thycotic). '
Expand Down
83 changes: 56 additions & 27 deletions keepercommander/importer/cyberark/cyberark.py
Original file line number Diff line number Diff line change
Expand Up @@ -1214,11 +1214,56 @@ def _do_import_inner(self, filename, **kwargs):
self.import_user_groups(
pvwa_host, authorization_token, params,
cyberark_users=cyberark_users,
target_node=kwargs.get("target_node"),
)

print_formatted_text(HTML("\nImport <ansigreen>completed</ansigreen>"))

def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_users=None):
def _resolve_provisioning_node_id(self, params, target_node=None):
"""Resolve the enterprise node for CyberArk teams/roles/users.

If ``target_node`` is set (name or numeric ID), resolve it via
``EnterpriseCommand.resolve_nodes``. Otherwise fall back to the first
user-root node (same default as ``enterprise-user --add`` / ``enterprise-team --add``).

Returns the node id, or ``None`` if resolution fails (errors are printed).
"""
if target_node:
nodes = list(EnterpriseCommand.resolve_nodes(params, target_node))
if len(nodes) == 0:
print_formatted_text(
HTML(
f"<ansired>Cannot provision into node:</ansired> "
f'node "<b>{target_node}</b>" was not found.'
)
)
return None
if len(nodes) > 1:
print_formatted_text(
HTML(
f"<ansired>Cannot provision into node:</ansired> "
f'more than one node named "<b>{target_node}</b>" was found. '
f"Use the numeric node ID."
)
)
return None
return nodes[0]["node_id"]

# Default: first user-root node, then first tree root (parent_id unset/0).
for nid in params.enterprise.get("user_root_nodes", []) or []:
return nid
for n in params.enterprise.get("nodes", []) or []:
if not n.get("parent_id"):
return n["node_id"]
print_formatted_text(
HTML(
"<ansired>Cannot create Keeper Teams/users:</ansired> no root node found in the "
"enterprise tree."
)
)
return None

def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_users=None, target_node=None):
"""Fetch CyberArk User Groups and create them as Keeper Enterprise Teams.

This mirrors the ``enterprise-team --add`` command flow: for each
Expand Down Expand Up @@ -1304,25 +1349,17 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us
existing_team_names.add(team["name"].lower())

# Determine the target node id (same default as enterprise-team --add):
# the first user-root node when no --node was specified.
node_id = None
for nid in params.enterprise.get("user_root_nodes", []) or []:
node_id = nid
break
if node_id is None:
# Fall back to the first node in the tree (root has parent_id=0)
for n in params.enterprise.get("nodes", []) or []:
if not n.get("parent_id"):
node_id = n["node_id"]
break
# --target-node when specified, otherwise the first user-root node.
node_id = self._resolve_provisioning_node_id(params, target_node)
if node_id is None:
return
if target_node:
print_formatted_text(
HTML(
"<ansired>Cannot create Keeper Teams:</ansired> no root node found in the "
"enterprise tree."
f"Provisioning teams, roles, and users into node "
f"<b>{target_node}</b> (id <b>{node_id}</b>)"
)
)
return

print_formatted_text(
HTML(f"Importing <b>{len(groups)}</b> user groups as Keeper Teams (members not provisioned):\n"),
Expand Down Expand Up @@ -1743,21 +1780,13 @@ def _create_keeper_users_and_assign_roles(self, groups, cyberark_users, params,
if uname:
existing_user_by_email[uname] = u

# Determine the target node (root node) for new invitations.
invite_node_id = None
for nid in params.enterprise.get("user_root_nodes", []) or []:
invite_node_id = nid
break
if invite_node_id is None:
for n in params.enterprise.get("nodes", []) or []:
if not n.get("parent_id"):
invite_node_id = n["node_id"]
break
# Use the caller-resolved provisioning node (from --target-node or root default).
invite_node_id = node_id
if invite_node_id is None:
print_formatted_text(
HTML(
"\n<ansired>Cannot invite Keeper users:</ansired> no root node found in "
"the enterprise tree."
"\n<ansired>Cannot invite Keeper users:</ansired> no target node was "
"resolved for provisioning."
)
)
return
Expand Down
4 changes: 3 additions & 1 deletion keepercommander/importer/imp_exp.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,7 @@ def _import(params, file_format, filename, **kwargs):
dry_run = kwargs.get('dry_run') is True
show_skipped = kwargs.get('show_skipped') is True
secret_ids = kwargs.get('secret_ids')
target_node = kwargs.get('target_node')

import_into = kwargs.get('import_into') or ''
if import_into:
Expand All @@ -771,7 +772,8 @@ def _import(params, file_format, filename, **kwargs):
classic_shared = shared and not use_nsf

for x in importer.execute(filename, params=params, users_only=import_users, filter_folder=filter_folder,
old_domain=old_domain, new_domain=new_domain, tmpdir=tmpdir, secret_ids=secret_ids, dry_run=dry_run):
old_domain=old_domain, new_domain=new_domain, tmpdir=tmpdir, secret_ids=secret_ids,
dry_run=dry_run, target_node=target_node):
if isinstance(x, ImportRecord):
if filter_folder and not importer.support_folder_filter():
if not x.folders:
Expand Down
58 changes: 58 additions & 0 deletions tests/test_cyberark_pam_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,38 @@ def test_select_specific(self):
result = CyberArkPAMImportCommand._interactive_safe_picker(safes)
assert result == "Alpha,Gamma"

def test_select_range(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
from unittest.mock import patch
safes = [{"safeName": f"S{i}"} for i in range(1, 6)]
with patch("builtins.input", return_value="2-4"):
result = CyberArkPAMImportCommand._interactive_safe_picker(safes)
assert result == "S2,S3,S4"

def test_select_mixed_ranges_and_indexes(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
from unittest.mock import patch
safes = [{"safeName": f"S{i}"} for i in range(1, 21)]
with patch("builtins.input", return_value="1,2,3,6-9,11,14-18"):
result = CyberArkPAMImportCommand._interactive_safe_picker(safes)
assert result == "S1,S2,S3,S6,S7,S8,S9,S11,S14,S15,S16,S17,S18"

def test_select_reversed_range(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
from unittest.mock import patch
safes = [{"safeName": f"S{i}"} for i in range(1, 6)]
with patch("builtins.input", return_value="4-1"):
result = CyberArkPAMImportCommand._interactive_safe_picker(safes)
assert result == "S1,S2,S3,S4"

def test_select_deduplicates(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
from unittest.mock import patch
safes = [{"safeName": f"S{i}"} for i in range(1, 6)]
with patch("builtins.input", return_value="1,1-3,2"):
result = CyberArkPAMImportCommand._interactive_safe_picker(safes)
assert result == "S1,S2,S3"

def test_select_invalid_input(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
from unittest.mock import patch
Expand All @@ -1470,6 +1502,32 @@ def test_eof_returns_none(self):
assert result is None


class TestParseIndexSelection:
"""Unit tests for _parse_index_selection."""

def test_single_indexes(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
assert CyberArkPAMImportCommand._parse_index_selection("1,3", 5) == [0, 2]

def test_range(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
assert CyberArkPAMImportCommand._parse_index_selection("1-4", 10) == [0, 1, 2, 3]

def test_mixed(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
assert CyberArkPAMImportCommand._parse_index_selection("1,2,3,6-9,11,14-18", 20) == [
0, 1, 2, 5, 6, 7, 8, 10, 13, 14, 15, 16, 17
]

def test_out_of_range_skipped(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
assert CyberArkPAMImportCommand._parse_index_selection("1,99,2-3,50-60", 5) == [0, 1, 2]

def test_whitespace_tolerant(self):
from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand
assert CyberArkPAMImportCommand._parse_index_selection(" 1 , 3 - 5 , 7 ", 10) == [0, 2, 3, 4, 6]


class TestListSafesDetailed:
"""Tests for _list_safes_detailed."""

Expand Down