Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 67 additions & 23 deletions keepercommander/service/commands/integrations/gchat_app_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@

from .... import vault
from ....display import bcolors
from ....error import CommandError
from ...docker import GChatConfig, GChatConstants
from .approvals_setup import (
ApprovalsChannelProfile,
print_approvals_config,
)
from .integration_setup_base import IntegrationSetupCommand

# Short Pub/Sub IDs, or full resource names:
Expand All @@ -33,6 +38,24 @@
r'^projects/([^/]+)/topics/([A-Za-z][\w.-]{2,})$'
)

def _validate_gchat_space_id(space_id: str) -> bool:
prefix = GChatConstants.SPACE_ID_PREFIX
return bool(space_id and space_id.startswith(prefix) and len(space_id) > len(prefix))


GCHAT_APPROVALS_PROFILE = ApprovalsChannelProfile(
channel_header='APPROVALS_SPACE_ID',
single_channel_description='Google Chat space ID for approval notifications',
channel_description='Google Chat space where approval requests for this approver team are sent',
default_channel_description=(
'Default space for EPM privilege requests, SSO Cloud device approvals, '
'and approval requests from users not assigned to an approver team'
),
channel_prompt='Space ID (starts with spaces/):',
validate_channel=_validate_gchat_space_id,
channel_error="Invalid Approvals Space ID (must start with 'spaces/' and include a space name)",
)


class GChatAppSetupCommand(IntegrationSetupCommand):
"""Google Chat App integration setup."""
Expand All @@ -52,6 +75,9 @@ def get_default_record_name(self) -> str:
def get_integration_config_marker_field(self) -> str:
return GChatConstants.FIELD_SERVICE_ACCOUNT_JSON

def get_approvals_profile(self):
return GCHAT_APPROVALS_PROFILE

# ── Google Chat-specific configuration ────────────────────────

def collect_integration_config(self, params):
Expand Down Expand Up @@ -82,14 +108,6 @@ def collect_integration_config(self, params):
google_project_id,
)

print(f"\n{bcolors.BOLD}CHAT_APPROVALS_SPACE_ID:{bcolors.ENDC}")
print(f" Google Chat space where approval cards are posted")
chat_approvals_space_id = self._prompt_with_validation(
"Space ID (starts with spaces/):",
self._is_valid_space_id,
"Invalid Approvals Space ID (must start with 'spaces/' and include a space name)"
)

print(f"\n{bcolors.BOLD}CHAT COMMAND IDs:{bcolors.ENDC}")
print(f" Slash command IDs configured for the Google Chat app")
chat_command_request_record_id = self._prompt_command_id(
Expand All @@ -109,6 +127,14 @@ def collect_integration_config(self, params):
GChatConstants.DEFAULT_COMMAND_CREATE_SECRET_ID,
)

profile = self.get_approvals_profile()
if profile is None:
raise CommandError(
self.get_command_name(),
'Internal error: Google Chat approvals profile not configured'
)
approvals = self._collect_approvals_config(params, profile)

pedm_enabled, pedm_interval = self._collect_pedm_config()
da_enabled, da_interval = self._collect_device_approval_config()

Expand All @@ -119,7 +145,7 @@ def collect_integration_config(self, params):
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,
approvals=approvals,
chat_command_request_record_id=chat_command_request_record_id,
chat_command_request_folder_id=chat_command_request_folder_id,
chat_command_external_share_id=chat_command_external_share_id,
Expand All @@ -146,11 +172,6 @@ def build_record_custom_fields(self, config):
vault.TypedField.new_field(
'text', config.google_topic_id, GChatConstants.FIELD_TOPIC_ID
),
vault.TypedField.new_field(
'text',
config.chat_approvals_space_id,
GChatConstants.FIELD_APPROVALS_SPACE_ID,
),
vault.TypedField.new_field(
'text',
config.chat_command_request_record_id,
Expand All @@ -171,6 +192,7 @@ def build_record_custom_fields(self, config):
config.chat_command_create_secret_id,
GChatConstants.FIELD_COMMAND_CREATE_SECRET_ID,
),
*self._gchat_approvals_record_fields(config.approvals),
vault.TypedField.new_field(
'text',
'true' if config.pedm_enabled else 'false',
Expand All @@ -193,6 +215,35 @@ def build_record_custom_fields(self, config):
),
]

def _gchat_approvals_record_fields(self, approvals):
import json
teams_json = ''
if approvals.multi_channel_enabled:
teams_data = []
for team in approvals.teams:
teams_data.append({
'team_uid': team.team_uid,
'name': team.name,
'space_id': team.channel_id,
'folder_uids': team.folder_uids,
'record_uids': team.record_uids,
})
teams_json = json.dumps(teams_data, indent=2)

