From 08d20342ad6de5ed32a14f99b0e9c875a714cacf Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Mon, 27 Jul 2026 16:16:29 +0530 Subject: [PATCH 1/4] Implement Sailpoint App Setup Command for sailpoint integration --- keepercommander/command_categories.py | 3 +- keepercommander/commands/start_service.py | 8 +- keepercommander/service/app.py | 3 + .../service/commands/create_service.py | 11 +- .../service/commands/integrations/__init__.py | 2 + .../integrations/integration_setup_base.py | 12 +- .../integrations/sailpoint/__init__.py | 32 +++ .../sailpoint/apply_entitlements.py | 217 ++++++++++++++ .../integrations/sailpoint/command_hook.py | 166 +++++++++++ .../integrations/sailpoint/command_parse.py | 197 +++++++++++++ .../integrations/sailpoint/command_policy.py | 48 ++++ .../integrations/sailpoint/config_fields.py | 54 ++++ .../integrations/sailpoint/constants.py | 62 ++++ .../integrations/sailpoint/pending_store.py | 174 ++++++++++++ .../commands/integrations/sailpoint/poller.py | 93 ++++++ .../integrations/sailpoint/scim_guard.py | 91 ++++++ .../integrations/sailpoint/service.py | 138 +++++++++ .../integrations/sailpoint_app_setup.py | 171 +++++++++++ keepercommander/service/config/models.py | 2 +- keepercommander/service/docker/__init__.py | 5 +- .../service/docker/compose_builder.py | 8 +- keepercommander/service/docker/models.py | 6 + keepercommander/service/util/command_util.py | 36 ++- unit-tests/service/test_sailpoint_pending.py | 265 ++++++++++++++++++ 24 files changed, 1793 insertions(+), 11 deletions(-) create mode 100644 keepercommander/service/commands/integrations/sailpoint/__init__.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/command_hook.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/command_parse.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/command_policy.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/config_fields.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/constants.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/pending_store.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/poller.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/scim_guard.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/service.py create mode 100644 keepercommander/service/commands/integrations/sailpoint_app_setup.py create mode 100644 unit-tests/service/test_sailpoint_pending.py diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py index d7c1504f5..f1be4583b 100644 --- a/keepercommander/command_categories.py +++ b/keepercommander/command_categories.py @@ -83,7 +83,8 @@ # Service Mode REST API 'Service Mode REST API': { 'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status', - 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup' + 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup', + 'sailpoint-app-setup' }, # Email Configuration Commands diff --git a/keepercommander/commands/start_service.py b/keepercommander/commands/start_service.py index 6524a596f..fafaf1568 100644 --- a/keepercommander/commands/start_service.py +++ b/keepercommander/commands/start_service.py @@ -13,7 +13,9 @@ from ..service.commands.config_operation import AddConfigService from ..service.commands.handle_service import StartService, StopService, ServiceStatus from ..service.commands.service_docker_setup import ServiceDockerSetupCommand -from ..service.commands.integrations import SlackAppSetupCommand, TeamsAppSetupCommand +from ..service.commands.integrations import ( + SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand, +) def register_commands(commands): commands['service-create'] = CreateService() @@ -24,6 +26,7 @@ def register_commands(commands): commands['service-docker-setup'] = ServiceDockerSetupCommand() commands['slack-app-setup'] = SlackAppSetupCommand() commands['teams-app-setup'] = TeamsAppSetupCommand() + commands['sailpoint-app-setup'] = SailPointAppSetupCommand() def register_command_info(aliases, command_info): service_classes = [ @@ -34,7 +37,8 @@ def register_command_info(aliases, command_info): ServiceStatus, ServiceDockerSetupCommand, SlackAppSetupCommand, - TeamsAppSetupCommand + TeamsAppSetupCommand, + SailPointAppSetupCommand, ] for service_class in service_classes: diff --git a/keepercommander/service/app.py b/keepercommander/service/app.py index ea2b741a0..5e6748764 100644 --- a/keepercommander/service/app.py +++ b/keepercommander/service/app.py @@ -46,6 +46,9 @@ def handle_rate_limit_exceeded(e): logger.debug("Initializing API routes") init_routes(app) + from .commands.integrations.sailpoint.service import SailPointService + SailPointService.start_background_services() + print("Keeper Commander Service initialization complete") return app diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py index 6e89f8496..d2ae29e17 100644 --- a/keepercommander/service/commands/create_service.py +++ b/keepercommander/service/commands/create_service.py @@ -92,9 +92,18 @@ def execute(self, params: KeeperParams, **kwargs) -> None: from ..core.globals import init_globals init_globals(params) - filtered_kwargs = {k: v for k, v in kwargs.items() if k in ['port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', 'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption', 'encryption_key', 'token_expiration']} + filtered_kwargs = {k: v for k, v in kwargs.items() if k in [ + 'port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', + 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', + 'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption', + 'encryption_key', 'token_expiration', + ]} args = StreamlineArgs(**filtered_kwargs) + # Optional SailPoint: enable when SAILPOINT_RECORD points at a marked config record + from .integrations.sailpoint.service import SailPointService + SailPointService.maybe_enable(params, args) + from .integrations.vault_metadata import get_existing_api_key, write_service_metadata existing_api_key = ( get_existing_api_key(params, args.update_vault_record) diff --git a/keepercommander/service/commands/integrations/__init__.py b/keepercommander/service/commands/integrations/__init__.py index e4e4570ae..7375a8cc8 100644 --- a/keepercommander/service/commands/integrations/__init__.py +++ b/keepercommander/service/commands/integrations/__init__.py @@ -14,9 +14,11 @@ from .integration_setup_base import IntegrationSetupCommand from .slack_app_setup import SlackAppSetupCommand from .teams_app_setup import TeamsAppSetupCommand +from .sailpoint_app_setup import SailPointAppSetupCommand __all__ = [ 'IntegrationSetupCommand', 'SlackAppSetupCommand', 'TeamsAppSetupCommand', + 'SailPointAppSetupCommand', ] diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 55da36f5b..1ff828195 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -498,8 +498,16 @@ def _print_integration_resources(self, record_uid: str, config) -> None: name = self.get_integration_name() print(f" • {name} Config Record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") self.print_integration_specific_resources(config) - print(f" • EPM Integration: {bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}") - print(f" • Device Approval: {bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}") + if hasattr(config, 'pedm_enabled'): + print( + f" • EPM Integration: " + f"{bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}" + ) + if hasattr(config, 'device_approval_enabled'): + print( + f" • Device Approval: " + f"{bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}" + ) # -- Optional feature collectors ----------------------------------- diff --git a/keepercommander/service/commands/integrations/sailpoint/__init__.py b/keepercommander/service/commands/integrations/sailpoint/__init__.py new file mode 100644 index 000000000..125f8a046 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/__init__.py @@ -0,0 +1,32 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + for role in params.enterprise.get('roles') or []: + display = (role.get('data') or {}).get('displayname') or '' + if str(role.get('role_id')) == role_name or display.lower() == role_name.lower(): + return True + return False + + @staticmethod + def _team_exists(params: KeeperParams, team_name: str) -> bool: + for team in params.enterprise.get('teams') or []: + if team.get('team_uid') == team_name or (team.get('name') or '').lower() == team_name.lower(): + return True + return False + + @staticmethod + def _run(params: KeeperParams, command: str) -> None: + from ..... import cli + logger.info(f'SailPoint apply: {command}') + cli.do_command(params, command) + + @staticmethod + def _is_missing_target(error: Exception) -> bool: + msg = str(error).lower() + return any(h in msg for h in _NOT_FOUND_HINTS) + + @staticmethod + def _shell_quote(value: str) -> str: + return shlex.quote(str(value)) + + @classmethod + def user_is_active(cls, params: KeeperParams, email: str) -> bool: + user = SailPointScimGuard.find_user(params, email) + return bool(user and user.get('status') == 'active') + + @classmethod + def _apply_folder(cls, params: KeeperParams, email: str, folder: Dict[str, Any]) -> None: + uid = folder.get('uid') + if not uid: + raise ValueError('Folder entry missing uid') + kind = (folder.get('kind') or 'classic').lower() + email_q = cls._shell_quote(email) + uid_q = cls._shell_quote(uid) + if kind == 'nsf': + role = folder.get('role') or 'viewer' + cls._run( + params, + f'nsf-share-folder -a grant -e {email_q} -r {cls._shell_quote(role)} {uid_q}', + ) + return + + flags = [] + manage_records = folder.get('manage_records') + manage_users = folder.get('manage_users') + if manage_records in ('on', 'off'): + flags.append(f'--manage-records {manage_records}') + if manage_users in ('on', 'off'): + flags.append(f'--manage-users {manage_users}') + flag_str = (' ' + ' '.join(flags)) if flags else '' + cls._run(params, f'share-folder -a grant --email {email_q}{flag_str} {uid_q}') + + @classmethod + def _apply_record(cls, params: KeeperParams, email: str, record: Dict[str, Any]) -> None: + uid = record.get('uid') + if not uid: + raise ValueError('Record entry missing uid') + kind = (record.get('kind') or 'classic').lower() + email_q = cls._shell_quote(email) + uid_q = cls._shell_quote(uid) + if kind == 'nsf': + role = record.get('role') or 'viewer' + cls._run( + params, + f'nsf-share-record -a grant -e {email_q} -r {cls._shell_quote(role)} {uid_q}', + ) + return + + flags = [] + if record.get('can_edit'): + flags.append('--write') + if record.get('can_share'): + flags.append('--share') + flag_str = (' ' + ' '.join(flags)) if flags else '' + cls._run(params, f'share-record --email {email_q}{flag_str} {uid_q}') + + @classmethod + def apply_for_user( + cls, + params: KeeperParams, + email: str, + entry: Dict[str, Any], + *, + entitlement_scope: str = 'both', + ) -> Tuple[Dict[str, Any], List[str]]: + remaining = { + 'created_at': entry.get('created_at'), + 'last_error': None, + 'roles': list(entry.get('roles') or []), + 'teams': list(entry.get('teams') or []), + 'folders': [dict(x) for x in (entry.get('folders') or [])], + 'records': [dict(x) for x in (entry.get('records') or [])], + } + dropped: List[str] = [] + scim_user = SailPointScimGuard.is_scim_managed_user(params, email) + email_q = cls._shell_quote(email) + + if scim_user: + if remaining['roles'] or remaining['teams']: + dropped.append( + f'SCIM-managed user {email}: skipped pending roles/teams (identity coexistence)' + ) + remaining['roles'] = [] + remaining['teams'] = [] + else: + still_roles = [] + for role in remaining['roles']: + if not cls._role_exists(params, role): + dropped.append(f'Role not found, dropped: {role}') + continue + try: + cls._run(params, f'enterprise-user {email_q} --add-role {cls._shell_quote(role)}') + except Exception as e: + logger.warning(f'Failed to add role {role} for {email}: {e}') + still_roles.append(role) + remaining['last_error'] = str(e) + remaining['roles'] = still_roles + + still_teams = [] + for team in remaining['teams']: + if not cls._team_exists(params, team): + dropped.append(f'Team not found, dropped: {team}') + continue + try: + cls._run(params, f'enterprise-user {email_q} --add-team {cls._shell_quote(team)}') + except Exception as e: + logger.warning(f'Failed to add team {team} for {email}: {e}') + still_teams.append(team) + remaining['last_error'] = str(e) + remaining['teams'] = still_teams + + allow_folders = entitlement_scope in ('folders', 'both') + allow_records = entitlement_scope in ('records', 'both') + + still_folders = [] + if allow_folders: + for folder in remaining['folders']: + uid = folder.get('uid') + if not uid: + dropped.append('Folder entry missing uid, dropped') + continue + try: + cls._apply_folder(params, email, folder) + except Exception as e: + if cls._is_missing_target(e): + dropped.append(f'Folder not found, dropped: {uid}') + else: + logger.warning(f'Failed to share folder {uid} with {email}: {e}') + still_folders.append(folder) + remaining['last_error'] = str(e) + remaining['folders'] = still_folders + elif remaining['folders']: + dropped.append('Folder shares skipped by entitlement_scope') + remaining['folders'] = [] + + still_records = [] + if allow_records: + for record in remaining['records']: + uid = record.get('uid') + if not uid: + dropped.append('Record entry missing uid, dropped') + continue + try: + cls._apply_record(params, email, record) + except Exception as e: + if cls._is_missing_target(e): + dropped.append(f'Record not found, dropped: {uid}') + else: + logger.warning(f'Failed to share record {uid} with {email}: {e}') + still_records.append(record) + remaining['last_error'] = str(e) + remaining['records'] = still_records + elif remaining['records']: + dropped.append('Record shares skipped by entitlement_scope') + remaining['records'] = [] + + if SailPointPendingStore.entry_is_empty(remaining): + remaining = {} + return remaining, dropped diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py new file mode 100644 index 000000000..d8d04a0d1 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -0,0 +1,166 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[Tuple[Any, int]]: + """Return (response, status_code) to short-circuit, or None to continue.""" + invite = SailPointCommandParser.parse_invite(command) + if invite and invite.emails: + return self._before_invite(params, invite) + + share = SailPointCommandParser.parse_share(command) + if share: + return self._before_share(params, share) + + mutation = SailPointCommandParser.parse_identity_mutation(command) + if mutation: + for email in mutation.emails: + err = SailPointScimGuard.identity_change_error(params, email) + if err: + return {'status': 'error', 'error': err}, 403 + return None + + def after_command(self, params: KeeperParams, command: str, success: bool) -> None: + if not success: + return + invite = SailPointCommandParser.parse_invite(command) + if not invite or not invite.emails or (not invite.roles and not invite.teams): + return + + roles = list(invite.roles) if invite.roles else None + teams = list(invite.teams) if invite.teams else None + queued: List[str] = [] + + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: + nonlocal queued + queued = [] + result = pending + for email in invite.emails: + if ( + SailPointScimGuard.find_user(params, email) + and SailPointScimGuard.identity_change_error(params, email) + ): + continue + result = SailPointPendingStore.merge_entry( + result, email, roles=roles, teams=teams + ) + queued.append(email) + return result + + SailPointPendingStore.update(params, self.record_uid, updater) + for email in queued: + logger.info( + f'Queued SailPoint pending entitlements for {email} ' + f'(roles={invite.roles} teams={invite.teams})' + ) + + def _before_invite(self, params: KeeperParams, invite) -> Optional[Tuple[Any, int]]: + roles = list(invite.roles) + teams = list(invite.teams) + for email in invite.emails: + existing = SailPointScimGuard.find_user(params, email) + if existing and (roles or teams): + err = SailPointScimGuard.identity_change_error(params, email) + if err: + return {'status': 'error', 'error': err}, 403 + return None + + def _user_status(self, params: KeeperParams, email: str) -> Optional[str]: + user = SailPointScimGuard.find_user(params, email) + return user.get('status') if user else None + + @staticmethod + def _folder_payload(share: ParsedShare, target: str) -> Dict[str, Any]: + item: Dict[str, Any] = {'uid': target} + if share.is_nsf: + item['kind'] = 'nsf' + item['role'] = share.nsf_role or 'viewer' + else: + item['kind'] = 'classic' + item['manage_records'] = share.manage_records + item['manage_users'] = share.manage_users + return item + + @staticmethod + def _record_payload(share: ParsedShare, target: str) -> Dict[str, Any]: + item: Dict[str, Any] = {'uid': target} + if share.is_nsf: + item['kind'] = 'nsf' + item['role'] = share.nsf_role or 'viewer' + else: + item['kind'] = 'classic' + item['can_edit'] = share.can_edit + item['can_share'] = share.can_share + return item + + def _before_share(self, params: KeeperParams, share: ParsedShare) -> Optional[Tuple[Any, int]]: + scope = read_entitlement_scope(params, self.record_uid) + if share.is_folder and scope == 'records': + return { + 'status': 'error', + 'error': 'SailPoint entitlement_scope is records-only; share-folder is not allowed.', + }, 403 + if share.is_record and scope == 'folders': + return { + 'status': 'error', + 'error': 'SailPoint entitlement_scope is folders-only; share-record is not allowed.', + }, 403 + + deferred = [e for e in share.emails if self._user_status(params, e) != 'active'] + if not deferred: + return None + + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: + result = pending + for email in deferred: + if share.is_folder: + folders = [self._folder_payload(share, t) for t in share.targets] + result = SailPointPendingStore.merge_entry(result, email, folders=folders) + else: + records = [self._record_payload(share, t) for t in share.targets] + result = SailPointPendingStore.merge_entry(result, email, records=records) + return result + + SailPointPendingStore.update(params, self.record_uid, updater) + for email in deferred: + logger.info( + f'Queued SailPoint pending ' + f'{"folder" if share.is_folder else "record"} share for {email} ' + f'-> {", ".join(share.targets)}' + ) + + active = [e for e in share.emails if e not in deferred] + message = f'User(s) not yet active; queued share for: {", ".join(deferred)}.' + if active: + message += ( + f' Active user(s) were not shared in this request (call share separately): ' + f'{", ".join(active)}.' + ) + return {'status': 'success', 'message': message, 'queued': deferred}, 200 diff --git a/keepercommander/service/commands/integrations/sailpoint/command_parse.py b/keepercommander/service/commands/integrations/sailpoint/command_parse.py new file mode 100644 index 000000000..dba8e5388 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -0,0 +1,197 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + return self.targets[-1] if self.targets else None + + +@dataclass +class ParsedIdentityMutation: + """enterprise-user identity change (role/team/node) — used for SCIM coexistence.""" + + emails: List[str] = field(default_factory=list) + + +class SailPointCommandParser: + """Parse enterprise-user invite and share-* command strings.""" + + @staticmethod + def _tokenize(command: str) -> List[str]: + try: + return shlex.split(command) + except ValueError: + return command.split() + + @classmethod + def parse_invite(cls, command: str) -> Optional[ParsedInvite]: + tokens = cls._tokenize(command) + if not tokens or tokens[0] not in ('enterprise-user', 'eu'): + return None + + parsed = ParsedInvite() + i = 1 + positional: List[str] = [] + while i < len(tokens): + t = tokens[i] + if t in ('--invite', '--add', '-invite'): + parsed.is_invite = True + i += 1 + elif t in ('--node', '-n') and i + 1 < len(tokens): + parsed.node = tokens[i + 1] + i += 2 + elif t == '--add-role' and i + 1 < len(tokens): + parsed.roles.append(tokens[i + 1]) + i += 2 + elif t == '--add-team' and i + 1 < len(tokens): + parsed.teams.append(tokens[i + 1]) + i += 2 + elif t.startswith('-'): + if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): + i += 2 + else: + i += 1 + else: + positional.append(t) + i += 1 + + parsed.emails = [e for e in positional if '@' in e] + return parsed if parsed.is_invite else None + + @classmethod + def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutation]: + tokens = cls._tokenize(command) + if not tokens or tokens[0] not in ('enterprise-user', 'eu'): + return None + + emails: List[str] = [] + mutating = False + i = 1 + while i < len(tokens): + t = tokens[i] + if t in _IDENTITY_FLAGS: + mutating = True + if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): + i += 2 + else: + i += 1 + elif t.startswith('-'): + if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): + i += 2 + else: + i += 1 + else: + if '@' in t: + emails.append(t) + i += 1 + + if not mutating or not emails: + return None + return ParsedIdentityMutation(emails=emails) + + @classmethod + def parse_share(cls, command: str) -> Optional[ParsedShare]: + tokens = cls._tokenize(command) + if not tokens: + return None + name = tokens[0] + if name not in _FOLDER_CMDS and name not in _RECORD_CMDS: + return None + + parsed = 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, + ) + i = 1 + positional: List[str] = [] + while i < len(tokens): + t = tokens[i] + if t in ('-e', '--email') and i + 1 < len(tokens): + parsed.emails.append(tokens[i + 1]) + i += 2 + elif t in ('-w', '--write'): + parsed.can_edit = True + i += 1 + elif t in ('-s', '--share') and name in _RECORD_CMDS: + parsed.can_share = True + i += 1 + elif t in ('-p', '--manage-records') and i + 1 < len(tokens): + parsed.manage_records = tokens[i + 1] + i += 2 + elif t in ('-o', '--manage-users') and i + 1 < len(tokens): + parsed.manage_users = tokens[i + 1] + i += 2 + elif ( + t in ('-r', '--role') + and name in (_NSF_FOLDER | _NSF_RECORD) + and i + 1 < len(tokens) + ): + parsed.nsf_role = tokens[i + 1] + i += 2 + elif t.startswith('-'): + if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): + i += 2 + else: + i += 1 + else: + positional.append(t) + i += 1 + + if name in _RECORD_CMDS: + # Classic/NSF record share: single target (last positional). + if positional: + parsed.targets = [positional[-1]] + else: + parsed.targets = list(positional) + + return parsed if parsed.emails and parsed.targets else None diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py new file mode 100644 index 000000000..966b43e7a --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -0,0 +1,48 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Keep only SailPoint-allowed commands; always drop banned secret-bearing ones.""" + allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} + banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} + parts: List[str] = [] + seen = set() + for raw in (commands or '').split(','): + cmd = raw.strip() + if not cmd: + continue + key = cmd.lower() + if key in banned or key not in allowed: + continue + if key in seen: + continue + seen.add(key) + parts.append(cmd) + if not parts: + return ','.join(SAILPOINT_ALLOWED_COMMANDS) + return ','.join(parts) + + @classmethod + def default_allowlist(cls) -> str: + return cls.sanitize(','.join(SAILPOINT_ALLOWED_COMMANDS)) diff --git a/keepercommander/service/commands/integrations/sailpoint/config_fields.py b/keepercommander/service/commands/integrations/sailpoint/config_fields.py new file mode 100644 index 000000000..3a954032f --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -0,0 +1,54 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Tuple[str, int]: + from ..... import vault + + scope = SCOPE_BOTH + interval = DEFAULT_POLL_INTERVAL_SECONDS + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return scope, interval + + for field in record.custom: + if field.label == ENTITLEMENT_SCOPE_FIELD: + value = (field.get_default_value() or SCOPE_BOTH).strip().lower() + if value in _VALID_SCOPES: + scope = value + elif field.label == POLL_INTERVAL_FIELD: + try: + interval = max(_MIN_POLL_INTERVAL, int(field.get_default_value() or interval)) + except (TypeError, ValueError): + pass + return scope, interval + + +def read_entitlement_scope(params: KeeperParams, record_uid: str) -> str: + scope, _ = read_scope_and_interval(params, record_uid) + return scope diff --git a/keepercommander/service/commands/integrations/sailpoint/constants.py b/keepercommander/service/commands/integrations/sailpoint/constants.py new file mode 100644 index 000000000..2391f586e --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -0,0 +1,62 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + @classmethod + def empty_entry(cls) -> Dict[str, Any]: + return { + 'created_at': cls._utc_now(), + 'last_error': None, + 'roles': [], + 'teams': [], + 'folders': [], + 'records': [], + } + + @classmethod + def load(cls, params: KeeperParams, record_uid: str) -> Dict[str, Any]: + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return {} + for field in record.custom: + if field.label != PENDING_ENTITLEMENTS_FIELD: + continue + raw = field.get_default_value() + if not raw: + return {} + try: + data = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, json.JSONDecodeError): + logger.warning(f'Invalid pending_entitlements JSON on record {record_uid}') + return {} + return data if isinstance(data, dict) else {} + return {} + + @classmethod + def _write(cls, params: KeeperParams, record_uid: str, pending: Dict[str, Any]) -> None: + payload = json.dumps(pending, indent=2, sort_keys=True) + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + raise RuntimeError(f'SailPoint config record {record_uid} missing or not typed') + + preserved = [ + f for f in (record.custom or []) + if f.label != PENDING_ENTITLEMENTS_FIELD + ] + record.custom = preserved + [ + vault.TypedField.new_field('text', payload, PENDING_ENTITLEMENTS_FIELD), + ] + record_management.update_record(params, record) + params.sync_data = True + api.sync_down(params) + + @classmethod + def update( + cls, + params: KeeperParams, + record_uid: str, + updater: Callable[[Dict[str, Any]], Dict[str, Any]], + ) -> Dict[str, Any]: + """ + Sync, load, apply updater, write. Retries on stale revision so concurrent + writers merge against the latest remote state instead of overwriting it. + """ + last_error: Optional[Exception] = None + for attempt in range(1, _MAX_ATTEMPTS + 1): + try: + params.sync_data = True + api.sync_down(params) + current = cls.load(params, record_uid) + next_state = updater(deepcopy(current)) + if next_state is None: + next_state = {} + if not isinstance(next_state, dict): + raise TypeError('pending entitlements updater must return a dict') + if json.dumps(next_state, sort_keys=True) == json.dumps(current, sort_keys=True): + return current + cls._write(params, record_uid, next_state) + return next_state + except Exception as e: + last_error = e + if not any(h in str(e).lower() for h in _STALE_HINTS) or attempt == _MAX_ATTEMPTS: + break + logger.warning( + f'Stale revision updating pending entitlements; retrying ({attempt}/{_MAX_ATTEMPTS})' + ) + + raise RuntimeError(f'Failed to save pending entitlements: {last_error}') + + @staticmethod + def _merge_share_list( + existing: List[Dict[str, Any]], items: List[Dict[str, Any]], uid_key: str + ) -> List[Dict[str, Any]]: + by_uid = {str(x.get(uid_key)): dict(x) for x in existing if x.get(uid_key)} + for item in items: + uid = item.get(uid_key) + if not uid: + continue + key = str(uid) + if key in by_uid: + by_uid[key].update(item) + else: + by_uid[key] = dict(item) + return list(by_uid.values()) + + @classmethod + def merge_entry( + cls, + pending: Dict[str, Any], + email: str, + *, + roles: Optional[List[str]] = None, + teams: Optional[List[str]] = None, + folders: Optional[List[Dict[str, Any]]] = None, + records: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + result = deepcopy(pending) + key = email.strip().lower() + entry = result.get(key) or cls.empty_entry() + if 'created_at' not in entry: + entry['created_at'] = cls._utc_now() + + if roles: + entry['roles'] = sorted(set(entry.get('roles') or []) | set(roles)) + if teams: + entry['teams'] = sorted(set(entry.get('teams') or []) | set(teams)) + if folders: + entry['folders'] = cls._merge_share_list(entry.get('folders') or [], folders, 'uid') + if records: + entry['records'] = cls._merge_share_list(entry.get('records') or [], records, 'uid') + + entry['last_error'] = None + result[key] = entry + return result + + @staticmethod + def entry_is_empty(entry: Dict[str, Any]) -> bool: + return not ( + entry.get('roles') + or entry.get('teams') + or entry.get('folders') + or entry.get('records') + ) diff --git a/keepercommander/service/commands/integrations/sailpoint/poller.py b/keepercommander/service/commands/integrations/sailpoint/poller.py new file mode 100644 index 000000000..9ad20d1d6 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/poller.py @@ -0,0 +1,93 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + scope, _ = read_scope_and_interval(params, self.record_uid) + + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: + if not pending: + return pending + + api.query_enterprise(params) + for email in list(pending.keys()): + if not SailPointEntitlementApplier.user_is_active(params, email): + continue + remaining, dropped = SailPointEntitlementApplier.apply_for_user( + params, email, pending[email], entitlement_scope=scope + ) + for msg in dropped: + logger.warning(f'SailPoint pending: {msg}') + if remaining and not SailPointPendingStore.entry_is_empty(remaining): + pending[email] = remaining + else: + del pending[email] + logger.info(f'SailPoint pending entitlements completed for {email}') + return pending + + SailPointPendingStore.update(params, self.record_uid, updater) + + def _loop(self) -> None: + from ....core.globals import ensure_params_loaded + + logger.info(f'SailPoint entitlement poller started (record={self.record_uid})') + while True: + interval = DEFAULT_POLL_INTERVAL_SECONDS + try: + params = ensure_params_loaded() + if params: + params.service_mode = True + from .service import SailPointService + SailPointService.bind_params(params, self.record_uid) + _, interval = read_scope_and_interval(params, self.record_uid) + self.reconcile(params) + except Exception as e: + logger.error(f'SailPoint poller cycle failed: {e}') + time.sleep(interval) + + @classmethod + def start(cls, record_uid: str) -> None: + if not record_uid: + return + with cls._lock: + if cls._started: + return + poller = cls(record_uid) + thread = threading.Thread( + target=poller._loop, name='sailpoint-entitlement-poller', daemon=True + ) + thread.start() + cls._started = True diff --git a/keepercommander/service/commands/integrations/sailpoint/scim_guard.py b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py new file mode 100644 index 000000000..4d673f608 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py @@ -0,0 +1,91 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + if not params.enterprise: + api.query_enterprise(params) + + @staticmethod + def _scim_node_ids(params: KeeperParams) -> Set[int]: + nodes: Set[int] = set() + for scim in params.enterprise.get('scims') or []: + node_id = scim.get('node_id') + if node_id: + nodes.add(int(node_id)) + for node in params.enterprise.get('nodes') or []: + if node.get('scim_id'): + nodes.add(int(node['node_id'])) + return nodes + + @staticmethod + def _node_ancestors(params: KeeperParams, node_id: int) -> Iterable[int]: + by_id = {int(n['node_id']): n for n in params.enterprise.get('nodes') or []} + current = node_id + seen = set() + while current and current not in seen: + yield current + seen.add(current) + parent = by_id.get(current, {}).get('parent_id') + if not parent or int(parent) == current: + break + current = int(parent) + + @classmethod + def is_scim_managed_node(cls, params: KeeperParams, node_id: Optional[int]) -> bool: + cls.ensure_enterprise(params) + if not node_id or not params.enterprise: + return False + scim_nodes = cls._scim_node_ids(params) + if not scim_nodes: + return False + return any(n in scim_nodes for n in cls._node_ancestors(params, int(node_id))) + + @classmethod + def find_user(cls, params: KeeperParams, email: str): + cls.ensure_enterprise(params) + if not params.enterprise: + return None + target = email.strip().lower() + for user in params.enterprise.get('users') or []: + if (user.get('username') or '').lower() == target: + return user + return None + + @classmethod + def is_scim_managed_user(cls, params: KeeperParams, email: str) -> bool: + user = cls.find_user(params, email) + if not user: + return False + return cls.is_scim_managed_node(params, user.get('node_id')) + + @classmethod + def identity_change_error(cls, params: KeeperParams, email: str) -> Optional[str]: + if cls.is_scim_managed_user(params, email): + return ( + f'User {email} is managed by an existing SCIM provider. ' + 'SailPoint may only change folder/record (and admin) entitlements; ' + 'node/team/role identity changes are not allowed.' + ) + return None diff --git a/keepercommander/service/commands/integrations/sailpoint/service.py b/keepercommander/service/commands/integrations/sailpoint/service.py new file mode 100644 index 000000000..8c50d4014 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -0,0 +1,138 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + from ..... import vault + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return False + for field in record.custom: + if field.label == SAILPOINT_MARKER_FIELD: + value = str(field.get_default_value() or '').strip().lower() + return value in ('true', '1', 'yes', 'y') + return False + + @classmethod + def record_uid(cls, params: Optional[KeeperParams] = None) -> Optional[str]: + if params is not None: + uid = getattr(params, cls.PARAMS_ATTR, None) + if uid: + return str(uid).strip() or None + env_uid = (os.environ.get(SAILPOINT_RECORD_ENV) or '').strip() + return env_uid or None + + @classmethod + def bind_params(cls, params: KeeperParams, record_uid: Optional[str] = None) -> KeeperParams: + uid = (record_uid or cls.record_uid(params) or '').strip() + if uid: + setattr(params, cls.PARAMS_ATTR, uid) + return params + + @classmethod + def maybe_enable(cls, params: KeeperParams, args) -> None: + """ + If ``SAILPOINT_RECORD`` is set and the record has the SailPoint marker, + bind params and sanitize the Service Mode command allowlist. + """ + uid = (os.environ.get(SAILPOINT_RECORD_ENV) or '').strip() + if not uid: + return + try: + if not cls.record_has_marker(params, uid): + logger.warning( + f'{SAILPOINT_RECORD_ENV}={uid} is set but record is missing ' + f'{SAILPOINT_MARKER_FIELD}; SailPoint mode not enabled' + ) + return + except Exception as e: + logger.warning(f'SailPoint marker check failed; mode not enabled: {e}') + return + + cls.bind_params(params, uid) + if args.commands: + cleaned = SailPointCommandPolicy.sanitize(args.commands) + if cleaned != args.commands: + print( + 'SailPoint mode: removed disallowed/sensitive commands from allowlist ' + f'before service-create.\n Was: {args.commands}\n Now: {cleaned}' + ) + args.commands = cleaned + + @classmethod + def start_background_services(cls) -> None: + from ....core.globals import get_current_params + params = get_current_params() + uid = cls.record_uid(params) + if not uid: + return + if not params: + logger.warning('SailPoint poller not started: Keeper params not loaded') + return + try: + if not cls.record_has_marker(params, uid): + logger.warning( + f'SailPoint poller not started: record {uid} missing {SAILPOINT_MARKER_FIELD}' + ) + return + except Exception as e: + logger.warning(f'SailPoint poller not started: marker check failed: {e}') + return + cls.bind_params(params, uid) + try: + from .poller import SailPointEntitlementPoller + SailPointEntitlementPoller.start(uid) + except Exception as e: + logger.warning(f'SailPoint poller not started: {e}') + + @classmethod + def handle_command(cls, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: + cls.bind_params(params) + uid = cls.record_uid(params) + if not uid: + return None + if not cls.record_has_marker(params, uid): + return None + return SailPointCommandHook(uid).before_command(params, command) + + @classmethod + def after_command(cls, params: KeeperParams, command: str, success: bool = True) -> None: + cls.bind_params(params) + uid = cls.record_uid(params) + if not uid or not cls.record_has_marker(params, uid): + return + SailPointCommandHook(uid).after_command(params, command, success) diff --git a/keepercommander/service/commands/integrations/sailpoint_app_setup.py b/keepercommander/service/commands/integrations/sailpoint_app_setup.py new file mode 100644 index 000000000..f663e8875 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -0,0 +1,171 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return SAILPOINT_RECORD_ENV + + def get_service_commands(self) -> str: + return SailPointCommandPolicy.default_allowlist() + + def collect_integration_config(self, params): + print(f"\n{bcolors.BOLD}ENTITLEMENT SCOPE:{bcolors.ENDC}") + print(f" Control which share entitlements SailPoint may manage via Service Mode") + print(f" Options: {SCOPE_FOLDERS}, {SCOPE_RECORDS}, {SCOPE_BOTH}") + while True: + scope = input( + f"{bcolors.OKBLUE}Scope [Press Enter for {SCOPE_BOTH}]:{bcolors.ENDC} " + ).strip().lower() or SCOPE_BOTH + if scope in (SCOPE_FOLDERS, SCOPE_RECORDS, SCOPE_BOTH): + break + print(f"{bcolors.FAIL}Error: Enter folders, records, or both{bcolors.ENDC}") + + print(f"\n{bcolors.BOLD}POLL INTERVAL:{bcolors.ENDC}") + print(f" How often (seconds) to check whether invited users have become Active") + while True: + raw = input( + f"{bcolors.OKBLUE}Interval seconds " + f"[Press Enter for {DEFAULT_POLL_INTERVAL_SECONDS}]:{bcolors.ENDC} " + ).strip() + if not raw: + interval = DEFAULT_POLL_INTERVAL_SECONDS + break + try: + interval = max(15, int(raw)) + break + except ValueError: + print(f"{bcolors.FAIL}Error: Enter a whole number of seconds (>= 15){bcolors.ENDC}") + + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ SailPoint Configuration Complete!{bcolors.ENDC}") + return SailPointConfig( + entitlement_scope=scope, + poll_interval_seconds=interval, + ) + + def build_record_custom_fields(self, config): + return [ + vault.TypedField.new_field('text', 'true', SAILPOINT_MARKER_FIELD), + vault.TypedField.new_field('text', config.entitlement_scope, ENTITLEMENT_SCOPE_FIELD), + vault.TypedField.new_field('text', str(config.poll_interval_seconds), POLL_INTERVAL_FIELD), + vault.TypedField.new_field('text', json.dumps({}), PENDING_ENTITLEMENTS_FIELD), + ] + + def _run_integration_setup(self, params, setup_result: SetupResult, + service_config: ServiceConfig, + 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) + + DockerSetupPrinter.print_step(1, 2, f"Creating SailPoint config record '{record_name}'...") + custom_fields = self.build_record_custom_fields(config) + record_uid = self._create_integration_record( + params, record_name, setup_result.folder_uid, custom_fields + ) + + DockerSetupPrinter.print_step(2, 2, 'Updating docker-compose.yml (Commander service only)...') + self._update_docker_compose(setup_result, service_config, record_uid, config) + return record_uid, config + + def _update_record_custom_fields(self, params, record_uid: str, custom_fields) -> None: + """Preserve existing pending_entitlements JSON when re-running setup.""" + existing_pending = SailPointPendingStore.load(params, record_uid) + if existing_pending: + payload = json.dumps(existing_pending, indent=2, sort_keys=True) + custom_fields = [ + f for f in custom_fields if getattr(f, 'label', None) != PENDING_ENTITLEMENTS_FIELD + ] + [vault.TypedField.new_field('text', payload, PENDING_ENTITLEMENTS_FIELD)] + super()._update_record_custom_fields(params, record_uid, custom_fields) + + def _update_docker_compose(self, setup_result, service_config, record_uid, config=None): + """Commander-only compose: COMMANDER_RECORD + SAILPOINT_RECORD (no SailPoint app container).""" + compose_file = os.path.join(os.getcwd(), 'docker-compose.yml') + compose_exists = os.path.exists(compose_file) + if compose_exists: + DockerSetupPrinter.print_warning( + 'Rewriting docker-compose.yml for SailPoint Commander service. Hand edits will be lost.' + ) + + try: + cfg = asdict(service_config) + cfg['commands'] = SailPointCommandPolicy.sanitize(cfg.get('commands') or '') + builder = DockerComposeBuilder( + setup_result, + cfg, + commander_service_name=self.get_commander_service_name(), + commander_container_name=self.get_commander_container_name(), + commander_environment={ + DOCKER_RECORD_ENV: setup_result.record_uid, + self.get_record_env_key(): record_uid, + }, + ) + with open(compose_file, 'w') as f: + f.write(builder.build()) + DockerSetupPrinter.print_success( + 'docker-compose.yml regenerated successfully' + if compose_exists else 'docker-compose.yml created successfully' + ) + except Exception as e: + raise CommandError(self.get_command_name(), f'Failed to update docker-compose.yml: {str(e)}') + + def print_integration_specific_resources(self, config): + print(f" • Entitlement Scope: {bcolors.OKBLUE}{config.entitlement_scope}{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}") + + def print_integration_commands(self): + print(f"\n{bcolors.BOLD}SailPoint / Service Mode usage:{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}• enterprise-user user@co.com --invite --add-role Role --add-team Team{bcolors.ENDC}") + 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") diff --git a/keepercommander/service/config/models.py b/keepercommander/service/config/models.py index f508a001b..59aa43d65 100644 --- a/keepercommander/service/config/models.py +++ b/keepercommander/service/config/models.py @@ -37,4 +37,4 @@ class ServiceConfigData: cloudflare: str = "n" cloudflare_tunnel_token: str = "" cloudflare_custom_domain: str = "" - cloudflare_public_url: str = "" \ No newline at end of file + cloudflare_public_url: str = "" diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index c30315181..a625e5543 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -20,8 +20,8 @@ """ from .models import ( - DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep, - ApproverTeam, ApprovalsConfig, + DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SailPointConfig, + SetupStep, ApproverTeam, ApprovalsConfig, ) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase @@ -33,6 +33,7 @@ 'ServiceConfig', 'SlackConfig', 'TeamsConfig', + 'SailPointConfig', 'ApproverTeam', 'ApprovalsConfig', 'SetupStep', diff --git a/keepercommander/service/docker/compose_builder.py b/keepercommander/service/docker/compose_builder.py index be3bf5ffa..f24083c6c 100644 --- a/keepercommander/service/docker/compose_builder.py +++ b/keepercommander/service/docker/compose_builder.py @@ -16,11 +16,14 @@ class DockerComposeBuilder: """Builds docker-compose.yml for Commander + integration services.""" - def __init__(self, setup_result, config: Dict[str, Any], commander_service_name: str = 'commander', commander_container_name: str = 'keeper-service'): + def __init__(self, setup_result, config: Dict[str, Any], commander_service_name: str = 'commander', + commander_container_name: str = 'keeper-service', + commander_environment: Dict[str, str] = None): self.setup_result = setup_result self.config = config self.commander_service_name = commander_service_name self.commander_container_name = commander_container_name + self.commander_environment = commander_environment or {} self._service_cmd_parts: List[str] = [] self._volumes: List[str] = [] self._services: Dict[str, Dict[str, Any]] = {} @@ -59,6 +62,9 @@ def _build_commander_service(self) -> Dict[str, Any]: 'healthcheck': self._build_healthcheck(), 'restart': 'unless-stopped' } + + if self.commander_environment: + service['environment'] = dict(self.commander_environment) if self._volumes: service['volumes'] = self._volumes diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index d81e26175..972dad578 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -123,3 +123,9 @@ class TeamsConfig: device_approval_enabled: bool = False device_approval_polling_interval: int = 120 + +@dataclass +class SailPointConfig: + entitlement_scope: str = 'both' # folders | records | both + poll_interval_seconds: int = 60 + diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index d49839bb7..ab5ceaa82 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -160,6 +160,14 @@ def execute(cls, command: str) -> Tuple[Any, int]: params.service_mode = True command = ensure_record_add_json_format(html.unescape(command)) + + from ..commands.integrations.sailpoint.service import SailPointService + sailpoint_response = SailPointService.handle_command(params, command) + if sailpoint_response is not None: + response, status_code = sailpoint_response + response = CommandExecutor.encrypt_response(response) + return response, status_code + return_value, printed_output, log_output = CommandExecutor.capture_output_and_logs(params, command) response = return_value if return_value else printed_output @@ -172,8 +180,34 @@ def execute(cls, command: str) -> Tuple[Any, int]: # Always let the parser handle the response (including empty responses and logs) response = parse_keeper_response(command, response, log_output) - response, status_code = cls._finalize_parsed_response(response) + if isinstance(response, dict): + # Extract status_code and remove it from response body + if 'status_code' in response: + status_code = response.pop('status_code') + elif response.get("status") == "error": + status_code = 400 + elif response.get("status") == "warning": + status_code = 400 + else: + status_code = 200 + else: + status_code = 200 + + if status_code == 200: + try: + SailPointService.after_command(params, command, success=True) + except Exception as e: + logger.error(f'SailPoint post-process failed: {e}') + err = { + 'status': 'error', + 'error': ( + 'Command succeeded but SailPoint pending entitlement ' + f'queue failed: {e}' + ), + } + return CommandExecutor.encrypt_response(err), 500 + response = CommandExecutor.encrypt_response(response) logger.debug(f"Command executed successfully") return response, status_code diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py new file mode 100644 index 000000000..62189299e --- /dev/null +++ b/unit-tests/service/test_sailpoint_pending.py @@ -0,0 +1,265 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Date: Tue, 28 Jul 2026 15:31:04 +0530 Subject: [PATCH 2/4] Add code enhancements and validations --- keepercommander/service/app.py | 6 ++- .../service/commands/create_service.py | 6 ++- .../sailpoint/apply_entitlements.py | 19 +++---- .../integrations/sailpoint/command_hook.py | 53 ++++++++++++------- .../integrations/sailpoint/command_parse.py | 9 ++++ .../integrations/sailpoint/command_policy.py | 23 +++----- .../integrations/sailpoint/config_fields.py | 24 +++++---- .../integrations/sailpoint/pending_store.py | 40 +++++++------- .../integrations/sailpoint/scim_guard.py | 17 +++--- .../integrations/sailpoint/service.py | 37 +++++++------ keepercommander/service/util/command_util.py | 31 ++++------- unit-tests/service/test_sailpoint_pending.py | 27 ++++++++++ 12 files changed, 172 insertions(+), 120 deletions(-) diff --git a/keepercommander/service/app.py b/keepercommander/service/app.py index 5e6748764..03a74bcdf 100644 --- a/keepercommander/service/app.py +++ b/keepercommander/service/app.py @@ -11,6 +11,7 @@ from flask import Flask, jsonify import logging +import os from werkzeug.middleware.proxy_fix import ProxyFix from flask_limiter.errors import RateLimitExceeded from .decorators.security import limiter, is_behind_proxy @@ -46,8 +47,9 @@ def handle_rate_limit_exceeded(e): logger.debug("Initializing API routes") init_routes(app) - from .commands.integrations.sailpoint.service import SailPointService - SailPointService.start_background_services() + if (os.environ.get('SAILPOINT_RECORD') or '').strip(): + from .commands.integrations.sailpoint.service import SailPointService + SailPointService.start_background_services() print("Keeper Commander Service initialization complete") return app diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py index d2ae29e17..d035fde91 100644 --- a/keepercommander/service/commands/create_service.py +++ b/keepercommander/service/commands/create_service.py @@ -10,6 +10,7 @@ # import argparse +import os from typing import Any, Dict, Optional from ..config.service_config import ServiceConfig from ..config.config_validation import ValidationError @@ -101,8 +102,9 @@ def execute(self, params: KeeperParams, **kwargs) -> None: args = StreamlineArgs(**filtered_kwargs) # Optional SailPoint: enable when SAILPOINT_RECORD points at a marked config record - from .integrations.sailpoint.service import SailPointService - SailPointService.maybe_enable(params, args) + if (os.environ.get('SAILPOINT_RECORD') or '').strip(): + from .integrations.sailpoint.service import SailPointService + SailPointService.maybe_enable(params, args) from .integrations.vault_metadata import get_existing_api_key, write_service_metadata existing_api_key = ( diff --git a/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py b/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py index 677037822..66f5dbbe0 100644 --- a/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py +++ b/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py @@ -29,18 +29,19 @@ class SailPointEntitlementApplier: @staticmethod def _role_exists(params: KeeperParams, role_name: str) -> bool: - for role in params.enterprise.get('roles') or []: - display = (role.get('data') or {}).get('displayname') or '' - if str(role.get('role_id')) == role_name or display.lower() == role_name.lower(): - return True - return False + return any( + str(role.get('role_id')) == role_name + or ((role.get('data') or {}).get('displayname') or '').lower() == role_name.lower() + for role in params.enterprise.get('roles') or [] + ) @staticmethod def _team_exists(params: KeeperParams, team_name: str) -> bool: - for team in params.enterprise.get('teams') or []: - if team.get('team_uid') == team_name or (team.get('name') or '').lower() == team_name.lower(): - return True - return False + return any( + team.get('team_uid') == team_name + or (team.get('name') or '').lower() == team_name.lower() + for team in params.enterprise.get('teams') or [] + ) @staticmethod def _run(params: KeeperParams, command: str) -> None: diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py index d8d04a0d1..443bbed3d 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_hook.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -41,10 +41,15 @@ def before_command(self, params: KeeperParams, command: str) -> Optional[Tuple[A mutation = SailPointCommandParser.parse_identity_mutation(command) if mutation: - for email in mutation.emails: - err = SailPointScimGuard.identity_change_error(params, email) - if err: - return {'status': 'error', 'error': err}, 403 + err = next( + ( + e for email in mutation.emails + if (e := SailPointScimGuard.identity_change_error(params, email)) + ), + None, + ) + if err: + return {'status': 'error', 'error': err}, 403 return None def after_command(self, params: KeeperParams, command: str, success: bool) -> None: @@ -58,20 +63,21 @@ def after_command(self, params: KeeperParams, command: str, success: bool) -> No teams = list(invite.teams) if invite.teams else None queued: List[str] = [] + def _can_queue(email: str) -> bool: + return not ( + SailPointScimGuard.find_user(params, email) + and SailPointScimGuard.identity_change_error(params, email) + ) + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: nonlocal queued - queued = [] + eligible = [email for email in invite.emails if _can_queue(email)] + queued = list(eligible) result = pending - for email in invite.emails: - if ( - SailPointScimGuard.find_user(params, email) - and SailPointScimGuard.identity_change_error(params, email) - ): - continue + for email in eligible: result = SailPointPendingStore.merge_entry( result, email, roles=roles, teams=teams ) - queued.append(email) return result SailPointPendingStore.update(params, self.record_uid, updater) @@ -84,12 +90,18 @@ def updater(pending: Dict[str, Any]) -> Dict[str, Any]: def _before_invite(self, params: KeeperParams, invite) -> Optional[Tuple[Any, int]]: roles = list(invite.roles) teams = list(invite.teams) - for email in invite.emails: - existing = SailPointScimGuard.find_user(params, email) - if existing and (roles or teams): - err = SailPointScimGuard.identity_change_error(params, email) - if err: - return {'status': 'error', 'error': err}, 403 + if not (roles or teams): + return None + err = next( + ( + e for email in invite.emails + if SailPointScimGuard.find_user(params, email) + and (e := SailPointScimGuard.identity_change_error(params, email)) + ), + None, + ) + if err: + return {'status': 'error', 'error': err}, 403 return None def _user_status(self, params: KeeperParams, email: str) -> Optional[str]: @@ -121,6 +133,11 @@ def _record_payload(share: ParsedShare, target: str) -> Dict[str, Any]: return item def _before_share(self, params: KeeperParams, share: ParsedShare) -> 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 + scope = read_entitlement_scope(params, self.record_uid) if share.is_folder and scope == 'records': return { diff --git a/keepercommander/service/commands/integrations/sailpoint/command_parse.py b/keepercommander/service/commands/integrations/sailpoint/command_parse.py index dba8e5388..6499ccf6c 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_parse.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -40,6 +40,7 @@ class ParsedShare: command: str emails: List[str] = field(default_factory=list) targets: List[str] = field(default_factory=list) + action: str = 'grant' can_edit: bool = False can_share: bool = False manage_records: Optional[str] = None @@ -53,6 +54,11 @@ class ParsedShare: def target(self) -> Optional[str]: return self.targets[-1] if self.targets else None + @property + def is_grant(self) -> bool: + """Only grant (default) is deferred for Invited users; revoke/remove run natively.""" + return (self.action or 'grant').lower() == 'grant' + @dataclass class ParsedIdentityMutation: @@ -159,6 +165,9 @@ def parse_share(cls, command: str) -> Optional[ParsedShare]: if t in ('-e', '--email') and i + 1 < len(tokens): parsed.emails.append(tokens[i + 1]) i += 2 + elif t in ('-a', '--action') and i + 1 < len(tokens): + parsed.action = tokens[i + 1].strip().lower() or 'grant' + i += 2 elif t in ('-w', '--write'): parsed.can_edit = True i += 1 diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py index 966b43e7a..03935cd23 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_policy.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -13,8 +13,6 @@ from __future__ import annotations -from typing import List - from .constants import SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS @@ -26,19 +24,14 @@ def sanitize(cls, commands: str) -> str: """Keep only SailPoint-allowed commands; always drop banned secret-bearing ones.""" allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} - parts: List[str] = [] - seen = set() - for raw in (commands or '').split(','): - cmd = raw.strip() - if not cmd: - continue - key = cmd.lower() - if key in banned or key not in allowed: - continue - if key in seen: - continue - seen.add(key) - parts.append(cmd) + filtered = [ + cmd for raw in (commands or '').split(',') + if (cmd := raw.strip()) + and (key := cmd.lower()) not in banned + and key in allowed + ] + # Preserve first-seen casing while deduping by lower-case key + parts = list({cmd.lower(): cmd for cmd in filtered}.values()) if not parts: return ','.join(SAILPOINT_ALLOWED_COMMANDS) return ','.join(parts) diff --git a/keepercommander/service/commands/integrations/sailpoint/config_fields.py b/keepercommander/service/commands/integrations/sailpoint/config_fields.py index 3a954032f..562f2d97e 100644 --- a/keepercommander/service/commands/integrations/sailpoint/config_fields.py +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -36,16 +36,20 @@ def read_scope_and_interval(params: KeeperParams, record_uid: str) -> Tuple[str, if not isinstance(record, vault.TypedRecord) or not record.custom: return scope, interval - for field in record.custom: - if field.label == ENTITLEMENT_SCOPE_FIELD: - value = (field.get_default_value() or SCOPE_BOTH).strip().lower() - if value in _VALID_SCOPES: - scope = value - elif field.label == POLL_INTERVAL_FIELD: - try: - interval = max(_MIN_POLL_INTERVAL, int(field.get_default_value() or interval)) - except (TypeError, ValueError): - pass + by_label = {field.label: field for field in record.custom if field.label} + + scope_field = by_label.get(ENTITLEMENT_SCOPE_FIELD) + if scope_field: + value = (scope_field.get_default_value() or SCOPE_BOTH).strip().lower() + if value in _VALID_SCOPES: + scope = value + + interval_field = by_label.get(POLL_INTERVAL_FIELD) + if interval_field: + try: + interval = max(_MIN_POLL_INTERVAL, int(interval_field.get_default_value() or interval)) + except (TypeError, ValueError): + pass return scope, interval diff --git a/keepercommander/service/commands/integrations/sailpoint/pending_store.py b/keepercommander/service/commands/integrations/sailpoint/pending_store.py index 03a7a6e5b..25f9e6820 100644 --- a/keepercommander/service/commands/integrations/sailpoint/pending_store.py +++ b/keepercommander/service/commands/integrations/sailpoint/pending_store.py @@ -50,19 +50,21 @@ def load(cls, params: KeeperParams, record_uid: str) -> Dict[str, Any]: record = vault.KeeperRecord.load(params, record_uid) if not isinstance(record, vault.TypedRecord) or not record.custom: return {} - for field in record.custom: - if field.label != PENDING_ENTITLEMENTS_FIELD: - continue - raw = field.get_default_value() - if not raw: - return {} - try: - data = json.loads(raw) if isinstance(raw, str) else raw - except (TypeError, json.JSONDecodeError): - logger.warning(f'Invalid pending_entitlements JSON on record {record_uid}') - return {} - return data if isinstance(data, dict) else {} - return {} + field = next( + (f for f in record.custom if f.label == PENDING_ENTITLEMENTS_FIELD), + None, + ) + if not field: + return {} + raw = field.get_default_value() + if not raw: + return {} + try: + data = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, json.JSONDecodeError): + logger.warning(f'Invalid pending_entitlements JSON on record {record_uid}') + return {} + return data if isinstance(data, dict) else {} @classmethod def _write(cls, params: KeeperParams, record_uid: str, pending: Dict[str, Any]) -> None: @@ -110,8 +112,9 @@ def update( return next_state except Exception as e: last_error = e - if not any(h in str(e).lower() for h in _STALE_HINTS) or attempt == _MAX_ATTEMPTS: - break + stale = any(h in str(e).lower() for h in _STALE_HINTS) + if not stale or attempt == _MAX_ATTEMPTS: + raise RuntimeError(f'Failed to save pending entitlements: {last_error}') from e logger.warning( f'Stale revision updating pending entitlements; retrying ({attempt}/{_MAX_ATTEMPTS})' ) @@ -123,11 +126,8 @@ def _merge_share_list( existing: List[Dict[str, Any]], items: List[Dict[str, Any]], uid_key: str ) -> List[Dict[str, Any]]: by_uid = {str(x.get(uid_key)): dict(x) for x in existing if x.get(uid_key)} - for item in items: - uid = item.get(uid_key) - if not uid: - continue - key = str(uid) + for item in (x for x in items if x.get(uid_key)): + key = str(item[uid_key]) if key in by_uid: by_uid[key].update(item) else: diff --git a/keepercommander/service/commands/integrations/sailpoint/scim_guard.py b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py index 4d673f608..0e2a42812 100644 --- a/keepercommander/service/commands/integrations/sailpoint/scim_guard.py +++ b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py @@ -42,15 +42,13 @@ def _scim_node_ids(params: KeeperParams) -> Set[int]: @staticmethod def _node_ancestors(params: KeeperParams, node_id: int) -> Iterable[int]: by_id = {int(n['node_id']): n for n in params.enterprise.get('nodes') or []} - current = node_id + current: Optional[int] = node_id seen = set() while current and current not in seen: yield current seen.add(current) parent = by_id.get(current, {}).get('parent_id') - if not parent or int(parent) == current: - break - current = int(parent) + current = int(parent) if parent and int(parent) != current else None @classmethod def is_scim_managed_node(cls, params: KeeperParams, node_id: Optional[int]) -> bool: @@ -68,10 +66,13 @@ def find_user(cls, params: KeeperParams, email: str): if not params.enterprise: return None target = email.strip().lower() - for user in params.enterprise.get('users') or []: - if (user.get('username') or '').lower() == target: - return user - return None + return next( + ( + user for user in params.enterprise.get('users') or [] + if (user.get('username') or '').lower() == target + ), + None, + ) @classmethod def is_scim_managed_user(cls, params: KeeperParams, email: str) -> bool: diff --git a/keepercommander/service/commands/integrations/sailpoint/service.py b/keepercommander/service/commands/integrations/sailpoint/service.py index 8c50d4014..69772d42c 100644 --- a/keepercommander/service/commands/integrations/sailpoint/service.py +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -37,15 +37,17 @@ class SailPointService: @classmethod def record_has_marker(cls, params: KeeperParams, record_uid: str) -> bool: + if not record_uid: + return False from ..... import vault record = vault.KeeperRecord.load(params, record_uid) if not isinstance(record, vault.TypedRecord) or not record.custom: return False - for field in record.custom: - if field.label == SAILPOINT_MARKER_FIELD: - value = str(field.get_default_value() or '').strip().lower() - return value in ('true', '1', 'yes', 'y') - return False + return any( + str(field.get_default_value() or '').strip().lower() in ('true', '1', 'yes', 'y') + for field in record.custom + if field.label == SAILPOINT_MARKER_FIELD + ) @classmethod def record_uid(cls, params: Optional[KeeperParams] = None) -> Optional[str]: @@ -66,12 +68,12 @@ def bind_params(cls, params: KeeperParams, record_uid: Optional[str] = None) -> @classmethod def maybe_enable(cls, params: KeeperParams, args) -> None: """ - If ``SAILPOINT_RECORD`` is set and the record has the SailPoint marker, - bind params and sanitize the Service Mode command allowlist. + Bind params and sanitize the Service Mode command allowlist when the + SailPoint config record has the integration marker. + + Callers must gate on ``SAILPOINT_RECORD`` before invoking this. """ - uid = (os.environ.get(SAILPOINT_RECORD_ENV) or '').strip() - if not uid: - return + uid = cls.record_uid(params) try: if not cls.record_has_marker(params, uid): logger.warning( @@ -95,14 +97,17 @@ def maybe_enable(cls, params: KeeperParams, args) -> None: @classmethod def start_background_services(cls) -> None: + """ + Start the entitlement poller when SailPoint is enabled. + + Callers must gate on ``SAILPOINT_RECORD`` before invoking this. + """ from ....core.globals import get_current_params params = get_current_params() - uid = cls.record_uid(params) - if not uid: - return if not params: logger.warning('SailPoint poller not started: Keeper params not loaded') return + uid = cls.record_uid(params) try: if not cls.record_has_marker(params, uid): logger.warning( @@ -121,18 +126,18 @@ def start_background_services(cls) -> None: @classmethod def handle_command(cls, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: + """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" cls.bind_params(params) uid = cls.record_uid(params) - if not uid: - return None if not cls.record_has_marker(params, uid): return None return SailPointCommandHook(uid).before_command(params, command) @classmethod def after_command(cls, params: KeeperParams, command: str, success: bool = True) -> None: + """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" cls.bind_params(params) uid = cls.record_uid(params) - if not uid or not cls.record_has_marker(params, uid): + if not cls.record_has_marker(params, uid): return SailPointCommandHook(uid).after_command(params, command, success) diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index ab5ceaa82..4e8e2dfb9 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -10,6 +10,7 @@ # import io, html +import os import sys import json import logging @@ -161,12 +162,14 @@ def execute(cls, command: str) -> Tuple[Any, int]: command = ensure_record_add_json_format(html.unescape(command)) - from ..commands.integrations.sailpoint.service import SailPointService - sailpoint_response = SailPointService.handle_command(params, command) - if sailpoint_response is not None: - response, status_code = sailpoint_response - response = CommandExecutor.encrypt_response(response) - return response, status_code + 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) + if sailpoint_response is not None: + response, status_code = sailpoint_response + response = CommandExecutor.encrypt_response(response) + return response, status_code return_value, printed_output, log_output = CommandExecutor.capture_output_and_logs(params, command) response = return_value if return_value else printed_output @@ -180,21 +183,9 @@ def execute(cls, command: str) -> Tuple[Any, int]: # Always let the parser handle the response (including empty responses and logs) response = parse_keeper_response(command, response, log_output) - - if isinstance(response, dict): - # Extract status_code and remove it from response body - if 'status_code' in response: - status_code = response.pop('status_code') - elif response.get("status") == "error": - status_code = 400 - elif response.get("status") == "warning": - status_code = 400 - else: - status_code = 200 - else: - status_code = 200 + response, status_code = cls._finalize_parsed_response(response) - if status_code == 200: + if status_code == 200 and sailpoint_enabled: try: SailPointService.after_command(params, command, success=True) except Exception as e: diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py index 62189299e..4f461e83e 100644 --- a/unit-tests/service/test_sailpoint_pending.py +++ b/unit-tests/service/test_sailpoint_pending.py @@ -69,10 +69,36 @@ def test_parse_share_record(self): parsed = SailPointCommandParser.parse_share('share-record -e user@co.com -w RECORD_UID') self.assertIsNotNone(parsed) self.assertTrue(parsed.is_record) + self.assertTrue(parsed.is_grant) self.assertTrue(parsed.can_edit) self.assertEqual(parsed.target, 'RECORD_UID') self.assertEqual(parsed.targets, ['RECORD_UID']) + def test_parse_share_record_revoke_is_not_grant(self): + parsed = SailPointCommandParser.parse_share( + 'share-record -a revoke -e user@co.com RECORD_UID' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.action, 'revoke') + self.assertFalse(parsed.is_grant) + + def test_parse_share_folder_remove_is_not_grant(self): + parsed = SailPointCommandParser.parse_share( + 'share-folder -a remove -e user@co.com FOLDER_UID' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.action, 'remove') + self.assertFalse(parsed.is_grant) + + def test_parse_nsf_share_folder_remove_is_not_grant(self): + parsed = SailPointCommandParser.parse_share( + 'nsf-share-folder -a remove -e user@co.com -r viewer NSF_UID' + ) + self.assertIsNotNone(parsed) + self.assertTrue(parsed.is_nsf) + self.assertEqual(parsed.action, 'remove') + self.assertFalse(parsed.is_grant) + def test_parse_share_folder_multi_target(self): parsed = SailPointCommandParser.parse_share( 'share-folder -e user@co.com --manage-records on FOLDER_A FOLDER_B' @@ -89,6 +115,7 @@ def test_parse_nsf_share_folder_role(self): self.assertIsNotNone(parsed) self.assertTrue(parsed.is_nsf) self.assertTrue(parsed.is_folder) + self.assertTrue(parsed.is_grant) self.assertEqual(parsed.nsf_role, 'content-manager') self.assertEqual(parsed.targets, ['NSF_UID']) From cf3bcecc20844d89308093062c3ae69dae0ec1df Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 30 Jul 2026 15:49:22 +0530 Subject: [PATCH 3/4] Improve SailPoint Service Mode: capability gates, better logs, remove Contributor from NSF, and safer pending apply. --- keepercommander/commands/folder.py | 4 +- .../commands/nested_share_folder/helpers.py | 10 +- .../sailpoint/apply_entitlements.py | 118 +++-- .../integrations/sailpoint/command_hook.py | 207 +++++--- .../integrations/sailpoint/command_parse.py | 184 ++++--- .../integrations/sailpoint/command_policy.py | 77 ++- .../integrations/sailpoint/config_fields.py | 71 ++- .../integrations/sailpoint/constants.py | 12 +- .../integrations/sailpoint/pending_store.py | 18 + .../commands/integrations/sailpoint/poller.py | 75 ++- .../integrations/sailpoint/service.py | 5 +- .../integrations/sailpoint/share_targets.py | 121 +++++ .../integrations/sailpoint_app_setup.py | 56 ++- keepercommander/service/docker/models.py | 6 +- keepercommander/service/util/command_util.py | 12 + .../service/util/verified_command.py | 75 ++- keepercommander/sync_down.py | 2 +- unit-tests/service/test_sailpoint_pending.py | 458 +++++++++++++++++- unit-tests/test_nsf_acl_cache.py | 8 +- 19 files changed, 1253 insertions(+), 266 deletions(-) create mode 100644 keepercommander/service/commands/integrations/sailpoint/share_targets.py diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index 180ea64bb..7da2d0da8 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -1910,7 +1910,6 @@ def _tree_json_dumps(obj, level=0, indent=2): _NSF_ROLE_ABBREV = { 'viewer': 'VW', - 'contributor': 'CT', 'share-manager': 'SM', 'content-manager': 'CM', 'content-share-manager': 'CSM', @@ -2513,7 +2512,6 @@ def print_share_permissions_key(): lines.extend([ 'OW = NSF Owner', 'VW = NSF Viewer', - 'CT = NSF Contributor', 'SM = NSF Share Manager', 'CM = NSF Content Manager', 'CSM = NSF Content + Share Manager', @@ -2705,7 +2703,7 @@ def tree_node(node, parent_path=''): } if nsf_shares: key['nsf'] = { - 'OW': 'Owner', 'VW': 'Viewer', 'CT': 'Contributor', 'SM': 'Share Manager', + 'OW': 'Owner', 'VW': 'Viewer', 'SM': 'Share Manager', 'CM': 'Content Manager', 'CSM': 'Content + Share Manager', 'FM': 'Full Manager', } payload['share_permissions_key'] = key diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index 855c6c700..00ad50152 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -464,7 +464,7 @@ def infer_role(access): Follows the official permission matrix:: full-manager > content-share-manager > share-manager > - content-manager > viewer > contributor > requestor > navigator + content-manager > viewer > requestor > navigator The distinguishing trait between ``share-manager`` and ``content-share-manager`` is the ability to *edit* records: both roles @@ -485,9 +485,7 @@ def infer_role(access): return 'content-manager' if get('can_view') and get('can_list_access'): return 'viewer' - if get('can_view'): - return 'contributor' - if get('can_view_title'): + if get('can_view') or get('can_view_title'): return 'requestor' return 'navigator' @@ -507,8 +505,8 @@ def role_label(access_role_type): # Map backend AccessRoleType enum names to Nested Share Folder display labels. # Source of truth: folder_pb2.AccessRoleType (NAVIGATOR=0 ... MANAGER=6). _ACCESS_ROLE_DISPLAY_LABELS = { - 'NAVIGATOR': 'contributor', - 'REQUESTOR': 'contributor', + 'NAVIGATOR': 'navigator', + 'REQUESTOR': 'requestor', 'VIEWER': 'viewer', 'SHARED_MANAGER': 'share-manager', 'CONTENT_MANAGER': 'content-manager', diff --git a/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py b/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py index 66f5dbbe0..0e8449091 100644 --- a/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py +++ b/keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py @@ -14,7 +14,7 @@ from __future__ import annotations import shlex -from typing import Any, Dict, List, Tuple +from typing import Any, Callable, Dict, List, Tuple from .....params import KeeperParams from ....decorators.logging import logger @@ -46,7 +46,6 @@ def _team_exists(params: KeeperParams, team_name: str) -> bool: @staticmethod def _run(params: KeeperParams, command: str) -> None: from ..... import cli - logger.info(f'SailPoint apply: {command}') cli.do_command(params, command) @staticmethod @@ -86,7 +85,7 @@ def _apply_folder(cls, params: KeeperParams, email: str, folder: Dict[str, Any]) flags.append(f'--manage-records {manage_records}') if manage_users in ('on', 'off'): flags.append(f'--manage-users {manage_users}') - flag_str = (' ' + ' '.join(flags)) if flags else '' + flag_str = f" {' '.join(flags)}" if flags else '' cls._run(params, f'share-folder -a grant --email {email_q}{flag_str} {uid_q}') @classmethod @@ -110,9 +109,39 @@ def _apply_record(cls, params: KeeperParams, email: str, record: Dict[str, Any]) flags.append('--write') if record.get('can_share'): flags.append('--share') - flag_str = (' ' + ' '.join(flags)) if flags else '' + flag_str = f" {' '.join(flags)}" if flags else '' cls._run(params, f'share-record --email {email_q}{flag_str} {uid_q}') + @classmethod + def _apply_items( + cls, + params: KeeperParams, + email: str, + items: List[Dict[str, Any]], + *, + label: str, + apply_one: Callable[[KeeperParams, str, Dict[str, Any]], None], + remaining: Dict[str, Any], + dropped: List[str], + ) -> List[Dict[str, Any]]: + still: List[Dict[str, Any]] = [] + for item in items: + uid = item.get('uid') + if not uid: + dropped.append(f'{label} entry missing uid, dropped') + continue + try: + apply_one(params, email, item) + logger.info(f'SailPoint: shared {label.lower()} {uid} with {email}') + except Exception as e: + if cls._is_missing_target(e): + dropped.append(f'{label} not found, dropped: {uid}') + else: + logger.warning(f'Failed to share {label.lower()} {uid} with {email}: {e}') + still.append(item) + remaining['last_error'] = str(e) + return still + @classmethod def apply_for_user( cls, @@ -120,7 +149,10 @@ def apply_for_user( email: str, entry: Dict[str, Any], *, - entitlement_scope: str = 'both', + allow_roles: bool = True, + allow_teams: bool = True, + allow_folders: bool = True, + allow_records: bool = True, ) -> Tuple[Dict[str, Any], List[str]]: remaining = { 'created_at': entry.get('created_at'), @@ -134,6 +166,13 @@ def apply_for_user( scim_user = SailPointScimGuard.is_scim_managed_user(params, email) email_q = cls._shell_quote(email) + if not allow_roles and remaining['roles']: + dropped.append('Pending roles skipped by allow_roles=false') + remaining['roles'] = [] + if not allow_teams and remaining['teams']: + dropped.append('Pending teams skipped by allow_teams=false') + remaining['teams'] = [] + if scim_user: if remaining['roles'] or remaining['teams']: dropped.append( @@ -148,7 +187,11 @@ def apply_for_user( dropped.append(f'Role not found, dropped: {role}') continue try: - cls._run(params, f'enterprise-user {email_q} --add-role {cls._shell_quote(role)}') + cls._run( + params, + f'enterprise-user -f {email_q} --add-role {cls._shell_quote(role)}', + ) + logger.info(f"SailPoint: added role '{role}' to {email}") except Exception as e: logger.warning(f'Failed to add role {role} for {email}: {e}') still_roles.append(role) @@ -161,56 +204,43 @@ def apply_for_user( dropped.append(f'Team not found, dropped: {team}') continue try: - cls._run(params, f'enterprise-user {email_q} --add-team {cls._shell_quote(team)}') + cls._run( + params, + f'enterprise-user {email_q} --add-team {cls._shell_quote(team)}', + ) + logger.info(f"SailPoint: added team '{team}' to {email}") except Exception as e: logger.warning(f'Failed to add team {team} for {email}: {e}') still_teams.append(team) remaining['last_error'] = str(e) remaining['teams'] = still_teams - allow_folders = entitlement_scope in ('folders', 'both') - allow_records = entitlement_scope in ('records', 'both') - - still_folders = [] if allow_folders: - for folder in remaining['folders']: - uid = folder.get('uid') - if not uid: - dropped.append('Folder entry missing uid, dropped') - continue - try: - cls._apply_folder(params, email, folder) - except Exception as e: - if cls._is_missing_target(e): - dropped.append(f'Folder not found, dropped: {uid}') - else: - logger.warning(f'Failed to share folder {uid} with {email}: {e}') - still_folders.append(folder) - remaining['last_error'] = str(e) - remaining['folders'] = still_folders + remaining['folders'] = cls._apply_items( + params, + email, + remaining['folders'], + label='Folder', + apply_one=cls._apply_folder, + remaining=remaining, + dropped=dropped, + ) elif remaining['folders']: - dropped.append('Folder shares skipped by entitlement_scope') + dropped.append('Pending folders skipped by allow_folders=false') remaining['folders'] = [] - still_records = [] if allow_records: - for record in remaining['records']: - uid = record.get('uid') - if not uid: - dropped.append('Record entry missing uid, dropped') - continue - try: - cls._apply_record(params, email, record) - except Exception as e: - if cls._is_missing_target(e): - dropped.append(f'Record not found, dropped: {uid}') - else: - logger.warning(f'Failed to share record {uid} with {email}: {e}') - still_records.append(record) - remaining['last_error'] = str(e) - remaining['records'] = still_records + remaining['records'] = cls._apply_items( + params, + email, + remaining['records'], + label='Record', + apply_one=cls._apply_record, + remaining=remaining, + dropped=dropped, + ) elif remaining['records']: - dropped.append('Record shares skipped by entitlement_scope') + dropped.append('Pending records skipped by allow_records=false') remaining['records'] = [] if SailPointPendingStore.entry_is_empty(remaining): diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py index 443bbed3d..ea2288b51 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_hook.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -15,12 +15,17 @@ from typing import Any, Dict, List, Optional, Tuple +from ..... import api from .....params import KeeperParams from ....decorators.logging import logger from .command_parse import ParsedShare, SailPointCommandParser -from .config_fields import read_entitlement_scope +from .command_policy import SailPointCommandPolicy +from .config_fields import SailPointCapabilities, read_capabilities from .pending_store import SailPointPendingStore from .scim_guard import SailPointScimGuard +from .share_targets import validate_share_targets + +_ER_CMDS = frozenset({'enterprise-role', 'er'}) class SailPointCommandHook: @@ -31,75 +36,149 @@ def __init__(self, record_uid: str): def before_command(self, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: """Return (response, status_code) to short-circuit, or None to continue.""" + 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 + + er_error = SailPointCommandPolicy.validate_enterprise_role(command) + if er_error: + return {'status': 'error', 'error': er_error}, 403 + invite = SailPointCommandParser.parse_invite(command) if invite and invite.emails: return self._before_invite(params, invite) share = SailPointCommandParser.parse_share(command) if share: - return self._before_share(params, 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) mutation = SailPointCommandParser.parse_identity_mutation(command) if mutation: - err = next( - ( - e for email in mutation.emails - if (e := SailPointScimGuard.identity_change_error(params, email)) - ), - None, - ) + err = self._first_scim_identity_error(params, mutation.emails) if err: return {'status': 'error', 'error': err}, 403 return None + @staticmethod + def _first_scim_identity_error(params: KeeperParams, emails: List[str]) -> Optional[str]: + for email in emails: + err = SailPointScimGuard.identity_change_error(params, email) + if err: + return err + return None + + @staticmethod + def _check_capability_gates(command: str, caps: SailPointCapabilities) -> Optional[str]: + tokens = SailPointCommandParser.tokenize(command) + if not tokens: + return None + name = tokens[0].lower() + + if name in _ER_CMDS and not caps.allow_roles: + return ( + 'SailPoint allow_roles is disabled; enterprise-role / er is not allowed.' + ) + + invite = SailPointCommandParser.parse_invite(command) + if invite: + if invite.roles and not caps.allow_roles: + return ( + 'SailPoint allow_roles is disabled; --add-role is not allowed.' + ) + if invite.teams and not caps.allow_teams: + return ( + 'SailPoint allow_teams is disabled; --add-team is not allowed.' + ) + return None + + mutation = SailPointCommandParser.parse_identity_mutation(command) + if mutation: + if mutation.has_role_change and not caps.allow_roles: + return ( + 'SailPoint allow_roles is disabled; ' + '--add-role / --remove-role is not allowed.' + ) + if mutation.has_team_change and not caps.allow_teams: + return ( + 'SailPoint allow_teams is disabled; ' + '--add-team / --remove-team is not allowed.' + ) + return None + def after_command(self, params: KeeperParams, command: str, success: bool) -> None: if not success: return invite = SailPointCommandParser.parse_invite(command) - if not invite or not invite.emails or (not invite.roles and not invite.teams): + if not invite or not invite.emails: + return + + for email in invite.emails: + logger.info(f'SailPoint: user invited {email}') + + if not invite.roles and not invite.teams: return + api.query_enterprise(params) roles = list(invite.roles) if invite.roles else None teams = list(invite.teams) if invite.teams else None - queued: List[str] = [] + created: List[str] = [] + updated: List[str] = [] - def _can_queue(email: str) -> bool: - return not ( - SailPointScimGuard.find_user(params, email) - and SailPointScimGuard.identity_change_error(params, email) - ) + eligible: List[str] = [] + for email in invite.emails: + user = SailPointScimGuard.find_user(params, email) + if not user: + logger.warning( + f'SailPoint: skip pending queue; user not found after invite: {email}' + ) + continue + if SailPointScimGuard.identity_change_error(params, email): + continue + eligible.append(email) + if not eligible: + return def updater(pending: Dict[str, Any]) -> Dict[str, Any]: - nonlocal queued - eligible = [email for email in invite.emails if _can_queue(email)] - queued = list(eligible) + nonlocal created, updated result = pending for email in eligible: + key = email.strip().lower() + existed = key in result result = SailPointPendingStore.merge_entry( result, email, roles=roles, teams=teams ) + (updated if existed else created).append(email) return result - SailPointPendingStore.update(params, self.record_uid, updater) - for email in queued: - logger.info( - f'Queued SailPoint pending entitlements for {email} ' - f'(roles={invite.roles} teams={invite.teams})' - ) + next_state = SailPointPendingStore.update(params, self.record_uid, updater) + self._log_pending_writes(next_state, created, updated) + + @staticmethod + def _log_pending_writes( + pending: Dict[str, Any], + created: List[str], + updated: List[str], + ) -> None: + for email in created: + entry = pending.get(email.strip().lower()) or {} + summary = SailPointPendingStore.summarize_entry(entry) + detail = f' ({summary})' if summary else '' + logger.info(f'SailPoint: pending entitlements created for {email}{detail}') + for email in updated: + entry = pending.get(email.strip().lower()) or {} + summary = SailPointPendingStore.summarize_entry(entry) + detail = f' ({summary})' if summary else '' + logger.info(f'SailPoint: pending entitlements updated for {email}{detail}') def _before_invite(self, params: KeeperParams, invite) -> Optional[Tuple[Any, int]]: - roles = list(invite.roles) - teams = list(invite.teams) - if not (roles or teams): + if not (invite.roles or invite.teams): return None - err = next( - ( - e for email in invite.emails - if SailPointScimGuard.find_user(params, email) - and (e := SailPointScimGuard.identity_change_error(params, email)) - ), - None, - ) + err = self._first_scim_identity_error(params, invite.emails) if err: return {'status': 'error', 'error': err}, 403 return None @@ -132,52 +211,66 @@ def _record_payload(share: ParsedShare, target: str) -> Dict[str, Any]: item['can_share'] = share.can_share return item - def _before_share(self, params: KeeperParams, share: ParsedShare) -> Optional[Tuple[Any, int]]: + def _before_share( + self, + params: KeeperParams, + 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 - scope = read_entitlement_scope(params, self.record_uid) - if share.is_folder and scope == 'records': + if share.is_folder and not caps.allow_folders: return { 'status': 'error', - 'error': 'SailPoint entitlement_scope is records-only; share-folder is not allowed.', + 'error': 'SailPoint allow_folders is disabled; share-folder is not allowed.', }, 403 - if share.is_record and scope == 'folders': + if share.is_record and not caps.allow_records: return { 'status': 'error', - 'error': 'SailPoint entitlement_scope is folders-only; share-record is not allowed.', + 'error': 'SailPoint allow_records is disabled; share-record is not allowed.', }, 403 deferred = [e for e in share.emails if self._user_status(params, e) != 'active'] if not deferred: return None + active = [e for e in share.emails if e not in deferred] + if active: + return { + 'status': 'error', + 'error': ( + 'SailPoint cannot mix Active and non-Active users in one share request. ' + f'Share Active users separately ({", ".join(active)}); ' + f'queue non-Active users separately ({", ".join(deferred)}).' + ), + }, 400 + + created: List[str] = [] + updated: List[str] = [] + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: + nonlocal created, updated result = pending for email in deferred: + key = email.strip().lower() + existed = key in result if share.is_folder: folders = [self._folder_payload(share, t) for t in share.targets] result = SailPointPendingStore.merge_entry(result, email, folders=folders) else: records = [self._record_payload(share, t) for t in share.targets] result = SailPointPendingStore.merge_entry(result, email, records=records) + (updated if existed else created).append(email) return result - SailPointPendingStore.update(params, self.record_uid, updater) - for email in deferred: - logger.info( - f'Queued SailPoint pending ' - f'{"folder" if share.is_folder else "record"} share for {email} ' - f'-> {", ".join(share.targets)}' - ) + next_state = SailPointPendingStore.update(params, self.record_uid, updater) + self._log_pending_writes(next_state, created, updated) - active = [e for e in share.emails if e not in deferred] - message = f'User(s) not yet active; queued share for: {", ".join(deferred)}.' - if active: - message += ( - f' Active user(s) were not shared in this request (call share separately): ' - f'{", ".join(active)}.' - ) - return {'status': 'success', 'message': message, 'queued': deferred}, 200 + return { + 'status': 'success', + 'message': f'User(s) not yet active; queued share for: {", ".join(deferred)}.', + 'queued': deferred, + }, 200 diff --git a/keepercommander/service/commands/integrations/sailpoint/command_parse.py b/keepercommander/service/commands/integrations/sailpoint/command_parse.py index 6499ccf6c..7286919aa 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_parse.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -15,15 +15,14 @@ import shlex from dataclasses import dataclass, field -from typing import List, Optional +from typing import List, Optional, Tuple _NSF_FOLDER = frozenset({'nsf-share-folder'}) _NSF_RECORD = frozenset({'nsf-share-record'}) -_FOLDER_CMDS = frozenset({'share-folder', 'nsf-share-folder', 'sf'}) -_RECORD_CMDS = frozenset({'share-record', 'nsf-share-record', 'sr'}) -_IDENTITY_FLAGS = frozenset({ - '--add-role', '--remove-role', '--add-team', '--remove-team', '--node', '-n', -}) +_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'}) @dataclass @@ -65,87 +64,140 @@ class ParsedIdentityMutation: """enterprise-user identity change (role/team/node) — used for SCIM coexistence.""" emails: List[str] = field(default_factory=list) + has_role_change: bool = False + has_team_change: bool = False + has_node_change: bool = False class SailPointCommandParser: """Parse enterprise-user invite and share-* command strings.""" @staticmethod - def _tokenize(command: str) -> List[str]: + def tokenize(command: str) -> List[str]: try: return shlex.split(command) except ValueError: 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 + + @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 + @classmethod def parse_invite(cls, command: str) -> Optional[ParsedInvite]: - tokens = cls._tokenize(command) - if not tokens or tokens[0] not in ('enterprise-user', 'eu'): + tokens = cls.tokenize(command) + if not tokens or tokens[0] not in _EU_CMDS: return None parsed = ParsedInvite() + emails: List[str] = [] i = 1 - positional: List[str] = [] while i < len(tokens): t = tokens[i] - if t in ('--invite', '--add', '-invite'): + if t in _INVITE_FLAGS: parsed.is_invite = True i += 1 - elif t in ('--node', '-n') and i + 1 < len(tokens): - parsed.node = tokens[i + 1] - i += 2 - elif t == '--add-role' and i + 1 < len(tokens): - parsed.roles.append(tokens[i + 1]) - i += 2 - elif t == '--add-team' and i + 1 < len(tokens): - parsed.teams.append(tokens[i + 1]) - i += 2 + 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('-'): - if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): - i += 2 - else: - i += 1 + i = cls._skip_unknown_flag(tokens, i) else: - positional.append(t) + if '@' in t: + emails.append(t) i += 1 - parsed.emails = [e for e in positional if '@' in e] + parsed.emails = emails return parsed if parsed.is_invite else None @classmethod def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutation]: - tokens = cls._tokenize(command) - if not tokens or tokens[0] not in ('enterprise-user', 'eu'): + tokens = cls.tokenize(command) + if not tokens or tokens[0] not in _EU_CMDS: return None emails: List[str] = [] - mutating = False + has_role = False + has_team = False + has_node = False i = 1 while i < len(tokens): t = tokens[i] - if t in _IDENTITY_FLAGS: - mutating = True - if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): - i += 2 - else: - i += 1 + 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('-'): - if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): - i += 2 - else: - i += 1 + i = cls._skip_unknown_flag(tokens, i) else: if '@' in t: emails.append(t) i += 1 - if not mutating or not emails: + if not (has_role or has_team or has_node) or not emails: return None - return ParsedIdentityMutation(emails=emails) + return ParsedIdentityMutation( + emails=emails, + has_role_change=has_role, + has_team_change=has_team, + has_node_change=has_node, + ) @classmethod def parse_share(cls, command: str) -> Optional[ParsedShare]: - tokens = cls._tokenize(command) + tokens = cls.tokenize(command) if not tokens: return None name = tokens[0] @@ -162,42 +214,38 @@ def parse_share(cls, command: str) -> Optional[ParsedShare]: positional: List[str] = [] while i < len(tokens): t = tokens[i] - if t in ('-e', '--email') and i + 1 < len(tokens): - parsed.emails.append(tokens[i + 1]) - i += 2 - elif t in ('-a', '--action') and i + 1 < len(tokens): - parsed.action = tokens[i + 1].strip().lower() or 'grant' - i += 2 + 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 name in _RECORD_CMDS: + elif t in ('-s', '--share') and parsed.is_record: parsed.can_share = True i += 1 - elif t in ('-p', '--manage-records') and i + 1 < len(tokens): - parsed.manage_records = tokens[i + 1] - i += 2 - elif t in ('-o', '--manage-users') and i + 1 < len(tokens): - parsed.manage_users = tokens[i + 1] - i += 2 - elif ( - t in ('-r', '--role') - and name in (_NSF_FOLDER | _NSF_RECORD) - and i + 1 < len(tokens) - ): - parsed.nsf_role = tokens[i + 1] - i += 2 + 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('-'): - if i + 1 < len(tokens) and not tokens[i + 1].startswith('-'): - i += 2 - else: - i += 1 + i = cls._skip_unknown_flag(tokens, i) else: positional.append(t) i += 1 - if name in _RECORD_CMDS: - # Classic/NSF record share: single target (last positional). + if parsed.is_record: if positional: parsed.targets = [positional[-1]] else: diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py index 03935cd23..7c5148e8e 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_policy.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -13,15 +13,56 @@ from __future__ import annotations +from typing import Optional + +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', +}) + +_ER_BLOCKED_PREFIXES = ( + '--name=', + '--new-user=', + '--enforcement=', +) + +_ER_ALLOWED_HINT = ( + '--add-admin, --remove-admin, --add-privilege, --remove-privilege ' + '(plus --node, --cascade, -f)' +) + class SailPointCommandPolicy: """Sanitize / restrict commands allowed for SailPoint Service Mode.""" @classmethod def sanitize(cls, commands: str) -> str: - """Keep only SailPoint-allowed commands; always drop banned secret-bearing ones.""" + """ + 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. + """ allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} filtered = [ @@ -30,12 +71,36 @@ def sanitize(cls, commands: str) -> str: and (key := cmd.lower()) not in banned and key in allowed ] - # Preserve first-seen casing while deduping by lower-case key - parts = list({cmd.lower(): cmd for cmd in filtered}.values()) - if not parts: - return ','.join(SAILPOINT_ALLOWED_COMMANDS) - return ','.join(parts) + # Input order first, then any missing required allowlist entries. + by_key = {cmd.lower(): cmd for cmd in filtered} + for cmd in SAILPOINT_ALLOWED_COMMANDS: + key = cmd.lower() + if key not in banned and key not in by_key: + by_key[key] = cmd + return ','.join(by_key.values()) @classmethod def default_allowlist(cls) -> str: return cls.sanitize(','.join(SAILPOINT_ALLOWED_COMMANDS)) + + @classmethod + 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 ``). + """ + 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): + flag = token.split('=', 1)[0] + return ( + f'SailPoint mode does not allow enterprise-role {flag}. ' + f'Allowed: {_ER_ALLOWED_HINT}.' + ) + return None diff --git a/keepercommander/service/commands/integrations/sailpoint/config_fields.py b/keepercommander/service/commands/integrations/sailpoint/config_fields.py index 562f2d97e..ce2fa7e67 100644 --- a/keepercommander/service/commands/integrations/sailpoint/config_fields.py +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -13,46 +13,77 @@ from __future__ import annotations -from typing import Tuple +from dataclasses import dataclass from .....params import KeeperParams from .constants import ( + ALLOW_FOLDERS_FIELD, + ALLOW_RECORDS_FIELD, + ALLOW_ROLES_FIELD, + ALLOW_TEAMS_FIELD, DEFAULT_POLL_INTERVAL_SECONDS, - ENTITLEMENT_SCOPE_FIELD, + MIN_POLL_INTERVAL_SECONDS, POLL_INTERVAL_FIELD, - SCOPE_BOTH, ) -_VALID_SCOPES = frozenset({'folders', 'records', 'both'}) -_MIN_POLL_INTERVAL = 15 +_TRUE_VALUES = frozenset({'true', '1', 'yes', 'y', 'on'}) +_FALSE_VALUES = frozenset({'false', '0', 'no', 'n', 'off'}) -def read_scope_and_interval(params: KeeperParams, record_uid: str) -> Tuple[str, int]: +@dataclass(frozen=True) +class SailPointCapabilities: + """Share and identity entitlement gates (nodes are never gated).""" + + allow_folders: bool = True + allow_records: bool = True + allow_roles: bool = True + allow_teams: bool = True + poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS + + +def parse_bool(raw, default: bool = True) -> bool: + """Parse common truthy/falsey config strings; empty/unknown → default.""" + if raw is None: + return default + text = str(raw).strip().lower() + if not text: + return default + if text in _TRUE_VALUES: + return True + if text in _FALSE_VALUES: + return False + return default + + +def read_capabilities(params: KeeperParams, record_uid: str) -> SailPointCapabilities: from ..... import vault - scope = SCOPE_BOTH - interval = DEFAULT_POLL_INTERVAL_SECONDS + caps = SailPointCapabilities() record = vault.KeeperRecord.load(params, record_uid) if not isinstance(record, vault.TypedRecord) or not record.custom: - return scope, interval + return caps by_label = {field.label: field for field in record.custom if field.label} - scope_field = by_label.get(ENTITLEMENT_SCOPE_FIELD) - if scope_field: - value = (scope_field.get_default_value() or SCOPE_BOTH).strip().lower() - if value in _VALID_SCOPES: - scope = value + def _bool_field(label: str) -> bool: + field = by_label.get(label) + return parse_bool(field.get_default_value() if field else None, default=True) + interval = DEFAULT_POLL_INTERVAL_SECONDS interval_field = by_label.get(POLL_INTERVAL_FIELD) if interval_field: try: - interval = max(_MIN_POLL_INTERVAL, int(interval_field.get_default_value() or interval)) + interval = max( + MIN_POLL_INTERVAL_SECONDS, + int(interval_field.get_default_value() or interval), + ) except (TypeError, ValueError): pass - return scope, interval - -def read_entitlement_scope(params: KeeperParams, record_uid: str) -> str: - scope, _ = read_scope_and_interval(params, record_uid) - return scope + return SailPointCapabilities( + allow_folders=_bool_field(ALLOW_FOLDERS_FIELD), + allow_records=_bool_field(ALLOW_RECORDS_FIELD), + allow_roles=_bool_field(ALLOW_ROLES_FIELD), + allow_teams=_bool_field(ALLOW_TEAMS_FIELD), + poll_interval_seconds=interval, + ) diff --git a/keepercommander/service/commands/integrations/sailpoint/constants.py b/keepercommander/service/commands/integrations/sailpoint/constants.py index 2391f586e..7933933ea 100644 --- a/keepercommander/service/commands/integrations/sailpoint/constants.py +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -22,20 +22,22 @@ SAILPOINT_MARKER_FIELD = 'sailpoint_integration' PENDING_ENTITLEMENTS_FIELD = 'pending_entitlements' -ENTITLEMENT_SCOPE_FIELD = 'entitlement_scope' +ALLOW_FOLDERS_FIELD = 'allow_folders' +ALLOW_RECORDS_FIELD = 'allow_records' +ALLOW_ROLES_FIELD = 'allow_roles' +ALLOW_TEAMS_FIELD = 'allow_teams' POLL_INTERVAL_FIELD = 'poll_interval_seconds' DEFAULT_POLL_INTERVAL_SECONDS = 60 - -SCOPE_FOLDERS = 'folders' -SCOPE_RECORDS = 'records' -SCOPE_BOTH = 'both' +MIN_POLL_INTERVAL_SECONDS = 15 SAILPOINT_ALLOWED_COMMANDS = ( 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', + 'enterprise-role', + 'er', 'enterprise-down', 'share-folder', 'share-record', diff --git a/keepercommander/service/commands/integrations/sailpoint/pending_store.py b/keepercommander/service/commands/integrations/sailpoint/pending_store.py index 25f9e6820..162023a90 100644 --- a/keepercommander/service/commands/integrations/sailpoint/pending_store.py +++ b/keepercommander/service/commands/integrations/sailpoint/pending_store.py @@ -172,3 +172,21 @@ def entry_is_empty(entry: Dict[str, Any]) -> bool: or entry.get('folders') or entry.get('records') ) + + @staticmethod + def summarize_entry(entry: Dict[str, Any]) -> str: + """Human-readable pending payload for INFO logs (counts only for shares).""" + parts: List[str] = [] + roles = list(entry.get('roles') or []) + teams = list(entry.get('teams') or []) + folders = entry.get('folders') or [] + records = entry.get('records') or [] + if roles: + parts.append(f'roles={roles}') + if teams: + parts.append(f'teams={teams}') + if folders: + parts.append(f'folders={len(folders)}') + if records: + parts.append(f'records={len(records)}') + return ' '.join(parts) diff --git a/keepercommander/service/commands/integrations/sailpoint/poller.py b/keepercommander/service/commands/integrations/sailpoint/poller.py index 9ad20d1d6..9fd9b8061 100644 --- a/keepercommander/service/commands/integrations/sailpoint/poller.py +++ b/keepercommander/service/commands/integrations/sailpoint/poller.py @@ -15,13 +15,13 @@ import threading import time -from typing import Any, Dict +from typing import Any, Dict, List from ..... import api from .....params import KeeperParams from ....decorators.logging import logger from .apply_entitlements import SailPointEntitlementApplier -from .config_fields import read_scope_and_interval +from .config_fields import read_capabilities from .constants import DEFAULT_POLL_INTERVAL_SECONDS from .pending_store import SailPointPendingStore @@ -36,34 +36,59 @@ def __init__(self, record_uid: str): self.record_uid = record_uid def reconcile(self, params: KeeperParams) -> None: - scope, _ = read_scope_and_interval(params, self.record_uid) - - def updater(pending: Dict[str, Any]) -> Dict[str, Any]: - if not pending: - return pending - - api.query_enterprise(params) - for email in list(pending.keys()): - if not SailPointEntitlementApplier.user_is_active(params, email): - continue - remaining, dropped = SailPointEntitlementApplier.apply_for_user( - params, email, pending[email], entitlement_scope=scope - ) - for msg in dropped: - logger.warning(f'SailPoint pending: {msg}') - if remaining and not SailPointPendingStore.entry_is_empty(remaining): - pending[email] = remaining - else: - del pending[email] - logger.info(f'SailPoint pending entitlements completed for {email}') - return pending + """ + Apply entitlements outside the pending-store write path so stale-revision + retries do not re-run share/role commands. + """ + caps = read_capabilities(params, self.record_uid) + pending = SailPointPendingStore.load(params, self.record_uid) + if not pending: + return + + api.query_enterprise(params) + remaining_by_email: Dict[str, Dict[str, Any]] = {} + cleared: List[str] = [] + + for email, entry in list(pending.items()): + if not SailPointEntitlementApplier.user_is_active(params, email): + continue + logger.info( + f'SailPoint: user {email} is Active; applying pending entitlements' + ) + remaining, dropped = SailPointEntitlementApplier.apply_for_user( + params, + email, + entry, + allow_roles=caps.allow_roles, + allow_teams=caps.allow_teams, + allow_folders=caps.allow_folders, + allow_records=caps.allow_records, + ) + for msg in dropped: + logger.warning(f'SailPoint pending: {msg}') + if remaining and not SailPointPendingStore.entry_is_empty(remaining): + remaining_by_email[email] = remaining + else: + cleared.append(email) + logger.info(f'SailPoint: pending entitlements cleared for {email}') + + if not remaining_by_email and not cleared: + return + + def updater(current: Dict[str, Any]) -> Dict[str, Any]: + for email, remaining in remaining_by_email.items(): + if email in current: + current[email] = remaining + for email in cleared: + current.pop(email, None) + return current SailPointPendingStore.update(params, self.record_uid, updater) def _loop(self) -> None: from ....core.globals import ensure_params_loaded - logger.info(f'SailPoint entitlement poller started (record={self.record_uid})') + logger.info('SailPoint: entitlement poller started') while True: interval = DEFAULT_POLL_INTERVAL_SECONDS try: @@ -72,7 +97,7 @@ def _loop(self) -> None: params.service_mode = True from .service import SailPointService SailPointService.bind_params(params, self.record_uid) - _, interval = read_scope_and_interval(params, self.record_uid) + interval = read_capabilities(params, self.record_uid).poll_interval_seconds self.reconcile(params) except Exception as e: logger.error(f'SailPoint poller cycle failed: {e}') diff --git a/keepercommander/service/commands/integrations/sailpoint/service.py b/keepercommander/service/commands/integrations/sailpoint/service.py index 69772d42c..ccaf58555 100644 --- a/keepercommander/service/commands/integrations/sailpoint/service.py +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -20,6 +20,7 @@ from ....decorators.logging import logger from .command_hook import SailPointCommandHook from .command_policy import SailPointCommandPolicy +from .config_fields import parse_bool from .constants import PARAMS_ATTR, SAILPOINT_MARKER_FIELD, SAILPOINT_RECORD_ENV @@ -44,9 +45,9 @@ def record_has_marker(cls, params: KeeperParams, record_uid: str) -> bool: if not isinstance(record, vault.TypedRecord) or not record.custom: return False return any( - str(field.get_default_value() or '').strip().lower() in ('true', '1', 'yes', 'y') + field.label == SAILPOINT_MARKER_FIELD + and parse_bool(field.get_default_value(), default=False) for field in record.custom - if field.label == SAILPOINT_MARKER_FIELD ) @classmethod diff --git a/keepercommander/service/commands/integrations/sailpoint/share_targets.py b/keepercommander/service/commands/integrations/sailpoint/share_targets.py new file mode 100644 index 000000000..70418cc46 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/share_targets.py @@ -0,0 +1,121 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + return bool(uid) and uid in getattr(params, 'nested_share_folders', {}) + + +def is_nsf_record(params: KeeperParams, uid: str) -> bool: + return bool(uid) and uid in getattr(params, 'nested_share_records', {}) + + +def is_classic_record(params: KeeperParams, uid: str) -> bool: + if not uid or is_nsf_record(params, uid): + return False + return uid in (params.record_cache or {}) + + +def is_classic_shared_folder(params: KeeperParams, uid: str) -> bool: + if not uid or is_nsf_folder(params, uid): + return False + if uid in (params.shared_folder_cache or {}): + return True + folder = (params.folder_cache or {}).get(uid) + if not folder: + return False + return folder.type in ( + BaseFolderNode.SharedFolderType, + BaseFolderNode.SharedFolderFolderType, + ) + + +def validate_share_targets(params: KeeperParams, share: ParsedShare) -> Optional[str]: + """ + Ensure share command family matches target type. + + Classic share-* must not target NSF UIDs, and nsf-share-* must not target + classic shared-folder / record UIDs. + """ + for uid in share.targets: + if share.is_folder and share.is_nsf: + if is_nsf_folder(params, uid): + continue + if is_classic_shared_folder(params, uid): + return ( + f'Target "{uid}" is a classic shared folder; ' + f'use share-folder instead of nsf-share-folder.' + ) + return ( + f'Target "{uid}" is not a Nested Share Folder; ' + f'nsf-share-folder requires an NSF folder UID.' + ) + + if share.is_folder and not share.is_nsf: + if is_classic_shared_folder(params, uid): + continue + if is_nsf_folder(params, uid): + return ( + f'Target "{uid}" is a Nested Share Folder; ' + f'use nsf-share-folder instead of share-folder.' + ) + return ( + f'Target "{uid}" is not a classic shared folder; ' + f'share-folder requires a shared-folder UID.' + ) + + if share.is_record and share.is_nsf: + if is_nsf_record(params, uid): + continue + if is_classic_record(params, uid): + return ( + f'Target "{uid}" is a classic record; ' + f'use share-record instead of nsf-share-record.' + ) + if is_nsf_folder(params, uid) or is_classic_shared_folder(params, uid): + return ( + f'Target "{uid}" is a folder; ' + f'nsf-share-record requires an NSF record UID.' + ) + return ( + f'Target "{uid}" is not an NSF record; ' + f'nsf-share-record requires an NSF record UID.' + ) + + if share.is_record and not share.is_nsf: + if is_classic_record(params, uid): + continue + if is_nsf_record(params, uid): + return ( + f'Target "{uid}" is an NSF record; ' + f'use nsf-share-record instead of share-record.' + ) + if is_nsf_folder(params, uid) or is_classic_shared_folder(params, uid): + return ( + f'Target "{uid}" is a folder; ' + f'share-record requires a classic record UID.' + ) + return ( + f'Target "{uid}" is not a classic record; ' + f'share-record requires a record UID.' + ) + + return None diff --git a/keepercommander/service/commands/integrations/sailpoint_app_setup.py b/keepercommander/service/commands/integrations/sailpoint_app_setup.py index f663e8875..d52fd9041 100644 --- a/keepercommander/service/commands/integrations/sailpoint_app_setup.py +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -22,16 +22,17 @@ from .integration_setup_base import IntegrationSetupCommand from .sailpoint.command_policy import SailPointCommandPolicy from .sailpoint.constants import ( + ALLOW_FOLDERS_FIELD, + ALLOW_RECORDS_FIELD, + ALLOW_ROLES_FIELD, + ALLOW_TEAMS_FIELD, DEFAULT_POLL_INTERVAL_SECONDS, DOCKER_RECORD_ENV, - ENTITLEMENT_SCOPE_FIELD, + MIN_POLL_INTERVAL_SECONDS, PENDING_ENTITLEMENTS_FIELD, POLL_INTERVAL_FIELD, SAILPOINT_MARKER_FIELD, SAILPOINT_RECORD_ENV, - SCOPE_BOTH, - SCOPE_FOLDERS, - SCOPE_RECORDS, ) from .sailpoint.pending_store import SailPointPendingStore @@ -57,16 +58,15 @@ def get_service_commands(self) -> str: return SailPointCommandPolicy.default_allowlist() def collect_integration_config(self, params): - print(f"\n{bcolors.BOLD}ENTITLEMENT SCOPE:{bcolors.ENDC}") + print(f"\n{bcolors.BOLD}SHARE ENTITLEMENTS:{bcolors.ENDC}") print(f" Control which share entitlements SailPoint may manage via Service Mode") - print(f" Options: {SCOPE_FOLDERS}, {SCOPE_RECORDS}, {SCOPE_BOTH}") - while True: - scope = input( - f"{bcolors.OKBLUE}Scope [Press Enter for {SCOPE_BOTH}]:{bcolors.ENDC} " - ).strip().lower() or SCOPE_BOTH - if scope in (SCOPE_FOLDERS, SCOPE_RECORDS, SCOPE_BOTH): - break - print(f"{bcolors.FAIL}Error: Enter folders, records, or both{bcolors.ENDC}") + allow_folders = self._prompt_yes_no('Allow folder shares?', default=True) + allow_records = self._prompt_yes_no('Allow record shares?', default=True) + + print(f"\n{bcolors.BOLD}IDENTITY ENTITLEMENTS:{bcolors.ENDC}") + print(f" Control whether SailPoint may assign roles/teams via enterprise-user") + 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}POLL INTERVAL:{bcolors.ENDC}") print(f" How often (seconds) to check whether invited users have become Active") @@ -79,21 +79,38 @@ def collect_integration_config(self, params): interval = DEFAULT_POLL_INTERVAL_SECONDS break try: - interval = max(15, int(raw)) + interval = max(MIN_POLL_INTERVAL_SECONDS, int(raw)) break except ValueError: - print(f"{bcolors.FAIL}Error: Enter a whole number of seconds (>= 15){bcolors.ENDC}") + print( + f"{bcolors.FAIL}Error: Enter a whole number of seconds " + f"(>= {MIN_POLL_INTERVAL_SECONDS}){bcolors.ENDC}" + ) print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ SailPoint Configuration Complete!{bcolors.ENDC}") return SailPointConfig( - entitlement_scope=scope, + allow_folders=allow_folders, + allow_records=allow_records, + allow_roles=allow_roles, + allow_teams=allow_teams, poll_interval_seconds=interval, ) def build_record_custom_fields(self, config): return [ vault.TypedField.new_field('text', 'true', SAILPOINT_MARKER_FIELD), - vault.TypedField.new_field('text', config.entitlement_scope, ENTITLEMENT_SCOPE_FIELD), + vault.TypedField.new_field( + 'text', 'true' if config.allow_folders else 'false', ALLOW_FOLDERS_FIELD + ), + vault.TypedField.new_field( + 'text', 'true' if config.allow_records else 'false', ALLOW_RECORDS_FIELD + ), + vault.TypedField.new_field( + 'text', 'true' if config.allow_roles else 'false', ALLOW_ROLES_FIELD + ), + vault.TypedField.new_field( + 'text', 'true' if config.allow_teams else 'false', ALLOW_TEAMS_FIELD + ), vault.TypedField.new_field('text', str(config.poll_interval_seconds), POLL_INTERVAL_FIELD), vault.TypedField.new_field('text', json.dumps({}), PENDING_ENTITLEMENTS_FIELD), ] @@ -157,7 +174,10 @@ def _update_docker_compose(self, setup_result, service_config, record_uid, confi raise CommandError(self.get_command_name(), f'Failed to update docker-compose.yml: {str(e)}') def print_integration_specific_resources(self, config): - print(f" • Entitlement Scope: {bcolors.OKBLUE}{config.entitlement_scope}{bcolors.ENDC}") + print(f" • Allow Folders: {bcolors.OKBLUE}{config.allow_folders}{bcolors.ENDC}") + 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" • 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}") diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index 972dad578..f4874d880 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -126,6 +126,10 @@ class TeamsConfig: @dataclass class SailPointConfig: - entitlement_scope: str = 'both' # folders | records | both + allow_folders: bool = True + allow_records: bool = True + allow_roles: bool = True + allow_teams: bool = True + # 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 4e8e2dfb9..a352d6d43 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -14,6 +14,7 @@ import sys import json import logging +import shlex from typing import Any, Tuple, Optional from .config_reader import ConfigReader from .exceptions import CommandExecutionError @@ -24,6 +25,7 @@ is_throttle_error, throttle_error_response, ) +from .verified_command import Verifycommand from ..core.globals import get_current_params from ..decorators.logging import logger, debug_decorator, sanitize_debug_data from ... import cli, utils @@ -162,6 +164,16 @@ def execute(cls, command: str) -> Tuple[Any, int]: command = ensure_record_add_json_format(html.unescape(command)) + try: + command_tokens = shlex.split(command) + except ValueError: + command_tokens = command.split() + force_error = Verifycommand.validate_enterprise_user_add_role_force( + command_tokens, params + ) + if force_error: + return {"status": "error", "error": force_error}, 400 + sailpoint_enabled = bool((os.environ.get('SAILPOINT_RECORD') or '').strip()) if sailpoint_enabled: from ..commands.integrations.sailpoint.service import SailPointService diff --git a/keepercommander/service/util/verified_command.py b/keepercommander/service/util/verified_command.py index e622da0e9..2f62f5f3a 100644 --- a/keepercommander/service/util/verified_command.py +++ b/keepercommander/service/util/verified_command.py @@ -16,7 +16,7 @@ def validate_append_command(command): has_notes = bool(notes_value.strip()) break elif arg == "--notes": - # Check if there's a value after --notes flag + # Check for a value after --notes flag arg_index = command.index(arg) if arg_index + 1 < len(command) and not command[arg_index + 1].startswith("-"): notes_value = command[arg_index + 1] @@ -85,4 +85,75 @@ def validate_transform_folder_command(command): if missing_params: return f"Missing required parameters: {' and '.join(missing_params)}" - return None \ No newline at end of file + return None + + @staticmethod + def _enterprise_user_add_roles(command): + """Collect --add-role values from an enterprise-user command token list.""" + roles = [] + i = 1 + while i < len(command): + arg = command[i] + if arg == '--add-role' and i + 1 < len(command) and not command[i + 1].startswith('-'): + roles.append(command[i + 1]) + i += 2 + continue + if arg.startswith('--add-role='): + roles.append(arg.split('=', 1)[1]) + i += 1 + return roles + + @staticmethod + def _is_managed_admin_role(params, role_name): + """True when the role has administrative (managed node) permissions.""" + if not params or not getattr(params, 'enterprise', None): + return False + role_id = None + for role in params.enterprise.get('roles') or []: + display = ((role.get('data') or {}).get('displayname') or '').strip() + if str(role.get('role_id')) == str(role_name) or display.lower() == str(role_name).lower(): + role_id = role.get('role_id') + break + if role_id is None: + return False + return any( + mn.get('role_id') == role_id + for mn in (params.enterprise.get('managed_nodes') or []) + ) + + @classmethod + def validate_enterprise_user_add_role_force(cls, command, params=None): + """ + Admin roles prompt for confirmation on --add-role. Service Mode cannot + answer that prompt, so require -f/--force (same pattern as transform-folder). + Skips invite/--add flows (roles are deferred, not applied interactively). + """ + if not command or command[0] not in ('enterprise-user', 'eu'): + return None + + # Invite queues roles for later; no interactive admin-role prompt here. + if any(a in ('--invite', '--add') for a in command[1:]): + return None + + roles = cls._enterprise_user_add_roles(command) + if not roles: + return None + + has_force = any(a in ('-f', '--force') for a in command[1:]) + if has_force: + return None + + if params is not None: + try: + from ... import api + if not getattr(params, 'enterprise', None): + api.query_enterprise(params) + except Exception: + pass + if not any(cls._is_managed_admin_role(params, role) for role in roles): + return None + + return ( + 'Missing required parameters: -f/--force flag to bypass ' + 'interactive confirmation' + ) diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py index 45fd3a510..3910a1c22 100644 --- a/keepercommander/sync_down.py +++ b/keepercommander/sync_down.py @@ -98,7 +98,7 @@ def delete_team_key(team_uid): while not done: if token: request.continuationToken = token - response = api.communicate_rest(params, request, 'vault/sync_down', rs_type=SyncDown_pb2.SyncDownResponse) + response = api.communicate_rest(params, request, 'vault/sync_down', rs_type=SyncDown_pb2.SyncDownResponse,timeout=1000) done = not response.hasMore token = response.continuationToken if response.cacheStatus == SyncDown_pb2.CLEAR: diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py index 4f461e83e..f8ee726fb 100644 --- a/unit-tests/service/test_sailpoint_pending.py +++ b/unit-tests/service/test_sailpoint_pending.py @@ -27,6 +27,10 @@ from keepercommander.service.commands.integrations.sailpoint.scim_guard import ( SailPointScimGuard, ) +from keepercommander.service.commands.integrations.sailpoint.share_targets import ( + validate_share_targets, +) +from keepercommander.service.util.verified_command import Verifycommand class SailPointParseTest(unittest.TestCase): @@ -41,6 +45,40 @@ def test_parse_invite_with_role_team(self): self.assertEqual(parsed.roles, ['Admin']) self.assertEqual(parsed.teams, ['AWS']) + def test_parse_invite_multiple_roles_and_teams(self): + parsed = SailPointCommandParser.parse_invite( + 'enterprise-user user@co.com --invite ' + '--add-role R1 --add-role R2 --add-team T1 --add-team T2' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.roles, ['R1', 'R2']) + self.assertEqual(parsed.teams, ['T1', 'T2']) + + def test_parse_invite_equals_form_single_value(self): + parsed = SailPointCommandParser.parse_invite( + 'eu user@co.com --invite --add-role=R1 --add-team=T1' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.roles, ['R1']) + self.assertEqual(parsed.teams, ['T1']) + + def test_parse_invite_multi_value_after_one_flag_matches_commander(self): + # Commander rejects "--add-role R1 R2"; R2 is left as a positional (not a role). + parsed = SailPointCommandParser.parse_invite( + 'enterprise-user user@co.com --invite --add-role R1 R2 --add-team T1 T2' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.roles, ['R1']) + self.assertEqual(parsed.teams, ['T1']) + + def test_parse_invite_comma_is_literal_role_name(self): + # Commander treats this as one role named "R1,R2", not two roles. + parsed = SailPointCommandParser.parse_invite( + 'eu user@co.com --invite --add-role=R1,R2' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.roles, ['R1,R2']) + def test_parse_invite_alias(self): parsed = SailPointCommandParser.parse_invite('eu someone@x.com --add') self.assertIsNotNone(parsed) @@ -57,6 +95,18 @@ def test_parse_identity_mutation(self): ) self.assertIsNotNone(parsed) self.assertEqual(parsed.emails, ['user@co.com']) + self.assertTrue(parsed.has_role_change) + self.assertFalse(parsed.has_team_change) + self.assertFalse(parsed.has_node_change) + + def test_parse_identity_mutation_team_and_node(self): + parsed = SailPointCommandParser.parse_identity_mutation( + 'eu user@co.com --add-team T1 --node Sales' + ) + self.assertIsNotNone(parsed) + self.assertTrue(parsed.has_team_change) + self.assertTrue(parsed.has_node_change) + self.assertFalse(parsed.has_role_change) def test_parse_identity_mutation_ignores_non_mutating(self): self.assertIsNone( @@ -128,6 +178,28 @@ def test_parse_nsf_share_record_role(self): self.assertTrue(parsed.is_record) self.assertEqual(parsed.nsf_role, 'viewer') + def test_parse_share_equals_forms(self): + parsed = SailPointCommandParser.parse_share( + 'share-record --email=user@co.com --action=revoke RECORD_UID' + ) + self.assertIsNotNone(parsed) + self.assertEqual(parsed.emails, ['user@co.com']) + self.assertEqual(parsed.action, 'revoke') + self.assertFalse(parsed.is_grant) + + folder = SailPointCommandParser.parse_share( + 'share-folder --email=user@co.com --manage-records=on FOLDER_UID' + ) + self.assertIsNotNone(folder) + self.assertEqual(folder.manage_records, 'on') + self.assertEqual(folder.targets, ['FOLDER_UID']) + + nsf = SailPointCommandParser.parse_share( + 'nsf-share-folder --email=user@co.com --role=content-manager NSF_UID' + ) + self.assertIsNotNone(nsf) + self.assertEqual(nsf.nsf_role, 'content-manager') + class SailPointPolicyTest(unittest.TestCase): def test_sanitize_strips_get(self): @@ -137,13 +209,54 @@ def test_sanitize_strips_get(self): self.assertIn('enterprise-user', cleaned.split(',')) self.assertIn('share-record', cleaned.split(',')) + def test_sanitize_adds_enterprise_role_when_missing(self): + cleaned = SailPointCommandPolicy.sanitize( + 'whoami,sync-down,get,enterprise-info,enterprise-user,enterprise-down,' + 'share-folder,share-record,nsf-share-folder,nsf-share-record,tree' + ) + parts = cleaned.split(',') + self.assertNotIn('get', parts) + self.assertIn('enterprise-role', parts) + self.assertIn('er', parts) + def test_default_allowlist_matches_integration_list(self): expected = [ - 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-down', - 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', 'tree', + 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-role', 'er', + 'enterprise-down', 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', + 'tree', ] self.assertEqual(SailPointCommandPolicy.default_allowlist().split(','), expected) + def test_enterprise_role_blocks_add_delete_add_user(self): + for cmd in ( + "enterprise-role --add 'New Role'", + "er 'QA Role' --delete", + "er 'QA Role' --add-user user@co.com", + "enterprise-role 'QA Role' --copy", + "er 'QA Role' --name 'Renamed'", + "er 'QA Role' --enforcement restrict_sharing:true", + ): + err = SailPointCommandPolicy.validate_enterprise_role(cmd) + self.assertIsNotNone(err, cmd) + self.assertIn('does not allow enterprise-role', err) + + def test_enterprise_role_allows_admin_and_privilege(self): + for cmd in ( + "er 'QA Role'", + "enterprise-role 'QA Role' -aa 'Metron Security' --cascade on", + "er 'QA Role' --node 'Metron Security' -ap MANAGE_USER -ap MANAGE_NODES", + "er -f 'QA Role' -ra 'Metron Security'", + "er 'QA Role' --node 'Metron Security' -rp MANAGE_TEAMS", + ): + self.assertIsNone(SailPointCommandPolicy.validate_enterprise_role(cmd), cmd) + + def test_enterprise_role_gate_ignores_other_commands(self): + self.assertIsNone( + SailPointCommandPolicy.validate_enterprise_role( + 'enterprise-user user@co.com --add-role Admin' + ) + ) + class SailPointPendingMergeTest(unittest.TestCase): def test_merge_by_email(self): @@ -207,7 +320,7 @@ def test_apply_nsf_folder_and_record_commands(self): with mock.patch.object(SailPointEntitlementApplier, '_run', side_effect=lambda p, c: runs.append(c)): remaining, dropped = SailPointEntitlementApplier.apply_for_user( - params, 'user@co.com', entry, entitlement_scope='both' + params, 'user@co.com', entry ) self.assertEqual(remaining, {}) @@ -239,7 +352,7 @@ def test_does_not_drop_on_generic_invalid(self): side_effect=RuntimeError('invalid permission combination'), ): remaining, dropped = SailPointEntitlementApplier.apply_for_user( - params, 'user@co.com', entry, entitlement_scope='both' + params, 'user@co.com', entry ) self.assertEqual(dropped, []) @@ -288,5 +401,342 @@ def test_default_record_name_and_env_key(self): self.assertEqual(cmd.get_default_folder_name(), 'Commander Service Mode - SailPoint') +class SailPointShareTargetValidationTest(unittest.TestCase): + def _params(self): + params = mock.Mock() + params.nested_share_folders = {'NSF_FOLDER': {}} + params.nested_share_records = {'NSF_RECORD': {}} + params.shared_folder_cache = {'CLASSIC_SF': {}} + params.record_cache = {'CLASSIC_REC': {}, 'NSF_RECORD': {}} + params.folder_cache = {} + return params + + def test_share_folder_rejects_nsf_folder(self): + share = SailPointCommandParser.parse_share( + 'share-folder -e user@co.com NSF_FOLDER' + ) + err = validate_share_targets(self._params(), share) + self.assertIsNotNone(err) + self.assertIn('nsf-share-folder', err) + + def test_nsf_share_folder_rejects_classic_folder(self): + share = SailPointCommandParser.parse_share( + 'nsf-share-folder -e user@co.com -r viewer CLASSIC_SF' + ) + err = validate_share_targets(self._params(), share) + self.assertIsNotNone(err) + self.assertIn('share-folder', err) + + def test_share_record_rejects_nsf_folder_and_nsf_record(self): + params = self._params() + share_folder = SailPointCommandParser.parse_share( + 'share-record -e user@co.com NSF_FOLDER' + ) + err = validate_share_targets(params, share_folder) + self.assertIsNotNone(err) + self.assertIn('folder', err.lower()) + + share_rec = SailPointCommandParser.parse_share( + 'share-record -e user@co.com NSF_RECORD' + ) + err = validate_share_targets(params, share_rec) + self.assertIsNotNone(err) + self.assertIn('nsf-share-record', err) + + def test_nsf_share_record_rejects_classic_record(self): + share = SailPointCommandParser.parse_share( + 'nsf-share-record -e user@co.com -r viewer CLASSIC_REC' + ) + err = validate_share_targets(self._params(), share) + self.assertIsNotNone(err) + self.assertIn('share-record', err) + + def test_matching_targets_ok(self): + params = self._params() + cases = [ + 'share-folder -e user@co.com CLASSIC_SF', + 'nsf-share-folder -e user@co.com -r viewer NSF_FOLDER', + 'share-record -e user@co.com CLASSIC_REC', + 'nsf-share-record -e user@co.com -r viewer NSF_RECORD', + ] + for cmd in cases: + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNone(validate_share_targets(params, share), cmd) + + +class EnterpriseUserForceValidationTest(unittest.TestCase): + def test_admin_role_requires_force(self): + params = mock.Mock() + params.enterprise = { + 'roles': [{'role_id': 10, 'data': {'displayname': 'Commander RTI'}}], + 'managed_nodes': [{'role_id': 10, 'managed_node_id': 1}], + } + err = Verifycommand.validate_enterprise_user_add_role_force( + ['enterprise-user', 'user@co.com', '--add-role', 'Commander RTI'], + params, + ) + self.assertIsNotNone(err) + self.assertIn('-f/--force', err) + + def test_admin_role_with_force_ok(self): + params = mock.Mock() + params.enterprise = { + 'roles': [{'role_id': 10, 'data': {'displayname': 'Commander RTI'}}], + 'managed_nodes': [{'role_id': 10, 'managed_node_id': 1}], + } + err = Verifycommand.validate_enterprise_user_add_role_force( + ['eu', '-f', 'user@co.com', '--add-role', 'Commander RTI'], + params, + ) + self.assertIsNone(err) + + def test_non_admin_role_ok_without_force(self): + params = mock.Mock() + params.enterprise = { + 'roles': [{'role_id': 11, 'data': {'displayname': 'QA Role'}}], + 'managed_nodes': [{'role_id': 10, 'managed_node_id': 1}], + } + err = Verifycommand.validate_enterprise_user_add_role_force( + ['enterprise-user', 'user@co.com', '--add-role', 'QA Role'], + params, + ) + self.assertIsNone(err) + + def test_invite_skips_force_check(self): + params = mock.Mock() + params.enterprise = { + 'roles': [{'role_id': 10, 'data': {'displayname': 'Commander RTI'}}], + 'managed_nodes': [{'role_id': 10, 'managed_node_id': 1}], + } + err = Verifycommand.validate_enterprise_user_add_role_force( + [ + 'enterprise-user', 'user@co.com', '--invite', + '--add-role', 'Commander RTI', + ], + params, + ) + self.assertIsNone(err) + + def test_apply_role_uses_force_flag(self): + params = mock.Mock() + params.enterprise = { + 'users': [{'username': 'user@co.com', 'node_id': 1, 'status': 'active'}], + 'nodes': [{'node_id': 1}], + 'scims': [], + 'roles': [{'role_id': 10, 'data': {'displayname': 'Admin'}}], + 'teams': [], + } + runs = [] + with mock.patch.object( + SailPointEntitlementApplier, '_run', side_effect=lambda p, c: runs.append(c) + ): + SailPointEntitlementApplier.apply_for_user( + params, + 'user@co.com', + {'roles': ['Admin'], 'teams': [], 'folders': [], 'records': []}, + ) + self.assertTrue(runs) + self.assertIn(' -f ', f' {runs[0]} ') + self.assertIn('--add-role', runs[0]) + + +class SailPointCapabilityGateTest(unittest.TestCase): + def test_roles_off_blocks_invite_add_role_and_er(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + caps = SailPointCapabilities(allow_roles=False, allow_teams=True) + err = SailPointCommandHook._check_capability_gates( + "eu user@co.com --invite --node N --add-role R", caps + ) + self.assertIsNotNone(err) + self.assertIn('allow_roles', err) + + err = SailPointCommandHook._check_capability_gates( + "er 'Demo Role' -aa 'Node'", caps + ) + self.assertIsNotNone(err) + self.assertIn('enterprise-role', err) + + def test_teams_off_blocks_add_team_allows_node(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + caps = SailPointCapabilities(allow_roles=True, allow_teams=False) + err = SailPointCommandHook._check_capability_gates( + 'eu user@co.com --add-team Slack', caps + ) + self.assertIsNotNone(err) + self.assertIn('allow_teams', err) + + err = SailPointCommandHook._check_capability_gates( + "eu user@co.com --invite --node 'Service Account Node'", caps + ) + self.assertIsNone(err) + + err = SailPointCommandHook._check_capability_gates( + 'eu user@co.com --node Other', caps + ) + self.assertIsNone(err) + + def test_apply_skips_folders_and_records_when_disallowed(self): + params = mock.Mock() + params.enterprise = { + 'users': [{'username': 'user@co.com', 'node_id': 1, 'status': 'active'}], + 'nodes': [{'node_id': 1}], + 'scims': [], + 'roles': [], + 'teams': [], + } + runs = [] + with mock.patch.object( + SailPointEntitlementApplier, '_run', side_effect=lambda p, c: runs.append(c) + ): + remaining, dropped = SailPointEntitlementApplier.apply_for_user( + params, + 'user@co.com', + { + 'roles': [], + 'teams': [], + 'folders': [{'uid': 'FOLDER1', 'kind': 'classic'}], + 'records': [{'uid': 'REC1'}], + }, + allow_folders=False, + allow_records=False, + ) + self.assertEqual(runs, []) + self.assertEqual(remaining, {}) + self.assertTrue(any('allow_folders' in m for m in dropped)) + self.assertTrue(any('allow_records' in m for m in dropped)) + + def test_mixed_active_and_invited_share_rejected(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + params = mock.Mock() + params.enterprise = { + 'users': [ + {'username': 'active@co.com', 'node_id': 1, 'status': 'active'}, + {'username': 'invited@co.com', 'node_id': 1, 'status': 'invited'}, + ], + 'nodes': [{'node_id': 1}], + 'scims': [], + } + share = SailPointCommandParser.parse_share( + 'share-record -e active@co.com -e invited@co.com RECORD_UID' + ) + hook = SailPointCommandHook('cfg-uid') + response, status = hook._before_share( + params, share, SailPointCapabilities() + ) + self.assertEqual(status, 400) + self.assertIn('mix Active and non-Active', response['error']) + + def test_after_command_skips_missing_user(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + + params = mock.Mock() + params.enterprise = { + 'users': [], + 'nodes': [], + 'scims': [], + } + hook = SailPointCommandHook('cfg-uid') + with mock.patch.object(SailPointPendingStore, 'update') as update_mock: + hook.after_command( + params, + 'eu missing@co.com --invite --add-role Admin', + success=True, + ) + update_mock.assert_not_called() + + def test_poller_applies_outside_store_update(self): + from keepercommander.service.commands.integrations.sailpoint.poller import ( + SailPointEntitlementPoller, + ) + + params = mock.Mock() + params.enterprise = { + 'users': [{'username': 'user@co.com', 'node_id': 1, 'status': 'active'}], + 'nodes': [{'node_id': 1}], + 'scims': [], + 'roles': [{'role_id': 10, 'data': {'displayname': 'Admin'}}], + 'teams': [], + } + pending = { + 'user@co.com': { + 'roles': ['Admin'], + 'teams': [], + 'folders': [], + 'records': [], + } + } + apply_calls = [] + update_calls = [] + + def fake_update(p, uid, updater): + update_calls.append(updater(dict(pending))) + return update_calls[-1] + + poller = SailPointEntitlementPoller('cfg-uid') + with mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.poller.read_capabilities', + return_value=mock.Mock( + allow_roles=True, allow_teams=True, allow_folders=True, allow_records=True + ), + ), mock.patch.object(SailPointPendingStore, 'load', return_value=pending), mock.patch.object( + SailPointPendingStore, 'update', side_effect=fake_update + ), mock.patch.object( + SailPointEntitlementApplier, + 'apply_for_user', + side_effect=lambda *a, **k: (apply_calls.append(1) or ({}, [])), + ), mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.poller.api.query_enterprise' + ): + poller.reconcile(params) + + self.assertEqual(len(apply_calls), 1) + self.assertEqual(len(update_calls), 1) + self.assertNotIn('user@co.com', update_calls[0]) + + def test_apply_skips_roles_when_disallowed(self): + params = mock.Mock() + params.enterprise = { + 'users': [{'username': 'user@co.com', 'node_id': 1, 'status': 'active'}], + 'nodes': [{'node_id': 1}], + 'scims': [], + 'roles': [{'role_id': 10, 'data': {'displayname': 'Admin'}}], + 'teams': [], + } + runs = [] + with mock.patch.object( + SailPointEntitlementApplier, '_run', side_effect=lambda p, c: runs.append(c) + ): + remaining, dropped = SailPointEntitlementApplier.apply_for_user( + params, + 'user@co.com', + {'roles': ['Admin'], 'teams': [], 'folders': [], 'records': []}, + allow_roles=False, + allow_teams=True, + ) + self.assertEqual(runs, []) + self.assertEqual(remaining, {}) + self.assertTrue(any('allow_roles' in m for m in dropped)) + + if __name__ == '__main__': unittest.main() diff --git a/unit-tests/test_nsf_acl_cache.py b/unit-tests/test_nsf_acl_cache.py index 31d67354b..3fc511ad5 100644 --- a/unit-tests/test_nsf_acl_cache.py +++ b/unit-tests/test_nsf_acl_cache.py @@ -306,18 +306,18 @@ def test_nsf_record_prefers_direct_over_inherited(self): self.params.nested_share_record_share_cache['NR1'] = [ {'owner': True, 'accessor_name': 'owner@x.com', 'access_type': 'AT_USER', 'access_role_type': 6, 'inherited': False}, - # Inherited folder role first in raw list — should lose to direct CT + # Inherited folder role first in raw list — should lose to direct requestor {'owner': False, 'accessor_name': 'carol@x.com', 'access_type': 'AT_USER', 'access_role_type': 2, 'inherited': True}, {'owner': False, 'accessor_name': 'carol@x.com', 'access_type': 'AT_USER', - 'access_role_type': 1, 'inherited': False}, # contributor + 'access_role_type': 1, 'inherited': False}, # requestor ] data, text = _nsf_record_share_data(self.params, 'NR1') - self.assertIn('carol@x.com:CT', text) + self.assertIn('carol@x.com:RE', text) self.assertNotIn('carol@x.com:VW', text) carol = [u for u in data['users'] if u.get('email') == 'carol@x.com'] self.assertEqual(len(carol), 1) - self.assertEqual(carol[0]['permissions'], ['CT']) + self.assertEqual(carol[0]['permissions'], ['RE']) def test_nsf_record_falls_back_to_folder_acl(self): from keepercommander.commands.folder import _nsf_record_share_data From 28a0b649dc02083606e3cfde44c3c2ea65d30c98 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 30 Jul 2026 19:03:29 +0530 Subject: [PATCH 4/4] Fix sync down timeout --- keepercommander/sync_down.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py index 3910a1c22..45fd3a510 100644 --- a/keepercommander/sync_down.py +++ b/keepercommander/sync_down.py @@ -98,7 +98,7 @@ def delete_team_key(team_uid): while not done: if token: request.continuationToken = token - response = api.communicate_rest(params, request, 'vault/sync_down', rs_type=SyncDown_pb2.SyncDownResponse,timeout=1000) + response = api.communicate_rest(params, request, 'vault/sync_down', rs_type=SyncDown_pb2.SyncDownResponse) done = not response.hasMore token = response.continuationToken if response.cacheStatus == SyncDown_pb2.CLEAR: