diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py new file mode 100644 index 000000000..50e4d0fc3 --- /dev/null +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -0,0 +1,361 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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 list(dict.fromkeys(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 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) + + 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 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]], + 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 = 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.' + + +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 _validate_uids( + uids: List[str], + known_uids: Set[str], + uid_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 + 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 ' + 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], + uid_label: str, + mistyped: Optional[Dict[str, Set[str]]] = None, +) -> 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 [] + 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 + errors = _validate_uids(uids, known_uids, uid_label, mistyped=mistyped) + if errors: + for error in errors: + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + continue + 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], + 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: + return ApprovalsConfig( + multi_channel_enabled=False, + 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) + 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, + 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 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) + + return ApprovalsConfig( + multi_channel_enabled=True, + single_channel_id=_prompt_channel( + profile, + prompt_with_validation, + profile.default_channel_description, + header=f'DEFAULT {profile.channel_header}', + ), + teams=teams, + ) + + +def _collect_team_boundaries( + params: 'KeeperParams', + teams: List[ApproverTeam], +) -> List[ApproverTeam]: + 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: + 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, + }, + ) + records = _prompt_vault_uid_list( + f'RECORD UIDs FOR {team.name}', + 'Comma-separated record UIDs (optional, press Enter to skip)', + 'Record UIDs:', + record_uids, + 'record', + mistyped={ + '"{uid}" is a shared folder UID, not a record UID': shared_folder_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: + 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', + FIELD_MULTI_CHANNEL_ENABLED, + ), + vault.TypedField.new_field( + 'text', + config.single_channel_id, + FIELD_APPROVALS_CHANNEL_ID, + ), + vault.TypedField.new_field('multiline', teams_json, FIELD_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}") + 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: + 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..e82045365 --- /dev/null +++ b/keepercommander/service/commands/integrations/approvals_sync.py @@ -0,0 +1,252 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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') + + 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 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=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( + 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 = '', + sync_vault: bool = True, +) -> ApprovalsConfig: + if sync_vault: + 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 504e6b1df..55da36f5b 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 @@ -26,8 +26,10 @@ 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, 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}$' @@ -48,7 +50,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 @@ -63,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: @@ -97,7 +107,15 @@ 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' + 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,' + '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) ---------- @@ -138,7 +156,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, @@ -148,6 +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' ) + 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 @@ -156,6 +190,31 @@ def _build_parser(self) -> argparse.ArgumentParser: def execute(self, params, **kwargs): name = self.get_integration_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', + ) + 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): + raise CommandError( + self.get_command_name(), + 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') + ) + 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') # Phase 1 -- Docker service mode setup @@ -178,9 +237,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 +309,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) @@ -307,6 +368,70 @@ 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: + 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 + 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, 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") + 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(), + sync_vault=sync_vault, + ) + # -- Docker Compose update ----------------------------------------- def _update_docker_compose(self, setup_result: SetupResult, @@ -381,7 +506,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() @@ -392,7 +517,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() @@ -401,6 +526,28 @@ def _collect_device_approval_config(self) -> Tuple[bool, int]: # -- Input / validation -------------------------------------------- + def _prompt_yes_no(self, question: str, default: bool = False) -> bool: + 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( + 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..b8fcb2447 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 @@ -22,9 +27,15 @@ 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): + 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 +60,9 @@ 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')" - ) + 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() @@ -66,7 +73,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 +85,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 +95,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..f3795b1ef 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") @@ -348,13 +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""" - 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 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..d81e26175 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,37 @@ 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: + 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."""