From 9754cadd382bcb40f0c908da9a93ca7a99fbd0a0 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Mon, 29 Jun 2026 16:42:27 +0530 Subject: [PATCH 1/6] Implement multi channel flow for slack-app-setup command --- .../commands/integrations/approvals_setup.py | 342 ++++++++++++++++++ .../integrations/integration_setup_base.py | 37 +- .../commands/integrations/slack_app_setup.py | 21 +- .../commands/integrations/teams_app_setup.py | 2 +- .../service/commands/service_docker_setup.py | 16 +- keepercommander/service/docker/__init__.py | 7 +- keepercommander/service/docker/models.py | 27 +- keepercommander/service/docker/printer.py | 3 +- keepercommander/service/docker/setup_base.py | 18 + 9 files changed, 441 insertions(+), 32 deletions(-) create mode 100644 keepercommander/service/commands/integrations/approvals_setup.py diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py new file mode 100644 index 000000000..6dc8467ef --- /dev/null +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -0,0 +1,342 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + if not value or not value.strip(): + return False + try: + return len(utils.base64_url_decode(value.strip())) == 16 + except Exception: + return False + + +def parse_comma_separated_uids(raw: str) -> List[str]: + if not raw or not raw.strip(): + return [] + return [part.strip() for part in raw.split(',') if part.strip()] + + +def _build_team_lookup(params: 'KeeperParams') -> Tuple[Dict[str, Tuple[str, str]], Dict[str, List[Tuple[str, str]]]]: + """Build team UID and case-insensitive name lookups from cached vault data.""" + if params.available_team_cache is None: + try: + api.load_available_teams(params) + except Exception: + pass + + by_uid: Dict[str, Tuple[str, str]] = {} + by_name_lower: Dict[str, List[Tuple[str, str]]] = defaultdict(list) + + def add_team(team_uid: str, team_name: str) -> None: + if not team_uid: + return + name = team_name or team_uid + if team_uid not in by_uid: + by_uid[team_uid] = (team_uid, name) + by_name_lower[name.casefold()].append((team_uid, name)) + + for team_uid, team_data in params.team_cache.items(): + add_team(team_uid, team_data.get('name', '')) + + enterprise = params.enterprise or {} + for source_key in ('teams', 'queued_teams'): + for team in enterprise.get(source_key, []): + add_team(team.get('team_uid', ''), team.get('name', '')) + + for team in params.available_team_cache or []: + add_team(team.get('team_uid', ''), team.get('team_name', '')) + + return by_uid, by_name_lower + + +def _resolve_keeper_team( + team_input: str, + by_uid: Dict[str, Tuple[str, str]], + by_name_lower: Dict[str, List[Tuple[str, str]]], +) -> Tuple[Optional[Tuple[str, str]], Optional[str]]: + value = team_input.strip() + if not value: + return None, 'Team name or UID is required' + + if value in by_uid: + return by_uid[value], None + + matches = by_name_lower.get(value.casefold(), []) + unique = {(uid, name) for uid, name in matches} + unique_matches = list(unique) + if len(unique_matches) == 1: + return unique_matches[0], None + if len(unique_matches) > 1: + return None, f'Team name "{value}" is not unique. Use the team UID.' + + return None, f'Team "{value}" not found. Use a valid team name or team UID.' + + +def _prompt_keeper_team( + by_uid: Dict[str, Tuple[str, str]], + by_name_lower: Dict[str, List[Tuple[str, str]]], +) -> Tuple[str, str]: + print(f"\n{bcolors.BOLD}TEAM:{bcolors.ENDC}") + print(f" Keeper team name or team UID for this approver group") + while True: + value = input(f"{bcolors.OKBLUE}Team name or UID:{bcolors.ENDC} ").strip() + resolved, error = _resolve_keeper_team(value, by_uid, by_name_lower) + if resolved: + return resolved + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + + +def _build_known_folder_uids(params: 'KeeperParams') -> Set[str]: + uids: Set[str] = set() + uids.update(params.folder_cache.keys()) + uids.update(params.shared_folder_cache.keys()) + uids.update(params.subfolder_cache.keys()) + uids.update(getattr(params, 'nested_share_folders', {}).keys()) + uids.discard('') + return uids + + +def _build_known_record_uids(params: 'KeeperParams') -> Set[str]: + uids: Set[str] = set(params.record_cache.keys()) + uids.update(getattr(params, 'nested_share_records', {}).keys()) + return uids + + +def _validate_uid_list_format(value: str) -> bool: + if not value or not value.strip(): + return True + uids = parse_comma_separated_uids(value) + if not uids: + return False + return all(is_valid_keeper_uid(uid) for uid in uids) + + +def _validate_uids_in_vault( + uids: List[str], + known_uids: Set[str], + known_other_uids: Set[str], + uid_label: str, + other_label: str, +) -> List[str]: + errors: List[str] = [] + for uid in uids: + if uid in known_uids: + continue + if uid in known_other_uids: + errors.append(f'"{uid}" is a {other_label} UID, not a {uid_label} UID') + else: + errors.append( + f'{uid_label} UID "{uid}" not found in your vault ' + f'(run "sync-down" if it was recently added)' + ) + return errors + + +def _prompt_vault_uid_list( + header: str, + description: str, + prompt_label: str, + known_uids: Set[str], + known_other_uids: Set[str], + uid_label: str, + other_label: str, +) -> List[str]: + print(f"\n{bcolors.BOLD}{header}:{bcolors.ENDC}") + print(f" {description}") + while True: + value = input(f"{bcolors.OKBLUE}{prompt_label}{bcolors.ENDC} ").strip() + if not value: + return [] + if not _validate_uid_list_format(value): + print(f"{bcolors.FAIL}Error: Each UID must be a valid Keeper {uid_label} UID{bcolors.ENDC}") + continue + uids = parse_comma_separated_uids(value) + errors = _validate_uids_in_vault(uids, known_uids, known_other_uids, uid_label, other_label) + if errors: + for error in errors: + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + continue + return uids + + +def collect_approvals_config( + params: 'KeeperParams', + prompt_yes_no: Callable[[str, bool], bool], + prompt_with_validation: Callable[[str, Callable[[str], bool], str], str], + profile: ApprovalsChannelProfile, +) -> ApprovalsConfig: + """Collect single- or multi-channel approval routing configuration.""" + print(f"\n{bcolors.BOLD}MULTI-CHANNEL APPROVERS:{bcolors.ENDC}") + print(f" Route approval notifications to different channels by approver team") + multi_channel = prompt_yes_no('Enable multi-channel approvers?', default=False) + + if not multi_channel: + print(f"\n{bcolors.BOLD}{profile.channel_header}:{bcolors.ENDC}") + print(f" {profile.channel_description}") + single_channel_id = prompt_with_validation( + profile.channel_prompt, + profile.validate_channel, + profile.channel_error, + ) + return ApprovalsConfig( + multi_channel_enabled=False, + single_channel_id=single_channel_id, + ) + + teams: List[ApproverTeam] = [] + by_uid, by_name_lower = _build_team_lookup(params) + while True: + team_uid, team_name = _prompt_keeper_team(by_uid, by_name_lower) + + print(f"\n{bcolors.BOLD}{profile.channel_header}:{bcolors.ENDC}") + print(f" {profile.channel_description} for {team_name}") + channel_id = prompt_with_validation( + profile.channel_prompt, + profile.validate_channel, + profile.channel_error, + ) + teams.append(ApproverTeam( + team_uid=team_uid, + name=team_name, + channel_id=channel_id, + )) + + if not prompt_yes_no('Add another approver team?', default=False): + break + + print(f"\n{bcolors.BOLD}FOLDER/RECORD BOUNDARIES (optional):{bcolors.ENDC}") + print(f" Restrict each team to specific folder or record UIDs") + specify_boundaries = prompt_yes_no( + 'Specify folder or record UIDs per team?', + default=False, + ) + if specify_boundaries: + teams = _collect_team_boundaries(params, teams) + + return ApprovalsConfig( + multi_channel_enabled=True, + teams=teams, + ) + + +def _collect_team_boundaries( + params: 'KeeperParams', + teams: List[ApproverTeam], +) -> List[ApproverTeam]: + known_folder_uids = _build_known_folder_uids(params) + known_record_uids = _build_known_record_uids(params) + updated: List[ApproverTeam] = [] + for team in teams: + folder_uids = _prompt_vault_uid_list( + f'FOLDER UIDs FOR {team.name}', + 'Comma-separated folder UIDs (optional, press Enter to skip)', + 'Folder UIDs:', + known_folder_uids, + known_record_uids, + 'folder', + 'record', + ) + record_uids = _prompt_vault_uid_list( + f'RECORD UIDs FOR {team.name}', + 'Comma-separated record UIDs (optional, press Enter to skip)', + 'Record UIDs:', + known_record_uids, + known_folder_uids, + 'record', + 'folder', + ) + updated.append(ApproverTeam( + team_uid=team.team_uid, + name=team.name, + channel_id=team.channel_id, + folder_uids=folder_uids, + record_uids=record_uids, + )) + return updated + + +def approvals_config_to_record_fields(config: ApprovalsConfig) -> List: + teams_json = '' + if config.multi_channel_enabled and config.teams: + teams_json = json.dumps([ + { + 'team_uid': team.team_uid, + 'name': team.name, + 'channel_id': team.channel_id, + 'folder_uids': team.folder_uids, + 'record_uids': team.record_uids, + } + for team in config.teams + ], indent=2) + + return [ + vault.TypedField.new_field( + 'text', + 'true' if config.multi_channel_enabled else 'false', + 'multi_channel_approvers_enabled', + ), + vault.TypedField.new_field( + 'text', + '' if config.multi_channel_enabled else config.single_channel_id, + 'approvals_channel_id', + ), + vault.TypedField.new_field('multiline', teams_json, 'approvals_teams'), + ] + + +def print_approvals_config(config: ApprovalsConfig) -> None: + if not config.multi_channel_enabled: + print(f" • Approvals Channel: {bcolors.OKBLUE}{config.single_channel_id}{bcolors.ENDC}") + return + + print(f" • Multi-Channel Approvers: {bcolors.OKBLUE}enabled ({len(config.teams)} teams){bcolors.ENDC}") + for team in config.teams: + print(f" • {team.name} ({team.team_uid}): channel {bcolors.OKBLUE}{team.channel_id}{bcolors.ENDC}") + if team.folder_uids: + print(f" Folder UIDs: {', '.join(team.folder_uids)}") + if team.record_uids: + print(f" Record UIDs: {', '.join(team.record_uids)}") diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 504e6b1df..f5cc4166e 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -26,8 +26,9 @@ from ...config.config_validation import ConfigValidator, ValidationError from ...docker import ( SetupResult, DockerSetupPrinter, DockerSetupConstants, - ServiceConfig, DockerComposeBuilder, DockerSetupBase + ServiceConfig, DockerComposeBuilder, DockerSetupBase, ApprovalsConfig, ) +from .approvals_setup import ApprovalsChannelProfile, collect_approvals_config UUID_PATTERN = re.compile( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' @@ -48,7 +49,7 @@ def get_integration_name(self) -> str: """e.g. 'Slack', 'Teams' -- drives all naming conventions.""" @abstractmethod - def collect_integration_config(self) -> Any: + def collect_integration_config(self, params) -> Any: """Prompt user for config values, return a config dataclass.""" @abstractmethod @@ -138,7 +139,7 @@ def _build_parser(self) -> argparse.ArgumentParser: ) parser.add_argument( '--config-path', dest='config_path', type=str, - help='Path to config.json file (default: ~/.keeper/config.json)' + help='Path to config.json file (default: active session config file)' ) parser.add_argument( '--timeout', dest='timeout', type=str, default=DockerSetupConstants.DEFAULT_TIMEOUT, @@ -178,9 +179,11 @@ def execute(self, params, **kwargs): def _run_base_docker_setup(self, params, kwargs: Dict[str, Any]) -> Tuple[SetupResult, ServiceConfig, str]: docker_cmd = ServiceDockerSetupCommand() - config_path = kwargs.get('config_path') or os.path.expanduser('~/.keeper/config.json') - if not os.path.isfile(config_path): - raise CommandError(self.get_command_name(), f'Config file not found: {config_path}') + config_path = self._require_commander_config_file( + self.get_command_name(), + kwargs.get('config_path'), + params, + ) DockerSetupPrinter.print_header("Docker Setup") @@ -248,7 +251,7 @@ def _run_integration_setup(self, params, setup_result: SetupResult, name = self.get_integration_name() DockerSetupPrinter.print_header(f"{name} App Configuration") - config = self.collect_integration_config() + config = self.collect_integration_config(params) DockerSetupPrinter.print_step(1, 2, f"Creating {name} config record '{record_name}'...") custom_fields = self.build_record_custom_fields(config) @@ -401,6 +404,26 @@ def _collect_device_approval_config(self) -> Tuple[bool, int]: # -- Input / validation -------------------------------------------- + def _prompt_yes_no(self, question: str, default: bool = False) -> bool: + if default: + suffix = '[Press Enter for Yes] (y/n):' + else: + suffix = '[Press Enter for No] (y/n):' + response = input( + f"{bcolors.OKBLUE}{question} {suffix}{bcolors.ENDC} " + ).strip().lower() + if not response: + return default + return response == 'y' + + def _collect_approvals_config(self, params, profile: ApprovalsChannelProfile) -> ApprovalsConfig: + return collect_approvals_config( + params=params, + prompt_yes_no=self._prompt_yes_no, + prompt_with_validation=self._prompt_with_validation, + profile=profile, + ) + def _prompt_with_validation(self, prompt: str, validator, error_msg: str) -> str: while True: value = input(f"{bcolors.OKBLUE}{prompt}{bcolors.ENDC} ").strip() diff --git a/keepercommander/service/commands/integrations/slack_app_setup.py b/keepercommander/service/commands/integrations/slack_app_setup.py index cebfb4a9a..0fe36afaa 100644 --- a/keepercommander/service/commands/integrations/slack_app_setup.py +++ b/keepercommander/service/commands/integrations/slack_app_setup.py @@ -14,6 +14,11 @@ from .... import vault from ....display import bcolors from ...docker import SlackConfig +from .approvals_setup import ( + SLACK_APPROVALS_PROFILE, + approvals_config_to_record_fields, + print_approvals_config, +) from .integration_setup_base import IntegrationSetupCommand @@ -24,7 +29,7 @@ def get_integration_name(self): # ── Slack-specific configuration ────────────────────────────── - def collect_integration_config(self): + def collect_integration_config(self, params): print(f"\n{bcolors.BOLD}SLACK_APP_TOKEN:{bcolors.ENDC}") print(f" App-level token for Slack App") slack_app_token = self._prompt_with_validation( @@ -49,13 +54,7 @@ def collect_integration_config(self): "Invalid Slack Signing Secret (must be exactly 32 characters)" ) - print(f"\n{bcolors.BOLD}APPROVALS_CHANNEL_ID:{bcolors.ENDC}") - print(f" Slack channel ID for approval notifications") - approvals_channel_id = self._prompt_with_validation( - "Channel ID (starts with C):", - lambda c: c and c.startswith('C'), - "Invalid Approvals Channel ID (must start with 'C')" - ) + approvals = self._collect_approvals_config(params, SLACK_APPROVALS_PROFILE) pedm_enabled, pedm_interval = self._collect_pedm_config() da_enabled, da_interval = self._collect_device_approval_config() @@ -66,7 +65,7 @@ def collect_integration_config(self): slack_app_token=slack_app_token, slack_bot_token=slack_bot_token, slack_signing_secret=slack_signing_secret, - approvals_channel_id=approvals_channel_id, + approvals=approvals, pedm_enabled=pedm_enabled, pedm_polling_interval=pedm_interval, device_approval_enabled=da_enabled, @@ -78,7 +77,7 @@ def build_record_custom_fields(self, config): vault.TypedField.new_field('secret', config.slack_app_token, 'slack_app_token'), vault.TypedField.new_field('secret', config.slack_bot_token, 'slack_bot_token'), vault.TypedField.new_field('secret', config.slack_signing_secret, 'slack_signing_secret'), - vault.TypedField.new_field('text', config.approvals_channel_id, 'approvals_channel_id'), + *approvals_config_to_record_fields(config.approvals), vault.TypedField.new_field('text', 'true' if config.pedm_enabled else 'false', 'pedm_enabled'), vault.TypedField.new_field('text', str(config.pedm_polling_interval), 'pedm_polling_interval'), vault.TypedField.new_field('text', 'true' if config.device_approval_enabled else 'false', 'device_approval_enabled'), @@ -88,7 +87,7 @@ def build_record_custom_fields(self, config): # ── Display ─────────────────────────────────────────────────── def print_integration_specific_resources(self, config): - print(f" • Approvals Channel: {bcolors.OKBLUE}{config.approvals_channel_id}{bcolors.ENDC}") + print_approvals_config(config.approvals) def print_integration_commands(self): print(f"\n{bcolors.BOLD}Slack Commands Available:{bcolors.ENDC}") diff --git a/keepercommander/service/commands/integrations/teams_app_setup.py b/keepercommander/service/commands/integrations/teams_app_setup.py index 3d3b80aaf..f12b113a4 100644 --- a/keepercommander/service/commands/integrations/teams_app_setup.py +++ b/keepercommander/service/commands/integrations/teams_app_setup.py @@ -28,7 +28,7 @@ def get_integration_name(self): # ── Teams-specific configuration ────────────────────────────── - def collect_integration_config(self): + def collect_integration_config(self, params): print(f"\n{bcolors.BOLD}CLIENT_ID:{bcolors.ENDC}") print(f" Azure AD App Registration Client ID") client_id = self._prompt_with_validation( diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py index af74f3480..67f38df84 100644 --- a/keepercommander/service/commands/service_docker_setup.py +++ b/keepercommander/service/commands/service_docker_setup.py @@ -47,7 +47,7 @@ ) service_docker_setup_parser.add_argument( '--config-path', dest='config_path', type=str, - help='Path to config.json file (default: ~/.keeper/config.json)' + help='Path to config.json file (default: active session config file)' ) service_docker_setup_parser.add_argument( '--timeout', dest='timeout', type=str, default=DockerSetupConstants.DEFAULT_TIMEOUT, @@ -72,7 +72,11 @@ def execute(self, params, **kwargs): self._require_file_based_config(params, 'service-docker-setup') # Parse arguments - config_path = self._get_config_path(kwargs.get('config_path') or params.config_filename) + config_path = self._require_commander_config_file( + 'service-docker-setup', + kwargs.get('config_path'), + params, + ) # Print header DockerSetupPrinter.print_header("Docker Setup") @@ -351,10 +355,4 @@ def _get_token_expiration_config(self) -> Dict[str, str]: def _get_config_path(self, config_path: str = None) -> str: """Get and validate config file path""" - if not config_path: - config_path = os.path.expanduser('~/.keeper/config.json') - - if not os.path.isfile(config_path): - raise CommandError('service-docker-setup', f'Config file not found: {config_path}') - - return config_path + return self._require_commander_config_file('service-docker-setup', config_path) diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index 97f4e998b..c30315181 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -19,7 +19,10 @@ - Docker Compose generation """ -from .models import DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep +from .models import ( + DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep, + ApproverTeam, ApprovalsConfig, +) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase from .compose_builder import DockerComposeBuilder @@ -30,6 +33,8 @@ 'ServiceConfig', 'SlackConfig', 'TeamsConfig', + 'ApproverTeam', + 'ApprovalsConfig', 'SetupStep', 'DockerSetupPrinter', 'DockerSetupBase', diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index d601c5581..02da40b67 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -11,8 +11,9 @@ """Docker setup data models and constants.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum +from typing import List # ======================== @@ -77,17 +78,39 @@ class ServiceConfig: cloudflare_public_url: str = '' +@dataclass +class ApproverTeam: + team_uid: str + name: str + channel_id: str + folder_uids: List[str] = field(default_factory=list) + record_uids: List[str] = field(default_factory=list) + + +@dataclass +class ApprovalsConfig: + multi_channel_enabled: bool + single_channel_id: str = '' + teams: List[ApproverTeam] = field(default_factory=list) + + @dataclass class SlackConfig: slack_app_token: str slack_bot_token: str slack_signing_secret: str - approvals_channel_id: str + approvals: ApprovalsConfig pedm_enabled: bool = False pedm_polling_interval: int = 120 device_approval_enabled: bool = False device_approval_polling_interval: int = 120 + @property + def approvals_channel_id(self) -> str: + if self.approvals.multi_channel_enabled: + return '' + return self.approvals.single_channel_id + @dataclass class TeamsConfig: diff --git a/keepercommander/service/docker/printer.py b/keepercommander/service/docker/printer.py index af1b66dfd..224e44c30 100644 --- a/keepercommander/service/docker/printer.py +++ b/keepercommander/service/docker/printer.py @@ -68,7 +68,8 @@ def print_common_deployment_steps(port: str, config_path: str = None) -> None: print(f"\n{bcolors.BOLD}Step 1: Quit from this session{bcolors.ENDC}") print(f" {bcolors.OKGREEN}quit{bcolors.ENDC}") - config_file = config_path if config_path else '~/.keeper/config.json' + from ... import utils + config_file = config_path if config_path else str(utils.get_default_path() / 'config.json') print(f"\n{bcolors.BOLD}Step 2: Delete the local config.json file{bcolors.ENDC}") print(f" {bcolors.OKGREEN}rm {shlex.quote(config_file)}{bcolors.ENDC}") print(f" Why? Prevents device token conflicts - Docker will download its own config.") diff --git a/keepercommander/service/docker/setup_base.py b/keepercommander/service/docker/setup_base.py index 9bc1ff6f6..49285225a 100644 --- a/keepercommander/service/docker/setup_base.py +++ b/keepercommander/service/docker/setup_base.py @@ -39,6 +39,24 @@ class DockerSetupBase: """Base class for Docker setup with reusable core logic""" + @staticmethod + def resolve_commander_config_path(config_path: str = None, params=None) -> str: + """Resolve config.json using the same rules as login/shell startup.""" + if config_path: + resolved = os.path.expanduser(config_path) + elif params and getattr(params, 'config_filename', None): + resolved = os.path.expanduser(params.config_filename) + else: + resolved = str(utils.get_default_path() / 'config.json') + return os.path.abspath(resolved) + + @staticmethod + def _require_commander_config_file(command_name: str, config_path: str = None, params=None) -> str: + resolved = DockerSetupBase.resolve_commander_config_path(config_path, params) + if not os.path.isfile(resolved): + raise CommandError(command_name, f'Config file not found: {resolved}') + return resolved + @staticmethod def _require_file_based_config(params, command_name: str) -> None: """Raise CommandError if credentials are in the OS keychain rather than config.json.""" From 22b74644efe94809d4663fc01df2c35659eaa7f7 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 30 Jun 2026 11:56:58 +0530 Subject: [PATCH 2/6] Add default channel prompt --- .../commands/integrations/approvals_setup.py | 12 ++++++++- .../integrations/integration_setup_base.py | 26 ++++++++++--------- keepercommander/service/docker/models.py | 2 -- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py index 6dc8467ef..941b1ab18 100644 --- a/keepercommander/service/commands/integrations/approvals_setup.py +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -257,8 +257,17 @@ def collect_approvals_config( if specify_boundaries: teams = _collect_team_boundaries(params, teams) + print(f"\n{bcolors.BOLD}DEFAULT {profile.channel_header}:{bcolors.ENDC}") + print(f" {profile.channel_description} for users not assigned to any approver team") + default_channel_id = prompt_with_validation( + profile.channel_prompt, + profile.validate_channel, + profile.channel_error, + ) + return ApprovalsConfig( multi_channel_enabled=True, + single_channel_id=default_channel_id, teams=teams, ) @@ -321,7 +330,7 @@ def approvals_config_to_record_fields(config: ApprovalsConfig) -> List: ), vault.TypedField.new_field( 'text', - '' if config.multi_channel_enabled else config.single_channel_id, + config.single_channel_id, 'approvals_channel_id', ), vault.TypedField.new_field('multiline', teams_json, 'approvals_teams'), @@ -334,6 +343,7 @@ def print_approvals_config(config: ApprovalsConfig) -> None: return print(f" • Multi-Channel Approvers: {bcolors.OKBLUE}enabled ({len(config.teams)} teams){bcolors.ENDC}") + print(f" • Default Approvals Channel: {bcolors.OKBLUE}{config.single_channel_id}{bcolors.ENDC}") for team in config.teams: print(f" • {team.name} ({team.team_uid}): channel {bcolors.OKBLUE}{team.channel_id}{bcolors.ENDC}") if team.folder_uids: diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index f5cc4166e..d37cf0419 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -384,7 +384,7 @@ def _print_integration_resources(self, record_uid: str, config) -> None: def _collect_pedm_config(self) -> Tuple[bool, int]: print(f"\n{bcolors.BOLD}EPM (Endpoint Privilege Manager) Integration (optional):{bcolors.ENDC}") print(f" Integrate with Keeper EPM for privilege elevation") - enabled = input(f"{bcolors.OKBLUE}Enable EPM? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + enabled = self._prompt_yes_no('Enable EPM?', default=False) interval = 120 if enabled: interval_input = input(f"{bcolors.OKBLUE}EPM polling interval in seconds [Press Enter for 120]:{bcolors.ENDC} ").strip() @@ -395,7 +395,7 @@ def _collect_device_approval_config(self) -> Tuple[bool, int]: name = self.get_integration_name() print(f"\n{bcolors.BOLD}SSO Cloud Device Approval Integration (optional):{bcolors.ENDC}") print(f" Approve SSO Cloud device registrations via {name}") - enabled = input(f"{bcolors.OKBLUE}Enable Device Approval? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + enabled = self._prompt_yes_no('Enable Device Approval?', default=False) interval = 120 if enabled: interval_input = input(f"{bcolors.OKBLUE}Device approval polling interval in seconds [Press Enter for 120]:{bcolors.ENDC} ").strip() @@ -405,16 +405,18 @@ def _collect_device_approval_config(self) -> Tuple[bool, int]: # -- Input / validation -------------------------------------------- def _prompt_yes_no(self, question: str, default: bool = False) -> bool: - if default: - suffix = '[Press Enter for Yes] (y/n):' - else: - suffix = '[Press Enter for No] (y/n):' - response = input( - f"{bcolors.OKBLUE}{question} {suffix}{bcolors.ENDC} " - ).strip().lower() - if not response: - return default - return response == 'y' + suffix = '[Press Enter for Yes] (y/n):' if default else '[Press Enter for No] (y/n):' + while True: + response = input( + f"{bcolors.OKBLUE}{question} {suffix}{bcolors.ENDC} " + ).strip().lower() + if not response: + return default + if response == 'y': + return True + if response == 'n': + return False + print(f"{bcolors.FAIL}Error: Enter y, n, or press Enter for default{bcolors.ENDC}") def _collect_approvals_config(self, params, profile: ApprovalsChannelProfile) -> ApprovalsConfig: return collect_approvals_config( diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index 02da40b67..d81e26175 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -107,8 +107,6 @@ class SlackConfig: @property def approvals_channel_id(self) -> str: - if self.approvals.multi_channel_enabled: - return '' return self.approvals.single_channel_id From 7134fad61110454c854da31d22bb20e6e725767d Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 1 Jul 2026 16:35:10 +0530 Subject: [PATCH 3/6] Add list-team in command list --- .../service/commands/integrations/integration_setup_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index d37cf0419..00261a29f 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -98,7 +98,7 @@ def get_commander_container_name(self) -> str: return f'keeper-service-{self.get_integration_name().lower()}' def get_service_commands(self) -> str: - return 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,sync-down,list-sf' + return 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,sync-down,list-sf,list-team' # -- Parser (auto-built from name, cached per subclass) ---------- From 4292a37f3892ccedab2da9595ea6db963b589e0f Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Fri, 3 Jul 2026 12:30:50 +0530 Subject: [PATCH 4/6] Make default channel mandatory for multi channel --- .../commands/integrations/approvals_setup.py | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py index 941b1ab18..98a1d32e5 100644 --- a/keepercommander/service/commands/integrations/approvals_setup.py +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -29,7 +29,9 @@ class ApprovalsChannelProfile: """Platform-specific labels and channel ID validation.""" platform_name: str channel_header: str + single_channel_description: str channel_description: str + default_channel_description: str channel_prompt: str validate_channel: Callable[[str], bool] channel_error: str @@ -38,7 +40,12 @@ class ApprovalsChannelProfile: SLACK_APPROVALS_PROFILE = ApprovalsChannelProfile( platform_name='Slack', channel_header='APPROVALS_CHANNEL_ID', - channel_description='Slack channel ID for approval notifications', + single_channel_description='Slack channel ID for approval notifications', + channel_description='Slack channel where approval requests for this approver team are sent', + default_channel_description=( + 'Default channel for EPM privilege requests, SSO Cloud device approvals, ' + 'and approval requests from users not assigned to an approver team' + ), channel_prompt='Channel ID (starts with C):', validate_channel=lambda c: bool(c and c.startswith('C')), channel_error="Invalid Approvals Channel ID (must start with 'C')", @@ -203,6 +210,21 @@ def _prompt_vault_uid_list( return uids +def _prompt_channel( + profile: ApprovalsChannelProfile, + prompt_with_validation: Callable[[str, Callable[[str], bool], str], str], + description: str, + header: Optional[str] = None, +) -> str: + print(f"\n{bcolors.BOLD}{header or profile.channel_header}:{bcolors.ENDC}") + print(f" {description}") + return prompt_with_validation( + profile.channel_prompt, + profile.validate_channel, + profile.channel_error, + ) + + def collect_approvals_config( params: 'KeeperParams', prompt_yes_no: Callable[[str, bool], bool], @@ -215,12 +237,10 @@ def collect_approvals_config( multi_channel = prompt_yes_no('Enable multi-channel approvers?', default=False) if not multi_channel: - print(f"\n{bcolors.BOLD}{profile.channel_header}:{bcolors.ENDC}") - print(f" {profile.channel_description}") - single_channel_id = prompt_with_validation( - profile.channel_prompt, - profile.validate_channel, - profile.channel_error, + single_channel_id = _prompt_channel( + profile, + prompt_with_validation, + profile.single_channel_description, ) return ApprovalsConfig( multi_channel_enabled=False, @@ -257,12 +277,11 @@ def collect_approvals_config( if specify_boundaries: teams = _collect_team_boundaries(params, teams) - print(f"\n{bcolors.BOLD}DEFAULT {profile.channel_header}:{bcolors.ENDC}") - print(f" {profile.channel_description} for users not assigned to any approver team") - default_channel_id = prompt_with_validation( - profile.channel_prompt, - profile.validate_channel, - profile.channel_error, + default_channel_id = _prompt_channel( + profile, + prompt_with_validation, + profile.default_channel_description, + header=f'DEFAULT {profile.channel_header}', ) return ApprovalsConfig( From d25387f546bf367f0b5c5f134993f3f78bcdd6e3 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 14 Jul 2026 13:51:24 +0530 Subject: [PATCH 5/6] Add --sync-down for Slack multi-channel approvals config --- .../commands/integrations/approvals_setup.py | 193 +++++++------- .../commands/integrations/approvals_sync.py | 244 ++++++++++++++++++ .../integrations/integration_setup_base.py | 110 +++++++- .../commands/integrations/slack_app_setup.py | 6 + 4 files changed, 447 insertions(+), 106 deletions(-) create mode 100644 keepercommander/service/commands/integrations/approvals_sync.py diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py index 98a1d32e5..cd24aa384 100644 --- a/keepercommander/service/commands/integrations/approvals_setup.py +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -12,8 +12,9 @@ """Shared approval-channel setup for integration app setup commands.""" import json +import logging from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING from .... import api, utils, vault @@ -23,11 +24,21 @@ if TYPE_CHECKING: from ....params import KeeperParams +logger = logging.getLogger(__name__) + +FIELD_MULTI_CHANNEL_ENABLED = 'multi_channel_approvers_enabled' +FIELD_APPROVALS_CHANNEL_ID = 'approvals_channel_id' +FIELD_APPROVALS_TEAMS = 'approvals_teams' +APPROVALS_FIELD_LABELS = frozenset({ + FIELD_MULTI_CHANNEL_ENABLED, + FIELD_APPROVALS_CHANNEL_ID, + FIELD_APPROVALS_TEAMS, +}) + @dataclass class ApprovalsChannelProfile: """Platform-specific labels and channel ID validation.""" - platform_name: str channel_header: str single_channel_description: str channel_description: str @@ -38,7 +49,6 @@ class ApprovalsChannelProfile: SLACK_APPROVALS_PROFILE = ApprovalsChannelProfile( - platform_name='Slack', channel_header='APPROVALS_CHANNEL_ID', single_channel_description='Slack channel ID for approval notifications', channel_description='Slack channel where approval requests for this approver team are sent', @@ -67,13 +77,15 @@ def parse_comma_separated_uids(raw: str) -> List[str]: return [part.strip() for part in raw.split(',') if part.strip()] -def _build_team_lookup(params: 'KeeperParams') -> Tuple[Dict[str, Tuple[str, str]], Dict[str, List[Tuple[str, str]]]]: +def build_team_lookup( + params: 'KeeperParams', +) -> Tuple[Dict[str, Tuple[str, str]], Dict[str, List[Tuple[str, str]]]]: """Build team UID and case-insensitive name lookups from cached vault data.""" if params.available_team_cache is None: try: api.load_available_teams(params) - except Exception: - pass + except Exception as exc: + logger.debug('Could not load available teams: %s', exc) by_uid: Dict[str, Tuple[str, str]] = {} by_name_lower: Dict[str, List[Tuple[str, str]]] = defaultdict(list) @@ -100,6 +112,20 @@ def add_team(team_uid: str, team_name: str) -> None: return by_uid, by_name_lower +def build_shared_folder_uids(params: 'KeeperParams') -> Set[str]: + """Shared folders and nested share folders only (not private user folders).""" + uids = set(params.shared_folder_cache.keys()) + uids.update(getattr(params, 'nested_share_folders', {}).keys()) + uids.discard('') + return uids + + +def build_record_uids(params: 'KeeperParams') -> Set[str]: + uids = set(params.record_cache.keys()) + uids.update(getattr(params, 'nested_share_records', {}).keys()) + return uids + + def _resolve_keeper_team( team_input: str, by_uid: Dict[str, Tuple[str, str]], @@ -112,14 +138,11 @@ def _resolve_keeper_team( if value in by_uid: return by_uid[value], None - matches = by_name_lower.get(value.casefold(), []) - unique = {(uid, name) for uid, name in matches} - unique_matches = list(unique) - if len(unique_matches) == 1: - return unique_matches[0], None - if len(unique_matches) > 1: + matches = list({(uid, name) for uid, name in by_name_lower.get(value.casefold(), [])}) + if len(matches) == 1: + return matches[0], None + if len(matches) > 1: return None, f'Team name "{value}" is not unique. Use the team UID.' - return None, f'Team "{value}" not found. Use a valid team name or team UID.' @@ -137,44 +160,21 @@ def _prompt_keeper_team( print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") -def _build_known_folder_uids(params: 'KeeperParams') -> Set[str]: - uids: Set[str] = set() - uids.update(params.folder_cache.keys()) - uids.update(params.shared_folder_cache.keys()) - uids.update(params.subfolder_cache.keys()) - uids.update(getattr(params, 'nested_share_folders', {}).keys()) - uids.discard('') - return uids - - -def _build_known_record_uids(params: 'KeeperParams') -> Set[str]: - uids: Set[str] = set(params.record_cache.keys()) - uids.update(getattr(params, 'nested_share_records', {}).keys()) - return uids - - -def _validate_uid_list_format(value: str) -> bool: - if not value or not value.strip(): - return True - uids = parse_comma_separated_uids(value) - if not uids: - return False - return all(is_valid_keeper_uid(uid) for uid in uids) - - -def _validate_uids_in_vault( +def _validate_uids( uids: List[str], known_uids: Set[str], - known_other_uids: Set[str], uid_label: str, - other_label: str, + mistyped: Optional[Dict[str, Set[str]]] = None, ) -> List[str]: + """Return validation errors for UIDs. mistyped maps error message -> UID set.""" errors: List[str] = [] + mistyped = mistyped or {} for uid in uids: if uid in known_uids: continue - if uid in known_other_uids: - errors.append(f'"{uid}" is a {other_label} UID, not a {uid_label} UID') + typed_error = next((msg for msg, bad_uids in mistyped.items() if uid in bad_uids), None) + if typed_error: + errors.append(typed_error.format(uid=uid)) else: errors.append( f'{uid_label} UID "{uid}" not found in your vault ' @@ -188,9 +188,8 @@ def _prompt_vault_uid_list( description: str, prompt_label: str, known_uids: Set[str], - known_other_uids: Set[str], uid_label: str, - other_label: str, + mistyped: Optional[Dict[str, Set[str]]] = None, ) -> List[str]: print(f"\n{bcolors.BOLD}{header}:{bcolors.ENDC}") print(f" {description}") @@ -198,11 +197,11 @@ def _prompt_vault_uid_list( value = input(f"{bcolors.OKBLUE}{prompt_label}{bcolors.ENDC} ").strip() if not value: return [] - if not _validate_uid_list_format(value): + uids = parse_comma_separated_uids(value) + if not uids or not all(is_valid_keeper_uid(uid) for uid in uids): print(f"{bcolors.FAIL}Error: Each UID must be a valid Keeper {uid_label} UID{bcolors.ENDC}") continue - uids = parse_comma_separated_uids(value) - errors = _validate_uids_in_vault(uids, known_uids, known_other_uids, uid_label, other_label) + errors = _validate_uids(uids, known_uids, uid_label, mistyped=mistyped) if errors: for error in errors: print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") @@ -237,56 +236,43 @@ def collect_approvals_config( multi_channel = prompt_yes_no('Enable multi-channel approvers?', default=False) if not multi_channel: - single_channel_id = _prompt_channel( - profile, - prompt_with_validation, - profile.single_channel_description, - ) return ApprovalsConfig( multi_channel_enabled=False, - single_channel_id=single_channel_id, + single_channel_id=_prompt_channel( + profile, prompt_with_validation, profile.single_channel_description, + ), ) teams: List[ApproverTeam] = [] - by_uid, by_name_lower = _build_team_lookup(params) + by_uid, by_name_lower = build_team_lookup(params) while True: team_uid, team_name = _prompt_keeper_team(by_uid, by_name_lower) - - print(f"\n{bcolors.BOLD}{profile.channel_header}:{bcolors.ENDC}") - print(f" {profile.channel_description} for {team_name}") - channel_id = prompt_with_validation( - profile.channel_prompt, - profile.validate_channel, - profile.channel_error, + channel_id = _prompt_channel( + profile, + prompt_with_validation, + f'{profile.channel_description} for {team_name}', ) teams.append(ApproverTeam( team_uid=team_uid, name=team_name, channel_id=channel_id, )) - if not prompt_yes_no('Add another approver team?', default=False): break print(f"\n{bcolors.BOLD}FOLDER/RECORD BOUNDARIES (optional):{bcolors.ENDC}") - print(f" Restrict each team to specific folder or record UIDs") - specify_boundaries = prompt_yes_no( - 'Specify folder or record UIDs per team?', - default=False, - ) - if specify_boundaries: + print(f" Restrict each team to specific shared folder or record UIDs") + if prompt_yes_no('Specify shared folder or record UIDs per team?', default=False): teams = _collect_team_boundaries(params, teams) - default_channel_id = _prompt_channel( - profile, - prompt_with_validation, - profile.default_channel_description, - header=f'DEFAULT {profile.channel_header}', - ) - return ApprovalsConfig( multi_channel_enabled=True, - single_channel_id=default_channel_id, + single_channel_id=_prompt_channel( + profile, + prompt_with_validation, + profile.default_channel_description, + header=f'DEFAULT {profile.channel_header}', + ), teams=teams, ) @@ -295,41 +281,42 @@ def _collect_team_boundaries( params: 'KeeperParams', teams: List[ApproverTeam], ) -> List[ApproverTeam]: - known_folder_uids = _build_known_folder_uids(params) - known_record_uids = _build_known_record_uids(params) + shared_folder_uids = build_shared_folder_uids(params) + record_uids = build_record_uids(params) + user_folder_uids = ( + set(params.folder_cache.keys()) | set(params.subfolder_cache.keys()) + ) - shared_folder_uids - {''} + updated: List[ApproverTeam] = [] for team in teams: - folder_uids = _prompt_vault_uid_list( - f'FOLDER UIDs FOR {team.name}', - 'Comma-separated folder UIDs (optional, press Enter to skip)', - 'Folder UIDs:', - known_folder_uids, - known_record_uids, - 'folder', - 'record', + folders = _prompt_vault_uid_list( + f'SHARED FOLDER UIDs FOR {team.name}', + 'Comma-separated shared folder UIDs (optional, press Enter to skip)', + 'Shared Folder UIDs:', + shared_folder_uids, + 'shared folder', + mistyped={ + '"{uid}" is a record UID, not a shared folder UID': record_uids, + '"{uid}" is not a shared folder UID (use a shared folder UID from list-sf)': user_folder_uids, + }, ) - record_uids = _prompt_vault_uid_list( + records = _prompt_vault_uid_list( f'RECORD UIDs FOR {team.name}', 'Comma-separated record UIDs (optional, press Enter to skip)', 'Record UIDs:', - known_record_uids, - known_folder_uids, + record_uids, 'record', - 'folder', + mistyped={ + '"{uid}" is a shared folder UID, not a record UID': shared_folder_uids, + }, ) - updated.append(ApproverTeam( - team_uid=team.team_uid, - name=team.name, - channel_id=team.channel_id, - folder_uids=folder_uids, - record_uids=record_uids, - )) + updated.append(replace(team, folder_uids=folders, record_uids=records)) return updated def approvals_config_to_record_fields(config: ApprovalsConfig) -> List: teams_json = '' - if config.multi_channel_enabled and config.teams: + if config.multi_channel_enabled: teams_json = json.dumps([ { 'team_uid': team.team_uid, @@ -345,14 +332,14 @@ def approvals_config_to_record_fields(config: ApprovalsConfig) -> List: vault.TypedField.new_field( 'text', 'true' if config.multi_channel_enabled else 'false', - 'multi_channel_approvers_enabled', + FIELD_MULTI_CHANNEL_ENABLED, ), vault.TypedField.new_field( 'text', config.single_channel_id, - 'approvals_channel_id', + FIELD_APPROVALS_CHANNEL_ID, ), - vault.TypedField.new_field('multiline', teams_json, 'approvals_teams'), + vault.TypedField.new_field('multiline', teams_json, FIELD_APPROVALS_TEAMS), ] @@ -366,6 +353,6 @@ def print_approvals_config(config: ApprovalsConfig) -> None: for team in config.teams: print(f" • {team.name} ({team.team_uid}): channel {bcolors.OKBLUE}{team.channel_id}{bcolors.ENDC}") if team.folder_uids: - print(f" Folder UIDs: {', '.join(team.folder_uids)}") + print(f" Shared Folder UIDs: {', '.join(team.folder_uids)}") if team.record_uids: print(f" Record UIDs: {', '.join(team.record_uids)}") diff --git a/keepercommander/service/commands/integrations/approvals_sync.py b/keepercommander/service/commands/integrations/approvals_sync.py new file mode 100644 index 000000000..c88a0968f --- /dev/null +++ b/keepercommander/service/commands/integrations/approvals_sync.py @@ -0,0 +1,244 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + return bool( + self.removed + or self.renamed_to + or self.removed_folder_uids + or self.removed_record_uids + ) + + +@dataclass +class ApprovalsDriftReport: + entries: List[TeamDriftEntry] = field(default_factory=list) + + @property + def has_changes(self) -> bool: + return any(entry.has_changes for entry in self.entries) + + +def custom_fields_by_label(record: vault.KeeperRecord) -> Dict[str, str]: + result: Dict[str, str] = {} + for custom in getattr(record, 'custom', None) or []: + value = custom.get_default_value() + if value is not None: + result[custom.label] = str(value) + return result + + +def parse_approvals_from_record( + record: vault.KeeperRecord, + command_name: str = '', +) -> ApprovalsConfig: + fields = custom_fields_by_label(record) + multi_channel = fields.get(FIELD_MULTI_CHANNEL_ENABLED, 'false').strip().lower() == 'true' + single_channel_id = fields.get(FIELD_APPROVALS_CHANNEL_ID, '').strip() + teams_json = fields.get(FIELD_APPROVALS_TEAMS, '').strip() + + teams: List[ApproverTeam] = [] + if teams_json: + try: + raw_teams = json.loads(teams_json) + except json.JSONDecodeError as exc: + raise CommandError(command_name, f'Invalid {FIELD_APPROVALS_TEAMS} JSON: {exc}') from exc + if not isinstance(raw_teams, list): + raise CommandError(command_name, f'{FIELD_APPROVALS_TEAMS} must be a JSON array') + + for item in raw_teams: + if not isinstance(item, dict): + continue + team_uid = str(item.get('team_uid', '')).strip() + channel_id = str(item.get('channel_id', '')).strip() + if not team_uid or not channel_id: + continue + teams.append(ApproverTeam( + team_uid=team_uid, + name=str(item.get('name', '')).strip() or team_uid, + channel_id=channel_id, + folder_uids=[str(u).strip() for u in (item.get('folder_uids') or []) if str(u).strip()], + record_uids=[str(u).strip() for u in (item.get('record_uids') or []) if str(u).strip()], + )) + + return ApprovalsConfig( + multi_channel_enabled=multi_channel, + single_channel_id=single_channel_id, + teams=teams, + ) + + +def merge_approvals_custom_fields(existing_custom: List, config: ApprovalsConfig) -> List: + preserved = [f for f in (existing_custom or []) if f.label not in APPROVALS_FIELD_LABELS] + return preserved + approvals_config_to_record_fields(config) + + +def analyze_approvals_drift( + params: 'KeeperParams', + config: ApprovalsConfig, +) -> Tuple[ApprovalsConfig, ApprovalsDriftReport]: + by_uid, _ = build_team_lookup(params) + known_folders = build_shared_folder_uids(params) + known_records = build_record_uids(params) + + report = ApprovalsDriftReport() + cleaned_teams: List[ApproverTeam] = [] + + for team in config.teams: + if team.team_uid not in by_uid: + report.entries.append(TeamDriftEntry(team=team, removed=True)) + continue + + current_name = by_uid[team.team_uid][1] or team.name + removed_folders = [uid for uid in team.folder_uids if uid not in known_folders] + removed_records = [uid for uid in team.record_uids if uid not in known_records] + renamed_to = current_name if current_name != team.name else None + + entry = TeamDriftEntry( + team=team, + renamed_to=renamed_to, + removed_folder_uids=removed_folders, + removed_record_uids=removed_records, + ) + if entry.has_changes: + report.entries.append(entry) + + cleaned_teams.append(replace( + team, + name=current_name, + folder_uids=[uid for uid in team.folder_uids if uid in known_folders], + record_uids=[uid for uid in team.record_uids if uid in known_records], + )) + + cleaned = ApprovalsConfig( + multi_channel_enabled=config.multi_channel_enabled, + single_channel_id=config.single_channel_id, + teams=cleaned_teams, + ) + return cleaned, report + + +def print_approvals_drift_report(report: ApprovalsDriftReport) -> None: + print(f"\n{bcolors.BOLD}Vault sync changes:{bcolors.ENDC}") + if not report.has_changes: + print(f" {bcolors.OKGREEN}No removed or renamed approver references found.{bcolors.ENDC}") + return + + for entry in report.entries: + team = entry.team + label = f'{team.name} ({team.team_uid})' + if entry.removed: + print(f" {bcolors.FAIL}• Removed team (no longer in vault): {label}{bcolors.ENDC}") + continue + if entry.renamed_to: + print( + f" {bcolors.OKBLUE}• Team renamed: {team.name} → {entry.renamed_to} " + f'({team.team_uid}){bcolors.ENDC}' + ) + for folder_uid in entry.removed_folder_uids: + print(f" {bcolors.FAIL}• Removed shared folder UID for {label}: {folder_uid}{bcolors.ENDC}") + for record_uid in entry.removed_record_uids: + print(f" {bcolors.FAIL}• Removed record UID for {label}: {record_uid}{bcolors.ENDC}") + + +def sync_approvals_config( + params: 'KeeperParams', + config: ApprovalsConfig, + command_name: str = '', +) -> Tuple[ApprovalsConfig, ApprovalsDriftReport]: + """Remove stale vault references and refresh team names. Non-interactive.""" + if not config.multi_channel_enabled: + raise CommandError( + command_name, + 'Single-channel mode has no approver team mappings to sync. ' + 'Re-run setup or enable multi-channel approvers first.', + ) + + cleaned, report = analyze_approvals_drift(params, config) + print_approvals_drift_report(report) + + if not cleaned.teams and config.teams: + print( + f" {bcolors.OKBLUE}All configured approver teams were removed. " + f'Requests will use the default approvals channel until teams are added again.{bcolors.ENDC}' + ) + + return cleaned, report + + +def run_approvals_sync_down( + params: 'KeeperParams', + record_uid: str, + marker_field: str, + update_record: Callable[[str, ApprovalsConfig], None], + command_name: str = '', +) -> ApprovalsConfig: + params.sync_data = True + api.sync_down(params) + + record = vault.KeeperRecord.load(params, record_uid) + if not record: + raise CommandError(command_name, f'Record not found: {record_uid}') + + fields = custom_fields_by_label(record) + if marker_field not in fields: + raise CommandError( + command_name, + f'Record {record_uid} is not a supported integration config record ' + f'(missing "{marker_field}" field)', + ) + + config = parse_approvals_from_record(record, command_name=command_name) + updated, report = sync_approvals_config(params, config, command_name=command_name) + + if report.has_changes: + update_record(record_uid, updated) + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ Approver configuration synced with vault{bcolors.ENDC}") + else: + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ Approver configuration is already up to date{bcolors.ENDC}") + + print_approvals_config(updated) + return updated diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 00261a29f..dae3c48b4 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -16,7 +16,7 @@ import re from abc import ABC, abstractmethod from dataclasses import asdict -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple from ....commands.base import Command, raise_parse_exception, suppress_exit from ....display import bcolors @@ -28,7 +28,8 @@ SetupResult, DockerSetupPrinter, DockerSetupConstants, ServiceConfig, DockerComposeBuilder, DockerSetupBase, ApprovalsConfig, ) -from .approvals_setup import ApprovalsChannelProfile, collect_approvals_config +from .approvals_setup import ApprovalsChannelProfile, collect_approvals_config, is_valid_keeper_uid +from .approvals_sync import merge_approvals_custom_fields, run_approvals_sync_down UUID_PATTERN = re.compile( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' @@ -64,6 +65,14 @@ def print_integration_specific_resources(self, config) -> None: def print_integration_commands(self) -> None: """Print available bot commands for this integration.""" + def get_approvals_profile(self) -> Optional[ApprovalsChannelProfile]: + """Return approvals profile when this integration supports multi-channel approvers.""" + return None + + def get_integration_config_marker_field(self) -> str: + """Custom field label that identifies this integration's config record.""" + return f'{self.get_integration_name().lower()}_app_token' + # -- Convention defaults (derived from name, override if needed) - def get_command_name(self) -> str: @@ -98,7 +107,12 @@ def get_commander_container_name(self) -> str: return f'keeper-service-{self.get_integration_name().lower()}' def get_service_commands(self) -> str: - return 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,sync-down,list-sf,list-team' + # Include this integration's setup command so apps can call --sync-down via the service API. + return ( + 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,' + 'record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,' + f'sync-down,list-sf,list-team,{self.get_command_name()}' + ) # -- Parser (auto-built from name, cached per subclass) ---------- @@ -149,6 +163,15 @@ def _build_parser(self) -> argparse.ArgumentParser: '--skip-device-setup', dest='skip_device_setup', action='store_true', help='Skip device registration and setup if already configured' ) + parser.add_argument( + '--integration-record', '-r', dest='integration_record_uid', type=str, + help='Integration config record UID for --sync-down ' + f'(optional; defaults to "{default_record}" in "{default_folder}")' + ) + parser.add_argument( + '--sync-down', dest='sync_down', action='store_true', + help='Sync approver team/folder/record references in an existing config record with the vault' + ) parser.error = raise_parse_exception parser.exit = suppress_exit return parser @@ -157,6 +180,29 @@ def _build_parser(self) -> argparse.ArgumentParser: def execute(self, params, **kwargs): name = self.get_integration_name() + + if kwargs.get('sync_down'): + profile = self.get_approvals_profile() + if profile is None: + raise CommandError( + self.get_command_name(), + f'{name} does not support multi-channel approver sync yet', + ) + record_uid = kwargs.get('integration_record_uid') + if record_uid: + record_uid = record_uid.strip() + if not is_valid_keeper_uid(record_uid): + raise CommandError( + self.get_command_name(), + f'Invalid integration record UID: {record_uid}', + ) + else: + record_uid = self._resolve_default_integration_record( + params, kwargs.get('integration_record_name') + ) + self._execute_sync_down(params, record_uid) + return + self._require_file_based_config(params, f'{name.lower()}-app-setup') # Phase 1 -- Docker service mode setup @@ -310,6 +356,64 @@ def _update_record_custom_fields(self, params, record_uid: str, custom_fields: L except Exception as e: raise CommandError(self.get_command_name(), f'Failed to update record fields: {str(e)}') + def _patch_record_approvals(self, params, record_uid: str, approvals: ApprovalsConfig) -> None: + record = vault.KeeperRecord.load(params, record_uid) + if not record: + raise CommandError(self.get_command_name(), f'Record not found: {record_uid}') + if not isinstance(record, vault.TypedRecord): + raise CommandError( + self.get_command_name(), + f'Record {record_uid} is not a typed record and cannot store approvals config', + ) + merged = merge_approvals_custom_fields(record.custom, approvals) + self._update_record_custom_fields(params, record_uid, merged) + + def _find_folder_uid_by_name(self, params, folder_name: str) -> Optional[str]: + for folder_uid, folder_data in params.shared_folder_cache.items(): + if folder_data.get('name') == folder_name: + return folder_uid + for folder_uid, folder in params.folder_cache.items(): + if getattr(folder, 'name', None) == folder_name: + return folder_uid + return None + + def _resolve_default_integration_record(self, params, record_name: Optional[str] = None) -> str: + """Find the default integration config record by folder/name when -r is omitted.""" + params.sync_data = True + api.sync_down(params) + + record_name = record_name or self.get_default_record_name() + folder_name = self.get_default_folder_name() + folder_uid = self._find_folder_uid_by_name(params, folder_name) + if not folder_uid: + raise CommandError( + self.get_command_name(), + f'Default folder "{folder_name}" not found. Pass --integration-record .', + ) + + record_uid = self._find_record_in_folder(params, folder_uid, record_name) + if not record_uid: + raise CommandError( + self.get_command_name(), + f'Record "{record_name}" not found in folder "{folder_name}". ' + f'Pass --integration-record .', + ) + return record_uid + + def _execute_sync_down(self, params, record_uid: str) -> None: + name = self.get_integration_name() + print(f"\n{bcolors.BOLD}{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}") + + run_approvals_sync_down( + params=params, + record_uid=record_uid, + marker_field=self.get_integration_config_marker_field(), + update_record=lambda uid, approvals: self._patch_record_approvals(params, uid, approvals), + command_name=self.get_command_name(), + ) + # -- Docker Compose update ----------------------------------------- def _update_docker_compose(self, setup_result: SetupResult, diff --git a/keepercommander/service/commands/integrations/slack_app_setup.py b/keepercommander/service/commands/integrations/slack_app_setup.py index 0fe36afaa..3563908ec 100644 --- a/keepercommander/service/commands/integrations/slack_app_setup.py +++ b/keepercommander/service/commands/integrations/slack_app_setup.py @@ -27,6 +27,12 @@ class SlackAppSetupCommand(IntegrationSetupCommand): def get_integration_name(self): return 'Slack' + def get_approvals_profile(self): + return SLACK_APPROVALS_PROFILE + + def get_integration_config_marker_field(self): + return 'slack_app_token' + # ── Slack-specific configuration ────────────────────────────── def collect_integration_config(self, params): From b4b6cafe3dd67dc2ecc06b0d6287d8e73cc0f7bd Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 16 Jul 2026 12:18:34 +0530 Subject: [PATCH 6/6] Refactor slack-app changes --- .../commands/integrations/approvals_setup.py | 5 +- .../commands/integrations/approvals_sync.py | 18 +++-- .../integrations/integration_setup_base.py | 76 ++++++++++++------- .../commands/integrations/slack_app_setup.py | 4 +- .../service/commands/service_docker_setup.py | 4 - 5 files changed, 67 insertions(+), 40 deletions(-) diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py index cd24aa384..50e4d0fc3 100644 --- a/keepercommander/service/commands/integrations/approvals_setup.py +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -74,7 +74,7 @@ def is_valid_keeper_uid(value: str) -> bool: def parse_comma_separated_uids(raw: str) -> List[str]: if not raw or not raw.strip(): return [] - return [part.strip() for part in raw.split(',') if part.strip()] + return list(dict.fromkeys(part.strip() for part in raw.split(',') if part.strip())) def build_team_lookup( @@ -247,6 +247,9 @@ def collect_approvals_config( by_uid, by_name_lower = build_team_lookup(params) while True: team_uid, team_name = _prompt_keeper_team(by_uid, by_name_lower) + if any(team.team_uid == team_uid for team in teams): + print(f"{bcolors.FAIL}Error: Team \"{team_name}\" is already configured{bcolors.ENDC}") + continue channel_id = _prompt_channel( profile, prompt_with_validation, diff --git a/keepercommander/service/commands/integrations/approvals_sync.py b/keepercommander/service/commands/integrations/approvals_sync.py index c88a0968f..e82045365 100644 --- a/keepercommander/service/commands/integrations/approvals_sync.py +++ b/keepercommander/service/commands/integrations/approvals_sync.py @@ -89,19 +89,25 @@ def parse_approvals_from_record( if not isinstance(raw_teams, list): raise CommandError(command_name, f'{FIELD_APPROVALS_TEAMS} must be a JSON array') + seen_team_uids = set() for item in raw_teams: if not isinstance(item, dict): continue team_uid = str(item.get('team_uid', '')).strip() channel_id = str(item.get('channel_id', '')).strip() - if not team_uid or not channel_id: + if not team_uid or not channel_id or team_uid in seen_team_uids: continue + seen_team_uids.add(team_uid) teams.append(ApproverTeam( team_uid=team_uid, name=str(item.get('name', '')).strip() or team_uid, channel_id=channel_id, - folder_uids=[str(u).strip() for u in (item.get('folder_uids') or []) if str(u).strip()], - record_uids=[str(u).strip() for u in (item.get('record_uids') or []) if str(u).strip()], + folder_uids=list(dict.fromkeys( + str(u).strip() for u in (item.get('folder_uids') or []) if str(u).strip() + )), + record_uids=list(dict.fromkeys( + str(u).strip() for u in (item.get('record_uids') or []) if str(u).strip() + )), )) return ApprovalsConfig( @@ -215,9 +221,11 @@ def run_approvals_sync_down( marker_field: str, update_record: Callable[[str, ApprovalsConfig], None], command_name: str = '', + sync_vault: bool = True, ) -> ApprovalsConfig: - params.sync_data = True - api.sync_down(params) + if sync_vault: + params.sync_data = True + api.sync_down(params) record = vault.KeeperRecord.load(params, record_uid) if not record: diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index dae3c48b4..55da36f5b 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -107,12 +107,15 @@ def get_commander_container_name(self) -> str: return f'keeper-service-{self.get_integration_name().lower()}' def get_service_commands(self) -> str: - # Include this integration's setup command so apps can call --sync-down via the service API. - return ( + commands = ( 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,' 'record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,' - f'sync-down,list-sf,list-team,{self.get_command_name()}' + 'sync-down,list-sf,list-team' ) + # Allow service API callers to run --sync-down for multi-channel approvals configs. + if self.get_approvals_profile() is not None: + commands = f'{commands},{self.get_command_name()}' + return commands # -- Parser (auto-built from name, cached per subclass) ---------- @@ -163,15 +166,22 @@ def _build_parser(self) -> argparse.ArgumentParser: '--skip-device-setup', dest='skip_device_setup', action='store_true', help='Skip device registration and setup if already configured' ) - parser.add_argument( - '--integration-record', '-r', dest='integration_record_uid', type=str, - help='Integration config record UID for --sync-down ' - f'(optional; defaults to "{default_record}" in "{default_folder}")' - ) - parser.add_argument( - '--sync-down', dest='sync_down', action='store_true', - help='Sync approver team/folder/record references in an existing config record with the vault' - ) + if self.get_approvals_profile() is not None: + parser.add_argument( + '--sync-down', dest='sync_down', action='store_true', + help=( + 'Non-interactively sync multi-channel approvals config with the vault ' + '(remove deleted teams; drop missing shared-folder/record UIDs; ' + 'refresh team names). Requires an existing integration config record.' + ) + ) + parser.add_argument( + '--integration-record', '-r', dest='integration_record_uid', type=str, + help=( + 'UID of the integration config record for --sync-down ' + f'(optional; defaults to "{default_record}" in "{default_folder}")' + ) + ) parser.error = raise_parse_exception parser.exit = suppress_exit return parser @@ -182,13 +192,13 @@ def execute(self, params, **kwargs): name = self.get_integration_name() if kwargs.get('sync_down'): - profile = self.get_approvals_profile() - if profile is None: + if self.get_approvals_profile() is None: raise CommandError( self.get_command_name(), f'{name} does not support multi-channel approver sync yet', ) record_uid = kwargs.get('integration_record_uid') + sync_vault = True if record_uid: record_uid = record_uid.strip() if not is_valid_keeper_uid(record_uid): @@ -197,10 +207,12 @@ def execute(self, params, **kwargs): f'Invalid integration record UID: {record_uid}', ) else: + # Default resolution already syncs the vault. record_uid = self._resolve_default_integration_record( params, kwargs.get('integration_record_name') ) - self._execute_sync_down(params, record_uid) + sync_vault = False + self._execute_sync_down(params, record_uid, sync_vault=sync_vault) return self._require_file_based_config(params, f'{name.lower()}-app-setup') @@ -357,24 +369,29 @@ def _update_record_custom_fields(self, params, record_uid: str, custom_fields: L raise CommandError(self.get_command_name(), f'Failed to update record fields: {str(e)}') def _patch_record_approvals(self, params, record_uid: str, approvals: ApprovalsConfig) -> None: - record = vault.KeeperRecord.load(params, record_uid) - if not record: - raise CommandError(self.get_command_name(), f'Record not found: {record_uid}') - if not isinstance(record, vault.TypedRecord): - raise CommandError( - self.get_command_name(), - f'Record {record_uid} is not a typed record and cannot store approvals config', - ) - merged = merge_approvals_custom_fields(record.custom, approvals) - self._update_record_custom_fields(params, record_uid, merged) + try: + record = vault.KeeperRecord.load(params, record_uid) + if not record: + raise CommandError(self.get_command_name(), f'Record not found: {record_uid}') + if not isinstance(record, vault.TypedRecord): + raise CommandError( + self.get_command_name(), + f'Record {record_uid} is not a typed record and cannot store approvals config', + ) + record.custom = merge_approvals_custom_fields(record.custom, approvals) + record_management.update_record(params, record) + params.sync_data = True + api.sync_down(params) + except CommandError: + raise + except Exception as e: + raise CommandError(self.get_command_name(), f'Failed to update record fields: {str(e)}') def _find_folder_uid_by_name(self, params, folder_name: str) -> Optional[str]: + # Prefer shared folders; integration setup always creates a shared folder. for folder_uid, folder_data in params.shared_folder_cache.items(): if folder_data.get('name') == folder_name: return folder_uid - for folder_uid, folder in params.folder_cache.items(): - if getattr(folder, 'name', None) == folder_name: - return folder_uid return None def _resolve_default_integration_record(self, params, record_name: Optional[str] = None) -> str: @@ -400,7 +417,7 @@ def _resolve_default_integration_record(self, params, record_name: Optional[str] ) return record_uid - def _execute_sync_down(self, params, record_uid: str) -> None: + 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}") print(f" Reconciling approver teams, shared folders, and records with the vault") @@ -412,6 +429,7 @@ def _execute_sync_down(self, params, record_uid: str) -> None: marker_field=self.get_integration_config_marker_field(), update_record=lambda uid, approvals: self._patch_record_approvals(params, uid, approvals), command_name=self.get_command_name(), + sync_vault=sync_vault, ) # -- Docker Compose update ----------------------------------------- diff --git a/keepercommander/service/commands/integrations/slack_app_setup.py b/keepercommander/service/commands/integrations/slack_app_setup.py index 3563908ec..b8fcb2447 100644 --- a/keepercommander/service/commands/integrations/slack_app_setup.py +++ b/keepercommander/service/commands/integrations/slack_app_setup.py @@ -60,7 +60,9 @@ def collect_integration_config(self, params): "Invalid Slack Signing Secret (must be exactly 32 characters)" ) - approvals = self._collect_approvals_config(params, SLACK_APPROVALS_PROFILE) + profile = self.get_approvals_profile() + assert profile is not None + approvals = self._collect_approvals_config(params, profile) pedm_enabled, pedm_interval = self._collect_pedm_config() da_enabled, da_interval = self._collect_device_approval_config() diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py index 67f38df84..f3795b1ef 100644 --- a/keepercommander/service/commands/service_docker_setup.py +++ b/keepercommander/service/commands/service_docker_setup.py @@ -352,7 +352,3 @@ def _get_token_expiration_config(self) -> Dict[str, str]: break return {'token_expiration': ''} - - def _get_config_path(self, config_path: str = None) -> str: - """Get and validate config file path""" - return self._require_commander_config_file('service-docker-setup', config_path)