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 <vadimkerr@gmail.com>
This commit is contained in:
Innokentii Konstantinov 2024-07-19 18:08:52 +08:00 committed by GitHub
parent 8d82b078d3
commit 1465db36e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 319 additions and 82 deletions

View file

@ -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

View file

@ -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):

View file

@ -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.

View file

@ -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

View file

@ -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"]

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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
)

View file

@ -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:

View file

@ -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