From 961ece9c1c16a76893a8a2293347e943c9bc02cc Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 30 Jul 2026 19:06:51 +0530 Subject: [PATCH] Add sailpoint-app-setup command (#2246) * Implement Sailpoint App Setup Command for sailpoint integration * Add code enhancements and validations * Improve SailPoint Service Mode: capability gates, better logs, remove Contributor from NSF, and safer pending apply. * Fix sync down timeout --- keepercommander/command_categories.py | 3 +- keepercommander/commands/folder.py | 4 +- .../commands/nested_share_folder/helpers.py | 10 +- keepercommander/commands/start_service.py | 8 +- keepercommander/service/app.py | 5 + .../service/commands/create_service.py | 13 +- .../service/commands/integrations/__init__.py | 2 + .../integrations/integration_setup_base.py | 12 +- .../integrations/sailpoint/__init__.py | 32 + .../sailpoint/apply_entitlements.py | 248 ++++++ .../integrations/sailpoint/command_hook.py | 276 +++++++ .../integrations/sailpoint/command_parse.py | 254 ++++++ .../integrations/sailpoint/command_policy.py | 106 +++ .../integrations/sailpoint/config_fields.py | 89 +++ .../integrations/sailpoint/constants.py | 64 ++ .../integrations/sailpoint/pending_store.py | 192 +++++ .../commands/integrations/sailpoint/poller.py | 118 +++ .../integrations/sailpoint/scim_guard.py | 92 +++ .../integrations/sailpoint/service.py | 144 ++++ .../integrations/sailpoint/share_targets.py | 121 +++ .../integrations/sailpoint_app_setup.py | 191 +++++ keepercommander/service/config/models.py | 2 +- keepercommander/service/docker/__init__.py | 5 +- .../service/docker/compose_builder.py | 8 +- keepercommander/service/docker/models.py | 10 + keepercommander/service/util/command_util.py | 39 +- .../service/util/verified_command.py | 75 +- unit-tests/service/test_sailpoint_pending.py | 742 ++++++++++++++++++ unit-tests/test_nsf_acl_cache.py | 8 +- 29 files changed, 2847 insertions(+), 26 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/share_targets.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/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/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..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,6 +47,10 @@ def handle_rate_limit_exceeded(e): logger.debug("Initializing API routes") init_routes(app) + 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 6e89f8496..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 @@ -92,9 +93,19 @@ 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 + 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 = ( 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: + 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: + 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: + from ..... import cli + 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 = f" {' '.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 = 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, + params: KeeperParams, + email: str, + entry: Dict[str, Any], + *, + 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'), + '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 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( + 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 -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) + 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)}', + ) + 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 + + if allow_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('Pending folders skipped by allow_folders=false') + remaining['folders'] = [] + + if allow_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('Pending records skipped by allow_records=false') + 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..ea2288b51 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -0,0 +1,276 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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: + 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 = 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: + 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 + created: List[str] = [] + updated: List[str] = [] + + 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 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 + + 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]]: + if not (invite.roles or invite.teams): + return None + err = self._first_scim_identity_error(params, invite.emails) + 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, + 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 + + if share.is_folder and not caps.allow_folders: + return { + 'status': 'error', + 'error': 'SailPoint allow_folders is disabled; share-folder is not allowed.', + }, 403 + if share.is_record and not caps.allow_records: + return { + 'status': 'error', + '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 + + next_state = SailPointPendingStore.update(params, self.record_uid, updater) + self._log_pending_writes(next_state, created, updated) + + 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 new file mode 100644 index 000000000..7286919aa --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -0,0 +1,254 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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: + """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]: + 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 _EU_CMDS: + return None + + parsed = ParsedInvite() + emails: List[str] = [] + i = 1 + while i < len(tokens): + t = tokens[i] + if t in _INVITE_FLAGS: + parsed.is_invite = True + i += 1 + elif cls._matches_flag(t, '--node', '-n'): + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.node = value + elif cls._matches_flag(t, '--add-role'): + i = cls._append_flag_value(t, tokens, i, parsed.roles) + elif cls._matches_flag(t, '--add-team'): + i = cls._append_flag_value(t, tokens, i, parsed.teams) + elif t.startswith('-'): + i = cls._skip_unknown_flag(tokens, i) + else: + if '@' in t: + emails.append(t) + i += 1 + + parsed.emails = emails + return parsed if parsed.is_invite else None + + @classmethod + def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutation]: + tokens = cls.tokenize(command) + if not tokens or tokens[0] not in _EU_CMDS: + return None + + emails: List[str] = [] + has_role = False + has_team = False + has_node = False + i = 1 + while i < len(tokens): + t = tokens[i] + if cls._matches_flag(t, '--add-role', '--remove-role'): + has_role = True + _, i = cls._one_flag_value(t, tokens, i) + elif cls._matches_flag(t, '--add-team', '--remove-team'): + has_team = True + _, i = cls._one_flag_value(t, tokens, i) + elif cls._matches_flag(t, '--node', '-n'): + has_node = True + _, i = cls._one_flag_value(t, tokens, i) + elif t.startswith('-'): + i = cls._skip_unknown_flag(tokens, i) + else: + if '@' in t: + emails.append(t) + i += 1 + + if not (has_role or has_team or has_node) or not emails: + return None + 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) + 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 cls._matches_flag(t, '-e', '--email'): + value, i = cls._one_flag_value(t, tokens, i) + if value: + parsed.emails.append(value) + elif cls._matches_flag(t, '-a', '--action'): + value, i = cls._one_flag_value(t, tokens, i) + parsed.action = (value or 'grant').strip().lower() or 'grant' + elif t in ('-w', '--write'): + parsed.can_edit = True + i += 1 + elif t in ('-s', '--share') and parsed.is_record: + parsed.can_share = True + i += 1 + elif cls._matches_flag(t, '-p', '--manage-records'): + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.manage_records = value + elif cls._matches_flag(t, '-o', '--manage-users'): + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.manage_users = value + elif cls._matches_flag(t, '-r', '--role') and parsed.is_nsf: + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.nsf_role = value + elif t.startswith('-'): + i = cls._skip_unknown_flag(tokens, i) + else: + positional.append(t) + i += 1 + + if parsed.is_record: + if positional: + parsed.targets = [positional[-1]] + 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..7c5148e8e --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -0,0 +1,106 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Keep only SailPoint-allowed commands; always drop banned ones. + + Also ensures the full SailPoint allowlist is present so required + commands (e.g. enterprise-role/er) are not dropped when the input + list is a partial or older compose allowlist. + """ + allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} + banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} + filtered = [ + cmd for raw in (commands or '').split(',') + if (cmd := raw.strip()) + and (key := cmd.lower()) not in banned + and key in allowed + ] + # 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 new file mode 100644 index 000000000..ce2fa7e67 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -0,0 +1,89 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 + + caps = SailPointCapabilities() + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return caps + + by_label = {field.label: field for field in record.custom if field.label} + + def _bool_field(label: str) -> bool: + field = by_label.get(label) + return parse_bool(field.get_default_value() if field else None, default=True) + + interval = DEFAULT_POLL_INTERVAL_SECONDS + interval_field = by_label.get(POLL_INTERVAL_FIELD) + if interval_field: + try: + interval = max( + MIN_POLL_INTERVAL_SECONDS, + int(interval_field.get_default_value() or interval), + ) + except (TypeError, ValueError): + pass + + 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 new file mode 100644 index 000000000..7933933ea --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -0,0 +1,64 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 {} + 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: + 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 + 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})' + ) + + 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 (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: + 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') + ) + + @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 new file mode 100644 index 000000000..9fd9b8061 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/poller.py @@ -0,0 +1,118 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + """ + 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('SailPoint: entitlement poller started') + 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_capabilities(params, self.record_uid).poll_interval_seconds + 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..0e2a42812 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py @@ -0,0 +1,92 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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: 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') + 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: + 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() + 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: + 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..ccaf58555 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -0,0 +1,144 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 + return any( + field.label == SAILPOINT_MARKER_FIELD + and parse_bool(field.get_default_value(), default=False) + for field in record.custom + ) + + @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: + """ + 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 = cls.record_uid(params) + 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: + """ + 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() + 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( + 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]]: + """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" + cls.bind_params(params) + uid = cls.record_uid(params) + if not cls.record_has_marker(params, uid): + return None + return 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 cls.record_has_marker(params, uid): + return + SailPointCommandHook(uid).after_command(params, command, success) 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 new file mode 100644 index 000000000..d52fd9041 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -0,0 +1,191 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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}SHARE ENTITLEMENTS:{bcolors.ENDC}") + print(f" Control which share entitlements SailPoint may manage via Service Mode") + allow_folders = self._prompt_yes_no('Allow folder shares?', default=True) + 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") + 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(MIN_POLL_INTERVAL_SECONDS, int(raw)) + break + except ValueError: + 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( + 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', '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), + ] + + 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" • 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}") + + 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..f4874d880 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -123,3 +123,13 @@ class TeamsConfig: device_approval_enabled: bool = False device_approval_polling_interval: int = 120 + +@dataclass +class SailPointConfig: + 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 d49839bb7..a352d6d43 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -10,9 +10,11 @@ # import io, html +import os import sys import json import logging +import shlex from typing import Any, Tuple, Optional from .config_reader import ConfigReader from .exceptions import CommandExecutionError @@ -23,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 @@ -160,6 +163,26 @@ def execute(cls, command: str) -> Tuple[Any, int]: params.service_mode = True 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 + 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 @@ -173,7 +196,21 @@ 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 status_code == 200 and sailpoint_enabled: + 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/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/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py new file mode 100644 index 000000000..f8ee726fb --- /dev/null +++ b/unit-tests/service/test_sailpoint_pending.py @@ -0,0 +1,742 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | '