From 1465db36e55852bcd4a6cf5e30d389e688c53cea Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Fri, 19 Jul 2024 18:08:52 +0800 Subject: [PATCH] Support stack header from chatops-proxy (#4578) This PR supports new flow of selecting org to run command in a slack workspace if several stacks are using it. In this case user selects default stack to run commands or pass a --stack flag. Both handled by chatops-proxy which injects selected stack as a header. On a side note - I found one ScenarioStep with incompatible set of arguments with parent class. I didn't fixed it, just left TODO https://github.com/grafana/oncall/pull/4578/files#diff-e323b5f38ed665f73d5da3fa0575958ed54ab587f6521b4cd87e1491b5430f8bR364 Related to https://github.com/grafana/oncall-gateway/issues/256 --------- Co-authored-by: Vadim Stepanov --- .../slack/scenarios/alertgroup_appearance.py | 7 +- .../slack/scenarios/alertgroup_timeline.py | 2 + .../apps/slack/scenarios/declare_incident.py | 4 +- .../apps/slack/scenarios/distribute_alerts.py | 40 ++++--- .../slack/scenarios/invited_to_channel.py | 4 +- .../apps/slack/scenarios/manage_responders.py | 15 ++- .../scenarios/notified_user_not_in_channel.py | 4 +- engine/apps/slack/scenarios/onboarding.py | 7 +- engine/apps/slack/scenarios/paging.py | 70 +++++++++--- engine/apps/slack/scenarios/profile_update.py | 4 +- .../apps/slack/scenarios/resolution_note.py | 11 +- engine/apps/slack/scenarios/scenario_step.py | 11 ++ engine/apps/slack/scenarios/schedules.py | 4 +- .../slack/scenarios/shift_swap_requests.py | 4 +- engine/apps/slack/scenarios/slack_channel.py | 13 ++- .../scenarios/slack_channel_integration.py | 4 +- .../apps/slack/scenarios/slack_usergroup.py | 7 +- .../tests/test_interactive_api_endpoint.py | 64 +++++++++-- .../tests/test_scenario_steps/test_paging.py | 107 ++++++++++++++---- engine/apps/slack/views.py | 19 +++- 20 files changed, 319 insertions(+), 82 deletions(-) diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index 431e1a21..b07bd85b 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -18,6 +18,7 @@ from .step_mixins import AlertGroupActionsMixin if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): @@ -27,7 +28,8 @@ class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.Scenar self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -75,7 +77,8 @@ class UpdateAppearanceStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: from apps.alerts.models import AlertGroup diff --git a/engine/apps/slack/scenarios/alertgroup_timeline.py b/engine/apps/slack/scenarios/alertgroup_timeline.py index 87976b49..08f74b88 100644 --- a/engine/apps/slack/scenarios/alertgroup_timeline.py +++ b/engine/apps/slack/scenarios/alertgroup_timeline.py @@ -13,6 +13,7 @@ from apps.slack.types import ( PayloadType, ScenarioRoute, ) +from apps.user_management.models import Organization from .step_mixins import AlertGroupActionsMixin @@ -28,6 +29,7 @@ class OpenAlertGroupTimelineDialogStep(AlertGroupActionsMixin, scenario_step.Sce slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", payload: EventPayload, + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): diff --git a/engine/apps/slack/scenarios/declare_incident.py b/engine/apps/slack/scenarios/declare_incident.py index 7a7589df..cf50e20d 100644 --- a/engine/apps/slack/scenarios/declare_incident.py +++ b/engine/apps/slack/scenarios/declare_incident.py @@ -5,6 +5,7 @@ from apps.slack.types import BlockActionType, EventPayload, PayloadType, Scenari if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization class DeclareIncidentStep(scenario_step.ScenarioStep): @@ -12,7 +13,8 @@ class DeclareIncidentStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Slack sends a POST request to the backend upon clicking a button with a redirect link to Incident. diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 6eb55860..c4fb8890 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -50,6 +50,7 @@ from .step_mixins import AlertGroupActionsMixin if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization ATTACH_TO_ALERT_GROUPS_LIMIT = 20 @@ -222,7 +223,8 @@ class AlertShootingStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: pass @@ -239,7 +241,8 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: from apps.user_management.models import User @@ -275,7 +278,8 @@ class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -304,7 +308,8 @@ class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -324,7 +329,8 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -490,7 +496,8 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: # submit selection in modal window if payload["type"] == PayloadType.VIEW_SUBMISSION: @@ -542,7 +549,8 @@ class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -567,7 +575,8 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -594,7 +603,8 @@ class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: ResolutionNoteModalStep = scenario_step.ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") @@ -635,7 +645,8 @@ class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -655,7 +666,8 @@ class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -675,7 +687,8 @@ class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep) self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -736,7 +749,8 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: from apps.alerts.models import AlertGroup diff --git a/engine/apps/slack/scenarios/invited_to_channel.py b/engine/apps/slack/scenarios/invited_to_channel.py index e56ab9f4..acdd5fec 100644 --- a/engine/apps/slack/scenarios/invited_to_channel.py +++ b/engine/apps/slack/scenarios/invited_to_channel.py @@ -9,6 +9,7 @@ from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -19,7 +20,8 @@ class InvitedToChannelStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: if payload["event"]["user"] == slack_team_identity.bot_user_id: channel_id = payload["event"]["channel"] diff --git a/engine/apps/slack/scenarios/manage_responders.py b/engine/apps/slack/scenarios/manage_responders.py index 63b8bd7f..8068475a 100644 --- a/engine/apps/slack/scenarios/manage_responders.py +++ b/engine/apps/slack/scenarios/manage_responders.py @@ -18,7 +18,8 @@ from apps.slack.types import Block, BlockActionType, EventPayload, ModalView, Pa if typing.TYPE_CHECKING: from apps.alerts.models import AlertGroup from apps.slack.models import SlackTeamIdentity, SlackUserIdentity - from apps.user_management.models import User + from apps.user_management.models import Organization, User + MANAGE_RESPONDERS_USER_SELECT_ID = "responders_user_select" @@ -35,7 +36,8 @@ class StartManageResponders(AlertGroupActionsMixin, scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = self.get_alert_group(slack_team_identity, payload) if not self.is_authorized(alert_group): @@ -53,7 +55,8 @@ class ManageRespondersUserChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_user = _get_selected_user_from_payload(payload) @@ -99,7 +102,8 @@ class ManageRespondersConfirmUserChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_user = _get_selected_user_from_payload(payload) @@ -132,7 +136,8 @@ class ManageRespondersRemoveUser(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: alert_group = _get_alert_group_from_payload(payload) selected_user = _get_selected_user_from_payload(payload) diff --git a/engine/apps/slack/scenarios/notified_user_not_in_channel.py b/engine/apps/slack/scenarios/notified_user_not_in_channel.py index d3f045d4..f1af5831 100644 --- a/engine/apps/slack/scenarios/notified_user_not_in_channel.py +++ b/engine/apps/slack/scenarios/notified_user_not_in_channel.py @@ -6,6 +6,7 @@ from apps.slack.types import BlockActionType, EventPayload, PayloadType, Scenari if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization logger = logging.getLogger(__name__) @@ -20,7 +21,8 @@ class NotifiedUserNotInChannelStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: logger.info("Gracefully handle NotifiedUserNotInChannelStep. Do nothing.") pass diff --git a/engine/apps/slack/scenarios/onboarding.py b/engine/apps/slack/scenarios/onboarding.py index d84dc1ef..9ca5305b 100644 --- a/engine/apps/slack/scenarios/onboarding.py +++ b/engine/apps/slack/scenarios/onboarding.py @@ -6,6 +6,7 @@ from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization logger = logging.getLogger(__name__) @@ -19,7 +20,8 @@ class ImOpenStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: logger.info("InOpenStep, doing nothing.") @@ -29,7 +31,8 @@ class AppHomeOpenedStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: pass diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index b9191234..ee38dc36 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -121,7 +121,8 @@ class StartDirectPaging(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: input_id_prefix = _generate_input_id_prefix() @@ -136,6 +137,11 @@ class StartDirectPaging(scenario_step.ScenarioStep): "submit_routing_uid": FinishDirectPaging.routing_uid(), DataKey.USERS: {}, } + # We have access to predefined org only in StartDirectPaging, since it's a slash command. + # Chatops-Proxy adds a special header to slash commands payload to define the organization. + # Other Paging steps are triggered by buttons and actions, + # so we don't have access to predefined org and use private metadata instead. + private_metadata = _inject_predefined_org_to_private_metadata(predefined_org, private_metadata) initial_payload = {"view": {"private_metadata": json.dumps(private_metadata)}} view = render_dialog(slack_user_identity, slack_team_identity, initial_payload, initial=True) self._slack_client.views_open( @@ -153,13 +159,15 @@ class FinishDirectPaging(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: message = _get_message_from_payload(payload) private_metadata = json.loads(payload["view"]["private_metadata"]) + predefined_org = _get_predefined_org_from_private_metadata(private_metadata, slack_team_identity) channel_id = private_metadata["channel_id"] input_id_prefix = private_metadata["input_id_prefix"] - selected_organization = _get_selected_org_from_payload( + selected_organization = predefined_org or _get_selected_org_from_payload( payload, input_id_prefix, slack_team_identity, slack_user_identity ) @@ -245,7 +253,8 @@ class OnPagingOrgChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: updated_payload = reset_items(payload) view = render_dialog(slack_user_identity, slack_team_identity, updated_payload) @@ -263,7 +272,8 @@ class OnPagingTeamChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: view = render_dialog(slack_user_identity, slack_team_identity, payload) self._slack_client.views_update( @@ -283,7 +293,8 @@ class OnPagingUserChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: private_metadata = json.loads(payload["view"]["private_metadata"]) selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"]) @@ -336,7 +347,8 @@ class OnPagingItemActionChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: policy, key, user_pk = self._parse_action(payload) @@ -361,7 +373,8 @@ class OnPagingConfirmUserChange(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: metadata = json.loads(payload["view"]["private_metadata"]) @@ -409,7 +422,7 @@ def render_dialog( private_metadata = json.loads(payload["view"]["private_metadata"]) submit_routing_uid = private_metadata.get("submit_routing_uid") - # Get organizations available to user + predefined_org = _get_predefined_org_from_private_metadata(private_metadata, slack_team_identity) available_organizations = _get_available_organizations(slack_team_identity, slack_user_identity) if initial: @@ -417,15 +430,17 @@ def render_dialog( new_input_id_prefix = _generate_input_id_prefix() new_private_metadata = private_metadata new_private_metadata["input_id_prefix"] = new_input_id_prefix - selected_organization = available_organizations.first() + selected_organization = predefined_org if predefined_org else available_organizations.first() is_team_selected, selected_team = False, None else: # setup form using data/state old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( private_metadata ) - selected_organization = _get_selected_org_from_payload( - payload, old_input_id_prefix, slack_team_identity, slack_user_identity + selected_organization = ( + predefined_org + if predefined_org + else _get_selected_org_from_payload(payload, old_input_id_prefix, slack_team_identity, slack_user_identity) ) is_team_selected, selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix) @@ -441,8 +456,9 @@ def render_dialog( blocks.append(_get_message_input(payload)) - # Add organization select if more than one organization available for user - if len(available_organizations) > 1: + # Add organization select if org is not defined on chatops-proxy (it's should happen only in OSS) + # and user has access to multiple orgs. + if not predefined_org and len(available_organizations) > 1: organization_select = _get_organization_select( available_organizations, selected_organization, new_input_id_prefix ) @@ -570,6 +586,32 @@ def _get_selected_org_from_payload( return Organization.objects.filter(pk=selected_org_id).first() +def _inject_predefined_org_to_private_metadata( + predefined_org: typing.Optional["Organization"], private_metadata: dict +) -> dict: + """ + Injects predefined organization to private metadata. + Predefined org is org defined by chatops-proxy for slash commands. + """ + if predefined_org: + private_metadata["organization_id"] = predefined_org.pk + return private_metadata + + +def _get_predefined_org_from_private_metadata( + private_metadata: dict, + slack_team_identity: "SlackTeamIdentity", +) -> typing.Optional["Organization"]: + """ + Returns organization from private metadata. + """ + org_id = private_metadata.get("organization_id") + if not org_id: + return None + + return slack_team_identity.organizations.filter(pk=org_id).first() + + def _get_team_select_blocks( slack_user_identity: "SlackUserIdentity", organization: "Organization", diff --git a/engine/apps/slack/scenarios/profile_update.py b/engine/apps/slack/scenarios/profile_update.py index 3c46d9ba..fa7cd983 100644 --- a/engine/apps/slack/scenarios/profile_update.py +++ b/engine/apps/slack/scenarios/profile_update.py @@ -6,6 +6,7 @@ from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization class ProfileUpdateStep(scenario_step.ScenarioStep): @@ -13,7 +14,8 @@ class ProfileUpdateStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: Any update in Slack Profile. diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index 67ddea66..50f907e7 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -38,6 +38,8 @@ from .step_mixins import AlertGroupActionsMixin if typing.TYPE_CHECKING: from apps.alerts.models import AlertGroup, ResolutionNote, ResolutionNoteSlackMessage from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization + logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -65,7 +67,8 @@ class AddToResolutionNoteStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: from apps.alerts.models import ResolutionNote, ResolutionNoteSlackMessage from apps.slack.models import SlackMessage, SlackUserIdentity @@ -357,7 +360,8 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", payload: EventPayload, - data: ScenarioData | None = None, + # TODO: data is incompatible override, parent class has a different set of arguments + data: ScenarioData | None = None, # type: ignore ) -> None: if data: # Argument "data" is used when step is called from other step, e.g. AddRemoveThreadMessageStep @@ -642,7 +646,8 @@ class AddRemoveThreadMessageStep(UpdateResolutionNoteStep, scenario_step.Scenari self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: from apps.alerts.models import AlertGroup, ResolutionNote, ResolutionNoteSlackMessage diff --git a/engine/apps/slack/scenarios/scenario_step.py b/engine/apps/slack/scenarios/scenario_step.py index 37fa2911..99811ebb 100644 --- a/engine/apps/slack/scenarios/scenario_step.py +++ b/engine/apps/slack/scenarios/scenario_step.py @@ -48,7 +48,18 @@ class ScenarioStep(object): slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: + """ + process_scenario executes the logic of the step on slack interaction. + Args: + slack_user_identity: SlackUserIdentity who interacted with slack + slack_team_identity: Slack Workspace where interaction happened + payload: EventPayload from slack + predefined_org: + Organization where interaction happened. + It's optionally defined by chatops-proxy for slash commands and should be used only in SlashCommands steps + """ pass @classmethod diff --git a/engine/apps/slack/scenarios/schedules.py b/engine/apps/slack/scenarios/schedules.py index 4762d90c..105b8c47 100644 --- a/engine/apps/slack/scenarios/schedules.py +++ b/engine/apps/slack/scenarios/schedules.py @@ -20,6 +20,7 @@ from common.insight_log import EntityEvent, write_resource_insight_log if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): @@ -32,7 +33,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: action_type = payload["actions"][0]["type"] if action_type == BlockActionType.BUTTON: diff --git a/engine/apps/slack/scenarios/shift_swap_requests.py b/engine/apps/slack/scenarios/shift_swap_requests.py index d9c54bf1..d114f615 100644 --- a/engine/apps/slack/scenarios/shift_swap_requests.py +++ b/engine/apps/slack/scenarios/shift_swap_requests.py @@ -15,6 +15,7 @@ from apps.slack.utils import SlackDateFormat, format_datetime_to_slack, format_d if typing.TYPE_CHECKING: from apps.schedules.models import ShiftSwapRequest from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -197,7 +198,8 @@ class AcceptShiftSwapRequestStep(BaseShiftSwapRequestStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: from apps.schedules import exceptions from apps.schedules.models import ShiftSwapRequest diff --git a/engine/apps/slack/scenarios/slack_channel.py b/engine/apps/slack/scenarios/slack_channel.py index 882815e1..b9d8aa19 100644 --- a/engine/apps/slack/scenarios/slack_channel.py +++ b/engine/apps/slack/scenarios/slack_channel.py @@ -9,6 +9,7 @@ from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep): @@ -16,7 +17,8 @@ class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: Create or rename channel @@ -41,7 +43,8 @@ class SlackChannelDeletedEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: Delete channel @@ -63,7 +66,8 @@ class SlackChannelArchivedEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: Archive channel @@ -84,7 +88,8 @@ class SlackChannelUnArchivedEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: UnArchive channel diff --git a/engine/apps/slack/scenarios/slack_channel_integration.py b/engine/apps/slack/scenarios/slack_channel_integration.py index a4d1f637..a0bbc13b 100644 --- a/engine/apps/slack/scenarios/slack_channel_integration.py +++ b/engine/apps/slack/scenarios/slack_channel_integration.py @@ -7,6 +7,7 @@ from apps.slack.types import EventPayload, EventType, MessageEventSubtype, Paylo if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -17,7 +18,8 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: Any new message in channel. diff --git a/engine/apps/slack/scenarios/slack_usergroup.py b/engine/apps/slack/scenarios/slack_usergroup.py index 865ec688..79f409fd 100644 --- a/engine/apps/slack/scenarios/slack_usergroup.py +++ b/engine/apps/slack/scenarios/slack_usergroup.py @@ -7,6 +7,7 @@ from apps.slack.types import EventPayload, EventType, PayloadType, ScenarioRoute if typing.TYPE_CHECKING: from apps.slack.models import SlackTeamIdentity, SlackUserIdentity + from apps.user_management.models import Organization class SlackUserGroupEventStep(scenario_step.ScenarioStep): @@ -14,7 +15,8 @@ class SlackUserGroupEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: creation user groups or changes in user groups except its members. @@ -45,7 +47,8 @@ class SlackUserGroupMembersChangedEventStep(scenario_step.ScenarioStep): self, slack_user_identity: "SlackUserIdentity", slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, + payload: "EventPayload", + predefined_org: typing.Optional["Organization"] = None, ) -> None: """ Triggered by action: changed members in user group. diff --git a/engine/apps/slack/tests/test_interactive_api_endpoint.py b/engine/apps/slack/tests/test_interactive_api_endpoint.py index 2c117f78..a8be48c3 100644 --- a/engine/apps/slack/tests/test_interactive_api_endpoint.py +++ b/engine/apps/slack/tests/test_interactive_api_endpoint.py @@ -29,15 +29,18 @@ SLACK_BOT_USER_ID = "mncvnmvcmnvcmncv,,cx," SLACK_USER_ID = "iurtiurituritu" -def _make_request(payload): +def _make_request(payload, predefined_org=None): + headers = { + "HTTP_X_SLACK_SIGNATURE": "asdfasdf", + "HTTP_X_SLACK_REQUEST_TIMESTAMP": "xxcxcvx", + } + if predefined_org: + headers["HTTP_X_CHATOPS_STACK_ID"] = predefined_org.stack_id return APIClient().post( "/slack/interactive_api_endpoint/", format="json", data=payload, - **{ - "HTTP_X_SLACK_SIGNATURE": "asdfasdf", - "HTTP_X_SLACK_REQUEST_TIMESTAMP": "xxcxcvx", - }, + **headers, ) @@ -312,7 +315,52 @@ def test_grafana_escalate( response = _make_request(payload) assert response.status_code == status.HTTP_200_OK - mock_process_scenario.assert_called_once_with(slack_user_identity, slack_team_identity, payload) + mock_process_scenario.assert_called_once_with( + slack_user_identity, slack_team_identity, payload, predefined_org=None + ) + + +@patch("apps.slack.views.SlackEventApiEndpointView.verify_signature", return_value=True) +@patch.object(StartDirectPaging, "process_scenario") +@pytest.mark.django_db +def test_grafana_escalate_with_org_from_chatops_proxy_defines_org( + mock_process_scenario, + _mock_verify_signature, + make_organization, + make_slack_user_identity, + make_user, + slack_team_identity, +): + """ + Check StartDirectPaging.process_scenario gets called when a user types /grafana escalate. + UnifiedSlackApp commands are prefixed with /grafana. + """ + organization = make_organization(slack_team_identity=slack_team_identity) + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity, slack_id=SLACK_USER_ID) + make_user(organization=organization, slack_user_identity=slack_user_identity) + + payload = { + "token": "gIkuvaNzQIHg97ATvDxqgjtO", + "team_id": slack_team_identity.slack_id, + "team_domain": "example", + "enterprise_id": "E0001", + "enterprise_name": "Globular%20Construct%20Inc", + "channel_id": "C2147483705", + "channel_name": "test", + "user_id": slack_user_identity.slack_id, + "user_name": "Steve", + "command": "/grafana", + "text": "escalate", + "response_url": "https://hooks.slack.com/commands/1234/5678", + "trigger_id": "13345224609.738474920.8088930838d88f008e0", + "api": "api_value", + } + response = _make_request(payload, predefined_org=organization) + + assert response.status_code == status.HTTP_200_OK + mock_process_scenario.assert_called_once_with( + slack_user_identity, slack_team_identity, payload, predefined_org=organization + ) @patch("apps.slack.views.SlackEventApiEndpointView.verify_signature", return_value=True) @@ -353,4 +401,6 @@ def test_escalate( response = _make_request(payload) assert response.status_code == status.HTTP_200_OK - mock_process_scenario.assert_called_once_with(slack_user_identity, slack_team_identity, payload) + mock_process_scenario.assert_called_once_with( + slack_user_identity, slack_team_identity, payload, predefined_org=None + ) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py index a46ce6ab..0e80ac32 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -27,26 +27,43 @@ from apps.slack.scenarios.paging import ( from apps.user_management.models import Organization -def make_slack_payload(organization, team=None, user=None, current_users=None, actions=None): +def make_paging_view_slack_payload( + selected_org=None, predefined_org=None, team=None, user=None, current_users=None, actions=None +): + """ + Helper function to create a payload for paging view. + Args: + selected_org: selected organization + predefined_org: predefined organization parsed from chatops-proxy headers + team: selected team object. + user: selected user object. + current_users: Dictionary of current users. + actions: List of actions. + """ + organization = selected_org or predefined_org + if organization is None: + raise Exception("either selected or predifined org must be defined") + private_metadata = { + "input_id_prefix": "", + "channel_id": "123", + "submit_routing_uid": "FinishStepUID", + DataKey.USERS: current_users or {}, + } + if predefined_org: + private_metadata["organization_id"] = str(predefined_org.pk) payload = { "channel_id": "123", "trigger_id": "111", "view": { "id": "view-id", - "private_metadata": make_private_metadata( - { - "input_id_prefix": "", - "channel_id": "123", - "submit_routing_uid": "FinishStepUID", - DataKey.USERS: current_users or {}, - }, - organization, - ), + "private_metadata": make_private_metadata(private_metadata, organization), "state": { "values": { DIRECT_PAGING_ORG_SELECT_ID: { OnPagingOrgChange.routing_uid(): { - "selected_option": {"value": make_value({"id": organization.pk}, organization)} + "selected_option": { + "value": make_value({"id": organization.pk if selected_org else None}, organization) + } } }, DIRECT_PAGING_TEAM_SELECT_ID: { @@ -84,6 +101,50 @@ def test_initial_state( assert metadata[DataKey.USERS] == {} +@pytest.mark.django_db +def test_org_predefined( + make_organization_and_user_with_slack_identities, +): + """ + See get_org_from_chatops_proxy_header function. + """ + org, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + payload = {"channel_id": "123", "trigger_id": "111"} + + step = StartDirectPaging(slack_team_identity, user=user) + with patch.object(step._slack_client, "views_open") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload, predefined_org=org) + + view = mock_slack_api_call.call_args.kwargs["view"] + metadata = json.loads(view["private_metadata"]) + # Test that organization is injected to private metadata if it is defined by chatops-proxy. + assert metadata["organization_id"] == org.pk + # Test that organization select is not present if org defined by chatops-proxy. + for block in view["blocks"]: + if block.get("block_id") == DIRECT_PAGING_ORG_SELECT_ID: + raise AssertionError("Organization select block should not be present in the view") + + +@pytest.mark.django_db +def test_page_team_with_predefined_org(make_organization_and_user_with_slack_identities, make_team): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + team = make_team(organization) + payload = make_paging_view_slack_payload(predefined_org=organization, team=team) + + step = FinishDirectPaging(slack_team_identity) + with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: + with patch.object(step._slack_client, "api_call"): + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + mock_direct_paging.assert_called_once_with( + organization=organization, + from_user=user, + message="The Message", + team=team, + users=[], + ) + + @pytest.mark.parametrize("role", (LegacyAccessControlRole.VIEWER, LegacyAccessControlRole.NONE)) @pytest.mark.django_db def test_initial_unauthorized(make_organization_and_user_with_slack_identities, role): @@ -126,7 +187,7 @@ def test_add_user_no_warning(make_organization_and_user_with_slack_identities, m on_call_shift.add_rolling_users([[user]]) schedule.refresh_ical_file() - payload = make_slack_payload(organization=organization, user=user) + payload = make_paging_view_slack_payload(selected_org=organization, user=user) step = OnPagingUserChange(slack_team_identity) with patch.object(step._slack_client, "views_update") as mock_slack_api_call: @@ -161,7 +222,7 @@ def test_add_user_maximum_exceeded(make_organization_and_user_with_slack_identit on_call_shift.add_rolling_users([[user]]) schedule.refresh_ical_file() - payload = make_slack_payload(organization=organization, user=user) + payload = make_paging_view_slack_payload(selected_org=organization, user=user) step = OnPagingUserChange(slack_team_identity) with patch("apps.slack.scenarios.paging.PRIVATE_METADATA_MAX_LENGTH", 100): @@ -188,7 +249,7 @@ def test_add_user_maximum_exceeded(make_organization_and_user_with_slack_identit def test_add_user_raise_warning(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() # user is not on call - payload = make_slack_payload(organization=organization, user=user) + payload = make_paging_view_slack_payload(selected_org=organization, user=user) step = OnPagingUserChange(slack_team_identity) with patch.object(step._slack_client, "views_push") as mock_slack_api_call: @@ -209,8 +270,8 @@ def test_add_user_raise_warning(make_organization_and_user_with_slack_identities @pytest.mark.django_db def test_change_user_policy(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - payload = make_slack_payload( - organization=organization, + payload = make_paging_view_slack_payload( + selected_org=organization, actions=[ { "selected_option": { @@ -231,8 +292,8 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities): @pytest.mark.django_db def test_remove_user(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - payload = make_slack_payload( - organization=organization, + payload = make_paging_view_slack_payload( + selected_org=organization, actions=[ { "selected_option": { @@ -255,7 +316,7 @@ def test_remove_user(make_organization_and_user_with_slack_identities): @pytest.mark.django_db def test_trigger_paging_no_team_or_user_selected(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - payload = make_slack_payload(organization=organization) + payload = make_paging_view_slack_payload(selected_org=organization) step = FinishDirectPaging(slack_team_identity, user=user) @@ -277,7 +338,7 @@ def test_trigger_paging_unauthorized(make_organization_and_user_with_slack_ident organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities( role=role ) - payload = make_slack_payload(organization=organization) + payload = make_paging_view_slack_payload(selected_org=organization) step = FinishDirectPaging(slack_team_identity) with patch.object(step._slack_client, "api_call"): @@ -294,7 +355,9 @@ def test_trigger_paging_unauthorized(make_organization_and_user_with_slack_ident def test_trigger_paging_additional_responders(make_organization_and_user_with_slack_identities, make_team): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() team = make_team(organization) - payload = make_slack_payload(organization=organization, team=team, current_users={str(user.pk): Policy.IMPORTANT}) + payload = make_paging_view_slack_payload( + selected_org=organization, team=team, current_users={str(user.pk): Policy.IMPORTANT} + ) step = FinishDirectPaging(slack_team_identity) with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: @@ -314,7 +377,7 @@ def test_trigger_paging_additional_responders(make_organization_and_user_with_sl def test_page_team(make_organization_and_user_with_slack_identities, make_team): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() team = make_team(organization) - payload = make_slack_payload(organization=organization, team=team) + payload = make_paging_view_slack_payload(selected_org=organization, team=team) step = FinishDirectPaging(slack_team_identity) with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 7f23b70c..37eb70da 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -365,7 +365,8 @@ class SlackEventApiEndpointView(APIView): Step = route["step"] logger.info("Routing to {}".format(Step)) step = Step(slack_team_identity, organization, user) - step.process_scenario(slack_user_identity, slack_team_identity, payload) + org = get_org_from_chatops_proxy_header(request, slack_team_identity) + step.process_scenario(slack_user_identity, slack_team_identity, payload, predefined_org=org) step_was_found = True if payload_type == route_payload_type: @@ -592,3 +593,19 @@ class ResetSlackView(APIView): return Response({"error": e.error_message}, status=400) return Response(status=200) + + +def get_org_from_chatops_proxy_header(request, slack_team_identity) -> Organization | None: + """ + get_org_from_chatops_proxy_header extracts organization from the X-Chatops-Stack-ID header injected by chatops-proxy + """ + stack_id = request.META.get("HTTP_X_CHATOPS_STACK_ID") + if not stack_id: + return None + + try: + # get only orgs linked to the slack workspace to avoid tampering + return slack_team_identity.organizations.get(stack_id=stack_id) + except Organization.DoesNotExist: + logger.info(f"SlackEventApiEndpointView: get_org_from_header: organization with stack_id {stack_id} not found") + return None