diff --git a/keepercommander/commands/enterprise.py b/keepercommander/commands/enterprise.py index f4c2c7712..25c9bc212 100644 --- a/keepercommander/commands/enterprise.py +++ b/keepercommander/commands/enterprise.py @@ -132,6 +132,7 @@ def register_command_info(aliases, command_info): enterprise_node_parser = argparse.ArgumentParser(prog='enterprise-node', description='Manage an enterprise node') +enterprise_node_parser.add_argument('-f', '--force', dest='force', action='store_true', help='do not prompt for confirmation') enterprise_node_parser.add_argument('--wipe-out', dest='wipe_out', action='store_true', help='wipe out node content') enterprise_node_parser.add_argument('--add', dest='add', action='store_true', help='create node') enterprise_node_parser.add_argument('--parent', dest='parent', action='store', help='Parent Node Name or ID') @@ -1285,7 +1286,7 @@ def traverse_to_root(node_id, depth): if not node.get('parent_id'): raise CommandError('enterprise-node', 'Cannot wipe out root node') - answer = user_choice( + answer = 'y' if kwargs.get('force') else user_choice( bcolors.FAIL + bcolors.BOLD + '\nALERT!\n' + bcolors.ENDC + 'This action cannot be undone.\n\n' + 'Do you want to proceed with deletion?', 'yn', 'n') @@ -1309,14 +1310,15 @@ def traverse_to_root(node_id, depth): roles = [x for x in params.enterprise['roles'] if x['node_id'] in nodes] role_set = set([x['role_id'] for x in managed_nodes]) role_set = role_set.union([x['role_id'] for x in roles]) - for ru in params.enterprise['role_users']: - if ru['role_id'] in role_set: - rq = { - 'command': 'role_user_remove', - 'role_id': ru['role_id'], - 'enterprise_user_id': ru['enterprise_user_id'] - } - request_batch.append(rq) + if 'role_users' in params.enterprise: + for ru in params.enterprise['role_users']: + if ru['role_id'] in role_set: + rq = { + 'command': 'role_user_remove', + 'role_id': ru['role_id'], + 'enterprise_user_id': ru['enterprise_user_id'] + } + request_batch.append(rq) for mn in managed_nodes: rq = { 'command': 'role_managed_node_remove', @@ -1356,6 +1358,12 @@ def traverse_to_root(node_id, depth): 'node_id': node_id } request_batch.append(rq) + + # Check if there's anything to wipe out + if not request_batch: + node_name = node.get('data', {}).get('displayname') or str(node['node_id']) + logging.info('Node \'%s\' is empty. Nothing to wipe out.', node_name) + return elif parent_id or kwargs.get('displayname'): display_name = kwargs.get('displayname') def is_in_chain(node_id, parent_id): @@ -2157,17 +2165,62 @@ def execute(self, params, **kwargs): raise CommandError('enterprise-user', 'No root nodes were detected. Specify --node parameter') node_id = root_nodes[0] + # Collect role_ids for newly created roles + new_role_ids = [] for role_name in role_names: data = json.dumps({ "displayname": role_name }).encode('utf-8') + role_id = self.get_enterprise_id(params) + new_role_ids.append(role_id) rq = { "command": "role_add", - "role_id": self.get_enterprise_id(params), + "role_id": role_id, "node_id": node_id, "encrypted_data": utils.base64_url_encode(crypto.encrypt_aes_v1(data, tree_key)), "visible_below": (kwargs.get('visible_below') == 'on') or False, "new_user_inherit": (kwargs.get('new_user') == 'on') or False } request_batch.append(rq) + + if kwargs.get('add_admin') and new_role_ids: + skip_display = True + node_lookup = {} + if 'nodes' in params.enterprise: + for node in params.enterprise['nodes']: + node_lookup[str(node['node_id'])] = node + if node.get('parent_id'): + node_name = node['data'].get('displayname') + else: + node_name = params.enterprise['enterprise_name'] + node_name = node_name.lower() + value = node_lookup.get(node_name) + if value is None: + value = node + elif type(value) == list: + value.append(node) + else: + value = [value, node] + node_lookup[node_name] = value + + admin_nodes = {} + for admin_node_name in kwargs.get('add_admin'): + value = node_lookup.get(admin_node_name.lower()) + if value is None: + logging.warning('Node %s could not be resolved', admin_node_name) + elif isinstance(value, dict): + admin_nodes[value['node_id']] = value['data'].get('displayname') or params.enterprise['enterprise_name'] + elif isinstance(value, list): + logging.warning('Node name \'%s\' is not unique. Use Node ID. Skipping', admin_node_name) + + for role_id in new_role_ids: + for admin_node_id, admin_node_display_name in admin_nodes.items(): + rq = { + "command": "role_managed_node_add", + "role_id": role_id, + "managed_node_id": admin_node_id, + "cascade_node_management": (kwargs.get('cascade') == 'on') or False, + "tree_keys": [] + } + request_batch.append(rq) else: for role_name in role_names: logging.warning('Role %s is not found: Skipping', role_name) @@ -3057,6 +3110,88 @@ class EnterpriseTeamCommand(EnterpriseCommand): def get_parser(self): return enterprise_team_parser + @staticmethod + def _resolve_users(params, user_list): + """Resolve user names/IDs to user objects. Returns dict {user_id: user_node}""" + users = {} + for u in user_list: + uname = u.lower() + user_node = None + if 'users' in params.enterprise: + for user in params.enterprise['users']: + if uname in {str(user['enterprise_user_id']), user['username'].lower()}: + user_node = user + break + if user_node: + users[user_node['enterprise_user_id']] = user_node + else: + logging.warning('User %s could not be resolved', u) + return users + + @staticmethod + def _create_add_user_request(params, team_uid, team_key, user, hsf_flag): + """Create a request to add a user to a team with proper encryption""" + user_id = user['enterprise_user_id'] + username = user['username'] + + api.load_user_public_keys(params, [username], False) + user_keys = params.key_cache.get(username) + + if not user_keys: + logging.warning('Cannot get user %s public key', username) + return None + + rq = { + 'command': 'team_enterprise_user_add', + 'team_uid': team_uid, + 'enterprise_user_id': user_id, + 'user_type': 2 if hsf_flag == 'on' else 1 if hsf_flag else 0, + } + + if params.forbid_rsa: + if user_keys.ec: + ec_key = crypto.load_ec_public_key(user_keys.ec) + encrypted_team_key = crypto.encrypt_ec(team_key, ec_key) + rq['team_key'] = utils.base64_url_encode(encrypted_team_key) + rq['team_key_type'] = 'encrypted_by_public_key_ecc' + else: + logging.warning('User %s does not have EC key', username) + return None + else: + if user_keys.rsa: + rsa_key = crypto.load_rsa_public_key(user_keys.rsa) + encrypted_team_key = crypto.encrypt_rsa(team_key, rsa_key) + rq['team_key'] = utils.base64_url_encode(encrypted_team_key) + rq['team_key_type'] = 'encrypted_by_public_key' + else: + logging.warning('User %s does not have RSA key', username) + return None + + return rq + + @staticmethod + def _resolve_roles(params, role_list): + """Resolve role names/IDs to role objects. Returns dict {role_id: role_name} excluding admin roles""" + role_changes = {} + for role in role_list: + role_node = next(( + r for r in params.enterprise['roles'] + if role in (str(r['role_id']), r['data'].get('displayname')) + ), None) + if role_node: + # Check if role has administrative permissions + is_managed_role = any( + mn['role_id'] == role_node['role_id'] + for mn in params.enterprise.get('managed_nodes', []) + ) + if is_managed_role: + logging.warning('Teams cannot be assigned to roles with administrative permissions.') + else: + role_changes[role_node['role_id']] = role_node['data'].get('displayname') + else: + logging.warning('Role %s cannot be resolved', role) + return role_changes + def execute(self, params, **kwargs): if (kwargs.get('add') or kwargs.get('approve')) and kwargs.get('remove'): raise CommandError('enterprise-team', "'add'/'approve' and 'delete' commands are mutually exclusive.") @@ -3110,7 +3245,8 @@ def execute(self, params, **kwargs): matched_teams = list(matched.values()) request_batch = [] non_batch_update_msgs = [] - has_warnings = False + has_warnings = False + new_team_roles = None if kwargs.get('add') or kwargs.get('approve'): queue = [] @@ -3133,6 +3269,8 @@ def execute(self, params, **kwargs): raise CommandError('enterprise-user', 'No root nodes were detected. Specify --node parameter') node_id = root_nodes[0] + new_teams = {} # {team_uid: (team_name, team_key, is_new)} + for item in queue: is_new_team = type(item) == str team_name = item if is_new_team else item['name'] @@ -3164,6 +3302,35 @@ def execute(self, params, **kwargs): rq['private_key'] = utils.base64_url_encode(encrypted_rsa_private_key) request_batch.append(rq) + + if is_new_team: + new_teams[team_uid] = (team_name, team_key, True) + + if kwargs.get('add_user') and new_teams: + skip_display = True + users = self._resolve_users(params, kwargs.get('add_user')) + if not users: + has_warnings = True + + hsf = kwargs.get('hide_shared_folders') or '' + for team_uid, (team_name, team_key, is_new) in new_teams.items(): + for user_id, user in users.items(): + if user['status'] == 'active': + rq = self._create_add_user_request(params, team_uid, team_key, user, hsf) + if rq: + request_batch.append(rq) + else: + request_batch.append({ + 'command': 'team_queue_user', + 'team_uid': team_uid, + 'enterprise_user_id': user_id + }) + + # Role additions for new teams will be handled after team creation + new_team_roles = None + if kwargs.get('add_role') and new_teams: + skip_display = True + new_team_roles = (new_teams, kwargs.get('add_role')) else: for team_name in team_names: logging.warning('\'%s\' team is not found: Skipping', team_name) @@ -3280,11 +3447,31 @@ def execute(self, params, **kwargs): 'enterprise_user_id': user_id } else: - rq = { - 'command': 'team_enterprise_user_remove', - 'team_uid': team['team_uid'], - 'enterprise_user_id': user_id - } + is_member = False + username = user['username'] + team_name = team['name'] + + # Check in active team members + if 'team_users' in params.enterprise: + is_member = any(1 for t in params.enterprise['team_users'] + if t['team_uid'] == team_uid and t['enterprise_user_id'] == user_id) + + # Check in queued team members + if not is_member and 'queued_team_users' in params.enterprise: + for qtu in params.enterprise['queued_team_users']: + if qtu['team_uid'] == team_uid and user_id in qtu.get('users', []): + is_member = True + break + + if is_member: + rq = { + 'command': 'team_enterprise_user_remove', + 'team_uid': team['team_uid'], + 'enterprise_user_id': user_id + } + else: + logging.warning('User %s is not a member of team \'%s\'', username, team_name) + has_warnings = True if rq: request_batch.append(rq) elif node_id or kwargs.get('name') or kwargs.get('restrict_edit') or kwargs.get('restrict_share') or kwargs.get('restrict_view'): @@ -3333,11 +3520,35 @@ def execute(self, params, **kwargs): else: logging.warning('\'%s\' %s team failed to %s user %s: %s', team_name, 'queued' if command == 'team_queue_user' else '', 'delete' if command == 'team_enterprise_user_remove' else 'add', user_name, rs['message']) + elif command in {'role_team_add', 'role_team_remove'}: + role_id = rq.get('role_id') + role_name = next((r['data'].get('displayname') for r in params.enterprise.get('roles', []) + if r['role_id'] == role_id), str(role_id)) + action = 'assign' if command == 'role_team_add' else 'remove' + if rs['result'] == 'success': + logging.info('\'%s\' role %sed to team \'%s\'', role_name, action, team_name) + else: + logging.warning('Failed to %s role \'%s\' to/from team \'%s\': %s', action, role_name, team_name, rs['message']) if request_batch or len(non_batch_update_msgs) > 0: for update_msg in non_batch_update_msgs: logging.info(update_msg) api.query_enterprise(params) + + # Handle role additions for newly created teams (must be done after team exists) + if new_team_roles: + new_teams_dict, role_list = new_team_roles + # Fetch updated team data to get proper team objects + created_teams = [] + for team_uid in new_teams_dict.keys(): + team_data = next((t for t in params.enterprise.get('teams', []) if t['team_uid'] == team_uid), None) + if team_data: + created_teams.append(team_data) + + if created_teams: + role_msgs = self.change_team_roles(params, created_teams, role_list, None) + for msg in role_msgs: + logging.info(msg) elif not has_warnings: for team in matched_teams: print('\n') diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index 048039ba3..a6ce48250 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -148,9 +148,18 @@ def execute(cls, command: str) -> Tuple[Any, int]: # Always let the parser handle the response (including empty responses and logs) response = parse_keeper_response(command, response, log_output) - status_code = 200 - if isinstance(response, dict) and response.get("status") == "error": - status_code = 400 + if isinstance(response, dict): + # Extract status_code and remove it from response body + if 'status_code' in response: + status_code = response.pop('status_code') + elif response.get("status") == "error": + status_code = 400 + elif response.get("status") == "warning": + status_code = 400 + else: + status_code = 200 + else: + status_code = 200 response = CommandExecutor.encrypt_response(response) logger.debug(f"Command executed successfully") diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index 049590cfd..0ffdfc97d 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -930,38 +930,142 @@ def _parse_logging_based_command(command: str, response_str: str) -> Dict[str, A # Filter out biometric and persistent login messages for cleaner API responses response_str = KeeperResponseParser._filter_login_messages(response_str) - # Determine status based on common patterns + # Determine status and status_code based on patterns status = "success" + status_code = None + + response_lower = response_str.lower() + + success_indicators = [ + "created", "added", "removed", "updated", "deleted", + "successfully", "completed", "done" + ] + + has_success_indicator = any(indicator in response_lower for indicator in success_indicators) + + forbidden_patterns = [ + "not an msp administrator", + "permission denied", + "access denied", + "unauthorized access", + "forbidden", + "must be a root admin", + "admin privileges required", + "admin account required", + "insufficient privileges", + "not authorized", + "is restricted to", + "command is restricted" + ] + + conflict_patterns = [ + "already a member", + "already exists", + "already in", + "already accepted", + "duplicate" + ] + + not_found_patterns = [ + "could not be resolved", + "is not found", + "not found", + "does not exist", + "not a member of", + "cannot be found", + "cannot find" + ] + + bad_request_patterns = [ + "invalid", + "not valid", + "not allowed", + "not unique", + "unrecognized", + "reserved", + "character", + "empty", + "cannot be", + "cannot assign", + "cannot move", + "cannot get", + "not integer" + ] - # Check for error patterns (case insensitive) error_patterns = [ - "error", "failed", "invalid", "not found", "does not exist", - "permission denied", "unauthorized", "cannot be", "character", "reserved", "unrecognized" + "error", "failed", "failure" ] - # Check for warning patterns - warning_patterns = ["warning:", "already exists"] + warning_patterns = ["warning:", "skipping"] - response_lower = response_str.lower() + has_forbidden = any(pattern in response_lower for pattern in forbidden_patterns) + has_not_found = any(pattern in response_lower for pattern in not_found_patterns) + has_conflict = any(pattern in response_lower for pattern in conflict_patterns) + has_bad_request = any(pattern in response_lower for pattern in bad_request_patterns) + has_error = any(pattern in response_lower for pattern in error_patterns) + has_warning = any(pattern in response_lower for pattern in warning_patterns) - if any(pattern in response_lower for pattern in error_patterns): + if has_success_indicator and (has_not_found or has_bad_request or has_error): + return { + "status": "partial_success", + "status_code": 207, + "command": command.split()[0] if command.split() else command, + "message": response_str, + "data": None + } + + if has_forbidden: return { "status": "error", + "status_code": 403, "command": command.split()[0] if command.split() else command, "error": response_str } - elif any(pattern in response_lower for pattern in warning_patterns): + elif has_not_found: + return { + "status": "error", + "status_code": 500, + "command": command.split()[0] if command.split() else command, + "error": response_str + } + elif has_conflict: + return { + "status": "warning", + "status_code": 409, + "command": command.split()[0] if command.split() else command, + "message": response_str, + "data": None + } + elif has_bad_request: + return { + "status": "error", + "status_code": 400, + "command": command.split()[0] if command.split() else command, + "error": response_str + } + elif has_error: + return { + "status": "error", + "status_code": 500, + "command": command.split()[0] if command.split() else command, + "error": response_str + } + elif has_warning: status = "warning" + status_code = 400 # Return the actual log message with proper formatting if response_str: formatted_message = KeeperResponseParser._format_multiline_message(response_str) - return { + result = { "status": status, "command": command.split()[0] if command.split() else command, "message": formatted_message, "data": None } + if status_code: + result["status_code"] = status_code + return result else: # No output after cleaning - use existing empty response handler return KeeperResponseParser._handle_empty_response(command) diff --git a/unit-tests/test_command_enterprise.py b/unit-tests/test_command_enterprise.py index 7bdcea8f9..ac8a0bbde 100644 --- a/unit-tests/test_command_enterprise.py +++ b/unit-tests/test_command_enterprise.py @@ -196,6 +196,13 @@ def test_enterprise_team_user(self): cmd.execute(params, add_user=[ent_env.user2_email], team=[ent_env.team1_uid]) self.assertEqual(len(TestEnterprise.expected_commands), 0) + # Manually update the mock data to reflect that user2 is now in team1 + params.enterprise['team_users'].append({ + 'team_uid': ent_env.team1_uid, + 'enterprise_user_id': ent_env.user2_id, + 'user_type': 0 + }) + TestEnterprise.expected_commands = ['team_enterprise_user_remove'] cmd.execute(params, remove_user=[ent_env.user2_email], team=[ent_env.team1_uid]) self.assertEqual(len(TestEnterprise.expected_commands), 0)