return [
vault.TypedField.new_field(
'text',
'true' if approvals.multi_channel_enabled else 'false',
'multi_channel_approvers_enabled',
),
vault.TypedField.new_field(
'text',
approvals.single_channel_id,
'chat_approvals_space_id',
),
vault.TypedField.new_field('multiline', teams_json, 'approvals_teams'),
]

# ── Display ───────────────────────────────────────────────────

def print_integration_specific_resources(self, config):
Expand All @@ -202,10 +253,6 @@ def print_integration_specific_resources(self, config):
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}"
Expand All @@ -222,6 +269,7 @@ def print_integration_specific_resources(self, config):
f" • /keeper-create-secret ID: "
f"{bcolors.OKBLUE}{config.chat_command_create_secret_id}{bcolors.ENDC}"
)
print_approvals_config(config.approvals)

def print_integration_commands(self):
print(f"\n{bcolors.BOLD}Google Chat Commands Available:{bcolors.ENDC}")
Expand Down Expand Up @@ -315,7 +363,7 @@ def _load_service_account_json(cls, path: str) -> tuple[dict | None, str | None]
return cls._validate_service_account_dict(data)

@staticmethod
def _validate_service_account_dict(data: any) -> tuple[dict | None, str | None]:
def _validate_service_account_dict(data: object) -> tuple[dict | None, str | None]:
if not isinstance(data, dict):
return None, 'Service account JSON must be a JSON object'

Expand Down Expand Up @@ -389,7 +437,3 @@ def _normalize_topic_id(
google_project_id,
)

@staticmethod
def _is_valid_space_id(value: str) -> bool:
prefix = GChatConstants.SPACE_ID_PREFIX
return bool(value and value.startswith(prefix) and len(value) > len(prefix))
6 changes: 5 additions & 1 deletion keepercommander/service/docker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class GChatConfig:
google_project_id: str
google_subscription_id: str
google_topic_id: str
chat_approvals_space_id: str
approvals: ApprovalsConfig
chat_command_request_record_id: str = GChatConstants.DEFAULT_COMMAND_REQUEST_RECORD_ID
chat_command_request_folder_id: str = GChatConstants.DEFAULT_COMMAND_REQUEST_FOLDER_ID
chat_command_external_share_id: str = GChatConstants.DEFAULT_COMMAND_EXTERNAL_SHARE_ID
Expand All @@ -186,3 +186,7 @@ class GChatConfig:
pedm_polling_interval: int = 120
device_approval_enabled: bool = False
device_approval_polling_interval: int = 120

@property
def chat_approvals_space_id(self) -> str:
return self.approvals.single_channel_id
57 changes: 57 additions & 0 deletions unit-tests/service/test_docker_setup_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,63 @@ def test_find_record_in_folder_skips_non_owned(self, mock_get):
'OWNED_REC',
)

def test_gchat_find_folder_uid_by_name_skips_non_owned(self):
from keepercommander.service.commands.integrations.gchat_app_setup import (
GChatAppSetupCommand,
)

params = _params(shared_folder_cache={
'ATTACKER_SF': {
'name': FOLDER_NAME,
'owner_username': 'mallory@corp.example',
},
'OWNED_SF': {
'name': FOLDER_NAME,
'owner_username': 'operator@corp.example',
},
})
self.assertEqual(
GChatAppSetupCommand()._find_folder_uid_by_name(params, FOLDER_NAME),
'OWNED_SF',
)

def test_gchat_find_folder_uid_by_name_returns_none_for_squat_only(self):
from keepercommander.service.commands.integrations.gchat_app_setup import (
GChatAppSetupCommand,
)

params = _params(shared_folder_cache={
'ATTACKER_SF': {
'name': FOLDER_NAME,
'owner_username': 'mallory@corp.example',
},
})
self.assertIsNone(
GChatAppSetupCommand()._find_folder_uid_by_name(params, FOLDER_NAME)
)

@patch('keepercommander.service.commands.integrations.integration_setup_base.api.get_record')
def test_gchat_find_record_in_folder_skips_non_owned(self, mock_get):
from keepercommander.service.commands.integrations.gchat_app_setup import (
GChatAppSetupCommand,
)

owned = MagicMock(title=RECORD_NAME)
shared = MagicMock(title=RECORD_NAME)
mock_get.side_effect = lambda _p, uid: shared if uid == 'SHARED_REC' else owned

params = _params(
subfolder_record_cache={'FOLDER': ['SHARED_REC', 'OWNED_REC']},
record_owner_cache={
'SHARED_REC': RecordOwner(False, 'attacker'),
'OWNED_REC': RecordOwner(True, 'operator'),
},
)
self.assertEqual(
GChatAppSetupCommand()._find_record_in_folder(params, 'FOLDER', RECORD_NAME),
'OWNED_REC',
)


