From 6afa93cfdf17c0b7351af0d1caad6fb8b88a2025 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Wed, 29 Jul 2026 19:57:31 +0530 Subject: [PATCH 1/3] Add gchat-app-setup for Google Chat Service Mode integration. Collect and store Google Chat/Pub/Sub credentials in a vault record and generate docker-compose, matching the Slack/Teams setup flow. --- keepercommander/command_categories.py | 3 +- keepercommander/commands/start_service.py | 10 +- keepercommander/service/README.md | 56 +++ .../service/commands/integrations/__init__.py | 2 + .../commands/integrations/gchat_app_setup.py | 332 ++++++++++++++++++ .../integrations/integration_setup_base.py | 46 +-- keepercommander/service/docker/__init__.py | 5 +- keepercommander/service/docker/models.py | 15 + unit-tests/service/test_gchat_app_setup.py | 163 +++++++++ 9 files changed, 607 insertions(+), 25 deletions(-) create mode 100644 keepercommander/service/commands/integrations/gchat_app_setup.py create mode 100644 unit-tests/service/test_gchat_app_setup.py diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py index d7c1504f5..d3a1d8075 100644 --- a/keepercommander/command_categories.py +++ b/keepercommander/command_categories.py @@ -83,7 +83,8 @@ # Service Mode REST API 'Service Mode REST API': { 'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status', - 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup' + 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup', + 'gchat-app-setup' }, # Email Configuration Commands diff --git a/keepercommander/commands/start_service.py b/keepercommander/commands/start_service.py index 6524a596f..c3451fa22 100644 --- a/keepercommander/commands/start_service.py +++ b/keepercommander/commands/start_service.py @@ -13,7 +13,11 @@ from ..service.commands.config_operation import AddConfigService from ..service.commands.handle_service import StartService, StopService, ServiceStatus from ..service.commands.service_docker_setup import ServiceDockerSetupCommand -from ..service.commands.integrations import SlackAppSetupCommand, TeamsAppSetupCommand +from ..service.commands.integrations import ( + GChatAppSetupCommand, + SlackAppSetupCommand, + TeamsAppSetupCommand, +) def register_commands(commands): commands['service-create'] = CreateService() @@ -24,6 +28,7 @@ def register_commands(commands): commands['service-docker-setup'] = ServiceDockerSetupCommand() commands['slack-app-setup'] = SlackAppSetupCommand() commands['teams-app-setup'] = TeamsAppSetupCommand() + commands['gchat-app-setup'] = GChatAppSetupCommand() def register_command_info(aliases, command_info): service_classes = [ @@ -34,7 +39,8 @@ def register_command_info(aliases, command_info): ServiceStatus, ServiceDockerSetupCommand, SlackAppSetupCommand, - TeamsAppSetupCommand + TeamsAppSetupCommand, + GChatAppSetupCommand, ] for service_class in service_classes: diff --git a/keepercommander/service/README.md b/keepercommander/service/README.md index 9d89a2396..400af6ec9 100644 --- a/keepercommander/service/README.md +++ b/keepercommander/service/README.md @@ -20,6 +20,8 @@ The Service Mode module for Keeper Commander enables REST API integration by pro | `service-config-add` | Add new API configuration and command access settings | | `service-docker-setup` | Automated Docker service mode setup with KSM configuration | | `slack-app-setup` | Automated Slack App integration setup with Commander Service Mode | +| `teams-app-setup` | Automated Teams App integration setup with Commander Service Mode | +| `gchat-app-setup` | Automated Google Chat App integration setup with Commander Service Mode | ### Security Features - API key authentication @@ -500,6 +502,60 @@ This automates the complete setup for Slack App integration: The command generates a complete `docker-compose.yml` with both Commander service and Slack App service configured. +### Google Chat App Integration Setup + +For integrating Commander Service Mode with Google Chat, use the `gchat-app-setup` command: + +```bash +My Vault> gchat-app-setup +``` + +This automates the complete setup for Google Chat App integration: +- **Phase 1**: Runs Docker setup (same as `service-docker-setup`) +- **Phase 2**: Configures Google Chat App integration + - Collects service account JSON (Pub/Sub + Chat API worker credentials) + - Collects Google Project ID (defaults from the service account JSON when omitted) + - Collects Pub/Sub Topic ID, Subscription ID, Approvals Space ID, and slash command IDs + - Creates Google Chat configuration record + - Updates `docker-compose.yml` with Google Chat App service + - Supports optional EPM and Device Approval integrations + +**Configuration Options:** +- Port selection (default: 8900) +- Ngrok/Cloudflare tunneling for public URL exposure +- Google Chat service account / Pub/Sub / space credentials +- Optional EPM integration +- Optional SSO Cloud Device Approval + +The command generates a complete `docker-compose.yml` with both Commander service and Google Chat App service configured. + +**Vault config record fields** (read by the Google Chat app via KSM / `GCHAT_RECORD`): + +| Field label | Type | Description | +|-------------|------|-------------| +| `google_service_account_json` | secret | Full GCP service account JSON (Pub/Sub + Chat API) | +| `google_project_id` | text | GCP project ID | +| `google_topic_id` | text | Pub/Sub topic ID (short form) | +| `google_subscription_id` | text | Pub/Sub subscription ID (short form) | +| `chat_approvals_space_id` | text | Google Chat space ID (`spaces/...`) | +| `chat_command_request_record_id` | text | Slash command ID for `/keeper-request-record` | +| `chat_command_request_folder_id` | text | Slash command ID for `/keeper-request-folder` | +| `chat_command_one_time_share_id` | text | Slash command ID for `/keeper-one-time-share` | +| `pedm_enabled` | text | `true` / `false` | +| `pedm_polling_interval` | text | Seconds | +| `device_approval_enabled` | text | `true` / `false` | +| `device_approval_polling_interval` | text | Seconds | + +**Generated compose environment for the Google Chat service:** + +| Env var | Value | +|---------|-------| +| `KSM_CONFIG` | Base64 KSM config | +| `COMMANDER_RECORD` | Commander Docker config record UID | +| `GCHAT_RECORD` | Google Chat config record UID | + +Image name used in compose: `keeper/gchat-app:latest`. + --- ### Manual Authentication Methods (Alternative) diff --git a/keepercommander/service/commands/integrations/__init__.py b/keepercommander/service/commands/integrations/__init__.py index e4e4570ae..f8fb2352a 100644 --- a/keepercommander/service/commands/integrations/__init__.py +++ b/keepercommander/service/commands/integrations/__init__.py @@ -11,12 +11,14 @@ """Integration setup commands.""" +from .gchat_app_setup import GChatAppSetupCommand from .integration_setup_base import IntegrationSetupCommand from .slack_app_setup import SlackAppSetupCommand from .teams_app_setup import TeamsAppSetupCommand __all__ = [ 'IntegrationSetupCommand', + 'GChatAppSetupCommand', 'SlackAppSetupCommand', 'TeamsAppSetupCommand', ] diff --git a/keepercommander/service/commands/integrations/gchat_app_setup.py b/keepercommander/service/commands/integrations/gchat_app_setup.py new file mode 100644 index 000000000..434e75b1b --- /dev/null +++ b/keepercommander/service/commands/integrations/gchat_app_setup.py @@ -0,0 +1,332 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return 'Google Chat' + + def get_default_folder_name(self) -> str: + return 'Commander Service Mode - Google Chat App' + + def get_default_record_name(self) -> str: + return 'Commander Service Mode Google Chat App Config' + + def get_integration_config_marker_field(self) -> str: + return 'google_service_account_json' + + # ── Google Chat-specific configuration ──────────────────────── + + def collect_integration_config(self, params): + print(f"\n{bcolors.BOLD}GOOGLE_SERVICE_ACCOUNT_JSON:{bcolors.ENDC}") + print(f" Path to the Google Cloud service account JSON key, or paste the JSON") + print(f" (used for Pub/Sub pull and Google Chat API)") + google_service_account_json, project_from_json = self._prompt_service_account_json() + + print(f"\n{bcolors.BOLD}GOOGLE_PROJECT_ID:{bcolors.ENDC}") + print(f" Google Cloud project ID for Pub/Sub and Chat") + default_hint = f' [Press Enter for {project_from_json}]' if project_from_json else '' + while True: + project_input = input( + f"{bcolors.OKBLUE}Project ID{default_hint}:{bcolors.ENDC} " + ).strip() + google_project_id = project_input or project_from_json + if google_project_id: + break + print( + f"{bcolors.FAIL}Error: Google Project ID is required " + f"(enter a value or provide a valid service account JSON){bcolors.ENDC}" + ) + + print(f"\n{bcolors.BOLD}GOOGLE_TOPIC_ID:{bcolors.ENDC}") + print(f" Pub/Sub topic that receives Google Chat events") + print(f" Accepts a short ID or full path projects/{{project}}/topics/{{id}}") + google_topic_id = self._prompt_pubsub_id( + 'Topic ID:', + self._normalize_topic_id, + ) + + print(f"\n{bcolors.BOLD}GOOGLE_SUBSCRIPTION_ID:{bcolors.ENDC}") + print(f" Pub/Sub subscription used to pull Google Chat events") + print(f" Accepts a short ID or full path projects/{{project}}/subscriptions/{{id}}") + google_subscription_id = self._prompt_pubsub_id( + 'Subscription ID:', + self._normalize_subscription_id, + ) + + print(f"\n{bcolors.BOLD}CHAT_APPROVALS_SPACE_ID:{bcolors.ENDC}") + print(f" Google Chat space where approval cards are posted") + chat_approvals_space_id = self._prompt_with_validation( + "Space ID (starts with spaces/):", + self._is_valid_space_id, + "Invalid Approvals Space ID (must start with 'spaces/' and include a space name)" + ) + + print(f"\n{bcolors.BOLD}CHAT COMMAND IDs:{bcolors.ENDC}") + print(f" Slash command IDs configured for the Google Chat app") + chat_command_request_record_id = self._prompt_command_id( + '/keeper-request-record', '1' + ) + chat_command_request_folder_id = self._prompt_command_id( + '/keeper-request-folder', '2' + ) + chat_command_one_time_share_id = self._prompt_command_id( + '/keeper-one-time-share', '3' + ) + + pedm_enabled, pedm_interval = self._collect_pedm_config() + da_enabled, da_interval = self._collect_device_approval_config() + + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ Google Chat Configuration Complete!{bcolors.ENDC}") + + return GChatConfig( + google_service_account_json=google_service_account_json, + google_project_id=google_project_id, + google_subscription_id=google_subscription_id, + google_topic_id=google_topic_id, + chat_approvals_space_id=chat_approvals_space_id, + chat_command_request_record_id=chat_command_request_record_id, + chat_command_request_folder_id=chat_command_request_folder_id, + chat_command_one_time_share_id=chat_command_one_time_share_id, + pedm_enabled=pedm_enabled, + pedm_polling_interval=pedm_interval, + device_approval_enabled=da_enabled, + device_approval_polling_interval=da_interval, + ) + + def build_record_custom_fields(self, config): + return [ + vault.TypedField.new_field( + 'secret', config.google_service_account_json, 'google_service_account_json' + ), + vault.TypedField.new_field('text', config.google_project_id, 'google_project_id'), + vault.TypedField.new_field('text', config.google_subscription_id, 'google_subscription_id'), + vault.TypedField.new_field('text', config.google_topic_id, 'google_topic_id'), + vault.TypedField.new_field( + 'text', config.chat_approvals_space_id, 'chat_approvals_space_id' + ), + vault.TypedField.new_field( + 'text', config.chat_command_request_record_id, 'chat_command_request_record_id' + ), + vault.TypedField.new_field( + 'text', config.chat_command_request_folder_id, 'chat_command_request_folder_id' + ), + vault.TypedField.new_field( + 'text', config.chat_command_one_time_share_id, 'chat_command_one_time_share_id' + ), + 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', + ), + vault.TypedField.new_field( + 'text', + str(config.device_approval_polling_interval), + 'device_approval_polling_interval', + ), + ] + + # ── Display ─────────────────────────────────────────────────── + + def print_integration_specific_resources(self, config): + print(f" • Google Project ID: {bcolors.OKBLUE}{config.google_project_id}{bcolors.ENDC}") + print(f" • Pub/Sub Topic: {bcolors.OKBLUE}{config.google_topic_id}{bcolors.ENDC}") + print( + f" • Pub/Sub Subscription: " + f"{bcolors.OKBLUE}{config.google_subscription_id}{bcolors.ENDC}" + ) + print( + f" • Approvals Space: " + f"{bcolors.OKBLUE}{config.chat_approvals_space_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-request-record ID: " + f"{bcolors.OKBLUE}{config.chat_command_request_record_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-request-folder ID: " + f"{bcolors.OKBLUE}{config.chat_command_request_folder_id}{bcolors.ENDC}" + ) + print( + f" • /keeper-one-time-share ID: " + f"{bcolors.OKBLUE}{config.chat_command_one_time_share_id}{bcolors.ENDC}" + ) + + def print_integration_commands(self): + print(f"\n{bcolors.BOLD}Google Chat Commands Available:{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}• /keeper-request-record{bcolors.ENDC} - Request access to a record") + print(f" {bcolors.OKGREEN}• /keeper-request-folder{bcolors.ENDC} - Request access to a folder") + print( + f" {bcolors.OKGREEN}• /keeper-one-time-share{bcolors.ENDC} " + f"- Request a one-time share link\n" + ) + + # ── Validation helpers ──────────────────────────────────────── + + def _prompt_service_account_json(self) -> Tuple[str, str]: + while True: + value = input( + f"{bcolors.OKBLUE}Path to service account JSON file (or paste JSON):{bcolors.ENDC} " + ).strip() + parsed, error = self._load_service_account_json(value) + if parsed is not None: + return json.dumps(parsed, separators=(',', ':')), parsed.get('project_id', '') + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + + def _prompt_pubsub_id(self, prompt: str, normalizer) -> str: + while True: + value = input(f"{bcolors.OKBLUE}{prompt}{bcolors.ENDC} ").strip() + normalized, error = normalizer(value) + if normalized is not None: + return normalized + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + + def _prompt_command_id(self, command_name: str, default: str) -> str: + while True: + value = input( + f"{bcolors.OKBLUE}{command_name} command ID " + f"[Press Enter for {default}]:{bcolors.ENDC} " + ).strip() or default + if value.isdigit() and int(value) >= 1: + return value + print( + f"{bcolors.FAIL}Error: Slash command ID must be a positive integer{bcolors.ENDC}" + ) + + @classmethod + def _load_service_account_json(cls, value: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + if not value: + return None, 'Service account JSON path or JSON content is required' + + if value.lstrip().startswith('{'): + try: + data = json.loads(value) + except json.JSONDecodeError as exc: + return None, f'Invalid service account JSON: {exc}' + return cls._validate_service_account_dict(data) + + expanded = os.path.expanduser(value) + if not os.path.isfile(expanded): + return None, f'Service account JSON file not found: {value}' + + try: + with open(expanded, 'r', encoding='utf-8') as handle: + data = json.load(handle) + except json.JSONDecodeError as exc: + return None, f'Invalid service account JSON: {exc}' + except OSError as exc: + return None, f'Unable to read service account JSON: {exc}' + + return cls._validate_service_account_dict(data) + + @staticmethod + def _validate_service_account_dict( + data: Any, + ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + if not isinstance(data, dict): + return None, 'Service account JSON must be a JSON object' + + missing = [key for key in _SERVICE_ACCOUNT_REQUIRED_KEYS if not data.get(key)] + if missing: + return None, ( + 'Invalid service account JSON ' + f'(missing required fields: {", ".join(missing)})' + ) + + if data.get('type') != 'service_account': + return None, "Invalid service account JSON (type must be 'service_account')" + + return data, None + + @staticmethod + def _normalize_pubsub_id( + value: str, + resource_pattern: re.Pattern, + label: str, + resource_hint: str, + ) -> Tuple[Optional[str], Optional[str]]: + if not value: + return None, f'{label} is required' + + resource_match = resource_pattern.match(value) + if resource_match: + return resource_match.group(2), None + + if _PUBSUB_ID_PATTERN.match(value): + return value, None + + return None, ( + f'Invalid {label} ' + f'(use a short ID like keeper-chat-events, or {resource_hint})' + ) + + @classmethod + def _normalize_subscription_id(cls, value: str) -> Tuple[Optional[str], Optional[str]]: + return cls._normalize_pubsub_id( + value, + _SUBSCRIPTION_RESOURCE_PATTERN, + 'Pub/Sub Subscription ID', + 'projects/{project}/subscriptions/{id}', + ) + + @classmethod + def _normalize_topic_id(cls, value: str) -> Tuple[Optional[str], Optional[str]]: + return cls._normalize_pubsub_id( + value, + _TOPIC_RESOURCE_PATTERN, + 'Pub/Sub Topic ID', + 'projects/{project}/topics/{id}', + ) + + @staticmethod + def _is_valid_subscription_id(value: str) -> bool: + normalized, _ = GChatAppSetupCommand._normalize_subscription_id(value) + return normalized is not None + + @staticmethod + def _is_valid_space_id(value: str) -> bool: + return bool(value and value.startswith('spaces/') and len(value) > len('spaces/')) diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 55da36f5b..5eab664f6 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -49,6 +49,10 @@ class IntegrationSetupCommand(Command, DockerSetupBase, ABC): def get_integration_name(self) -> str: """e.g. 'Slack', 'Teams' -- drives all naming conventions.""" + def get_integration_display_name(self) -> str: + """User-facing product name. Defaults to get_integration_name().""" + return self.get_integration_name() + @abstractmethod def collect_integration_config(self, params) -> Any: """Prompt user for config values, return a config dataclass.""" @@ -127,13 +131,14 @@ def get_parser(self): def _build_parser(self) -> argparse.ArgumentParser: name = self.get_integration_name() + display_name = self.get_integration_display_name() name_lower = name.lower() default_folder = self.get_default_folder_name() default_record = self.get_default_record_name() parser = argparse.ArgumentParser( prog=f'{name_lower}-app-setup', - description=f'Automate {name} App integration setup with Commander Service Mode', + description=f'Automate {display_name} App integration setup with Commander Service Mode', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( @@ -152,7 +157,7 @@ def _build_parser(self) -> argparse.ArgumentParser: parser.add_argument( f'--{name_lower}-record-name', dest='integration_record_name', type=str, default=default_record, - help=f'Name for the {name} config record (default: "{default_record}")' + help=f'Name for the {display_name} config record (default: "{default_record}")' ) parser.add_argument( '--config-path', dest='config_path', type=str, @@ -190,12 +195,13 @@ def _build_parser(self) -> argparse.ArgumentParser: def execute(self, params, **kwargs): name = self.get_integration_name() + display_name = self.get_integration_display_name() if kwargs.get('sync_down'): if self.get_approvals_profile() is None: raise CommandError( self.get_command_name(), - f'{name} does not support multi-channel approver sync yet', + f'{display_name} does not support multi-channel approver sync yet', ) record_uid = kwargs.get('integration_record_uid') sync_vault = True @@ -223,7 +229,7 @@ def execute(self, params, **kwargs): DockerSetupPrinter.print_completion("Service Mode Configuration Complete!") # Phase 2 -- Integration-specific setup - print(f"\n{bcolors.BOLD}Phase 2: {name} App Integration Setup{bcolors.ENDC}") + print(f"\n{bcolors.BOLD}Phase 2: {display_name} App Integration Setup{bcolors.ENDC}") record_name = kwargs.get('integration_record_name', self.get_default_record_name()) record_uid, config = self._run_integration_setup( params, setup_result, service_config, record_name @@ -306,16 +312,16 @@ def _get_integration_service_configuration(self) -> ServiceConfig: def _run_integration_setup(self, params, setup_result: SetupResult, service_config: ServiceConfig, record_name: str) -> Tuple[str, Any]: - name = self.get_integration_name() + display_name = self.get_integration_display_name() - DockerSetupPrinter.print_header(f"{name} App Configuration") + DockerSetupPrinter.print_header(f"{display_name} App Configuration") config = self.collect_integration_config(params) - DockerSetupPrinter.print_step(1, 2, f"Creating {name} config record '{record_name}'...") + DockerSetupPrinter.print_step(1, 2, f"Creating {display_name} config record '{record_name}'...") custom_fields = self.build_record_custom_fields(config) record_uid = self._create_integration_record(params, record_name, setup_result.folder_uid, custom_fields) - DockerSetupPrinter.print_step(2, 2, f"Updating docker-compose.yml with {name} App service...") + DockerSetupPrinter.print_step(2, 2, f"Updating docker-compose.yml with {display_name} App service...") self._update_docker_compose(setup_result, service_config, record_uid, config) return record_uid, config @@ -333,8 +339,8 @@ def _create_integration_record(self, params, record_name: str, self._update_record_custom_fields(params, record_uid, custom_fields) - name = self.get_integration_name() - DockerSetupPrinter.print_success(f"{name} config record ready (UID: {record_uid})") + display_name = self.get_integration_display_name() + DockerSetupPrinter.print_success(f"{display_name} config record ready (UID: {record_uid})") return record_uid def _find_record_in_folder(self, params, folder_uid: str, record_name: str): @@ -418,8 +424,8 @@ def _resolve_default_integration_record(self, params, record_name: Optional[str] return record_uid def _execute_sync_down(self, params, record_uid: str, sync_vault: bool = True) -> None: - name = self.get_integration_name() - print(f"\n{bcolors.BOLD}{name} App Config Sync{bcolors.ENDC}") + display_name = self.get_integration_display_name() + print(f"\n{bcolors.BOLD}{display_name} App Config Sync{bcolors.ENDC}") print(f" Reconciling approver teams, shared folders, and records with the vault") print(f" Config record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") @@ -477,26 +483,26 @@ def _update_docker_compose(self, setup_result: SetupResult, def _print_success_message(self, setup_result: SetupResult, service_config: ServiceConfig, record_uid: str, config, config_path: str) -> None: - name = self.get_integration_name() + display_name = self.get_integration_display_name() - print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ {name} App Integration Setup Complete!{bcolors.ENDC}\n") + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ {display_name} App Integration Setup Complete!{bcolors.ENDC}\n") print(f"{bcolors.BOLD}Resources Created:{bcolors.ENDC}") print(f" {bcolors.BOLD}Phase 1 - Commander Service:{bcolors.ENDC}") DockerSetupPrinter.print_phase1_resources(setup_result, indent=" ") - print(f" {bcolors.BOLD}Phase 2 - {name} App:{bcolors.ENDC}") + print(f" {bcolors.BOLD}Phase 2 - {display_name} App:{bcolors.ENDC}") self._print_integration_resources(record_uid, config) DockerSetupPrinter.print_common_deployment_steps(str(service_config.port), config_path) container = self.get_docker_container_name() - print(f" {bcolors.OKGREEN}docker logs {container}{bcolors.ENDC} - View {name} App logs") + print(f" {bcolors.OKGREEN}docker logs {container}{bcolors.ENDC} - View {display_name} App logs") self.print_integration_commands() def _print_integration_resources(self, record_uid: str, config) -> None: - name = self.get_integration_name() - print(f" • {name} Config Record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") + display_name = self.get_integration_display_name() + print(f" • {display_name} Config Record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") self.print_integration_specific_resources(config) print(f" • EPM Integration: {bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}") print(f" • Device Approval: {bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}") @@ -514,9 +520,9 @@ def _collect_pedm_config(self) -> Tuple[bool, int]: return enabled, interval def _collect_device_approval_config(self) -> Tuple[bool, int]: - name = self.get_integration_name() + display_name = self.get_integration_display_name() print(f"\n{bcolors.BOLD}SSO Cloud Device Approval Integration (optional):{bcolors.ENDC}") - print(f" Approve SSO Cloud device registrations via {name}") + print(f" Approve SSO Cloud device registrations via {display_name}") enabled = self._prompt_yes_no('Enable Device Approval?', default=False) interval = 120 if enabled: diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index c30315181..746788ede 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -20,8 +20,8 @@ """ from .models import ( - DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep, - ApproverTeam, ApprovalsConfig, + DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, GChatConfig, + SetupStep, ApproverTeam, ApprovalsConfig, ) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase @@ -33,6 +33,7 @@ 'ServiceConfig', 'SlackConfig', 'TeamsConfig', + 'GChatConfig', 'ApproverTeam', 'ApprovalsConfig', 'SetupStep', diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index d81e26175..09addc8f4 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -123,3 +123,18 @@ class TeamsConfig: device_approval_enabled: bool = False device_approval_polling_interval: int = 120 + +@dataclass +class GChatConfig: + google_service_account_json: str + google_project_id: str + google_subscription_id: str + google_topic_id: str + chat_approvals_space_id: str + chat_command_request_record_id: str = '1' + chat_command_request_folder_id: str = '2' + chat_command_one_time_share_id: str = '3' + pedm_enabled: bool = False + pedm_polling_interval: int = 120 + device_approval_enabled: bool = False + device_approval_polling_interval: int = 120 diff --git a/unit-tests/service/test_gchat_app_setup.py b/unit-tests/service/test_gchat_app_setup.py new file mode 100644 index 000000000..3cd9cd77f --- /dev/null +++ b/unit-tests/service/test_gchat_app_setup.py @@ -0,0 +1,163 @@ +import json +import os +import tempfile +import unittest + +from keepercommander.service.commands.integrations.gchat_app_setup import GChatAppSetupCommand +from keepercommander.service.docker import GChatConfig + + +def _valid_service_account(**overrides): + data = { + 'type': 'service_account', + 'project_id': 'my-gcp-project', + 'private_key': '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + 'client_email': 'bot@my-gcp-project.iam.gserviceaccount.com', + } + data.update(overrides) + return data + + +class TestGChatAppSetupValidation(unittest.TestCase): + def setUp(self): + self.cmd = GChatAppSetupCommand() + + def test_command_naming(self): + self.assertEqual(self.cmd.get_integration_name(), 'GChat') + self.assertEqual(self.cmd.get_integration_display_name(), 'Google Chat') + self.assertEqual(self.cmd.get_command_name(), 'gchat-app-setup') + self.assertEqual(self.cmd.get_record_env_key(), 'GCHAT_RECORD') + self.assertEqual(self.cmd.get_docker_image(), 'keeper/gchat-app:latest') + self.assertEqual( + self.cmd.get_integration_config_marker_field(), + 'google_service_account_json', + ) + self.assertEqual(self.cmd.get_parser().prog, 'gchat-app-setup') + + def test_load_service_account_requires_value(self): + data, error = self.cmd._load_service_account_json('') + self.assertIsNone(data) + self.assertIn('required', error.lower()) + + def test_load_service_account_file_not_found(self): + data, error = self.cmd._load_service_account_json('/tmp/does-not-exist-gchat.json') + self.assertIsNone(data) + self.assertIn('not found', error.lower()) + + def test_load_service_account_invalid_type(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + json.dump(_valid_service_account(type='user'), handle) + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(data) + self.assertIn('service_account', error) + + def test_load_service_account_missing_fields(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + json.dump({'type': 'service_account'}, handle) + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(data) + self.assertIn('missing', error.lower()) + + def test_load_service_account_from_file(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + json.dump(_valid_service_account(), handle) + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) + self.assertIsNone(error) + self.assertEqual(data['project_id'], 'my-gcp-project') + + def test_load_service_account_from_inline_json(self): + data, error = self.cmd._load_service_account_json(json.dumps(_valid_service_account())) + self.assertIsNone(error) + self.assertEqual(data['client_email'], 'bot@my-gcp-project.iam.gserviceaccount.com') + + def test_load_service_account_invalid_inline_json(self): + data, error = self.cmd._load_service_account_json('{not-json') + self.assertIsNone(data) + self.assertIn('invalid service account json', error.lower()) + + def test_normalize_subscription_short_id(self): + value, error = self.cmd._normalize_subscription_id('keeper-chat-events') + self.assertIsNone(error) + self.assertEqual(value, 'keeper-chat-events') + + def test_normalize_subscription_full_resource_name(self): + value, error = self.cmd._normalize_subscription_id( + 'projects/my-gcp-project/subscriptions/keeper-chat-events' + ) + self.assertIsNone(error) + self.assertEqual(value, 'keeper-chat-events') + + def test_normalize_subscription_missing(self): + value, error = self.cmd._normalize_subscription_id('') + self.assertIsNone(value) + self.assertIn('required', error.lower()) + + def test_normalize_subscription_invalid(self): + value, error = self.cmd._normalize_subscription_id('ab') + self.assertIsNone(value) + self.assertIn('invalid', error.lower()) + + def test_normalize_topic_full_resource_name(self): + value, error = self.cmd._normalize_topic_id( + 'projects/my-gcp-project/topics/keeper-chat-topic' + ) + self.assertIsNone(error) + self.assertEqual(value, 'keeper-chat-topic') + + def test_normalize_topic_missing(self): + value, error = self.cmd._normalize_topic_id('') + self.assertIsNone(value) + self.assertIn('required', error.lower()) + + def test_space_id_validation(self): + self.assertTrue(self.cmd._is_valid_space_id('spaces/AAAA')) + self.assertFalse(self.cmd._is_valid_space_id('spaces/')) + self.assertFalse(self.cmd._is_valid_space_id('AAAA')) + self.assertFalse(self.cmd._is_valid_space_id('')) + + def test_build_record_custom_fields(self): + config = GChatConfig( + google_service_account_json='{"type":"service_account"}', + google_project_id='my-gcp-project', + google_subscription_id='keeper-chat-events', + google_topic_id='keeper-chat-topic', + chat_approvals_space_id='spaces/AAAA', + chat_command_request_record_id='1', + chat_command_request_folder_id='2', + chat_command_one_time_share_id='3', + pedm_enabled=True, + pedm_polling_interval=60, + device_approval_enabled=False, + device_approval_polling_interval=120, + ) + fields = { + field.label: field.get_default_value() + for field in self.cmd.build_record_custom_fields(config) + } + self.assertEqual(fields['google_service_account_json'], '{"type":"service_account"}') + self.assertEqual(fields['google_project_id'], 'my-gcp-project') + self.assertEqual(fields['google_subscription_id'], 'keeper-chat-events') + self.assertEqual(fields['google_topic_id'], 'keeper-chat-topic') + self.assertEqual(fields['chat_approvals_space_id'], 'spaces/AAAA') + self.assertEqual(fields['chat_command_request_record_id'], '1') + self.assertEqual(fields['chat_command_request_folder_id'], '2') + self.assertEqual(fields['chat_command_one_time_share_id'], '3') + self.assertEqual(fields['pedm_enabled'], 'true') + self.assertEqual(fields['pedm_polling_interval'], '60') + self.assertEqual(fields['device_approval_enabled'], 'false') + + +if __name__ == '__main__': + unittest.main() From f74e80887d8cb30367851b6809ed6b39418937fa Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 30 Jul 2026 12:20:23 +0530 Subject: [PATCH 2/3] Require a file path for Google Chat service account JSON. Drop unreliable inline JSON paste at the gchat-app-setup prompt; service account keys are too large for single-line terminal input. --- .../commands/integrations/gchat_app_setup.py | 25 +++++++------------ unit-tests/service/test_gchat_app_setup.py | 22 ++++++++++------ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/keepercommander/service/commands/integrations/gchat_app_setup.py b/keepercommander/service/commands/integrations/gchat_app_setup.py index 434e75b1b..8cc46b8fe 100644 --- a/keepercommander/service/commands/integrations/gchat_app_setup.py +++ b/keepercommander/service/commands/integrations/gchat_app_setup.py @@ -61,7 +61,7 @@ def get_integration_config_marker_field(self) -> str: def collect_integration_config(self, params): print(f"\n{bcolors.BOLD}GOOGLE_SERVICE_ACCOUNT_JSON:{bcolors.ENDC}") - print(f" Path to the Google Cloud service account JSON key, or paste the JSON") + print(f" Path to the Google Cloud service account JSON key file") print(f" (used for Pub/Sub pull and Google Chat API)") google_service_account_json, project_from_json = self._prompt_service_account_json() @@ -209,10 +209,10 @@ def print_integration_commands(self): def _prompt_service_account_json(self) -> Tuple[str, str]: while True: - value = input( - f"{bcolors.OKBLUE}Path to service account JSON file (or paste JSON):{bcolors.ENDC} " + path = input( + f"{bcolors.OKBLUE}Path to service account JSON file:{bcolors.ENDC} " ).strip() - parsed, error = self._load_service_account_json(value) + parsed, error = self._load_service_account_json(path) if parsed is not None: return json.dumps(parsed, separators=(',', ':')), parsed.get('project_id', '') print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") @@ -238,20 +238,13 @@ def _prompt_command_id(self, command_name: str, default: str) -> str: ) @classmethod - def _load_service_account_json(cls, value: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - if not value: - return None, 'Service account JSON path or JSON content is required' - - if value.lstrip().startswith('{'): - try: - data = json.loads(value) - except json.JSONDecodeError as exc: - return None, f'Invalid service account JSON: {exc}' - return cls._validate_service_account_dict(data) + def _load_service_account_json(cls, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + if not path: + return None, 'Service account JSON path is required' - expanded = os.path.expanduser(value) + expanded = os.path.expanduser(path) if not os.path.isfile(expanded): - return None, f'Service account JSON file not found: {value}' + return None, f'Service account JSON file not found: {path}' try: with open(expanded, 'r', encoding='utf-8') as handle: diff --git a/unit-tests/service/test_gchat_app_setup.py b/unit-tests/service/test_gchat_app_setup.py index 3cd9cd77f..94df8571b 100644 --- a/unit-tests/service/test_gchat_app_setup.py +++ b/unit-tests/service/test_gchat_app_setup.py @@ -34,7 +34,7 @@ def test_command_naming(self): ) self.assertEqual(self.cmd.get_parser().prog, 'gchat-app-setup') - def test_load_service_account_requires_value(self): + def test_load_service_account_requires_path(self): data, error = self.cmd._load_service_account_json('') self.assertIsNone(data) self.assertIn('required', error.lower()) @@ -44,6 +44,11 @@ def test_load_service_account_file_not_found(self): self.assertIsNone(data) self.assertIn('not found', error.lower()) + def test_load_service_account_rejects_inline_json(self): + data, error = self.cmd._load_service_account_json(json.dumps(_valid_service_account())) + self.assertIsNone(data) + self.assertIn('not found', error.lower()) + def test_load_service_account_invalid_type(self): with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: json.dump(_valid_service_account(type='user'), handle) @@ -77,13 +82,14 @@ def test_load_service_account_from_file(self): self.assertIsNone(error) self.assertEqual(data['project_id'], 'my-gcp-project') - def test_load_service_account_from_inline_json(self): - data, error = self.cmd._load_service_account_json(json.dumps(_valid_service_account())) - self.assertIsNone(error) - self.assertEqual(data['client_email'], 'bot@my-gcp-project.iam.gserviceaccount.com') - - def test_load_service_account_invalid_inline_json(self): - data, error = self.cmd._load_service_account_json('{not-json') + def test_load_service_account_invalid_json_file(self): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as handle: + handle.write('{not-json') + path = handle.name + try: + data, error = self.cmd._load_service_account_json(path) + finally: + os.unlink(path) self.assertIsNone(data) self.assertIn('invalid service account json', error.lower()) From 0c5da2297a531c9c4863ad7b94ae364fe55605bb Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 30 Jul 2026 17:00:01 +0530 Subject: [PATCH 3/3] updated to latest annotations --- .../commands/integrations/gchat_app_setup.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/keepercommander/service/commands/integrations/gchat_app_setup.py b/keepercommander/service/commands/integrations/gchat_app_setup.py index 8cc46b8fe..03dc60a83 100644 --- a/keepercommander/service/commands/integrations/gchat_app_setup.py +++ b/keepercommander/service/commands/integrations/gchat_app_setup.py @@ -11,10 +11,12 @@ """Google Chat App integration setup command.""" +from __future__ import annotations + import json import os import re -from typing import Any, Dict, Optional, Tuple +from typing import Any from .... import vault from ....display import bcolors @@ -207,7 +209,7 @@ def print_integration_commands(self): # ── Validation helpers ──────────────────────────────────────── - def _prompt_service_account_json(self) -> Tuple[str, str]: + def _prompt_service_account_json(self) -> tuple[str, str]: while True: path = input( f"{bcolors.OKBLUE}Path to service account JSON file:{bcolors.ENDC} " @@ -238,7 +240,7 @@ def _prompt_command_id(self, command_name: str, default: str) -> str: ) @classmethod - def _load_service_account_json(cls, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + def _load_service_account_json(cls, path: str) -> tuple[dict[str, Any] | None, str | None]: if not path: return None, 'Service account JSON path is required' @@ -259,7 +261,7 @@ def _load_service_account_json(cls, path: str) -> Tuple[Optional[Dict[str, Any]] @staticmethod def _validate_service_account_dict( data: Any, - ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + ) -> tuple[dict[str, Any] | None, str | None]: if not isinstance(data, dict): return None, 'Service account JSON must be a JSON object' @@ -278,10 +280,10 @@ def _validate_service_account_dict( @staticmethod def _normalize_pubsub_id( value: str, - resource_pattern: re.Pattern, + resource_pattern: re.Pattern[str], label: str, resource_hint: str, - ) -> Tuple[Optional[str], Optional[str]]: + ) -> tuple[str | None, str | None]: if not value: return None, f'{label} is required' @@ -298,7 +300,7 @@ def _normalize_pubsub_id( ) @classmethod - def _normalize_subscription_id(cls, value: str) -> Tuple[Optional[str], Optional[str]]: + def _normalize_subscription_id(cls, value: str) -> tuple[str | None, str | None]: return cls._normalize_pubsub_id( value, _SUBSCRIPTION_RESOURCE_PATTERN, @@ -307,7 +309,7 @@ def _normalize_subscription_id(cls, value: str) -> Tuple[Optional[str], Optional ) @classmethod - def _normalize_topic_id(cls, value: str) -> Tuple[Optional[str], Optional[str]]: + def _normalize_topic_id(cls, value: str) -> tuple[str | None, str | None]: return cls._normalize_pubsub_id( value, _TOPIC_RESOURCE_PATTERN,