diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py index ea2288b51..b05b05766 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_hook.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -34,35 +34,54 @@ class SailPointCommandHook: def __init__(self, record_uid: str): self.record_uid = record_uid - def before_command(self, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: - """Return (response, status_code) to short-circuit, or None to continue.""" + def before_command(self, params: KeeperParams, command: str) -> Tuple[str, Optional[Tuple[Any, int]]]: + """ + Prepare a Service Mode command for SailPoint. + + Returns ``(command_to_run, short_circuit)``. When ``short_circuit`` is + set, do not execute the command. ``command_to_run`` may be rewritten + (e.g. transfer-user target injection). + """ caps = read_capabilities(params, self.record_uid) scope_error = self._check_capability_gates(command, caps) if scope_error: - return {'status': 'error', 'error': scope_error}, 403 + return self._reject(command, scope_error, 403) - er_error = SailPointCommandPolicy.validate_enterprise_role(command) - if er_error: - return {'status': 'error', 'error': er_error}, 403 + policy_error = ( + SailPointCommandPolicy.validate_enterprise_role(command) + or SailPointCommandPolicy.validate_enterprise_user_delete(command) + ) + if policy_error: + return self._reject(command, policy_error, 403) + + command, transfer_error = SailPointCommandPolicy.prepare_transfer( + command, caps.transfer_target_email + ) + if transfer_error: + return self._reject(command, transfer_error, 400) invite = SailPointCommandParser.parse_invite(command) if invite and invite.emails: - return self._before_invite(params, invite) + return command, self._before_invite(params, invite) share = SailPointCommandParser.parse_share(command) if share: target_error = validate_share_targets(params, share) if target_error: - return {'status': 'error', 'error': target_error}, 400 - return self._before_share(params, share, caps) + return self._reject(command, target_error, 400) + return command, self._before_share(params, share, caps) mutation = SailPointCommandParser.parse_identity_mutation(command) if mutation: err = self._first_scim_identity_error(params, mutation.emails) if err: - return {'status': 'error', 'error': err}, 403 - return None + return self._reject(command, err, 403) + return command, None + + @staticmethod + def _reject(command: str, error: str, status_code: int) -> Tuple[str, Tuple[Any, int]]: + return command, ({'status': 'error', 'error': error}, status_code) @staticmethod def _first_scim_identity_error(params: KeeperParams, emails: List[str]) -> Optional[str]: @@ -217,11 +236,8 @@ def _before_share( share: ParsedShare, caps: SailPointCapabilities, ) -> Optional[Tuple[Any, int]]: - # Revoke/remove/owner must run through Commander so Service Mode returns the - # native error (e.g. User Not Found for Invited users). Only grant is deferred. - if not share.is_grant: - return None - + # Capability gates apply to every share action (grant, owner, revoke, cancel, + # remove). Deferral below is grant-only for Invited users. if share.is_folder and not caps.allow_folders: return { 'status': 'error', @@ -233,6 +249,10 @@ def _before_share( 'error': 'SailPoint allow_records is disabled; share-record is not allowed.', }, 403 + # Non-grant actions run through Commander (native errors, no pending queue). + if not share.is_grant: + return None + deferred = [e for e in share.emails if self._user_status(params, e) != 'active'] if not deferred: return None diff --git a/keepercommander/service/commands/integrations/sailpoint/command_parse.py b/keepercommander/service/commands/integrations/sailpoint/command_parse.py index 7286919aa..a90b22b62 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_parse.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -15,14 +15,14 @@ import shlex from dataclasses import dataclass, field -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Sequence, Tuple _NSF_FOLDER = frozenset({'nsf-share-folder'}) _NSF_RECORD = frozenset({'nsf-share-record'}) _FOLDER_CMDS = frozenset({'share-folder', 'nsf-share-folder'}) _RECORD_CMDS = frozenset({'share-record', 'nsf-share-record'}) _EU_CMDS = frozenset({'enterprise-user', 'eu'}) -_INVITE_FLAGS = frozenset({'--invite', '--add'}) +_TRANSFER_CMDS = frozenset({'transfer-user', 'tu'}) @dataclass @@ -69,8 +69,17 @@ class ParsedIdentityMutation: has_node_change: bool = False +@dataclass +class ParsedTransfer: + """transfer-user offboard request (target comes from SailPoint config).""" + + emails: List[str] = field(default_factory=list) + has_target_user: bool = False + has_force: bool = False + + class SailPointCommandParser: - """Parse enterprise-user invite and share-* command strings.""" + """Parse SailPoint Service Mode commands via Commander's argparse parsers.""" @staticmethod def tokenize(command: str) -> List[str]: @@ -80,112 +89,55 @@ def tokenize(command: str) -> List[str]: return command.split() @staticmethod - def _matches_flag(token: str, *names: str) -> bool: - """True for ``--flag``, ``-f``, or ``--flag=value`` forms.""" - for name in names: - if token == name: - return True - if name.startswith('--') and token.startswith(f'{name}='): - return True - return False - - @staticmethod - def _one_flag_value(token: str, tokens: List[str], index: int) -> Tuple[Optional[str], int]: - """ - Match Commander argparse append flags (one value per flag): - --add-role R1 - --add-role=R1 - """ - if '=' in token: - return token.split('=', 1)[1], index + 1 - if index + 1 < len(tokens) and not tokens[index + 1].startswith('-'): - return tokens[index + 1], index + 2 - return None, index + 1 - - @staticmethod - def _skip_unknown_flag(tokens: List[str], index: int) -> int: - """Advance past an unrecognized flag and an optional value token.""" - token = tokens[index] - if '=' in token: - return index + 1 - if index + 1 < len(tokens) and not tokens[index + 1].startswith('-'): - return index + 2 - return index + 1 + def parse_known(parser, argv: Sequence[str]) -> Optional[Tuple[Any, List[str]]]: + """Parse with a Commander parser; None when argparse rejects the argv.""" + from .....commands.base import ParseError - @classmethod - def _append_flag_value( - cls, - token: str, - tokens: List[str], - index: int, - dest: List[str], - ) -> int: - value, next_i = cls._one_flag_value(token, tokens, index) - if value is not None: - dest.append(value) - return next_i + try: + ns, unknown = parser.parse_known_args(list(argv)) + return ns, list(unknown) + except ParseError: + return None @classmethod def parse_invite(cls, command: str) -> Optional[ParsedInvite]: tokens = cls.tokenize(command) - if not tokens or tokens[0] not in _EU_CMDS: + if not tokens or tokens[0].lower() not in _EU_CMDS: return None - parsed = ParsedInvite() - emails: List[str] = [] - i = 1 - while i < len(tokens): - t = tokens[i] - if t in _INVITE_FLAGS: - parsed.is_invite = True - i += 1 - elif cls._matches_flag(t, '--node', '-n'): - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.node = value - elif cls._matches_flag(t, '--add-role'): - i = cls._append_flag_value(t, tokens, i, parsed.roles) - elif cls._matches_flag(t, '--add-team'): - i = cls._append_flag_value(t, tokens, i, parsed.teams) - elif t.startswith('-'): - i = cls._skip_unknown_flag(tokens, i) - else: - if '@' in t: - emails.append(t) - i += 1 - - parsed.emails = emails - return parsed if parsed.is_invite else None + from .....commands.enterprise import enterprise_user_parser + + parsed = cls.parse_known(enterprise_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + if not (ns.invite or ns.add): + return None + emails = [e for e in (ns.email or []) if isinstance(e, str) and '@' in e] + return ParsedInvite( + emails=emails, + node=ns.node, + roles=list(ns.add_role or []), + teams=list(ns.add_team or []), + is_invite=True, + ) @classmethod def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutation]: tokens = cls.tokenize(command) - if not tokens or tokens[0] not in _EU_CMDS: + if not tokens or tokens[0].lower() not in _EU_CMDS: return None - emails: List[str] = [] - has_role = False - has_team = False - has_node = False - i = 1 - while i < len(tokens): - t = tokens[i] - if cls._matches_flag(t, '--add-role', '--remove-role'): - has_role = True - _, i = cls._one_flag_value(t, tokens, i) - elif cls._matches_flag(t, '--add-team', '--remove-team'): - has_team = True - _, i = cls._one_flag_value(t, tokens, i) - elif cls._matches_flag(t, '--node', '-n'): - has_node = True - _, i = cls._one_flag_value(t, tokens, i) - elif t.startswith('-'): - i = cls._skip_unknown_flag(tokens, i) - else: - if '@' in t: - emails.append(t) - i += 1 + from .....commands.enterprise import enterprise_user_parser + parsed = cls.parse_known(enterprise_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + emails = [e for e in (ns.email or []) if isinstance(e, str) and '@' in e] + has_role = bool(ns.add_role or ns.remove_role) + has_team = bool(ns.add_team or ns.remove_team) + has_node = bool(ns.node) if not (has_role or has_team or has_node) or not emails: return None return ParsedIdentityMutation( @@ -195,60 +147,89 @@ def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutatio has_node_change=has_node, ) + @classmethod + def parse_transfer(cls, command: str) -> Optional[ParsedTransfer]: + tokens = cls.tokenize(command) + if not tokens or tokens[0].lower() not in _TRANSFER_CMDS: + return None + + from .....commands.transfer_account import transfer_user_parser + + parsed = cls.parse_known(transfer_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + return ParsedTransfer( + emails=[e for e in (ns.email or []) if isinstance(e, str) and '@' in e], + has_target_user=bool(ns.target_user), + has_force=bool(ns.force), + ) + @classmethod def parse_share(cls, command: str) -> Optional[ParsedShare]: tokens = cls.tokenize(command) if not tokens: return None - name = tokens[0] + name = tokens[0].lower() if name not in _FOLDER_CMDS and name not in _RECORD_CMDS: return None - parsed = ParsedShare( + parser = cls._share_parser(name) + if parser is None: + return None + parsed = cls.parse_known(parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + + is_folder = name in _FOLDER_CMDS + is_record = name in _RECORD_CMDS + is_nsf = name in _NSF_FOLDER or name in _NSF_RECORD + + if is_record: + emails = list(getattr(ns, 'email', None) or []) + record = getattr(ns, 'record', None) + targets = [record] if record else [] + else: + emails = list(getattr(ns, 'user', None) or []) + folder = getattr(ns, 'folder', None) or [] + targets = list(folder) if isinstance(folder, list) else ([folder] if folder else []) + + if not emails or not targets: + return None + + action = (getattr(ns, 'action', None) or 'grant').strip().lower() or 'grant' + result = ParsedShare( command=name, - is_folder=name in _FOLDER_CMDS, - is_record=name in _RECORD_CMDS, - is_nsf=name in _NSF_FOLDER or name in _NSF_RECORD, + emails=emails, + targets=targets, + action=action, + is_folder=is_folder, + is_record=is_record, + is_nsf=is_nsf, ) - i = 1 - positional: List[str] = [] - while i < len(tokens): - t = tokens[i] - if cls._matches_flag(t, '-e', '--email'): - value, i = cls._one_flag_value(t, tokens, i) - if value: - parsed.emails.append(value) - elif cls._matches_flag(t, '-a', '--action'): - value, i = cls._one_flag_value(t, tokens, i) - parsed.action = (value or 'grant').strip().lower() or 'grant' - elif t in ('-w', '--write'): - parsed.can_edit = True - i += 1 - elif t in ('-s', '--share') and parsed.is_record: - parsed.can_share = True - i += 1 - elif cls._matches_flag(t, '-p', '--manage-records'): - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.manage_records = value - elif cls._matches_flag(t, '-o', '--manage-users'): - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.manage_users = value - elif cls._matches_flag(t, '-r', '--role') and parsed.is_nsf: - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.nsf_role = value - elif t.startswith('-'): - i = cls._skip_unknown_flag(tokens, i) - else: - positional.append(t) - i += 1 - - if parsed.is_record: - if positional: - parsed.targets = [positional[-1]] + if is_nsf: + result.nsf_role = getattr(ns, 'role', None) + elif is_record: + result.can_edit = bool(getattr(ns, 'can_edit', False)) + result.can_share = bool(getattr(ns, 'can_share', False)) else: - parsed.targets = list(positional) + result.manage_records = getattr(ns, 'manage_records', None) + result.manage_users = getattr(ns, 'manage_users', None) + return result - return parsed if parsed.emails and parsed.targets else None + @staticmethod + def _share_parser(name: str): + if name == 'share-record': + from .....commands.register import share_record_parser + return share_record_parser + if name == 'share-folder': + from .....commands.register import share_folder_parser + return share_folder_parser + if name == 'nsf-share-record': + from .....commands.nested_share_folder.parsers import nested_share_record_share_parser + return nested_share_record_share_parser + if name == 'nsf-share-folder': + from .....commands.nested_share_folder.parsers import nested_share_folder_share_parser + return nested_share_folder_share_parser + return None diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py index 7c5148e8e..78b95d0e4 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_policy.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -13,44 +13,48 @@ from __future__ import annotations -from typing import Optional +import shlex +from typing import Any, Optional, Tuple +from .....utils import is_email from .command_parse import SailPointCommandParser from .constants import SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS _ENTERPRISE_ROLE_CMDS = frozenset({'enterprise-role', 'er'}) - -# Role create / destroy / membership / rename / enforcement — not allowed in SailPoint. -_ER_BLOCKED_FLAGS = frozenset({ - '--add', - '--copy', - '--clone', - '--delete', - '--name', - '--new-user', - '--enforcement', - '-au', - '--add-user', - '-ru', - '--remove-user', - '-at', - '--add-team', - '-rt', - '--remove-team', +_ENTERPRISE_USER_CMDS = frozenset({'enterprise-user', 'eu'}) + +# Destinations SailPoint may set on enterprise-role (argparse dest names). +# Anything else that resolves on the real parser is refused — spelling-independent. +_ER_ALLOWED_DESTS = frozenset({ + 'role', + 'force', + 'verbose', + 'format', + 'output', + 'node', + 'cascade', + 'add_admin', + 'remove_admin', + 'add_privilege', + 'remove_privilege', }) -_ER_BLOCKED_PREFIXES = ( - '--name=', - '--new-user=', - '--enforcement=', -) - _ER_ALLOWED_HINT = ( '--add-admin, --remove-admin, --add-privilege, --remove-privilege ' '(plus --node, --cascade, -f)' ) +def _arg_is_set(value: Any) -> bool: + if value is None or value is False: + return False + if value is True: + return True + if isinstance(value, (list, tuple, set)): + return len(value) > 0 + return True + + class SailPointCommandPolicy: """Sanitize / restrict commands allowed for SailPoint Service Mode.""" @@ -60,8 +64,8 @@ def sanitize(cls, commands: str) -> str: Keep only SailPoint-allowed commands; always drop banned ones. Also ensures the full SailPoint allowlist is present so required - commands (e.g. enterprise-role/er) are not dropped when the input - list is a partial or older compose allowlist. + commands are not dropped when the input list is a partial or older + compose allowlist. """ allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} @@ -88,19 +92,96 @@ def validate_enterprise_role(cls, command: str) -> Optional[str]: """ Restrict enterprise-role to admin/privilege ops only. - Returns an error message when blocked, or None when allowed - (including read-only ``er ``). + Uses Commander's ``enterprise_role_parser`` so ``--add-user``, + ``--add-user=``, ``--add-us``, and ``-au=`` are treated identically. """ tokens = SailPointCommandParser.tokenize(command) if not tokens or tokens[0].lower() not in _ENTERPRISE_ROLE_CMDS: return None - for token in tokens[1:]: - lower = token.lower() - if lower in _ER_BLOCKED_FLAGS or any(lower.startswith(p) for p in _ER_BLOCKED_PREFIXES): + from .....commands.enterprise import enterprise_role_parser + + parsed = SailPointCommandParser.parse_known(enterprise_role_parser, tokens[1:]) + if not parsed: + return None + ns, unknown = parsed + + for token in unknown: + if token.startswith('-'): flag = token.split('=', 1)[0] return ( f'SailPoint mode does not allow enterprise-role {flag}. ' f'Allowed: {_ER_ALLOWED_HINT}.' ) + + for dest, value in vars(ns).items(): + if dest in _ER_ALLOWED_DESTS or not _arg_is_set(value): + continue + flag = f'--{dest.replace("_", "-")}' + return ( + f'SailPoint mode does not allow enterprise-role {flag}. ' + f'Allowed: {_ER_ALLOWED_HINT}.' + ) + return None + + @classmethod + def validate_enterprise_user_delete(cls, command: str) -> Optional[str]: + """Ban enterprise-user --delete; offboard must use transfer-user.""" + tokens = SailPointCommandParser.tokenize(command) + if not tokens or tokens[0].lower() not in _ENTERPRISE_USER_CMDS: + return None + + from .....commands.enterprise import enterprise_user_parser + + parsed = SailPointCommandParser.parse_known(enterprise_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + if ns.delete: + return ( + 'SailPoint mode does not allow enterprise-user --delete. ' + 'Use transfer-user with the configured vault transfer target instead.' + ) return None + + @classmethod + def prepare_transfer(cls, command: str, target_email: str) -> Tuple[str, Optional[str]]: + """ + Validate transfer-user and append ``--target-user`` from config. + + Non-transfer commands return ``(command, None)``. + On validation failure return ``(command, error_message)``. + """ + transfer = SailPointCommandParser.parse_transfer(command) + if transfer is None: + return command, None + + if transfer.has_target_user: + return command, ( + 'SailPoint mode does not allow --target-user on transfer-user. ' + 'The vault transfer target is configured in sailpoint-app-setup.' + ) + if not transfer.has_force: + return command, ( + 'SailPoint transfer-user requires -f / --force ' + '(Service Mode cannot prompt for confirmation).' + ) + if not transfer.emails: + return command, 'SailPoint transfer-user requires at least one leaving-user email.' + + target = (target_email or '').strip() + if not target or not is_email(target): + return command, ( + 'SailPoint transfer target email is not configured or invalid. ' + 'Run sailpoint-app-setup (or set transfer_target_email on the SailPoint config record).' + ) + + target_key = target.lower() + for email in transfer.emails: + if email.strip().lower() == target_key: + return command, ( + f'Cannot transfer user {email} to itself; ' + 'leaving email must differ from the configured transfer target.' + ) + + return f'{command.rstrip()} --target-user {shlex.quote(target)}', None diff --git a/keepercommander/service/commands/integrations/sailpoint/config_fields.py b/keepercommander/service/commands/integrations/sailpoint/config_fields.py index ce2fa7e67..e5902b5fb 100644 --- a/keepercommander/service/commands/integrations/sailpoint/config_fields.py +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Optional from .....params import KeeperParams from .constants import ( @@ -24,6 +25,7 @@ DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, POLL_INTERVAL_FIELD, + TRANSFER_TARGET_EMAIL_FIELD, ) _TRUE_VALUES = frozenset({'true', '1', 'yes', 'y', 'on'}) @@ -32,12 +34,13 @@ @dataclass(frozen=True) class SailPointCapabilities: - """Share and identity entitlement gates (nodes are never gated).""" + """Runtime SailPoint config: entitlement gates plus transfer target.""" allow_folders: bool = True allow_records: bool = True allow_roles: bool = True allow_teams: bool = True + transfer_target_email: str = '' poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS @@ -55,6 +58,17 @@ def parse_bool(raw, default: bool = True) -> bool: return default +def _custom_field_value(by_label: dict, label: str) -> Optional[str]: + field = by_label.get(label) + if not field: + return None + value = field.get_default_value() + if value is None: + return None + text = str(value).strip() + return text or None + + def read_capabilities(params: KeeperParams, record_uid: str) -> SailPointCapabilities: from ..... import vault @@ -66,17 +80,13 @@ def read_capabilities(params: KeeperParams, record_uid: str) -> SailPointCapabil by_label = {field.label: field for field in record.custom if field.label} def _bool_field(label: str) -> bool: - field = by_label.get(label) - return parse_bool(field.get_default_value() if field else None, default=True) + return parse_bool(_custom_field_value(by_label, label), default=True) interval = DEFAULT_POLL_INTERVAL_SECONDS - interval_field = by_label.get(POLL_INTERVAL_FIELD) - if interval_field: + raw_interval = _custom_field_value(by_label, POLL_INTERVAL_FIELD) + if raw_interval: try: - interval = max( - MIN_POLL_INTERVAL_SECONDS, - int(interval_field.get_default_value() or interval), - ) + interval = max(MIN_POLL_INTERVAL_SECONDS, int(raw_interval)) except (TypeError, ValueError): pass @@ -85,5 +95,6 @@ def _bool_field(label: str) -> bool: allow_records=_bool_field(ALLOW_RECORDS_FIELD), allow_roles=_bool_field(ALLOW_ROLES_FIELD), allow_teams=_bool_field(ALLOW_TEAMS_FIELD), + transfer_target_email=_custom_field_value(by_label, TRANSFER_TARGET_EMAIL_FIELD) or '', poll_interval_seconds=interval, ) diff --git a/keepercommander/service/commands/integrations/sailpoint/constants.py b/keepercommander/service/commands/integrations/sailpoint/constants.py index 7933933ea..bff5e259e 100644 --- a/keepercommander/service/commands/integrations/sailpoint/constants.py +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -26,6 +26,7 @@ ALLOW_RECORDS_FIELD = 'allow_records' ALLOW_ROLES_FIELD = 'allow_roles' ALLOW_TEAMS_FIELD = 'allow_teams' +TRANSFER_TARGET_EMAIL_FIELD = 'transfer_target_email' POLL_INTERVAL_FIELD = 'poll_interval_seconds' DEFAULT_POLL_INTERVAL_SECONDS = 60 @@ -37,8 +38,8 @@ 'enterprise-info', 'enterprise-user', 'enterprise-role', - 'er', 'enterprise-down', + 'transfer-user', 'share-folder', 'share-record', 'nsf-share-folder', diff --git a/keepercommander/service/commands/integrations/sailpoint/service.py b/keepercommander/service/commands/integrations/sailpoint/service.py index ccaf58555..a1010685b 100644 --- a/keepercommander/service/commands/integrations/sailpoint/service.py +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -126,12 +126,19 @@ def start_background_services(cls) -> None: logger.warning(f'SailPoint poller not started: {e}') @classmethod - def handle_command(cls, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: - """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" + def handle_command( + cls, params: KeeperParams, command: str + ) -> Tuple[str, Optional[Tuple[Any, int]]]: + """ + Prepare a SailPoint Service Mode command. + + Returns ``(command_to_run, short_circuit)``. Callers must gate on + ``SAILPOINT_RECORD`` before invoking this. + """ cls.bind_params(params) uid = cls.record_uid(params) if not cls.record_has_marker(params, uid): - return None + return command, None return SailPointCommandHook(uid).before_command(params, command) @classmethod diff --git a/keepercommander/service/commands/integrations/sailpoint_app_setup.py b/keepercommander/service/commands/integrations/sailpoint_app_setup.py index d52fd9041..875eeee1d 100644 --- a/keepercommander/service/commands/integrations/sailpoint_app_setup.py +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -18,9 +18,11 @@ from .... import vault from ....display import bcolors from ....error import CommandError +from ....utils import is_email from ...docker import DockerComposeBuilder, DockerSetupPrinter, SailPointConfig, SetupResult, ServiceConfig from .integration_setup_base import IntegrationSetupCommand from .sailpoint.command_policy import SailPointCommandPolicy +from .sailpoint.config_fields import read_capabilities from .sailpoint.constants import ( ALLOW_FOLDERS_FIELD, ALLOW_RECORDS_FIELD, @@ -33,6 +35,7 @@ POLL_INTERVAL_FIELD, SAILPOINT_MARKER_FIELD, SAILPOINT_RECORD_ENV, + TRANSFER_TARGET_EMAIL_FIELD, ) from .sailpoint.pending_store import SailPointPendingStore @@ -57,7 +60,7 @@ def get_record_env_key(self) -> str: def get_service_commands(self) -> str: return SailPointCommandPolicy.default_allowlist() - def collect_integration_config(self, params): + def collect_integration_config(self, params, transfer_target_default: str = ''): print(f"\n{bcolors.BOLD}SHARE ENTITLEMENTS:{bcolors.ENDC}") print(f" Control which share entitlements SailPoint may manage via Service Mode") allow_folders = self._prompt_yes_no('Allow folder shares?', default=True) @@ -68,6 +71,10 @@ def collect_integration_config(self, params): allow_roles = self._prompt_yes_no('Allow role assignment?', default=True) allow_teams = self._prompt_yes_no('Allow team assignment?', default=True) + print(f"\n{bcolors.BOLD}VAULT TRANSFER TARGET:{bcolors.ENDC}") + print(f" Active user that receives vault data when SailPoint offboards via transfer-user") + transfer_target_email = self._prompt_transfer_target_email(transfer_target_default) + print(f"\n{bcolors.BOLD}POLL INTERVAL:{bcolors.ENDC}") print(f" How often (seconds) to check whether invited users have become Active") while True: @@ -93,9 +100,28 @@ def collect_integration_config(self, params): allow_records=allow_records, allow_roles=allow_roles, allow_teams=allow_teams, + transfer_target_email=transfer_target_email, poll_interval_seconds=interval, ) + def _prompt_transfer_target_email(self, default: str = '') -> str: + default = (default or '').strip() + while True: + if default: + prompt = ( + f"{bcolors.OKBLUE}Transfer target email " + f"[Press Enter for {default}]:{bcolors.ENDC} " + ) + else: + prompt = f"{bcolors.OKBLUE}Transfer target email (required):{bcolors.ENDC} " + value = input(prompt).strip() or default + if value and is_email(value): + return value + print( + f"{bcolors.FAIL}Error: Enter a valid email address" + f"{' or press Enter to keep the current value' if default else ''}{bcolors.ENDC}" + ) + def build_record_custom_fields(self, config): return [ vault.TypedField.new_field('text', 'true', SAILPOINT_MARKER_FIELD), @@ -111,6 +137,9 @@ def build_record_custom_fields(self, config): vault.TypedField.new_field( 'text', 'true' if config.allow_teams else 'false', ALLOW_TEAMS_FIELD ), + vault.TypedField.new_field( + 'text', config.transfer_target_email, TRANSFER_TARGET_EMAIL_FIELD + ), vault.TypedField.new_field('text', str(config.poll_interval_seconds), POLL_INTERVAL_FIELD), vault.TypedField.new_field('text', json.dumps({}), PENDING_ENTITLEMENTS_FIELD), ] @@ -120,7 +149,11 @@ def _run_integration_setup(self, params, setup_result: SetupResult, record_name: str): """Create/update dedicated SailPoint config record (not the Docker config record).""" DockerSetupPrinter.print_header('SailPoint Configuration') - config = self.collect_integration_config(params) + existing_uid = self._find_record_in_folder(params, setup_result.folder_uid, record_name) + transfer_default = '' + if existing_uid: + transfer_default = read_capabilities(params, existing_uid).transfer_target_email + config = self.collect_integration_config(params, transfer_target_default=transfer_default) DockerSetupPrinter.print_step(1, 2, f"Creating SailPoint config record '{record_name}'...") custom_fields = self.build_record_custom_fields(config) @@ -178,6 +211,9 @@ def print_integration_specific_resources(self, config): print(f" • Allow Records: {bcolors.OKBLUE}{config.allow_records}{bcolors.ENDC}") print(f" • Allow Roles: {bcolors.OKBLUE}{config.allow_roles}{bcolors.ENDC}") print(f" • Allow Teams: {bcolors.OKBLUE}{config.allow_teams}{bcolors.ENDC}") + print( + f" • Transfer Target: {bcolors.OKBLUE}{config.transfer_target_email}{bcolors.ENDC}" + ) print(f" • Poll Interval: {bcolors.OKBLUE}{config.poll_interval_seconds}s{bcolors.ENDC}") print(f" • Pending JSON field: {bcolors.OKBLUE}{PENDING_ENTITLEMENTS_FIELD}{bcolors.ENDC}") print(f" • Env key: {bcolors.OKBLUE}{self.get_record_env_key()}{bcolors.ENDC}") @@ -188,4 +224,6 @@ def print_integration_commands(self): print(f" Invite now; role/team queued until the user is Active") print(f" {bcolors.OKGREEN}• share-record -e user@co.com RECORD_UID{bcolors.ENDC}") print(f" {bcolors.OKGREEN}• share-folder -e user@co.com FOLDER_UID{bcolors.ENDC}") - print(f" Queued while invited; applied after activation\n") + print(f" Queued while invited; applied after activation") + print(f" {bcolors.OKGREEN}• transfer-user 'leaving@co.com' -f{bcolors.ENDC}") + print(f" Transfers vault to the configured target, then removes the leaving user\n") diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index f4874d880..251e6da60 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -130,6 +130,7 @@ class SailPointConfig: allow_records: bool = True allow_roles: bool = True allow_teams: bool = True + transfer_target_email: str = '' # Keep in sync with sailpoint.constants.DEFAULT_POLL_INTERVAL_SECONDS poll_interval_seconds: int = 60 diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index a352d6d43..5458b495c 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -177,7 +177,7 @@ def execute(cls, command: str) -> Tuple[Any, int]: sailpoint_enabled = bool((os.environ.get('SAILPOINT_RECORD') or '').strip()) if sailpoint_enabled: from ..commands.integrations.sailpoint.service import SailPointService - sailpoint_response = SailPointService.handle_command(params, command) + command, sailpoint_response = SailPointService.handle_command(params, command) if sailpoint_response is not None: response, status_code = sailpoint_response response = CommandExecutor.encrypt_response(response) diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py index f8ee726fb..f68b0c763 100644 --- a/unit-tests/service/test_sailpoint_pending.py +++ b/unit-tests/service/test_sailpoint_pending.py @@ -200,6 +200,21 @@ def test_parse_share_equals_forms(self): self.assertIsNotNone(nsf) self.assertEqual(nsf.nsf_role, 'content-manager') + def test_parse_transfer(self): + parsed = SailPointCommandParser.parse_transfer("transfer-user 'leaving@co.com' -f") + self.assertIsNotNone(parsed) + self.assertEqual(parsed.emails, ['leaving@co.com']) + self.assertTrue(parsed.has_force) + self.assertFalse(parsed.has_target_user) + + parsed = SailPointCommandParser.parse_transfer( + 'transfer-user leaving@co.com --target-user=other@co.com -f' + ) + self.assertTrue(parsed.has_target_user) + self.assertTrue(parsed.has_force) + + self.assertIsNone(SailPointCommandParser.parse_transfer('enterprise-user x@co.com --delete')) + class SailPointPolicyTest(unittest.TestCase): def test_sanitize_strips_get(self): @@ -217,16 +232,26 @@ def test_sanitize_adds_enterprise_role_when_missing(self): parts = cleaned.split(',') self.assertNotIn('get', parts) self.assertIn('enterprise-role', parts) - self.assertIn('er', parts) + self.assertNotIn('er', parts) def test_default_allowlist_matches_integration_list(self): expected = [ - 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-role', 'er', - 'enterprise-down', 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', + 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-role', + 'enterprise-down', 'transfer-user', + 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', 'tree', ] self.assertEqual(SailPointCommandPolicy.default_allowlist().split(','), expected) + def test_sanitize_adds_transfer_user_when_missing(self): + cleaned = SailPointCommandPolicy.sanitize( + 'whoami,sync-down,enterprise-info,enterprise-user,enterprise-down,' + 'share-folder,share-record,tree' + ) + parts = cleaned.split(',') + self.assertIn('transfer-user', parts) + self.assertNotIn('tu', parts) + def test_enterprise_role_blocks_add_delete_add_user(self): for cmd in ( "enterprise-role --add 'New Role'", @@ -235,6 +260,13 @@ def test_enterprise_role_blocks_add_delete_add_user(self): "enterprise-role 'QA Role' --copy", "er 'QA Role' --name 'Renamed'", "er 'QA Role' --enforcement restrict_sharing:true", + # Equals / abbrev / short= forms must resolve the same as blocked long flags. + "enterprise-role 'QA Role' --add-user=user@co.com", + "enterprise-role 'QA Role' --add-us user@co.com", + "enterprise-role 'QA Role' -au=user@co.com", + "enterprise-role 'QA Role' --dele -f", + "enterprise-role 'QA Role' --nam=Pwned", + "enterprise-role 'QA Role' --enforce=restrict_sharing_all:true", ): err = SailPointCommandPolicy.validate_enterprise_role(cmd) self.assertIsNotNone(err, cmd) @@ -257,6 +289,72 @@ def test_enterprise_role_gate_ignores_other_commands(self): ) ) + def test_enterprise_user_delete_blocked(self): + for cmd in ( + 'enterprise-user leaving@co.com --delete', + 'eu leaving@co.com --delete', + ): + err = SailPointCommandPolicy.validate_enterprise_user_delete(cmd) + self.assertIsNotNone(err, cmd) + self.assertIn('--delete', err) + self.assertIn('transfer-user', err) + + def test_enterprise_user_delete_allows_other_ops(self): + self.assertIsNone( + SailPointCommandPolicy.validate_enterprise_user_delete( + 'eu user@co.com --add-role Admin' + ) + ) + self.assertIsNone( + SailPointCommandPolicy.validate_enterprise_user_delete( + 'enterprise-user user@co.com --delete-alias old@co.com' + ) + ) + + def test_prepare_transfer_appends_config_email(self): + cmd = "transfer-user 'leaving@co.com' -f" + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertIsNone(err) + self.assertTrue(rewritten.startswith(cmd)) + tokens = SailPointCommandParser.tokenize(rewritten) + self.assertIn('--target-user', tokens) + self.assertEqual(tokens[tokens.index('--target-user') + 1], 'target@co.com') + + def test_prepare_transfer_rejects_explicit_target(self): + cmd = 'transfer-user leaving@co.com -f --target-user other@co.com' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIsNotNone(err) + self.assertIn('--target-user', err) + + def test_prepare_transfer_rejects_self_transfer(self): + cmd = 'transfer-user Target@Co.com -f' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIsNotNone(err) + self.assertIn('itself', err) + + def test_prepare_transfer_requires_force_and_valid_config(self): + cmd = 'transfer-user leaving@co.com' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIn('-f', err) + + cmd = 'transfer-user leaving@co.com -f' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, '') + self.assertEqual(rewritten, cmd) + self.assertIn('not configured', err) + + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'not-an-email') + self.assertEqual(rewritten, cmd) + self.assertIn('not configured', err) + + def test_prepare_transfer_ignores_other_commands(self): + cmd = 'enterprise-user user@co.com --add-role Admin' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIsNone(err) + class SailPointPendingMergeTest(unittest.TestCase): def test_merge_by_email(self): @@ -587,6 +685,35 @@ def test_teams_off_blocks_add_team_allows_node(self): ) self.assertIsNone(err) + def test_capability_gates_catch_abbreviated_flags(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + roles_off = SailPointCapabilities(allow_roles=False, allow_teams=True) + err = SailPointCommandHook._check_capability_gates( + 'enterprise-user user@co.com --add-rol Admin', roles_off + ) + self.assertIsNotNone(err) + self.assertIn('allow_roles', err) + + teams_off = SailPointCapabilities(allow_roles=True, allow_teams=False) + err = SailPointCommandHook._check_capability_gates( + 'enterprise-user user@co.com --add-tea Slack', teams_off + ) + self.assertIsNotNone(err) + self.assertIn('allow_teams', err) + + share = SailPointCommandParser.parse_share( + 'share-record --emai attacker@co.com --write SOMERECORDUID' + ) + self.assertIsNotNone(share) + self.assertEqual(share.emails, ['attacker@co.com']) + self.assertTrue(share.can_edit) + def test_apply_skips_folders_and_records_when_disallowed(self): params = mock.Mock() params.enterprise = { @@ -644,6 +771,115 @@ def test_mixed_active_and_invited_share_rejected(self): self.assertEqual(status, 400) self.assertIn('mix Active and non-Active', response['error']) + def test_share_capability_gates_apply_to_non_grant_actions(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + hook = SailPointCommandHook('cfg-uid') + params = mock.Mock() + records_off = SailPointCapabilities(allow_records=False, allow_folders=True) + folders_off = SailPointCapabilities(allow_records=True, allow_folders=False) + both_on = SailPointCapabilities(allow_records=True, allow_folders=True) + + for cmd in ( + 'share-record -e user@co.com --action owner RECORD_UID', + 'share-record -e user@co.com -a owner RECORD_UID', + 'share-record -e user@co.com --action revoke RECORD_UID', + 'share-record -e user@co.com --action cancel -f RECORD_UID', + 'nsf-share-record -e user@co.com --action owner -r viewer RECORD_UID', + ): + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNotNone(share, cmd) + self.assertFalse(share.is_grant, cmd) + response, status = hook._before_share(params, share, records_off) + self.assertEqual(status, 403, cmd) + self.assertIn('allow_records', response['error'], cmd) + self.assertIsNone(hook._before_share(params, share, both_on), cmd) + + for cmd in ( + 'share-folder -e user@co.com --action remove FOLDER_UID', + 'nsf-share-folder -e user@co.com --action remove FOLDER_UID', + ): + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNotNone(share, cmd) + self.assertFalse(share.is_grant, cmd) + response, status = hook._before_share(params, share, folders_off) + self.assertEqual(status, 403, cmd) + self.assertIn('allow_folders', response['error'], cmd) + self.assertIsNone(hook._before_share(params, share, both_on), cmd) + + def test_non_grant_share_never_queues_for_invited_user(self): + """Revoke/owner/remove must pass through to Commander, not pending entitlements.""" + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + from keepercommander.service.commands.integrations.sailpoint.pending_store import ( + SailPointPendingStore, + ) + + params = mock.Mock() + params.enterprise = { + 'users': [{'username': 'invited@co.com', 'node_id': 1, 'status': 'invited'}], + 'nodes': [{'node_id': 1}], + 'scims': [], + } + hook = SailPointCommandHook('cfg-uid') + caps = SailPointCapabilities(allow_records=True, allow_folders=True) + + with mock.patch.object(SailPointPendingStore, 'update') as update: + for cmd in ( + 'share-record -e invited@co.com --action revoke RECORD_UID', + 'share-record -e invited@co.com --action owner RECORD_UID', + 'share-record -e invited@co.com --action cancel -f RECORD_UID', + 'share-folder -e invited@co.com --action remove FOLDER_UID', + 'nsf-share-record -e invited@co.com --action owner -r viewer RECORD_UID', + 'nsf-share-folder -e invited@co.com --action remove FOLDER_UID', + ): + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNotNone(share, cmd) + self.assertFalse(share.is_grant, cmd) + self.assertIsNone(hook._before_share(params, share, caps), cmd) + update.assert_not_called() + + def test_before_command_injects_transfer_target(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + caps = SailPointCapabilities(transfer_target_email='target@co.com') + hook = SailPointCommandHook('cfg-uid') + with mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.command_hook.read_capabilities', + return_value=caps, + ): + command, short = hook.before_command( + mock.Mock(), "transfer-user 'leaving@co.com' -f" + ) + self.assertIsNone(short) + self.assertIn('--target-user', command) + self.assertIn('target@co.com', command) + + with mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.command_hook.read_capabilities', + return_value=caps, + ): + command, short = hook.before_command( + mock.Mock(), 'enterprise-user leaving@co.com --delete' + ) + self.assertIsNotNone(short) + self.assertEqual(short[1], 403) + self.assertIn('--delete', short[0]['error']) + def test_after_command_skips_missing_user(self): from keepercommander.service.commands.integrations.sailpoint.command_hook import ( SailPointCommandHook,