diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 370cb4759..8a892dedd 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.0.15' +__version__ = '18.1.0' diff --git a/keepercommander/api.py b/keepercommander/api.py index 1da4a1e32..ad83ace7a 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -1530,7 +1530,7 @@ def login_and_get_mc_params_login_v3(params: KeeperParams, mc_id): mc_params.rsa_key = params.rsa_key mc_params.rsa_key2 = params.rsa_key2 mc_params.ecc_key = params.ecc_key - mc_params.forbid_rsa = params.forbid_rsa + mc_params.forbid_rsa = resp.forbidKeyType2 mc_params.session_token = loginv3.CommonHelperMethods.bytes_to_url_safe_str(resp.encryptedSessionToken) mc_params.msp_tree_key = params.enterprise['unencrypted_tree_key'] diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py index f1be4583b..c2a6f6b54 100644 --- a/keepercommander/command_categories.py +++ b/keepercommander/command_categories.py @@ -84,7 +84,7 @@ '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', - 'sailpoint-app-setup' + 'sailpoint-app-setup', 'gchat-app-setup' }, # Email Configuration Commands diff --git a/keepercommander/commands/aram.py b/keepercommander/commands/aram.py index 5110bcd03..3a0a3d99f 100644 --- a/keepercommander/commands/aram.py +++ b/keepercommander/commands/aram.py @@ -1212,7 +1212,8 @@ def __init__(self): def get_sox_data(self, params): if not self.sox_data and is_compliance_reporting_enabled(params): - self.sox_data = get_compliance_data(params, 0, 0,False, min_updated=0, no_cache=False) + min_updated = int(datetime.datetime.now().timestamp()) if self.allow_sox_data_fetch else 0 + self.sox_data = get_compliance_data(params, 0, 0, False, min_updated=min_updated, no_cache=False) return self.sox_data def get_value(self, params, field, event): diff --git a/keepercommander/commands/connect.py b/keepercommander/commands/connect.py index cd8b820d8..af9acdfe3 100644 --- a/keepercommander/commands/connect.py +++ b/keepercommander/commands/connect.py @@ -194,15 +194,12 @@ def get_parameter_value(params, record, parameter, temp_files, **kwargs): @staticmethod def get_command_string(params, record, template, temp_files, **kwargs): # type: (KeeperParams, KeeperRecord, str, list, ...) -> str or None - command = template - while True: - m = endpoint_parameter_pattern.search(command) - if not m: - break - p = m.group(1) - pv = BaseConnectCommand.get_parameter_value(params, record, p, temp_files, **kwargs) - command = command[:m.start()] + (pv or '') + command[m.end():] - return command + # Single-pass substitution: re.sub never re-scans replacement text, so + # a field value containing ${…} cannot trigger a second expansion round. + def _replace(m): + pv = BaseConnectCommand.get_parameter_value(params, record, m.group(1), temp_files, **kwargs) + return pv or '' + return endpoint_parameter_pattern.sub(_replace, template) class ConnectSshCommand(BaseConnectCommand): diff --git a/keepercommander/commands/connect_prompts.py b/keepercommander/commands/connect_prompts.py index f22b1b4f2..d3cd66760 100644 --- a/keepercommander/commands/connect_prompts.py +++ b/keepercommander/commands/connect_prompts.py @@ -54,6 +54,13 @@ _DANGEROUS_ENV_PREFIXES = ('LD_', 'DYLD_') +# SSH -o options that cause local code execution via the SSH client itself. +_DANGEROUS_SSH_OPTIONS = frozenset({ + 'proxycommand', + 'localcommand', + 'permitlocalcommand', +}) + _INTERPRETER_DASH_C_FLAGS = ( '-c', '-lc', '-ic', '-Command', '-EncodedCommand', '-e', '-E', '/c', '/C', @@ -102,6 +109,29 @@ def _is_dangerous_env_name(name: Optional[str]) -> bool: return upper in _DANGEROUS_ENV_NAMES or upper.startswith(_DANGEROUS_ENV_PREFIXES) +def _dangerous_ssh_option(argv: List[str]) -> Optional[str]: + """Return the canonical option name if argv contains an SSH flag that causes + local code execution (ProxyCommand, LocalCommand, PermitLocalCommand). + Handles both merged (-oProxyCommand=…) and split (-o ProxyCommand=…) forms. + Returns None if no dangerous option is found. + """ + i = 0 + while i < len(argv): + token = argv[i] + # Merged form: -oProxyCommand=value or -OLocalCommand=value + if len(token) > 2 and token[0] == '-' and token[1] in ('o', 'O'): + opt_name = token[2:].split('=', 1)[0] + if opt_name.lower() in _DANGEROUS_SSH_OPTIONS: + return opt_name + # Split form: -o ProxyCommand=value + elif token in ('-o', '-O') and i + 1 < len(argv): + opt_name = argv[i + 1].split('=', 1)[0] + if opt_name.lower() in _DANGEROUS_SSH_OPTIONS: + return opt_name + i += 1 + return None + + def _looks_multi_statement(arg: str) -> bool: return ( len(arg) > _LONG_ARG_THRESHOLD @@ -243,6 +273,12 @@ def confirm_argv(stage: str, argv: List[str], record: Any = None) -> bool: warning_lines.append( 'argv[0] is a shell/scripting interpreter - it can run any code.' ) + dangerous_opt = _dangerous_ssh_option(argv) + if dangerous_opt is not None: + warning_lines.append( + f'{_sanitize(dangerous_opt)} is set — SSH will execute a local command' + ' on this machine with your privileges.' + ) _emit_warning(warning_lines) _emit_section('Command to execute:', _argv_block(argv)) sys.stderr.write('\n') diff --git a/keepercommander/commands/enterprise.py b/keepercommander/commands/enterprise.py index 437a801dc..57fffe7a6 100644 --- a/keepercommander/commands/enterprise.py +++ b/keepercommander/commands/enterprise.py @@ -797,17 +797,24 @@ def tree_node(node): elif column == 'transfer_status': row.append(user_status_dict['acct_transfer_status']) elif column == 'node': + node_path = self.get_node_path(params, u['node_id']) if is_verbose: - row.append(str(u['node_id'])) + row.append({ + 'node_id': str(u['node_id']), + 'node_name': node_path, + }) else: - row.append(self.get_node_path(params, u['node_id'])) + row.append(node_path) elif column == 'team_count': row.append(len([1 for t in teams.values() if t['users'] and user_id in t['users']])) elif column == 'teams': user_team_list = [t for t in teams.values() if t['users'] and user_id in t['users']] if is_verbose: - row.append([t['id'] for t in user_team_list]) + row.append([ + {'team_uid': t['id'], 'team_name': t['name']} + for t in user_team_list + ]) else: row.append([t['name'] for t in user_team_list]) elif column == 'role_count' or column == 'roles': @@ -822,7 +829,13 @@ def tree_node(node): row.append(len(role_ids)) else: if is_verbose: - row.append([str(role_id) for role_id in role_ids if role_id in roles]) + row.append([ + { + 'role_id': str(role_id), + 'role_name': roles[role_id]['name'], + } + for role_id in role_ids if role_id in roles + ]) else: role_names = [roles[role_id]['name'] for role_id in role_ids if role_id in roles] row.append(role_names) diff --git a/keepercommander/commands/start_service.py b/keepercommander/commands/start_service.py index fafaf1568..0d7eaded9 100644 --- a/keepercommander/commands/start_service.py +++ b/keepercommander/commands/start_service.py @@ -14,7 +14,10 @@ from ..service.commands.handle_service import StartService, StopService, ServiceStatus from ..service.commands.service_docker_setup import ServiceDockerSetupCommand from ..service.commands.integrations import ( - SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand, + GChatAppSetupCommand, + SlackAppSetupCommand, + TeamsAppSetupCommand, + SailPointAppSetupCommand, ) def register_commands(commands): @@ -27,6 +30,7 @@ def register_commands(commands): commands['slack-app-setup'] = SlackAppSetupCommand() commands['teams-app-setup'] = TeamsAppSetupCommand() commands['sailpoint-app-setup'] = SailPointAppSetupCommand() + commands['gchat-app-setup'] = GChatAppSetupCommand() def register_command_info(aliases, command_info): service_classes = [ @@ -39,9 +43,10 @@ def register_command_info(aliases, command_info): SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand, + GChatAppSetupCommand, ] for service_class in service_classes: parser = service_class() p = parser.get_parser() - command_info[p.prog] = p.description \ No newline at end of file + command_info[p.prog] = p.description diff --git a/keepercommander/enterprise.py b/keepercommander/enterprise.py index 2613c579d..74808877f 100644 --- a/keepercommander/enterprise.py +++ b/keepercommander/enterprise.py @@ -161,10 +161,14 @@ def load(self, params): # type: (KeeperParams) -> None rq.enterprisePublicKey = rsa_public_key rq.encryptedEnterprisePrivateKey = rsa_encrypted_private_key rq.keyType = proto.RSA - api.communicate_rest(params, rq, 'enterprise/set_enterprise_key_pair') - self._enterprise._rsa_key = rsa_private_key - keys['rsa_public_key'] = utils.base64_url_encode(rsa_public_key) - keys['rsa_encrypted_private_key'] = utils.base64_url_encode(rsa_encrypted_private_key) + try: + api.communicate_rest(params, rq, 'enterprise/set_enterprise_key_pair') + self._enterprise._rsa_key = rsa_private_key + keys['rsa_public_key'] = utils.base64_url_encode(rsa_public_key) + keys['rsa_encrypted_private_key'] = utils.base64_url_encode(rsa_encrypted_private_key) + except: + logging.info('Failed to set enterprise RSA key') + pass if 'ecc_encrypted_private_key' not in keys: ec_private, ec_public = crypto.generate_ec_key() diff --git a/keepercommander/importer/keepass/keepass.py b/keepercommander/importer/keepass/keepass.py index 32bd27cef..223635fd7 100644 --- a/keepercommander/importer/keepass/keepass.py +++ b/keepercommander/importer/keepass/keepass.py @@ -19,7 +19,7 @@ from typing import Dict from xml.sax.saxutils import escape -from pykeepass import PyKeePass +from pykeepass import PyKeePass, create_database from pykeepass.exceptions import CredentialsError from pykeepass.attachment import Attachment as KeepassAttachment from pykeepass.group import Group @@ -316,11 +316,7 @@ def do_export(self, filename, records, file_password=None, kbdx_key_file=None, * elif isinstance(x, SharedFolder): sfs.append(x) - template_file = os.path.join(os.path.dirname(__file__), 'template.kdbx') - - with PyKeePass(template_file, password='111111') as kdb: - kdb.password = password - kdb.keyfile = keyfile + with create_database(filename, password=password, keyfile=keyfile) as kdb: root = kdb.root_group for r in rs: diff --git a/keepercommander/importer/keepass/template.kdbx b/keepercommander/importer/keepass/template.kdbx deleted file mode 100644 index 04ff5e17d..000000000 Binary files a/keepercommander/importer/keepass/template.kdbx and /dev/null differ diff --git a/keepercommander/loginv3.py b/keepercommander/loginv3.py index 2985ad28f..ebdad678b 100644 --- a/keepercommander/loginv3.py +++ b/keepercommander/loginv3.py @@ -1155,7 +1155,7 @@ def accountSummary(params: KeeperParams): return api.communicate_rest(params, rq, 'login/account_summary', rs_type=AccountSummary_pb2.AccountSummaryElements) @staticmethod - def loginToMc(rest_context, session_token, mc_id): + def loginToMc(rest_context, session_token, mc_id): # type: (Any, str, int) -> enterprise_pb2.LoginToMcResponse endpoint = 'authentication/login_to_mc' diff --git a/keepercommander/plugins/adpasswd/adpasswd.py b/keepercommander/plugins/adpasswd/adpasswd.py index f3a05efdc..ec8f50834 100644 --- a/keepercommander/plugins/adpasswd/adpasswd.py +++ b/keepercommander/plugins/adpasswd/adpasswd.py @@ -59,7 +59,10 @@ def rotate(record, new_password): # type: (KeeperRecord, str) -> bool if not login and not user_dn: raise ValueError(f'Rotate AD password: User login or DN is not set.') - tls = ldap3.Tls(validate=ssl.CERT_NONE) + _tls_validate_map = {'none': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, 'required': ssl.CERT_REQUIRED} + tls_validate_str = (RecordMixin.get_record_field(record, 'cmdr:tls_verify') or 'required').lower() + tls_validate = _tls_validate_map.get(tls_validate_str, ssl.CERT_REQUIRED) + tls = ldap3.Tls(validate=tls_validate) server = ldap3.Server(host=host, port=port, use_ssl=True, tls=tls, connect_timeout=5, get_info=ldap3.ALL) with ldap3.Connection(server) as c: c.open() diff --git a/keepercommander/plugins/mysql/mysql.py b/keepercommander/plugins/mysql/mysql.py index 04c525fa3..71e43f601 100644 --- a/keepercommander/plugins/mysql/mysql.py +++ b/keepercommander/plugins/mysql/mysql.py @@ -65,11 +65,13 @@ def rotate(self, record, new_password, revert=False): is_old_version = vn < 5007006 except ValueError: pass + escape_login = pymysql.converters.escape_string(self.login) + escape_user_host = pymysql.converters.escape_string(self.user_host) escape_new_password = pymysql.converters.escape_string(new_password) if is_old_version: - sql = f"set password for '{self.login}'@'{self.user_host}' = password('{escape_new_password}')" + sql = f"set password for '{escape_login}'@'{escape_user_host}' = password('{escape_new_password}')" else: - sql = f"alter user '{self.login}'@'{self.user_host}' identified by '{escape_new_password}'" + sql = f"alter user '{escape_login}'@'{escape_user_host}' identified by '{escape_new_password}'" cursor.execute(sql) return True except pymysql.err.OperationalError as e: diff --git a/keepercommander/plugins/oracle/oracle.py b/keepercommander/plugins/oracle/oracle.py index d216a241e..7117a97cb 100644 --- a/keepercommander/plugins/oracle/oracle.py +++ b/keepercommander/plugins/oracle/oracle.py @@ -72,7 +72,9 @@ def rotate(self, record, new_password, revert=False): connection = oracledb.connect(**kwargs) with connection.cursor() as cursor: logging.debug(f'Connected to {dsn}') - sql = f'ALTER USER {user} IDENTIFIED BY "{new_password}" ACCOUNT UNLOCK' + quoted_user = '"' + user.replace('"', '""') + '"' + escaped_password = new_password.replace('"', '""') + sql = f'ALTER USER {quoted_user} IDENTIFIED BY "{escaped_password}" ACCOUNT UNLOCK' cursor.execute(sql) result = True except Exception as e: diff --git a/keepercommander/plugins/postgresql/postgresql.py b/keepercommander/plugins/postgresql/postgresql.py index 827a0a963..606f315ee 100644 --- a/keepercommander/plugins/postgresql/postgresql.py +++ b/keepercommander/plugins/postgresql/postgresql.py @@ -11,6 +11,7 @@ # import psycopg2 +import psycopg2.sql import logging """Commander Plugin for Postgres Database Server @@ -51,7 +52,9 @@ def rotate(self, record, new_password, revert=False): database=self.db) as connection: logging.debug(f'Connected to {self.host}') with connection.cursor() as cursor: - sql = f'alter user {self.login} with password %s' + sql = psycopg2.sql.SQL('ALTER USER {} WITH PASSWORD %s').format( + psycopg2.sql.Identifier(self.login) + ) cursor.execute(sql, (new_password,)) return True except Exception as e: diff --git a/keepercommander/rest_api.py b/keepercommander/rest_api.py index 2e3be217f..bae28ec21 100644 --- a/keepercommander/rest_api.py +++ b/keepercommander/rest_api.py @@ -26,7 +26,7 @@ from . import crypto, utils from cryptography.hazmat.primitives.asymmetric import rsa, ec -CLIENT_VERSION = 'c18.0.0' +CLIENT_VERSION = 'c18.1.0' SERVER_PUBLIC_KEYS = { 1: crypto.load_rsa_public_key(utils.base64_url_decode( @@ -227,6 +227,8 @@ def execute_rest(context, endpoint, payload, timeout=None): elif rs.status_code >= 400: if content_type.startswith('application/json'): failure = rs.json() + if isinstance(failure, list): + failure = failure[0] if failure else {} logging.debug('<<< Response Error: [%s]', failure) if rs.status_code == 401: if failure.get('error') == 'key': @@ -252,7 +254,7 @@ def execute_rest(context, endpoint, payload, timeout=None): context.server_key_id = server_key_id run_request = True continue - elif rs.status_code == 403: + elif rs.status_code in (403, 429): if failure.get('error') == 'throttled' and not context.fail_on_throttle: throttle_retries += 1 if throttle_retries > max_throttle_retries: diff --git a/keepercommander/rsync/command.py b/keepercommander/rsync/command.py index b71674b12..50fe30bd2 100644 --- a/keepercommander/rsync/command.py +++ b/keepercommander/rsync/command.py @@ -52,7 +52,7 @@ def get_parser(self): def execute(self, params, **kwargs): local_path = kwargs.get('local_path') - if not local_path: + if not isinstance(local_path, str) or not local_path: self.get_parser().print_help() return @@ -187,9 +187,13 @@ def execute(self, params, **kwargs): if len(to_download) > 0: logging.info('Downloading %d file(s):', len(to_download)) + safe_root = os.path.realpath(local_path) verified_folders = set() for file in to_download: - absolute_path = os.path.join(local_path, file.path) + absolute_path = os.path.realpath(os.path.join(local_path, file.path)) + if not (absolute_path == safe_root or absolute_path.startswith(safe_root + os.sep)): + logging.warning(f'Skipping entry with path outside sync root: {file.path}') + continue logging.info(absolute_path) folder_name = os.path.dirname(absolute_path) if folder_name not in verified_folders: diff --git a/keepercommander/service/README.md b/keepercommander/service/README.md index 9d89a2396..400af6ec9 100644 --- a/keepercommander/service/README.md +++ b/keepercommander/service/README.md @@ -20,6 +20,8 @@ The Service Mode module for Keeper Commander enables REST API integration by pro | `service-config-add` | Add new API configuration and command access settings | | `service-docker-setup` | Automated Docker service mode setup with KSM configuration | | `slack-app-setup` | Automated Slack App integration setup with Commander Service Mode | +| `teams-app-setup` | Automated Teams App integration setup with Commander Service Mode | +| `gchat-app-setup` | Automated Google Chat App integration setup with Commander Service Mode | ### Security Features - API key authentication @@ -500,6 +502,60 @@ This automates the complete setup for Slack App integration: The command generates a complete `docker-compose.yml` with both Commander service and Slack App service configured. +### Google Chat App Integration Setup + +For integrating Commander Service Mode with Google Chat, use the `gchat-app-setup` command: + +```bash +My Vault> gchat-app-setup +``` + +This automates the complete setup for Google Chat App integration: +- **Phase 1**: Runs Docker setup (same as `service-docker-setup`) +- **Phase 2**: Configures Google Chat App integration + - Collects service account JSON (Pub/Sub + Chat API worker credentials) + - Collects Google Project ID (defaults from the service account JSON when omitted) + - Collects Pub/Sub Topic ID, Subscription ID, Approvals Space ID, and slash command IDs + - Creates Google Chat configuration record + - Updates `docker-compose.yml` with Google Chat App service + - Supports optional EPM and Device Approval integrations + +**Configuration Options:** +- Port selection (default: 8900) +- Ngrok/Cloudflare tunneling for public URL exposure +- Google Chat service account / Pub/Sub / space credentials +- Optional EPM integration +- Optional SSO Cloud Device Approval + +The command generates a complete `docker-compose.yml` with both Commander service and Google Chat App service configured. + +**Vault config record fields** (read by the Google Chat app via KSM / `GCHAT_RECORD`): + +| Field label | Type | Description | +|-------------|------|-------------| +| `google_service_account_json` | secret | Full GCP service account JSON (Pub/Sub + Chat API) | +| `google_project_id` | text | GCP project ID | +| `google_topic_id` | text | Pub/Sub topic ID (short form) | +| `google_subscription_id` | text | Pub/Sub subscription ID (short form) | +| `chat_approvals_space_id` | text | Google Chat space ID (`spaces/...`) | +| `chat_command_request_record_id` | text | Slash command ID for `/keeper-request-record` | +| `chat_command_request_folder_id` | text | Slash command ID for `/keeper-request-folder` | +| `chat_command_one_time_share_id` | text | Slash command ID for `/keeper-one-time-share` | +| `pedm_enabled` | text | `true` / `false` | +| `pedm_polling_interval` | text | Seconds | +| `device_approval_enabled` | text | `true` / `false` | +| `device_approval_polling_interval` | text | Seconds | + +**Generated compose environment for the Google Chat service:** + +| Env var | Value | +|---------|-------| +| `KSM_CONFIG` | Base64 KSM config | +| `COMMANDER_RECORD` | Commander Docker config record UID | +| `GCHAT_RECORD` | Google Chat config record UID | + +Image name used in compose: `keeper/gchat-app:latest`. + --- ### Manual Authentication Methods (Alternative) diff --git a/keepercommander/service/commands/integrations/__init__.py b/keepercommander/service/commands/integrations/__init__.py index 7375a8cc8..3bfc837fc 100644 --- a/keepercommander/service/commands/integrations/__init__.py +++ b/keepercommander/service/commands/integrations/__init__.py @@ -11,6 +11,7 @@ """Integration setup commands.""" +from .gchat_app_setup import GChatAppSetupCommand from .integration_setup_base import IntegrationSetupCommand from .slack_app_setup import SlackAppSetupCommand from .teams_app_setup import TeamsAppSetupCommand @@ -18,6 +19,7 @@ __all__ = [ 'IntegrationSetupCommand', + 'GChatAppSetupCommand', 'SlackAppSetupCommand', 'TeamsAppSetupCommand', 'SailPointAppSetupCommand', diff --git a/keepercommander/service/commands/integrations/gchat_app_setup.py b/keepercommander/service/commands/integrations/gchat_app_setup.py new file mode 100644 index 000000000..7d0eb99f0 --- /dev/null +++ b/keepercommander/service/commands/integrations/gchat_app_setup.py @@ -0,0 +1,377 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return GChatConstants.DISPLAY_NAME + + def get_default_folder_name(self) -> str: + return GChatConstants.DEFAULT_FOLDER_NAME + + def get_default_record_name(self) -> str: + return GChatConstants.DEFAULT_RECORD_NAME + + def get_integration_config_marker_field(self) -> str: + return GChatConstants.FIELD_SERVICE_ACCOUNT_JSON + + # ── Google Chat-specific configuration ──────────────────────── + + def collect_integration_config(self, params): + print(f"\n{bcolors.BOLD}GOOGLE_SERVICE_ACCOUNT_JSON:{bcolors.ENDC}") + print(f" Path to the Google Cloud service account JSON key file") + print(f" (used for Pub/Sub pull and Google Chat API)") + google_service_account_json, project_from_json = self._prompt_service_account_json() + + print(f"\n{bcolors.BOLD}GOOGLE_PROJECT_ID:{bcolors.ENDC}") + print(f" Google Cloud project ID for Pub/Sub and Chat") + google_project_id = self._prompt_google_project_id(project_from_json) + + print(f"\n{bcolors.BOLD}GOOGLE_TOPIC_ID:{bcolors.ENDC}") + print(f" Pub/Sub topic that receives Google Chat events") + print(f" Accepts a short ID or full path projects/{{project}}/topics/{{id}}") + google_topic_id = self._prompt_pubsub_id( + 'Topic ID:', + self._normalize_topic_id, + google_project_id, + ) + + print(f"\n{bcolors.BOLD}GOOGLE_SUBSCRIPTION_ID:{bcolors.ENDC}") + print(f" Pub/Sub subscription used to pull Google Chat events") + print(f" Accepts a short ID or full path projects/{{project}}/subscriptions/{{id}}") + google_subscription_id = self._prompt_pubsub_id( + 'Subscription ID:', + self._normalize_subscription_id, + google_project_id, + ) + + print(f"\n{bcolors.BOLD}CHAT_APPROVALS_SPACE_ID:{bcolors.ENDC}") + print(f" Google Chat space where approval cards are posted") + chat_approvals_space_id = self._prompt_with_validation( + "Space ID (starts with spaces/):", + self._is_valid_space_id, + "Invalid Approvals Space ID (must start with 'spaces/' and include a space name)" + ) + + print(f"\n{bcolors.BOLD}CHAT COMMAND IDs:{bcolors.ENDC}") + print(f" Slash command IDs configured for the Google Chat app") + chat_command_request_record_id = self._prompt_command_id( + '/keeper-request-record', + GChatConstants.DEFAULT_COMMAND_REQUEST_RECORD_ID, + ) + chat_command_request_folder_id = self._prompt_command_id( + '/keeper-request-folder', + GChatConstants.DEFAULT_COMMAND_REQUEST_FOLDER_ID, + ) + chat_command_one_time_share_id = self._prompt_command_id( + '/keeper-one-time-share', + GChatConstants.DEFAULT_COMMAND_ONE_TIME_SHARE_ID, + ) + + pedm_enabled, pedm_interval = self._collect_pedm_config() + da_enabled, da_interval = self._collect_device_approval_config() + + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ Google Chat Configuration Complete!{bcolors.ENDC}") + + return GChatConfig( + google_service_account_json=google_service_account_json, + google_project_id=google_project_id, + google_subscription_id=google_subscription_id, + google_topic_id=google_topic_id, + chat_approvals_space_id=chat_approvals_space_id, + chat_command_request_record_id=chat_command_request_record_id, + chat_command_request_folder_id=chat_command_request_folder_id, + chat_command_one_time_share_id=chat_command_one_time_share_id, + pedm_enabled=pedm_enabled, + pedm_polling_interval=pedm_interval, + device_approval_enabled=da_enabled, + device_approval_polling_interval=da_interval, + ) + + def build_record_custom_fields(self, config): + return [ + vault.TypedField.new_field( + 'secret', + config.google_service_account_json, + GChatConstants.FIELD_SERVICE_ACCOUNT_JSON, + ), + vault.TypedField.new_field( + 'text', config.google_project_id, GChatConstants.FIELD_PROJECT_ID + ), + vault.TypedField.new_field( + 'text', config.google_subscription_id, GChatConstants.FIELD_SUBSCRIPTION_ID + ), + vault.TypedField.new_field( + 'text', config.google_topic_id, GChatConstants.FIELD_TOPIC_ID + ), + vault.TypedField.new_field( + 'text', + config.chat_approvals_space_id, + GChatConstants.FIELD_APPROVALS_SPACE_ID, + ), + vault.TypedField.new_field( + 'text', + config.chat_command_request_record_id, + GChatConstants.FIELD_COMMAND_REQUEST_RECORD_ID, + ), + vault.TypedField.new_field( + 'text', + config.chat_command_request_folder_id, + GChatConstants.FIELD_COMMAND_REQUEST_FOLDER_ID, + ), + vault.TypedField.new_field( + 'text', + config.chat_command_one_time_share_id, + GChatConstants.FIELD_COMMAND_ONE_TIME_SHARE_ID, + ), + vault.TypedField.new_field( + 'text', + 'true' if config.pedm_enabled else 'false', + GChatConstants.FIELD_PEDM_ENABLED, + ), + vault.TypedField.new_field( + 'text', + str(config.pedm_polling_interval), + GChatConstants.FIELD_PEDM_POLLING_INTERVAL, + ), + vault.TypedField.new_field( + 'text', + 'true' if config.device_approval_enabled else 'false', + GChatConstants.FIELD_DEVICE_APPROVAL_ENABLED, + ), + vault.TypedField.new_field( + 'text', + str(config.device_approval_polling_interval), + GChatConstants.FIELD_DEVICE_APPROVAL_POLLING_INTERVAL, + ), + ] + + # ── Display ─────────────────────────────────────────────────── + + def print_integration_specific_resources(self, config): + print(f" • Google Project ID: {bcolors.OKBLUE}{config.google_project_id}{bcolors.ENDC}") + print(f" • Pub/Sub Topic: {bcolors.OKBLUE}{config.google_topic_id}{bcolors.ENDC}") + print( + f" • Pub/Sub Subscription: " + f"{bcolors.OKBLUE}{config.google_subscription_id}{bcolors.ENDC}" + ) + print( + f" • Approvals Space: " + f"{bcolors.OKBLUE}{config.chat_approvals_space_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-request-record ID: " + f"{bcolors.OKBLUE}{config.chat_command_request_record_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-request-folder ID: " + f"{bcolors.OKBLUE}{config.chat_command_request_folder_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-one-time-share ID: " + f"{bcolors.OKBLUE}{config.chat_command_one_time_share_id}{bcolors.ENDC}" + ) + + def print_integration_commands(self): + print(f"\n{bcolors.BOLD}Google Chat Commands Available:{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}• /keeper-request-record{bcolors.ENDC} - Request access to a record") + print(f" {bcolors.OKGREEN}• /keeper-request-folder{bcolors.ENDC} - Request access to a folder") + print( + f" {bcolors.OKGREEN}• /keeper-one-time-share{bcolors.ENDC} " + f"- Request a one-time share link\n" + ) + + # ── Validation helpers ──────────────────────────────────────── + + def _prompt_service_account_json(self) -> tuple[str, str]: + while True: + path = input( + f"{bcolors.OKBLUE}Path to service account JSON file:{bcolors.ENDC} " + ).strip() + parsed, error = self._load_service_account_json(path) + if parsed is not None: + return json.dumps(parsed, separators=(',', ':')), parsed.get('project_id', '') + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + + def _prompt_google_project_id(self, project_from_json: str) -> str: + default_hint = f' [Press Enter for {project_from_json}]' if project_from_json else '' + while True: + project_input = input( + f"{bcolors.OKBLUE}Project ID{default_hint}:{bcolors.ENDC} " + ).strip() + google_project_id = project_input or project_from_json + if not google_project_id: + print( + f"{bcolors.FAIL}Error: Google Project ID is required " + f"(enter a value or provide a valid service account JSON){bcolors.ENDC}" + ) + continue + + if project_from_json and google_project_id != project_from_json: + print( + f"{bcolors.WARNING}Warning: Project ID \"{google_project_id}\" differs from " + f"service account project_id \"{project_from_json}\"{bcolors.ENDC}" + ) + if not self._prompt_yes_no( + 'Continue with this Project ID anyway?', + default=False, + ): + continue + + return google_project_id + + def _prompt_pubsub_id(self, prompt: str, normalizer, google_project_id: str) -> str: + while True: + value = input(f"{bcolors.OKBLUE}{prompt}{bcolors.ENDC} ").strip() + normalized, error = normalizer(value, google_project_id) + if normalized is not None: + return normalized + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + + def _prompt_command_id(self, command_name: str, default: str) -> str: + while True: + value = input( + f"{bcolors.OKBLUE}{command_name} command ID " + f"[Press Enter for {default}]:{bcolors.ENDC} " + ).strip() or default + if value.isdigit() and int(value) >= 1: + return value + print( + f"{bcolors.FAIL}Error: Slash command ID must be a positive integer{bcolors.ENDC}" + ) + + @classmethod + def _load_service_account_json(cls, path: str) -> tuple[dict | None, str | None]: + if not path: + return None, 'Service account JSON path is required' + + expanded = os.path.expanduser(path) + if not os.path.isfile(expanded): + return None, f'Service account JSON file not found: {path}' + + try: + with open(expanded, 'r', encoding='utf-8') as handle: + data = json.load(handle) + except json.JSONDecodeError as exc: + return None, f'Invalid service account JSON: {exc}' + except OSError as exc: + return None, f'Unable to read service account JSON: {exc}' + + return cls._validate_service_account_dict(data) + + @staticmethod + def _validate_service_account_dict(data: any) -> tuple[dict | None, str | None]: + if not isinstance(data, dict): + return None, 'Service account JSON must be a JSON object' + + missing = [ + key for key in GChatConstants.SERVICE_ACCOUNT_REQUIRED_KEYS if not data.get(key) + ] + if missing: + return None, ( + 'Invalid service account JSON ' + f'(missing required fields: {", ".join(missing)})' + ) + + if data.get('type') != GChatConstants.SERVICE_ACCOUNT_TYPE: + return None, ( + f"Invalid service account JSON " + f"(type must be '{GChatConstants.SERVICE_ACCOUNT_TYPE}')" + ) + + return data, None + + @staticmethod + def _normalize_pubsub_id( + value: str, + resource_pattern: re.Pattern[str], + label: str, + resource_hint: str, + google_project_id: str = '', + ) -> tuple[str | None, str | None]: + if not value: + return None, f'{label} is required' + + resource_match = resource_pattern.match(value) + if resource_match: + path_project = resource_match.group(1) + if google_project_id and path_project != google_project_id: + return None, ( + f'{label} project "{path_project}" does not match ' + f'GOOGLE_PROJECT_ID "{google_project_id}"' + ) + return resource_match.group(2), None + + if _PUBSUB_ID_PATTERN.match(value): + return value, None + + return None, ( + f'Invalid {label} ' + f'(use a short ID like keeper-chat-events, or {resource_hint})' + ) + + @classmethod + def _normalize_subscription_id( + cls, value: str, google_project_id: str = '' + ) -> tuple[str | None, str | None]: + return cls._normalize_pubsub_id( + value, + _SUBSCRIPTION_RESOURCE_PATTERN, + 'Pub/Sub Subscription ID', + 'projects/{project}/subscriptions/{id}', + google_project_id, + ) + + @classmethod + def _normalize_topic_id( + cls, value: str, google_project_id: str = '' + ) -> tuple[str | None, str | None]: + return cls._normalize_pubsub_id( + value, + _TOPIC_RESOURCE_PATTERN, + 'Pub/Sub Topic ID', + 'projects/{project}/topics/{id}', + google_project_id, + ) + + @staticmethod + def _is_valid_space_id(value: str) -> bool: + prefix = GChatConstants.SPACE_ID_PREFIX + return bool(value and value.startswith(prefix) and len(value) > len(prefix)) \ No newline at end of file diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 1ff828195..ee7e783ad 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -49,6 +49,10 @@ class IntegrationSetupCommand(Command, DockerSetupBase, ABC): def get_integration_name(self) -> str: """e.g. 'Slack', 'Teams' -- drives all naming conventions.""" + def get_integration_display_name(self) -> str: + """User-facing product name. Defaults to get_integration_name().""" + return self.get_integration_name() + @abstractmethod def collect_integration_config(self, params) -> Any: """Prompt user for config values, return a config dataclass.""" @@ -127,13 +131,14 @@ def get_parser(self): def _build_parser(self) -> argparse.ArgumentParser: name = self.get_integration_name() + display_name = self.get_integration_display_name() name_lower = name.lower() default_folder = self.get_default_folder_name() default_record = self.get_default_record_name() parser = argparse.ArgumentParser( prog=f'{name_lower}-app-setup', - description=f'Automate {name} App integration setup with Commander Service Mode', + description=f'Automate {display_name} App integration setup with Commander Service Mode', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( @@ -152,7 +157,7 @@ def _build_parser(self) -> argparse.ArgumentParser: parser.add_argument( f'--{name_lower}-record-name', dest='integration_record_name', type=str, default=default_record, - help=f'Name for the {name} config record (default: "{default_record}")' + help=f'Name for the {display_name} config record (default: "{default_record}")' ) parser.add_argument( '--config-path', dest='config_path', type=str, @@ -190,12 +195,13 @@ def _build_parser(self) -> argparse.ArgumentParser: def execute(self, params, **kwargs): name = self.get_integration_name() + display_name = self.get_integration_display_name() if kwargs.get('sync_down'): if self.get_approvals_profile() is None: raise CommandError( self.get_command_name(), - f'{name} does not support multi-channel approver sync yet', + f'{display_name} does not support multi-channel approver sync yet', ) record_uid = kwargs.get('integration_record_uid') sync_vault = True @@ -223,7 +229,7 @@ def execute(self, params, **kwargs): DockerSetupPrinter.print_completion("Service Mode Configuration Complete!") # Phase 2 -- Integration-specific setup - print(f"\n{bcolors.BOLD}Phase 2: {name} App Integration Setup{bcolors.ENDC}") + print(f"\n{bcolors.BOLD}Phase 2: {display_name} App Integration Setup{bcolors.ENDC}") record_name = kwargs.get('integration_record_name', self.get_default_record_name()) record_uid, config = self._run_integration_setup( params, setup_result, service_config, record_name @@ -306,16 +312,16 @@ def _get_integration_service_configuration(self) -> ServiceConfig: def _run_integration_setup(self, params, setup_result: SetupResult, service_config: ServiceConfig, record_name: str) -> Tuple[str, Any]: - name = self.get_integration_name() + display_name = self.get_integration_display_name() - DockerSetupPrinter.print_header(f"{name} App Configuration") + DockerSetupPrinter.print_header(f"{display_name} App Configuration") config = self.collect_integration_config(params) - DockerSetupPrinter.print_step(1, 2, f"Creating {name} config record '{record_name}'...") + DockerSetupPrinter.print_step(1, 2, f"Creating {display_name} 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, f"Updating docker-compose.yml with {name} App service...") + DockerSetupPrinter.print_step(2, 2, f"Updating docker-compose.yml with {display_name} App service...") self._update_docker_compose(setup_result, service_config, record_uid, config) return record_uid, config @@ -333,8 +339,8 @@ def _create_integration_record(self, params, record_name: str, self._update_record_custom_fields(params, record_uid, custom_fields) - name = self.get_integration_name() - DockerSetupPrinter.print_success(f"{name} config record ready (UID: {record_uid})") + display_name = self.get_integration_display_name() + DockerSetupPrinter.print_success(f"{display_name} config record ready (UID: {record_uid})") return record_uid def _find_record_in_folder(self, params, folder_uid: str, record_name: str): @@ -418,8 +424,8 @@ def _resolve_default_integration_record(self, params, record_name: Optional[str] return record_uid def _execute_sync_down(self, params, record_uid: str, sync_vault: bool = True) -> None: - name = self.get_integration_name() - print(f"\n{bcolors.BOLD}{name} App Config Sync{bcolors.ENDC}") + display_name = self.get_integration_display_name() + print(f"\n{bcolors.BOLD}{display_name} App Config Sync{bcolors.ENDC}") print(f" Reconciling approver teams, shared folders, and records with the vault") print(f" Config record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") @@ -477,26 +483,26 @@ def _update_docker_compose(self, setup_result: SetupResult, def _print_success_message(self, setup_result: SetupResult, service_config: ServiceConfig, record_uid: str, config, config_path: str) -> None: - name = self.get_integration_name() + display_name = self.get_integration_display_name() - print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ {name} App Integration Setup Complete!{bcolors.ENDC}\n") + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ {display_name} App Integration Setup Complete!{bcolors.ENDC}\n") print(f"{bcolors.BOLD}Resources Created:{bcolors.ENDC}") print(f" {bcolors.BOLD}Phase 1 - Commander Service:{bcolors.ENDC}") DockerSetupPrinter.print_phase1_resources(setup_result, indent=" ") - print(f" {bcolors.BOLD}Phase 2 - {name} App:{bcolors.ENDC}") + print(f" {bcolors.BOLD}Phase 2 - {display_name} App:{bcolors.ENDC}") self._print_integration_resources(record_uid, config) DockerSetupPrinter.print_common_deployment_steps(str(service_config.port), config_path) container = self.get_docker_container_name() - print(f" {bcolors.OKGREEN}docker logs {container}{bcolors.ENDC} - View {name} App logs") + print(f" {bcolors.OKGREEN}docker logs {container}{bcolors.ENDC} - View {display_name} App logs") self.print_integration_commands() 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}") + display_name = self.get_integration_display_name() + print(f" • {display_name} Config Record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") self.print_integration_specific_resources(config) if hasattr(config, 'pedm_enabled'): print( @@ -522,9 +528,9 @@ def _collect_pedm_config(self) -> Tuple[bool, int]: return enabled, interval def _collect_device_approval_config(self) -> Tuple[bool, int]: - name = self.get_integration_name() + display_name = self.get_integration_display_name() print(f"\n{bcolors.BOLD}SSO Cloud Device Approval Integration (optional):{bcolors.ENDC}") - print(f" Approve SSO Cloud device registrations via {name}") + print(f" Approve SSO Cloud device registrations via {display_name}") enabled = self._prompt_yes_no('Enable Device Approval?', default=False) interval = 120 if enabled: diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py index ea2288b51..b05b05766 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_hook.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -34,35 +34,54 @@ class SailPointCommandHook: def __init__(self, record_uid: str): self.record_uid = record_uid - def before_command(self, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: - """Return (response, status_code) to short-circuit, or None to continue.""" + def before_command(self, params: KeeperParams, command: str) -> Tuple[str, Optional[Tuple[Any, int]]]: + """ + Prepare a Service Mode command for SailPoint. + + Returns ``(command_to_run, short_circuit)``. When ``short_circuit`` is + set, do not execute the command. ``command_to_run`` may be rewritten + (e.g. transfer-user target injection). + """ caps = read_capabilities(params, self.record_uid) scope_error = self._check_capability_gates(command, caps) if scope_error: - return {'status': 'error', 'error': scope_error}, 403 + return self._reject(command, scope_error, 403) - er_error = SailPointCommandPolicy.validate_enterprise_role(command) - if er_error: - return {'status': 'error', 'error': er_error}, 403 + policy_error = ( + SailPointCommandPolicy.validate_enterprise_role(command) + or SailPointCommandPolicy.validate_enterprise_user_delete(command) + ) + if policy_error: + return self._reject(command, policy_error, 403) + + command, transfer_error = SailPointCommandPolicy.prepare_transfer( + command, caps.transfer_target_email + ) + if transfer_error: + return self._reject(command, transfer_error, 400) invite = SailPointCommandParser.parse_invite(command) if invite and invite.emails: - return self._before_invite(params, invite) + return command, self._before_invite(params, invite) share = SailPointCommandParser.parse_share(command) if share: target_error = validate_share_targets(params, share) if target_error: - return {'status': 'error', 'error': target_error}, 400 - return self._before_share(params, share, caps) + return self._reject(command, target_error, 400) + return command, self._before_share(params, share, caps) mutation = SailPointCommandParser.parse_identity_mutation(command) if mutation: err = self._first_scim_identity_error(params, mutation.emails) if err: - return {'status': 'error', 'error': err}, 403 - return None + return self._reject(command, err, 403) + return command, None + + @staticmethod + def _reject(command: str, error: str, status_code: int) -> Tuple[str, Tuple[Any, int]]: + return command, ({'status': 'error', 'error': error}, status_code) @staticmethod def _first_scim_identity_error(params: KeeperParams, emails: List[str]) -> Optional[str]: @@ -217,11 +236,8 @@ def _before_share( share: ParsedShare, caps: SailPointCapabilities, ) -> Optional[Tuple[Any, int]]: - # Revoke/remove/owner must run through Commander so Service Mode returns the - # native error (e.g. User Not Found for Invited users). Only grant is deferred. - if not share.is_grant: - return None - + # Capability gates apply to every share action (grant, owner, revoke, cancel, + # remove). Deferral below is grant-only for Invited users. if share.is_folder and not caps.allow_folders: return { 'status': 'error', @@ -233,6 +249,10 @@ def _before_share( 'error': 'SailPoint allow_records is disabled; share-record is not allowed.', }, 403 + # Non-grant actions run through Commander (native errors, no pending queue). + if not share.is_grant: + return None + deferred = [e for e in share.emails if self._user_status(params, e) != 'active'] if not deferred: return None diff --git a/keepercommander/service/commands/integrations/sailpoint/command_parse.py b/keepercommander/service/commands/integrations/sailpoint/command_parse.py index 7286919aa..a90b22b62 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_parse.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -15,14 +15,14 @@ import shlex from dataclasses import dataclass, field -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Sequence, Tuple _NSF_FOLDER = frozenset({'nsf-share-folder'}) _NSF_RECORD = frozenset({'nsf-share-record'}) _FOLDER_CMDS = frozenset({'share-folder', 'nsf-share-folder'}) _RECORD_CMDS = frozenset({'share-record', 'nsf-share-record'}) _EU_CMDS = frozenset({'enterprise-user', 'eu'}) -_INVITE_FLAGS = frozenset({'--invite', '--add'}) +_TRANSFER_CMDS = frozenset({'transfer-user', 'tu'}) @dataclass @@ -69,8 +69,17 @@ class ParsedIdentityMutation: has_node_change: bool = False +@dataclass +class ParsedTransfer: + """transfer-user offboard request (target comes from SailPoint config).""" + + emails: List[str] = field(default_factory=list) + has_target_user: bool = False + has_force: bool = False + + class SailPointCommandParser: - """Parse enterprise-user invite and share-* command strings.""" + """Parse SailPoint Service Mode commands via Commander's argparse parsers.""" @staticmethod def tokenize(command: str) -> List[str]: @@ -80,112 +89,55 @@ def tokenize(command: str) -> List[str]: return command.split() @staticmethod - def _matches_flag(token: str, *names: str) -> bool: - """True for ``--flag``, ``-f``, or ``--flag=value`` forms.""" - for name in names: - if token == name: - return True - if name.startswith('--') and token.startswith(f'{name}='): - return True - return False - - @staticmethod - def _one_flag_value(token: str, tokens: List[str], index: int) -> Tuple[Optional[str], int]: - """ - Match Commander argparse append flags (one value per flag): - --add-role R1 - --add-role=R1 - """ - if '=' in token: - return token.split('=', 1)[1], index + 1 - if index + 1 < len(tokens) and not tokens[index + 1].startswith('-'): - return tokens[index + 1], index + 2 - return None, index + 1 - - @staticmethod - def _skip_unknown_flag(tokens: List[str], index: int) -> int: - """Advance past an unrecognized flag and an optional value token.""" - token = tokens[index] - if '=' in token: - return index + 1 - if index + 1 < len(tokens) and not tokens[index + 1].startswith('-'): - return index + 2 - return index + 1 + def parse_known(parser, argv: Sequence[str]) -> Optional[Tuple[Any, List[str]]]: + """Parse with a Commander parser; None when argparse rejects the argv.""" + from .....commands.base import ParseError - @classmethod - def _append_flag_value( - cls, - token: str, - tokens: List[str], - index: int, - dest: List[str], - ) -> int: - value, next_i = cls._one_flag_value(token, tokens, index) - if value is not None: - dest.append(value) - return next_i + try: + ns, unknown = parser.parse_known_args(list(argv)) + return ns, list(unknown) + except ParseError: + return None @classmethod def parse_invite(cls, command: str) -> Optional[ParsedInvite]: tokens = cls.tokenize(command) - if not tokens or tokens[0] not in _EU_CMDS: + if not tokens or tokens[0].lower() not in _EU_CMDS: return None - parsed = ParsedInvite() - emails: List[str] = [] - i = 1 - while i < len(tokens): - t = tokens[i] - if t in _INVITE_FLAGS: - parsed.is_invite = True - i += 1 - elif cls._matches_flag(t, '--node', '-n'): - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.node = value - elif cls._matches_flag(t, '--add-role'): - i = cls._append_flag_value(t, tokens, i, parsed.roles) - elif cls._matches_flag(t, '--add-team'): - i = cls._append_flag_value(t, tokens, i, parsed.teams) - elif t.startswith('-'): - i = cls._skip_unknown_flag(tokens, i) - else: - if '@' in t: - emails.append(t) - i += 1 - - parsed.emails = emails - return parsed if parsed.is_invite else None + from .....commands.enterprise import enterprise_user_parser + + parsed = cls.parse_known(enterprise_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + if not (ns.invite or ns.add): + return None + emails = [e for e in (ns.email or []) if isinstance(e, str) and '@' in e] + return ParsedInvite( + emails=emails, + node=ns.node, + roles=list(ns.add_role or []), + teams=list(ns.add_team or []), + is_invite=True, + ) @classmethod def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutation]: tokens = cls.tokenize(command) - if not tokens or tokens[0] not in _EU_CMDS: + if not tokens or tokens[0].lower() not in _EU_CMDS: return None - emails: List[str] = [] - has_role = False - has_team = False - has_node = False - i = 1 - while i < len(tokens): - t = tokens[i] - if cls._matches_flag(t, '--add-role', '--remove-role'): - has_role = True - _, i = cls._one_flag_value(t, tokens, i) - elif cls._matches_flag(t, '--add-team', '--remove-team'): - has_team = True - _, i = cls._one_flag_value(t, tokens, i) - elif cls._matches_flag(t, '--node', '-n'): - has_node = True - _, i = cls._one_flag_value(t, tokens, i) - elif t.startswith('-'): - i = cls._skip_unknown_flag(tokens, i) - else: - if '@' in t: - emails.append(t) - i += 1 + from .....commands.enterprise import enterprise_user_parser + parsed = cls.parse_known(enterprise_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + emails = [e for e in (ns.email or []) if isinstance(e, str) and '@' in e] + has_role = bool(ns.add_role or ns.remove_role) + has_team = bool(ns.add_team or ns.remove_team) + has_node = bool(ns.node) if not (has_role or has_team or has_node) or not emails: return None return ParsedIdentityMutation( @@ -195,60 +147,89 @@ def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutatio has_node_change=has_node, ) + @classmethod + def parse_transfer(cls, command: str) -> Optional[ParsedTransfer]: + tokens = cls.tokenize(command) + if not tokens or tokens[0].lower() not in _TRANSFER_CMDS: + return None + + from .....commands.transfer_account import transfer_user_parser + + parsed = cls.parse_known(transfer_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + return ParsedTransfer( + emails=[e for e in (ns.email or []) if isinstance(e, str) and '@' in e], + has_target_user=bool(ns.target_user), + has_force=bool(ns.force), + ) + @classmethod def parse_share(cls, command: str) -> Optional[ParsedShare]: tokens = cls.tokenize(command) if not tokens: return None - name = tokens[0] + name = tokens[0].lower() if name not in _FOLDER_CMDS and name not in _RECORD_CMDS: return None - parsed = ParsedShare( + parser = cls._share_parser(name) + if parser is None: + return None + parsed = cls.parse_known(parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + + is_folder = name in _FOLDER_CMDS + is_record = name in _RECORD_CMDS + is_nsf = name in _NSF_FOLDER or name in _NSF_RECORD + + if is_record: + emails = list(getattr(ns, 'email', None) or []) + record = getattr(ns, 'record', None) + targets = [record] if record else [] + else: + emails = list(getattr(ns, 'user', None) or []) + folder = getattr(ns, 'folder', None) or [] + targets = list(folder) if isinstance(folder, list) else ([folder] if folder else []) + + if not emails or not targets: + return None + + action = (getattr(ns, 'action', None) or 'grant').strip().lower() or 'grant' + result = ParsedShare( command=name, - is_folder=name in _FOLDER_CMDS, - is_record=name in _RECORD_CMDS, - is_nsf=name in _NSF_FOLDER or name in _NSF_RECORD, + emails=emails, + targets=targets, + action=action, + is_folder=is_folder, + is_record=is_record, + is_nsf=is_nsf, ) - i = 1 - positional: List[str] = [] - while i < len(tokens): - t = tokens[i] - if cls._matches_flag(t, '-e', '--email'): - value, i = cls._one_flag_value(t, tokens, i) - if value: - parsed.emails.append(value) - elif cls._matches_flag(t, '-a', '--action'): - value, i = cls._one_flag_value(t, tokens, i) - parsed.action = (value or 'grant').strip().lower() or 'grant' - elif t in ('-w', '--write'): - parsed.can_edit = True - i += 1 - elif t in ('-s', '--share') and parsed.is_record: - parsed.can_share = True - i += 1 - elif cls._matches_flag(t, '-p', '--manage-records'): - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.manage_records = value - elif cls._matches_flag(t, '-o', '--manage-users'): - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.manage_users = value - elif cls._matches_flag(t, '-r', '--role') and parsed.is_nsf: - value, i = cls._one_flag_value(t, tokens, i) - if value is not None: - parsed.nsf_role = value - elif t.startswith('-'): - i = cls._skip_unknown_flag(tokens, i) - else: - positional.append(t) - i += 1 - - if parsed.is_record: - if positional: - parsed.targets = [positional[-1]] + if is_nsf: + result.nsf_role = getattr(ns, 'role', None) + elif is_record: + result.can_edit = bool(getattr(ns, 'can_edit', False)) + result.can_share = bool(getattr(ns, 'can_share', False)) else: - parsed.targets = list(positional) + result.manage_records = getattr(ns, 'manage_records', None) + result.manage_users = getattr(ns, 'manage_users', None) + return result - return parsed if parsed.emails and parsed.targets else None + @staticmethod + def _share_parser(name: str): + if name == 'share-record': + from .....commands.register import share_record_parser + return share_record_parser + if name == 'share-folder': + from .....commands.register import share_folder_parser + return share_folder_parser + if name == 'nsf-share-record': + from .....commands.nested_share_folder.parsers import nested_share_record_share_parser + return nested_share_record_share_parser + if name == 'nsf-share-folder': + from .....commands.nested_share_folder.parsers import nested_share_folder_share_parser + return nested_share_folder_share_parser + return None diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py index 7c5148e8e..78b95d0e4 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_policy.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -13,44 +13,48 @@ from __future__ import annotations -from typing import Optional +import shlex +from typing import Any, Optional, Tuple +from .....utils import is_email from .command_parse import SailPointCommandParser from .constants import SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS _ENTERPRISE_ROLE_CMDS = frozenset({'enterprise-role', 'er'}) - -# Role create / destroy / membership / rename / enforcement — not allowed in SailPoint. -_ER_BLOCKED_FLAGS = frozenset({ - '--add', - '--copy', - '--clone', - '--delete', - '--name', - '--new-user', - '--enforcement', - '-au', - '--add-user', - '-ru', - '--remove-user', - '-at', - '--add-team', - '-rt', - '--remove-team', +_ENTERPRISE_USER_CMDS = frozenset({'enterprise-user', 'eu'}) + +# Destinations SailPoint may set on enterprise-role (argparse dest names). +# Anything else that resolves on the real parser is refused — spelling-independent. +_ER_ALLOWED_DESTS = frozenset({ + 'role', + 'force', + 'verbose', + 'format', + 'output', + 'node', + 'cascade', + 'add_admin', + 'remove_admin', + 'add_privilege', + 'remove_privilege', }) -_ER_BLOCKED_PREFIXES = ( - '--name=', - '--new-user=', - '--enforcement=', -) - _ER_ALLOWED_HINT = ( '--add-admin, --remove-admin, --add-privilege, --remove-privilege ' '(plus --node, --cascade, -f)' ) +def _arg_is_set(value: Any) -> bool: + if value is None or value is False: + return False + if value is True: + return True + if isinstance(value, (list, tuple, set)): + return len(value) > 0 + return True + + class SailPointCommandPolicy: """Sanitize / restrict commands allowed for SailPoint Service Mode.""" @@ -60,8 +64,8 @@ def sanitize(cls, commands: str) -> str: Keep only SailPoint-allowed commands; always drop banned ones. Also ensures the full SailPoint allowlist is present so required - commands (e.g. enterprise-role/er) are not dropped when the input - list is a partial or older compose allowlist. + commands are not dropped when the input list is a partial or older + compose allowlist. """ allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} @@ -88,19 +92,96 @@ def validate_enterprise_role(cls, command: str) -> Optional[str]: """ Restrict enterprise-role to admin/privilege ops only. - Returns an error message when blocked, or None when allowed - (including read-only ``er ``). + Uses Commander's ``enterprise_role_parser`` so ``--add-user``, + ``--add-user=``, ``--add-us``, and ``-au=`` are treated identically. """ tokens = SailPointCommandParser.tokenize(command) if not tokens or tokens[0].lower() not in _ENTERPRISE_ROLE_CMDS: return None - for token in tokens[1:]: - lower = token.lower() - if lower in _ER_BLOCKED_FLAGS or any(lower.startswith(p) for p in _ER_BLOCKED_PREFIXES): + from .....commands.enterprise import enterprise_role_parser + + parsed = SailPointCommandParser.parse_known(enterprise_role_parser, tokens[1:]) + if not parsed: + return None + ns, unknown = parsed + + for token in unknown: + if token.startswith('-'): flag = token.split('=', 1)[0] return ( f'SailPoint mode does not allow enterprise-role {flag}. ' f'Allowed: {_ER_ALLOWED_HINT}.' ) + + for dest, value in vars(ns).items(): + if dest in _ER_ALLOWED_DESTS or not _arg_is_set(value): + continue + flag = f'--{dest.replace("_", "-")}' + return ( + f'SailPoint mode does not allow enterprise-role {flag}. ' + f'Allowed: {_ER_ALLOWED_HINT}.' + ) + return None + + @classmethod + def validate_enterprise_user_delete(cls, command: str) -> Optional[str]: + """Ban enterprise-user --delete; offboard must use transfer-user.""" + tokens = SailPointCommandParser.tokenize(command) + if not tokens or tokens[0].lower() not in _ENTERPRISE_USER_CMDS: + return None + + from .....commands.enterprise import enterprise_user_parser + + parsed = SailPointCommandParser.parse_known(enterprise_user_parser, tokens[1:]) + if not parsed: + return None + ns, _unknown = parsed + if ns.delete: + return ( + 'SailPoint mode does not allow enterprise-user --delete. ' + 'Use transfer-user with the configured vault transfer target instead.' + ) return None + + @classmethod + def prepare_transfer(cls, command: str, target_email: str) -> Tuple[str, Optional[str]]: + """ + Validate transfer-user and append ``--target-user`` from config. + + Non-transfer commands return ``(command, None)``. + On validation failure return ``(command, error_message)``. + """ + transfer = SailPointCommandParser.parse_transfer(command) + if transfer is None: + return command, None + + if transfer.has_target_user: + return command, ( + 'SailPoint mode does not allow --target-user on transfer-user. ' + 'The vault transfer target is configured in sailpoint-app-setup.' + ) + if not transfer.has_force: + return command, ( + 'SailPoint transfer-user requires -f / --force ' + '(Service Mode cannot prompt for confirmation).' + ) + if not transfer.emails: + return command, 'SailPoint transfer-user requires at least one leaving-user email.' + + target = (target_email or '').strip() + if not target or not is_email(target): + return command, ( + 'SailPoint transfer target email is not configured or invalid. ' + 'Run sailpoint-app-setup (or set transfer_target_email on the SailPoint config record).' + ) + + target_key = target.lower() + for email in transfer.emails: + if email.strip().lower() == target_key: + return command, ( + f'Cannot transfer user {email} to itself; ' + 'leaving email must differ from the configured transfer target.' + ) + + return f'{command.rstrip()} --target-user {shlex.quote(target)}', None diff --git a/keepercommander/service/commands/integrations/sailpoint/config_fields.py b/keepercommander/service/commands/integrations/sailpoint/config_fields.py index ce2fa7e67..e5902b5fb 100644 --- a/keepercommander/service/commands/integrations/sailpoint/config_fields.py +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Optional from .....params import KeeperParams from .constants import ( @@ -24,6 +25,7 @@ DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, POLL_INTERVAL_FIELD, + TRANSFER_TARGET_EMAIL_FIELD, ) _TRUE_VALUES = frozenset({'true', '1', 'yes', 'y', 'on'}) @@ -32,12 +34,13 @@ @dataclass(frozen=True) class SailPointCapabilities: - """Share and identity entitlement gates (nodes are never gated).""" + """Runtime SailPoint config: entitlement gates plus transfer target.""" allow_folders: bool = True allow_records: bool = True allow_roles: bool = True allow_teams: bool = True + transfer_target_email: str = '' poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS @@ -55,6 +58,17 @@ def parse_bool(raw, default: bool = True) -> bool: return default +def _custom_field_value(by_label: dict, label: str) -> Optional[str]: + field = by_label.get(label) + if not field: + return None + value = field.get_default_value() + if value is None: + return None + text = str(value).strip() + return text or None + + def read_capabilities(params: KeeperParams, record_uid: str) -> SailPointCapabilities: from ..... import vault @@ -66,17 +80,13 @@ def read_capabilities(params: KeeperParams, record_uid: str) -> SailPointCapabil by_label = {field.label: field for field in record.custom if field.label} def _bool_field(label: str) -> bool: - field = by_label.get(label) - return parse_bool(field.get_default_value() if field else None, default=True) + return parse_bool(_custom_field_value(by_label, label), default=True) interval = DEFAULT_POLL_INTERVAL_SECONDS - interval_field = by_label.get(POLL_INTERVAL_FIELD) - if interval_field: + raw_interval = _custom_field_value(by_label, POLL_INTERVAL_FIELD) + if raw_interval: try: - interval = max( - MIN_POLL_INTERVAL_SECONDS, - int(interval_field.get_default_value() or interval), - ) + interval = max(MIN_POLL_INTERVAL_SECONDS, int(raw_interval)) except (TypeError, ValueError): pass @@ -85,5 +95,6 @@ def _bool_field(label: str) -> bool: allow_records=_bool_field(ALLOW_RECORDS_FIELD), allow_roles=_bool_field(ALLOW_ROLES_FIELD), allow_teams=_bool_field(ALLOW_TEAMS_FIELD), + transfer_target_email=_custom_field_value(by_label, TRANSFER_TARGET_EMAIL_FIELD) or '', poll_interval_seconds=interval, ) diff --git a/keepercommander/service/commands/integrations/sailpoint/constants.py b/keepercommander/service/commands/integrations/sailpoint/constants.py index 7933933ea..bff5e259e 100644 --- a/keepercommander/service/commands/integrations/sailpoint/constants.py +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -26,6 +26,7 @@ ALLOW_RECORDS_FIELD = 'allow_records' ALLOW_ROLES_FIELD = 'allow_roles' ALLOW_TEAMS_FIELD = 'allow_teams' +TRANSFER_TARGET_EMAIL_FIELD = 'transfer_target_email' POLL_INTERVAL_FIELD = 'poll_interval_seconds' DEFAULT_POLL_INTERVAL_SECONDS = 60 @@ -37,8 +38,8 @@ 'enterprise-info', 'enterprise-user', 'enterprise-role', - 'er', 'enterprise-down', + 'transfer-user', 'share-folder', 'share-record', 'nsf-share-folder', diff --git a/keepercommander/service/commands/integrations/sailpoint/service.py b/keepercommander/service/commands/integrations/sailpoint/service.py index ccaf58555..a1010685b 100644 --- a/keepercommander/service/commands/integrations/sailpoint/service.py +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -126,12 +126,19 @@ def start_background_services(cls) -> None: logger.warning(f'SailPoint poller not started: {e}') @classmethod - def handle_command(cls, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: - """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" + def handle_command( + cls, params: KeeperParams, command: str + ) -> Tuple[str, Optional[Tuple[Any, int]]]: + """ + Prepare a SailPoint Service Mode command. + + Returns ``(command_to_run, short_circuit)``. Callers must gate on + ``SAILPOINT_RECORD`` before invoking this. + """ cls.bind_params(params) uid = cls.record_uid(params) if not cls.record_has_marker(params, uid): - return None + return command, None return SailPointCommandHook(uid).before_command(params, command) @classmethod diff --git a/keepercommander/service/commands/integrations/sailpoint_app_setup.py b/keepercommander/service/commands/integrations/sailpoint_app_setup.py index d52fd9041..875eeee1d 100644 --- a/keepercommander/service/commands/integrations/sailpoint_app_setup.py +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -18,9 +18,11 @@ from .... import vault from ....display import bcolors from ....error import CommandError +from ....utils import is_email from ...docker import DockerComposeBuilder, DockerSetupPrinter, SailPointConfig, SetupResult, ServiceConfig from .integration_setup_base import IntegrationSetupCommand from .sailpoint.command_policy import SailPointCommandPolicy +from .sailpoint.config_fields import read_capabilities from .sailpoint.constants import ( ALLOW_FOLDERS_FIELD, ALLOW_RECORDS_FIELD, @@ -33,6 +35,7 @@ POLL_INTERVAL_FIELD, SAILPOINT_MARKER_FIELD, SAILPOINT_RECORD_ENV, + TRANSFER_TARGET_EMAIL_FIELD, ) from .sailpoint.pending_store import SailPointPendingStore @@ -57,7 +60,7 @@ def get_record_env_key(self) -> str: def get_service_commands(self) -> str: return SailPointCommandPolicy.default_allowlist() - def collect_integration_config(self, params): + def collect_integration_config(self, params, transfer_target_default: str = ''): print(f"\n{bcolors.BOLD}SHARE ENTITLEMENTS:{bcolors.ENDC}") print(f" Control which share entitlements SailPoint may manage via Service Mode") allow_folders = self._prompt_yes_no('Allow folder shares?', default=True) @@ -68,6 +71,10 @@ def collect_integration_config(self, params): allow_roles = self._prompt_yes_no('Allow role assignment?', default=True) allow_teams = self._prompt_yes_no('Allow team assignment?', default=True) + print(f"\n{bcolors.BOLD}VAULT TRANSFER TARGET:{bcolors.ENDC}") + print(f" Active user that receives vault data when SailPoint offboards via transfer-user") + transfer_target_email = self._prompt_transfer_target_email(transfer_target_default) + print(f"\n{bcolors.BOLD}POLL INTERVAL:{bcolors.ENDC}") print(f" How often (seconds) to check whether invited users have become Active") while True: @@ -93,9 +100,28 @@ def collect_integration_config(self, params): allow_records=allow_records, allow_roles=allow_roles, allow_teams=allow_teams, + transfer_target_email=transfer_target_email, poll_interval_seconds=interval, ) + def _prompt_transfer_target_email(self, default: str = '') -> str: + default = (default or '').strip() + while True: + if default: + prompt = ( + f"{bcolors.OKBLUE}Transfer target email " + f"[Press Enter for {default}]:{bcolors.ENDC} " + ) + else: + prompt = f"{bcolors.OKBLUE}Transfer target email (required):{bcolors.ENDC} " + value = input(prompt).strip() or default + if value and is_email(value): + return value + print( + f"{bcolors.FAIL}Error: Enter a valid email address" + f"{' or press Enter to keep the current value' if default else ''}{bcolors.ENDC}" + ) + def build_record_custom_fields(self, config): return [ vault.TypedField.new_field('text', 'true', SAILPOINT_MARKER_FIELD), @@ -111,6 +137,9 @@ def build_record_custom_fields(self, config): vault.TypedField.new_field( 'text', 'true' if config.allow_teams else 'false', ALLOW_TEAMS_FIELD ), + vault.TypedField.new_field( + 'text', config.transfer_target_email, TRANSFER_TARGET_EMAIL_FIELD + ), vault.TypedField.new_field('text', str(config.poll_interval_seconds), POLL_INTERVAL_FIELD), vault.TypedField.new_field('text', json.dumps({}), PENDING_ENTITLEMENTS_FIELD), ] @@ -120,7 +149,11 @@ def _run_integration_setup(self, params, setup_result: SetupResult, record_name: str): """Create/update dedicated SailPoint config record (not the Docker config record).""" DockerSetupPrinter.print_header('SailPoint Configuration') - config = self.collect_integration_config(params) + existing_uid = self._find_record_in_folder(params, setup_result.folder_uid, record_name) + transfer_default = '' + if existing_uid: + transfer_default = read_capabilities(params, existing_uid).transfer_target_email + config = self.collect_integration_config(params, transfer_target_default=transfer_default) DockerSetupPrinter.print_step(1, 2, f"Creating SailPoint config record '{record_name}'...") custom_fields = self.build_record_custom_fields(config) @@ -178,6 +211,9 @@ def print_integration_specific_resources(self, config): print(f" • Allow Records: {bcolors.OKBLUE}{config.allow_records}{bcolors.ENDC}") print(f" • Allow Roles: {bcolors.OKBLUE}{config.allow_roles}{bcolors.ENDC}") print(f" • Allow Teams: {bcolors.OKBLUE}{config.allow_teams}{bcolors.ENDC}") + print( + f" • Transfer Target: {bcolors.OKBLUE}{config.transfer_target_email}{bcolors.ENDC}" + ) print(f" • Poll Interval: {bcolors.OKBLUE}{config.poll_interval_seconds}s{bcolors.ENDC}") print(f" • Pending JSON field: {bcolors.OKBLUE}{PENDING_ENTITLEMENTS_FIELD}{bcolors.ENDC}") print(f" • Env key: {bcolors.OKBLUE}{self.get_record_env_key()}{bcolors.ENDC}") @@ -188,4 +224,6 @@ def print_integration_commands(self): print(f" Invite now; role/team queued until the user is Active") print(f" {bcolors.OKGREEN}• share-record -e user@co.com RECORD_UID{bcolors.ENDC}") print(f" {bcolors.OKGREEN}• share-folder -e user@co.com FOLDER_UID{bcolors.ENDC}") - print(f" Queued while invited; applied after activation\n") + print(f" Queued while invited; applied after activation") + print(f" {bcolors.OKGREEN}• transfer-user 'leaving@co.com' -f{bcolors.ENDC}") + print(f" Transfers vault to the configured target, then removes the leaving user\n") diff --git a/keepercommander/service/config/cli_handler.py b/keepercommander/service/config/cli_handler.py index 3b5a048bc..ba3ecbb68 100644 --- a/keepercommander/service/config/cli_handler.py +++ b/keepercommander/service/config/cli_handler.py @@ -41,21 +41,35 @@ def execute_cli_command(self, params: KeeperParams, command: str) -> str: @debug_decorator def find_config_record(self, params: KeeperParams, title: str) -> Optional[str]: - """Find existing config record by exact title match using vault search.""" + """Find owned config record by exact title match using vault search. + + Only records owned by the current account are eligible. Shared or + non-owned records (even with a matching title) are ignored so that + service configuration cannot be supplied or overwritten by another user. + """ try: from ... import vault_extensions logger.debug(f"Searching for record with exact title: '{title}'") records = list(vault_extensions.find_records(params, title)) - # Filter to exact title match only for record in records: logger.debug(f"Checking record: '{record.title}' (UID: {record.record_uid})") - if record.title == title: - logger.debug(f"✓ Found exact title match: '{title}' (UID: {record.record_uid})") - return record.record_uid + if record.title != title: + continue + + owner = (params.record_owner_cache or {}).get(record.record_uid) + if not owner or not owner.owner: + logger.debug( + f"Skipping non-owned config record '{title}' " + f"(UID: {record.record_uid})" + ) + continue + + logger.debug(f"✓ Found owned exact title match: '{title}' (UID: {record.record_uid})") + return record.record_uid - logger.debug(f"✗ No record found with exact title: '{title}'") + logger.debug(f"✗ No owned record found with exact title: '{title}'") return None except Exception as e: diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index a625e5543..c30acd0e6 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, SailPointConfig, - SetupStep, ApproverTeam, ApprovalsConfig, + DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, + SailPointConfig, GChatConfig, GChatConstants, SetupStep, ApproverTeam, ApprovalsConfig, ) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase @@ -34,6 +34,8 @@ 'SlackConfig', 'TeamsConfig', 'SailPointConfig', + 'GChatConfig', + 'GChatConstants', 'ApproverTeam', 'ApprovalsConfig', 'SetupStep', @@ -41,4 +43,3 @@ 'DockerSetupBase', 'DockerComposeBuilder', ] - diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index f4874d880..c8090e228 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -130,6 +130,56 @@ class SailPointConfig: allow_records: bool = True allow_roles: bool = True allow_teams: bool = True + transfer_target_email: str = '' # Keep in sync with sailpoint.constants.DEFAULT_POLL_INTERVAL_SECONDS poll_interval_seconds: int = 60 + +class GChatConstants: + """Defaults and field labels for Google Chat app setup.""" + INTEGRATION_NAME = 'GChat' + DISPLAY_NAME = 'Google Chat' + DEFAULT_FOLDER_NAME = 'Commander Service Mode - Google Chat App' + DEFAULT_RECORD_NAME = 'Commander Service Mode Google Chat App Config' + + FIELD_SERVICE_ACCOUNT_JSON = 'google_service_account_json' + FIELD_PROJECT_ID = 'google_project_id' + FIELD_SUBSCRIPTION_ID = 'google_subscription_id' + FIELD_TOPIC_ID = 'google_topic_id' + FIELD_APPROVALS_SPACE_ID = 'chat_approvals_space_id' + FIELD_COMMAND_REQUEST_RECORD_ID = 'chat_command_request_record_id' + FIELD_COMMAND_REQUEST_FOLDER_ID = 'chat_command_request_folder_id' + FIELD_COMMAND_ONE_TIME_SHARE_ID = 'chat_command_one_time_share_id' + FIELD_PEDM_ENABLED = 'pedm_enabled' + FIELD_PEDM_POLLING_INTERVAL = 'pedm_polling_interval' + FIELD_DEVICE_APPROVAL_ENABLED = 'device_approval_enabled' + FIELD_DEVICE_APPROVAL_POLLING_INTERVAL = 'device_approval_polling_interval' + + DEFAULT_COMMAND_REQUEST_RECORD_ID = '1' + DEFAULT_COMMAND_REQUEST_FOLDER_ID = '2' + DEFAULT_COMMAND_ONE_TIME_SHARE_ID = '3' + + SPACE_ID_PREFIX = 'spaces/' + SERVICE_ACCOUNT_TYPE = 'service_account' + SERVICE_ACCOUNT_REQUIRED_KEYS = ( + 'type', + 'project_id', + 'private_key', + 'client_email', + ) + + +@dataclass +class GChatConfig: + google_service_account_json: str + google_project_id: str + google_subscription_id: str + google_topic_id: str + chat_approvals_space_id: str + chat_command_request_record_id: str = GChatConstants.DEFAULT_COMMAND_REQUEST_RECORD_ID + chat_command_request_folder_id: str = GChatConstants.DEFAULT_COMMAND_REQUEST_FOLDER_ID + chat_command_one_time_share_id: str = GChatConstants.DEFAULT_COMMAND_ONE_TIME_SHARE_ID + pedm_enabled: bool = False + pedm_polling_interval: int = 120 + device_approval_enabled: bool = False + device_approval_polling_interval: int = 120 diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index a352d6d43..5458b495c 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -177,7 +177,7 @@ def execute(cls, command: str) -> Tuple[Any, int]: sailpoint_enabled = bool((os.environ.get('SAILPOINT_RECORD') or '').strip()) if sailpoint_enabled: from ..commands.integrations.sailpoint.service import SailPointService - sailpoint_response = SailPointService.handle_command(params, command) + command, sailpoint_response = SailPointService.handle_command(params, command) if sailpoint_response is not None: response, status_code = sailpoint_response response = CommandExecutor.encrypt_response(response) diff --git a/unit-tests/service/test_gchat_app_setup.py b/unit-tests/service/test_gchat_app_setup.py new file mode 100644 index 000000000..00d812ad0 --- /dev/null +++ b/unit-tests/service/test_gchat_app_setup.py @@ -0,0 +1,194 @@ +import json +import os +import tempfile +import unittest + +from keepercommander.service.commands.integrations.gchat_app_setup import GChatAppSetupCommand +from keepercommander.service.docker import GChatConfig, GChatConstants + + +def _valid_service_account(**overrides): + data = { + 'type': GChatConstants.SERVICE_ACCOUNT_TYPE, + 'project_id': 'my-gcp-project', + 'private_key': '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + 'client_email': 'bot@my-gcp-project.iam.gserviceaccount.com', + } + data.update(overrides) + return data + + +class TestGChatAppSetupValidation(unittest.TestCase): + def setUp(self): + self.cmd = GChatAppSetupCommand() + + def test_command_naming(self): + self.assertEqual(self.cmd.get_integration_name(), GChatConstants.INTEGRATION_NAME) + self.assertEqual(self.cmd.get_integration_display_name(), GChatConstants.DISPLAY_NAME) + self.assertEqual(self.cmd.get_command_name(), 'gchat-app-setup') + self.assertEqual(self.cmd.get_record_env_key(), 'GCHAT_RECORD') + self.assertEqual(self.cmd.get_docker_image(), 'keeper/gchat-app:latest') + self.assertEqual( + self.cmd.get_integration_config_marker_field(), + GChatConstants.FIELD_SERVICE_ACCOUNT_JSON, + ) + self.assertEqual(self.cmd.get_parser().prog, 'gchat-app-setup') + + def test_load_service_account_requires_path(self): + data, error = self.cmd._load_service_account_json('') + self.assertIsNone(data) + self.assertIn('required', error.lower()) + + def test_load_service_account_file_not_found(self): + data, error = self.cmd._load_service_account_json('/tmp/does-not-exist-gchat.json') + self.assertIsNone(data) + self.assertIn('not found', error.lower()) + + def test_load_service_account_rejects_inline_json(self): + data, error = self.cmd._load_service_account_json(json.dumps(_valid_service_account())) + self.assertIsNone(data) + self.assertIn('not found', error.lower()) + + def test_load_service_account_invalid_type(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + json.dump(_valid_service_account(type='user'), handle) + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(data) + self.assertIn('service_account', error) + + def test_load_service_account_missing_fields(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + json.dump({'type': 'service_account'}, handle) + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(data) + self.assertIn('missing', error.lower()) + + def test_load_service_account_from_file(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + json.dump(_valid_service_account(), handle) + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(error) + self.assertEqual(data['project_id'], 'my-gcp-project') + + def test_load_service_account_invalid_json_file(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + handle.write('{not-json') + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(data) + self.assertIn('invalid service account json', error.lower()) + + def test_normalize_subscription_short_id(self): + value, error = self.cmd._normalize_subscription_id( + 'keeper-chat-events', 'my-gcp-project' + ) + self.assertIsNone(error) + self.assertEqual(value, 'keeper-chat-events') + + def test_normalize_subscription_full_resource_name(self): + value, error = self.cmd._normalize_subscription_id( + 'projects/my-gcp-project/subscriptions/keeper-chat-events', + 'my-gcp-project', + ) + self.assertIsNone(error) + self.assertEqual(value, 'keeper-chat-events') + + def test_normalize_subscription_rejects_project_mismatch(self): + value, error = self.cmd._normalize_subscription_id( + 'projects/other-project/subscriptions/keeper-chat-events', + 'my-gcp-project', + ) + self.assertIsNone(value) + self.assertIn('does not match', error.lower()) + self.assertIn('other-project', error) + self.assertIn('my-gcp-project', error) + + def test_normalize_subscription_missing(self): + value, error = self.cmd._normalize_subscription_id('', 'my-gcp-project') + self.assertIsNone(value) + self.assertIn('required', error.lower()) + + def test_normalize_subscription_invalid(self): + value, error = self.cmd._normalize_subscription_id('ab', 'my-gcp-project') + self.assertIsNone(value) + self.assertIn('invalid', error.lower()) + + def test_normalize_topic_full_resource_name(self): + value, error = self.cmd._normalize_topic_id( + 'projects/my-gcp-project/topics/keeper-chat-topic', + 'my-gcp-project', + ) + self.assertIsNone(error) + self.assertEqual(value, 'keeper-chat-topic') + + def test_normalize_topic_rejects_project_mismatch(self): + value, error = self.cmd._normalize_topic_id( + 'projects/other-project/topics/keeper-chat-topic', + 'my-gcp-project', + ) + self.assertIsNone(value) + self.assertIn('does not match', error.lower()) + + def test_normalize_topic_missing(self): + value, error = self.cmd._normalize_topic_id('', 'my-gcp-project') + self.assertIsNone(value) + self.assertIn('required', error.lower()) + + def test_space_id_validation(self): + self.assertTrue(self.cmd._is_valid_space_id('spaces/AAAA')) + self.assertFalse(self.cmd._is_valid_space_id('spaces/')) + self.assertFalse(self.cmd._is_valid_space_id('AAAA')) + self.assertFalse(self.cmd._is_valid_space_id('')) + + def test_build_record_custom_fields(self): + config = GChatConfig( + google_service_account_json='{"type":"service_account"}', + google_project_id='my-gcp-project', + google_subscription_id='keeper-chat-events', + google_topic_id='keeper-chat-topic', + chat_approvals_space_id='spaces/AAAA', + chat_command_request_record_id='1', + chat_command_request_folder_id='2', + chat_command_one_time_share_id='3', + pedm_enabled=True, + pedm_polling_interval=60, + device_approval_enabled=False, + device_approval_polling_interval=120, + ) + fields = { + field.label: field.get_default_value() + for field in self.cmd.build_record_custom_fields(config) + } + self.assertEqual( + fields[GChatConstants.FIELD_SERVICE_ACCOUNT_JSON], + '{"type":"service_account"}', + ) + self.assertEqual(fields[GChatConstants.FIELD_PROJECT_ID], 'my-gcp-project') + self.assertEqual(fields[GChatConstants.FIELD_SUBSCRIPTION_ID], 'keeper-chat-events') + self.assertEqual(fields[GChatConstants.FIELD_TOPIC_ID], 'keeper-chat-topic') + self.assertEqual(fields[GChatConstants.FIELD_APPROVALS_SPACE_ID], 'spaces/AAAA') + self.assertEqual(fields[GChatConstants.FIELD_COMMAND_REQUEST_RECORD_ID], '1') + self.assertEqual(fields[GChatConstants.FIELD_COMMAND_REQUEST_FOLDER_ID], '2') + self.assertEqual(fields[GChatConstants.FIELD_COMMAND_ONE_TIME_SHARE_ID], '3') + self.assertEqual(fields[GChatConstants.FIELD_PEDM_ENABLED], 'true') + self.assertEqual(fields[GChatConstants.FIELD_PEDM_POLLING_INTERVAL], '60') + self.assertEqual(fields[GChatConstants.FIELD_DEVICE_APPROVAL_ENABLED], 'false') + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py index f8ee726fb..f68b0c763 100644 --- a/unit-tests/service/test_sailpoint_pending.py +++ b/unit-tests/service/test_sailpoint_pending.py @@ -200,6 +200,21 @@ def test_parse_share_equals_forms(self): self.assertIsNotNone(nsf) self.assertEqual(nsf.nsf_role, 'content-manager') + def test_parse_transfer(self): + parsed = SailPointCommandParser.parse_transfer("transfer-user 'leaving@co.com' -f") + self.assertIsNotNone(parsed) + self.assertEqual(parsed.emails, ['leaving@co.com']) + self.assertTrue(parsed.has_force) + self.assertFalse(parsed.has_target_user) + + parsed = SailPointCommandParser.parse_transfer( + 'transfer-user leaving@co.com --target-user=other@co.com -f' + ) + self.assertTrue(parsed.has_target_user) + self.assertTrue(parsed.has_force) + + self.assertIsNone(SailPointCommandParser.parse_transfer('enterprise-user x@co.com --delete')) + class SailPointPolicyTest(unittest.TestCase): def test_sanitize_strips_get(self): @@ -217,16 +232,26 @@ def test_sanitize_adds_enterprise_role_when_missing(self): parts = cleaned.split(',') self.assertNotIn('get', parts) self.assertIn('enterprise-role', parts) - self.assertIn('er', parts) + self.assertNotIn('er', parts) def test_default_allowlist_matches_integration_list(self): expected = [ - 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-role', 'er', - 'enterprise-down', 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', + 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-role', + 'enterprise-down', 'transfer-user', + 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', 'tree', ] self.assertEqual(SailPointCommandPolicy.default_allowlist().split(','), expected) + def test_sanitize_adds_transfer_user_when_missing(self): + cleaned = SailPointCommandPolicy.sanitize( + 'whoami,sync-down,enterprise-info,enterprise-user,enterprise-down,' + 'share-folder,share-record,tree' + ) + parts = cleaned.split(',') + self.assertIn('transfer-user', parts) + self.assertNotIn('tu', parts) + def test_enterprise_role_blocks_add_delete_add_user(self): for cmd in ( "enterprise-role --add 'New Role'", @@ -235,6 +260,13 @@ def test_enterprise_role_blocks_add_delete_add_user(self): "enterprise-role 'QA Role' --copy", "er 'QA Role' --name 'Renamed'", "er 'QA Role' --enforcement restrict_sharing:true", + # Equals / abbrev / short= forms must resolve the same as blocked long flags. + "enterprise-role 'QA Role' --add-user=user@co.com", + "enterprise-role 'QA Role' --add-us user@co.com", + "enterprise-role 'QA Role' -au=user@co.com", + "enterprise-role 'QA Role' --dele -f", + "enterprise-role 'QA Role' --nam=Pwned", + "enterprise-role 'QA Role' --enforce=restrict_sharing_all:true", ): err = SailPointCommandPolicy.validate_enterprise_role(cmd) self.assertIsNotNone(err, cmd) @@ -257,6 +289,72 @@ def test_enterprise_role_gate_ignores_other_commands(self): ) ) + def test_enterprise_user_delete_blocked(self): + for cmd in ( + 'enterprise-user leaving@co.com --delete', + 'eu leaving@co.com --delete', + ): + err = SailPointCommandPolicy.validate_enterprise_user_delete(cmd) + self.assertIsNotNone(err, cmd) + self.assertIn('--delete', err) + self.assertIn('transfer-user', err) + + def test_enterprise_user_delete_allows_other_ops(self): + self.assertIsNone( + SailPointCommandPolicy.validate_enterprise_user_delete( + 'eu user@co.com --add-role Admin' + ) + ) + self.assertIsNone( + SailPointCommandPolicy.validate_enterprise_user_delete( + 'enterprise-user user@co.com --delete-alias old@co.com' + ) + ) + + def test_prepare_transfer_appends_config_email(self): + cmd = "transfer-user 'leaving@co.com' -f" + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertIsNone(err) + self.assertTrue(rewritten.startswith(cmd)) + tokens = SailPointCommandParser.tokenize(rewritten) + self.assertIn('--target-user', tokens) + self.assertEqual(tokens[tokens.index('--target-user') + 1], 'target@co.com') + + def test_prepare_transfer_rejects_explicit_target(self): + cmd = 'transfer-user leaving@co.com -f --target-user other@co.com' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIsNotNone(err) + self.assertIn('--target-user', err) + + def test_prepare_transfer_rejects_self_transfer(self): + cmd = 'transfer-user Target@Co.com -f' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIsNotNone(err) + self.assertIn('itself', err) + + def test_prepare_transfer_requires_force_and_valid_config(self): + cmd = 'transfer-user leaving@co.com' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIn('-f', err) + + cmd = 'transfer-user leaving@co.com -f' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, '') + self.assertEqual(rewritten, cmd) + self.assertIn('not configured', err) + + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'not-an-email') + self.assertEqual(rewritten, cmd) + self.assertIn('not configured', err) + + def test_prepare_transfer_ignores_other_commands(self): + cmd = 'enterprise-user user@co.com --add-role Admin' + rewritten, err = SailPointCommandPolicy.prepare_transfer(cmd, 'target@co.com') + self.assertEqual(rewritten, cmd) + self.assertIsNone(err) + class SailPointPendingMergeTest(unittest.TestCase): def test_merge_by_email(self): @@ -587,6 +685,35 @@ def test_teams_off_blocks_add_team_allows_node(self): ) self.assertIsNone(err) + def test_capability_gates_catch_abbreviated_flags(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + roles_off = SailPointCapabilities(allow_roles=False, allow_teams=True) + err = SailPointCommandHook._check_capability_gates( + 'enterprise-user user@co.com --add-rol Admin', roles_off + ) + self.assertIsNotNone(err) + self.assertIn('allow_roles', err) + + teams_off = SailPointCapabilities(allow_roles=True, allow_teams=False) + err = SailPointCommandHook._check_capability_gates( + 'enterprise-user user@co.com --add-tea Slack', teams_off + ) + self.assertIsNotNone(err) + self.assertIn('allow_teams', err) + + share = SailPointCommandParser.parse_share( + 'share-record --emai attacker@co.com --write SOMERECORDUID' + ) + self.assertIsNotNone(share) + self.assertEqual(share.emails, ['attacker@co.com']) + self.assertTrue(share.can_edit) + def test_apply_skips_folders_and_records_when_disallowed(self): params = mock.Mock() params.enterprise = { @@ -644,6 +771,115 @@ def test_mixed_active_and_invited_share_rejected(self): self.assertEqual(status, 400) self.assertIn('mix Active and non-Active', response['error']) + def test_share_capability_gates_apply_to_non_grant_actions(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + hook = SailPointCommandHook('cfg-uid') + params = mock.Mock() + records_off = SailPointCapabilities(allow_records=False, allow_folders=True) + folders_off = SailPointCapabilities(allow_records=True, allow_folders=False) + both_on = SailPointCapabilities(allow_records=True, allow_folders=True) + + for cmd in ( + 'share-record -e user@co.com --action owner RECORD_UID', + 'share-record -e user@co.com -a owner RECORD_UID', + 'share-record -e user@co.com --action revoke RECORD_UID', + 'share-record -e user@co.com --action cancel -f RECORD_UID', + 'nsf-share-record -e user@co.com --action owner -r viewer RECORD_UID', + ): + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNotNone(share, cmd) + self.assertFalse(share.is_grant, cmd) + response, status = hook._before_share(params, share, records_off) + self.assertEqual(status, 403, cmd) + self.assertIn('allow_records', response['error'], cmd) + self.assertIsNone(hook._before_share(params, share, both_on), cmd) + + for cmd in ( + 'share-folder -e user@co.com --action remove FOLDER_UID', + 'nsf-share-folder -e user@co.com --action remove FOLDER_UID', + ): + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNotNone(share, cmd) + self.assertFalse(share.is_grant, cmd) + response, status = hook._before_share(params, share, folders_off) + self.assertEqual(status, 403, cmd) + self.assertIn('allow_folders', response['error'], cmd) + self.assertIsNone(hook._before_share(params, share, both_on), cmd) + + def test_non_grant_share_never_queues_for_invited_user(self): + """Revoke/owner/remove must pass through to Commander, not pending entitlements.""" + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + from keepercommander.service.commands.integrations.sailpoint.pending_store import ( + SailPointPendingStore, + ) + + params = mock.Mock() + params.enterprise = { + 'users': [{'username': 'invited@co.com', 'node_id': 1, 'status': 'invited'}], + 'nodes': [{'node_id': 1}], + 'scims': [], + } + hook = SailPointCommandHook('cfg-uid') + caps = SailPointCapabilities(allow_records=True, allow_folders=True) + + with mock.patch.object(SailPointPendingStore, 'update') as update: + for cmd in ( + 'share-record -e invited@co.com --action revoke RECORD_UID', + 'share-record -e invited@co.com --action owner RECORD_UID', + 'share-record -e invited@co.com --action cancel -f RECORD_UID', + 'share-folder -e invited@co.com --action remove FOLDER_UID', + 'nsf-share-record -e invited@co.com --action owner -r viewer RECORD_UID', + 'nsf-share-folder -e invited@co.com --action remove FOLDER_UID', + ): + share = SailPointCommandParser.parse_share(cmd) + self.assertIsNotNone(share, cmd) + self.assertFalse(share.is_grant, cmd) + self.assertIsNone(hook._before_share(params, share, caps), cmd) + update.assert_not_called() + + def test_before_command_injects_transfer_target(self): + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + caps = SailPointCapabilities(transfer_target_email='target@co.com') + hook = SailPointCommandHook('cfg-uid') + with mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.command_hook.read_capabilities', + return_value=caps, + ): + command, short = hook.before_command( + mock.Mock(), "transfer-user 'leaving@co.com' -f" + ) + self.assertIsNone(short) + self.assertIn('--target-user', command) + self.assertIn('target@co.com', command) + + with mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.command_hook.read_capabilities', + return_value=caps, + ): + command, short = hook.before_command( + mock.Mock(), 'enterprise-user leaving@co.com --delete' + ) + self.assertIsNotNone(short) + self.assertEqual(short[1], 403) + self.assertIn('--delete', short[0]['error']) + def test_after_command_skips_missing_user(self): from keepercommander.service.commands.integrations.sailpoint.command_hook import ( SailPointCommandHook, diff --git a/unit-tests/service/test_service_config.py b/unit-tests/service/test_service_config.py index dd221f67d..302c0e815 100644 --- a/unit-tests/service/test_service_config.py +++ b/unit-tests/service/test_service_config.py @@ -161,3 +161,55 @@ def test_update_or_add_record(self, mock_record_handler): mock_record_handler.update_or_add_record.assert_called_once_with( params, self.service_config.title, self.service_config.config_path ) + + +class TestFindConfigRecordOwnership(unittest.TestCase): + """Service config records must be owned by the current account.""" + + TITLE = 'Commander Service Mode Config' + + def _make_record(self, uid, title=None): + record = MagicMock() + record.record_uid = uid + record.title = title or self.TITLE + return record + + @patch('keepercommander.vault_extensions.find_records') + def test_skips_non_owned_record(self, mock_find): + from keepercommander.params import RecordOwner + from keepercommander.service.config.cli_handler import CommandHandler + + shared = self._make_record('SHARED_UID') + owned = self._make_record('OWNED_UID') + mock_find.return_value = [shared, owned] + + params = MagicMock(spec=KeeperParams) + params.record_owner_cache = { + 'SHARED_UID': RecordOwner(False, 'attacker'), + 'OWNED_UID': RecordOwner(True, 'operator'), + } + + self.assertEqual(CommandHandler().find_config_record(params, self.TITLE), 'OWNED_UID') + + @patch('keepercommander.vault_extensions.find_records') + def test_returns_none_when_only_shared_match(self, mock_find): + from keepercommander.params import RecordOwner + from keepercommander.service.config.cli_handler import CommandHandler + + mock_find.return_value = [self._make_record('SHARED_UID')] + params = MagicMock(spec=KeeperParams) + params.record_owner_cache = { + 'SHARED_UID': RecordOwner(False, 'attacker'), + } + + self.assertIsNone(CommandHandler().find_config_record(params, self.TITLE)) + + @patch('keepercommander.vault_extensions.find_records') + def test_returns_none_when_owner_cache_missing(self, mock_find): + from keepercommander.service.config.cli_handler import CommandHandler + + mock_find.return_value = [self._make_record('UNKNOWN_UID')] + params = MagicMock(spec=KeeperParams) + params.record_owner_cache = {} + + self.assertIsNone(CommandHandler().find_config_record(params, self.TITLE)) diff --git a/unit-tests/test_command_enterprise.py b/unit-tests/test_command_enterprise.py index a7d17b9b0..9e56f87a4 100644 --- a/unit-tests/test_command_enterprise.py +++ b/unit-tests/test_command_enterprise.py @@ -56,7 +56,7 @@ def test_enterprise_info_command(self): cmd.execute(params, verbose=True) def test_enterprise_info_users_verbose_returns_ids(self): - """With -v, node/teams/roles columns should be IDs; without -v, names.""" + """With -v, node/teams/roles include separate name and ID fields; without -v, names only.""" params = get_connected_params() api.query_enterprise(params) cmd = enterprise.EnterpriseInfoCommand() @@ -74,9 +74,18 @@ def test_enterprise_info_users_verbose_returns_ids(self): params, users=True, format='json', columns=columns, verbose=True, quiet=True) users = json.loads(report) user1 = next(u for u in users if u['user_id'] == ent_env.user1_id) - self.assertEqual(user1['node'], str(ent_env.node1_id)) - self.assertEqual(user1['teams'], [ent_env.team1_uid]) - self.assertEqual(user1['roles'], [str(ent_env.role1_id)]) + self.assertEqual(user1['node'], { + 'node_id': str(ent_env.node1_id), + 'node_name': 'Enterprise 1', + }) + self.assertEqual(user1['teams'], [{ + 'team_uid': ent_env.team1_uid, + 'team_name': ent_env.team1_name, + }]) + self.assertEqual(user1['roles'], [{ + 'role_id': str(ent_env.role1_id), + 'role_name': ent_env.role1_name, + }]) def test_enterprise_add_user(self): params = get_connected_params() @@ -313,6 +322,27 @@ def test_audit_audit_report_parse_int_filter(self): arr.sort() self.assertListEqual(arr, [0, 1, 2, 3, 4, 5, 6, 7]) + def test_audit_report_sox_fetch_uses_freshness_when_record_details_allowed(self): + params = mock.Mock() + cmd = aram.AuditReportCommand() + cmd.allow_sox_data_fetch = True + sox_data = mock.Mock() + before = int(datetime.now().timestamp()) + with mock.patch('keepercommander.commands.aram.is_compliance_reporting_enabled', return_value=True), \ + mock.patch('keepercommander.commands.aram.get_compliance_data', return_value=sox_data) as mock_get: + self.assertIs(cmd.get_sox_data(params), sox_data) + min_updated = mock_get.call_args.kwargs.get('min_updated') + self.assertGreaterEqual(min_updated, before) + + def test_audit_report_sox_fetch_uses_cache_only_by_default(self): + params = mock.Mock() + cmd = aram.AuditReportCommand() + sox_data = mock.Mock() + with mock.patch('keepercommander.commands.aram.is_compliance_reporting_enabled', return_value=True), \ + mock.patch('keepercommander.commands.aram.get_compliance_data', return_value=sox_data) as mock_get: + self.assertIs(cmd.get_sox_data(params), sox_data) + self.assertEqual(mock_get.call_args.kwargs.get('min_updated'), 0) + def test_enterprise_push_command(self): params = get_connected_params() api.query_enterprise(params)