if __name__ == '__main__':
unittest.main()
75 changes: 67 additions & 8 deletions unit-tests/service/test_gchat_app_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import unittest

from keepercommander.service.commands.integrations.gchat_app_setup import GChatAppSetupCommand
from keepercommander.service.docker import GChatConfig, GChatConstants
from keepercommander.service.docker import GChatConfig, GChatConstants, ApprovalsConfig


def _valid_service_account(**overrides):
Expand Down Expand Up @@ -149,19 +149,50 @@ def test_normalize_topic_missing(self):
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_chat_approvals_space_id_property(self):
from keepercommander.service.docker import ApprovalsConfig
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',
approvals=ApprovalsConfig(multi_channel_enabled=False, single_channel_id='spaces/TEST'),
)
self.assertEqual(config.chat_approvals_space_id, 'spaces/TEST')
self.assertEqual(config.chat_approvals_space_id, config.approvals.single_channel_id)

def test_multi_channel_approval_config_serialization(self):
from keepercommander.service.docker import ApprovalsConfig, ApproverTeam

config = ApprovalsConfig(
multi_channel_enabled=True,
single_channel_id='spaces/DEFAULT',
teams=[
ApproverTeam(team_uid='team1', name='Team A', channel_id='spaces/A'),
ApproverTeam(team_uid='team2', name='Team B', channel_id='spaces/B',
folder_uids=['folder1']),
]
)
fields = {
field.label: field.get_default_value()
for field in self.cmd._gchat_approvals_record_fields(config)
}
self.assertEqual(fields['multi_channel_approvers_enabled'], 'true')
self.assertEqual(fields['chat_approvals_space_id'], 'spaces/DEFAULT')
teams_json = json.loads(fields['approvals_teams'])
self.assertEqual(len(teams_json), 2)
self.assertEqual(teams_json[0]['team_uid'], 'team1')
self.assertEqual(teams_json[0]['space_id'], 'spaces/A')
self.assertEqual(teams_json[1]['folder_uids'], ['folder1'])

def test_build_record_custom_fields(self):
from keepercommander.service.docker import ApprovalsConfig
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',
approvals=ApprovalsConfig(multi_channel_enabled=False, single_channel_id='spaces/AAAA'),
chat_command_request_record_id='1',
chat_command_request_folder_id='2',
chat_command_external_share_id='3',
Expand All @@ -182,7 +213,7 @@ def test_build_record_custom_fields(self):
self.assertEqual(fields[GChatConstants.FIELD_PROJECT_ID], 'my-gcp-project')
self.assertEqual(fields[GChatConstants.FIELD_SUBSCRIPTION_ID], 'keeper-chat-events')
self.assertEqual(fields[GChatConstants.FIELD_TOPIC_ID], 'keeper-chat-topic')
self.assertEqual(fields[GChatConstants.FIELD_APPROVALS_SPACE_ID], 'spaces/AAAA')
self.assertEqual(fields['chat_approvals_space_id'], 'spaces/AAAA')
self.assertEqual(fields[GChatConstants.FIELD_COMMAND_REQUEST_RECORD_ID], '1')
self.assertEqual(fields[GChatConstants.FIELD_COMMAND_REQUEST_FOLDER_ID], '2')
self.assertEqual(fields[GChatConstants.FIELD_COMMAND_EXTERNAL_SHARE_ID], '3')
Expand All @@ -191,6 +222,34 @@ def test_build_record_custom_fields(self):
self.assertEqual(fields[GChatConstants.FIELD_PEDM_POLLING_INTERVAL], '60')
self.assertEqual(fields[GChatConstants.FIELD_DEVICE_APPROVAL_ENABLED], 'false')

def test_get_approvals_profile(self):
profile = self.cmd.get_approvals_profile()
self.assertIsNotNone(profile)
self.assertEqual(profile.channel_header, 'APPROVALS_SPACE_ID')
self.assertEqual(profile.channel_prompt, 'Space ID (starts with spaces/):')
self.assertTrue(profile.validate_channel('spaces/AAAA'))
self.assertFalse(profile.validate_channel('invalid'))

def test_build_record_custom_fields_includes_approvals_fields(self):
from keepercommander.service.docker import ApprovalsConfig
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',
approvals=ApprovalsConfig(multi_channel_enabled=False, single_channel_id='spaces/AAAA'),
)
fields = {
field.label: field.get_default_value()
for field in self.cmd.build_record_custom_fields(config)
}
self.assertIn('multi_channel_approvers_enabled', fields)
self.assertIn('chat_approvals_space_id', fields)
self.assertIn('approvals_teams', fields)
self.assertEqual(fields['multi_channel_approvers_enabled'], 'false')
self.assertEqual(fields['chat_approvals_space_id'], 'spaces/AAAA')
self.assertEqual(fields['approvals_teams'], '')


if __name__ == '__main__':
unittest.main()