def test_link_user_identity(self): """Do the auth flow and assert that the identity was created.""" # Assert that the identity does not exist. assert not self.find_identity() linking_url = build_linking_url( self.integration, self.organization, "UXXXXXXX1", "CXXXXXXX9", "http://example.slack.com/response_url", ) response = self.client.get(linking_url) assert response.status_code == 200 self.assertTemplateUsed(response, "sentry/auth-link-identity.html") response = self.client.post(linking_url) assert response.status_code == 200 self.assertTemplateUsed(response, "sentry/integrations/slack/linked.html") # Assert that the identity was created. assert self.find_identity() assert len(responses.calls) >= 1 data = json.loads(str(responses.calls[0].request.body.decode("utf-8"))) assert SUCCESS_LINKED_MESSAGE in data["text"]
def prompt_link(self, slack_request: SlackDMRequest) -> None: associate_url = build_linking_url( integration=slack_request.integration, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) if not slack_request.channel_name: return payload = { "token": self._get_access_token(slack_request.integration), "channel": slack_request.channel_name, "user": slack_request.user_id, "text": "Link your Slack identity to Sentry to unfurl Discover charts.", **SlackPromptLinkMessageBuilder(associate_url).as_payload(), } client = SlackClient() try: client.post("/chat.postEphemeral", data=payload) except ApiError as e: logger.error("slack.event.unfurl-error", extra={"error": str(e)}, exc_info=True)
def test_link_user_identity(self): linking_url = build_linking_url( self.integration, self.external_id, self.channel_id, self.response_url ) response = self.client.post(linking_url) assert response.status_code == 200 assert len(responses.calls) >= 1 data = json.loads(str(responses.calls[0].request.body.decode("utf-8"))) assert SUCCESS_LINKED_MESSAGE in get_response_text(data)
def test_ask_linking(self): """Freezing time to prevent flakiness from timestamp mismatch.""" resp = self.post_webhook(slack_user={"id": "invalid-id", "domain": "example"}) associate_url = build_linking_url( self.integration, self.organization, "invalid-id", "C065W1189", self.response_url ) assert resp.status_code == 200, resp.content assert resp.data["response_type"] == "ephemeral" assert resp.data["text"] == LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)
def test_ask_linking(self, sign): """Patching out `sign` to prevent flakiness from timestamp mismatch.""" sign.return_value = "signed_parameters" resp = self.post_webhook(slack_user={"id": "invalid-id", "domain": "example"}) associate_url = build_linking_url( self.integration, self.org, "invalid-id", "C065W1189", self.response_url ) assert resp.status_code == 200, resp.content assert resp.data["response_type"] == "ephemeral" assert resp.data["text"] == LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)
def link_user(self, slack_request: SlackRequest) -> Any: if slack_request.has_identity: return self.reply( slack_request, ALREADY_LINKED_MESSAGE.format(username=slack_request.identity_str) ) integration = slack_request.integration organization = integration.organizations.all()[0] associate_url = build_linking_url( integration=integration, organization=organization, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.reply(slack_request, LINK_USER_MESSAGE.format(associate_url=associate_url))
def prompt_link( self, data: Mapping[str, Any], slack_request: SlackRequest, integration: Integration, ): # This will break if multiple Sentry orgs are added # to a single Slack workspace and a user is a part of one # org and not the other. Since we pick the first org # in the integration organizations set, we might be picking # the org the user is not a part of. organization = integration.organizations.all()[0] associate_url = build_linking_url( integration=integration, organization=organization, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) builder = BlockSlackMessageBuilder() blocks = [ builder.get_markdown_block( "Link your Slack identity to Sentry to unfurl Discover charts." ), builder.get_action_block([("Link", associate_url, "link"), ("Cancel", None, "ignore")]), ] payload = { "token": self._get_access_token(integration), "channel": data["channel"], "user": data["user"], "text": "Link your Slack identity to Sentry to unfurl Discover charts.", "blocks": json.dumps(blocks), } client = SlackClient() try: client.post("/chat.postEphemeral", data=payload) except ApiError as e: logger.error("slack.event.unfurl-error", extra={"error": str(e)}, exc_info=True)
def link_user(self, slack_request: SlackCommandRequest) -> Response: if slack_request.has_identity: return self.send_ephemeral_notification( ALREADY_LINKED_MESSAGE.format( username=slack_request.identity_str)) integration = slack_request.integration organization = integration.organizations.all()[0] associate_url = build_linking_url( integration=integration, organization=organization, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.send_ephemeral_notification( LINK_USER_MESSAGE.format(associate_url=associate_url))
def link_user(self, slack_request: SlackDMRequest) -> Response: if slack_request.has_identity: return self.reply( slack_request, ALREADY_LINKED_MESSAGE.format( username=slack_request.identity_str)) if not (slack_request.integration and slack_request.user_id and slack_request.channel_id): raise SlackRequestError(status=status.HTTP_400_BAD_REQUEST) associate_url = build_linking_url( integration=slack_request.integration, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.reply( slack_request, LINK_USER_MESSAGE.format(associate_url=associate_url))
def test_overwrites_existing_identities(self): external_id_2 = "slack-id2" # Create a second user. user2 = self.create_user(is_superuser=False) self.create_member( user=user2, organization=self.organization, role="member", teams=[self.team] ) Identity.objects.create( user=user2, idp=self.idp, external_id=external_id_2, status=IdentityStatus.VALID ) linking_url = build_linking_url( self.integration, external_id_2, self.channel_id, self.response_url ) self.client.post(linking_url) assert Identity.objects.filter(external_id=external_id_2, user=self.user).exists() assert not Identity.objects.filter(external_id=self.external_id, user=self.user).exists() assert not Identity.objects.filter(external_id=external_id_2, user=user2).exists()
def test_basic_flow(self): """Do the auth flow and assert that the identity was created.""" linking_url = build_linking_url( self.integration, self.external_id, self.channel_id, self.response_url ) # Load page. response = self.client.get(linking_url) assert response.status_code == 200 self.assertTemplateUsed(response, "sentry/auth-link-identity.html") # Link identity of user self.client.post(linking_url) identity = Identity.objects.filter(external_id="new-slack-id", user=self.user) assert len(identity) == 1 assert identity[0].idp == self.idp assert identity[0].status == IdentityStatus.VALID assert len(responses.calls) == 1
def test_basic_flow(self, unsign): unsign.return_value = { "integration_id": self.integration.id, "organization_id": self.org.id, "slack_id": "new-slack-id", "channel_id": "my-channel", "response_url": "http://example.slack.com/response_url", } linking_url = build_linking_url( self.integration, self.org, "new-slack-id", "my-channel", "http://example.slack.com/response_url", ) resp = self.client.get(linking_url) assert resp.status_code == 200 self.assertTemplateUsed(resp, "sentry/auth-link-identity.html") responses.add( method=responses.POST, url="http://example.slack.com/response_url", body='{"ok": true}', status=200, content_type="application/json", ) # Link identity of user self.client.post(linking_url) identity = Identity.objects.filter(external_id="new-slack-id", user=self.user1) assert len(identity) == 1 assert identity[0].idp == self.idp assert identity[0].status == IdentityStatus.VALID assert len(responses.calls) == 1
def test_overwrites_existing_identities(self, unsign): Identity.objects.create( user=self.user1, idp=self.idp, external_id="slack-id1", status=IdentityStatus.VALID ) Identity.objects.create( user=self.user2, idp=self.idp, external_id="slack-id2", status=IdentityStatus.VALID ) unsign.return_value = { "integration_id": self.integration.id, "organization_id": self.org.id, "slack_id": "slack-id2", "channel_id": "my-channel", "response_url": "http://example.slack.com/response_url", } linking_url = build_linking_url( self.integration, self.org, "slack-id2", "my-channel", "http://example.slack.com/response_url", ) responses.add( method=responses.POST, url="http://example.slack.com/response_url", body='{"ok": true}', status=200, content_type="application/json", ) self.client.post(linking_url) Identity.objects.get(external_id="slack-id2", user=self.user1) assert not Identity.objects.filter(external_id="slack-id1", user=self.user1).exists() assert not Identity.objects.filter(external_id="slack-id2", user=self.user2).exists()
def _handle_group_actions( self, slack_request: SlackActionRequest, request: Request, action_list: Sequence[MessageAction], ) -> Response: group = get_group(slack_request) if not group: return self.respond(status=403) # Determine the acting user by Slack identity. try: identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: return self.respond(status=403) if not identity: associate_url = build_linking_url( integration=slack_request.integration, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.respond_ephemeral( LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)) # Handle status dialog submission if (slack_request.type == "dialog_submission" and "resolve_type" in slack_request.data["submission"]): # Masquerade a status action action = MessageAction( name="status", value=slack_request.data["submission"]["resolve_type"], ) try: self.on_status(request, identity, group, action) except client.ApiError as error: return self.api_error(slack_request, group, identity, error, "status_dialog") attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=[action]).build() body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"]) # use the original response_url to update the link attachment slack_client = SlackClient() try: slack_client.post( slack_request.callback_data["orig_response_url"], data=body, json=True) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond() # Usually we'll want to respond with the updated attachment including # the list of actions taken. However, when opening a dialog we do not # have anything to update the message with and will use the # response_url later to update it. defer_attachment_update = False # Handle interaction actions for action in action_list: try: if action.name == "status": self.on_status(request, identity, group, action) elif action.name == "assign": self.on_assign(request, identity, group, action) elif action.name == "resolve_dialog": self.open_resolve_dialog(slack_request, group) defer_attachment_update = True except client.ApiError as error: return self.api_error(slack_request, group, identity, error, action.name) if defer_attachment_update: return self.respond() # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=action_list).build() body = self.construct_reply(attachment, is_message=_is_message(slack_request.data)) return self.respond(body)
def post(self, request: Request) -> Response: logging_data: Dict[str, str] = {} try: slack_request = SlackActionRequest(request) slack_request.validate() except SlackRequestError as e: return self.respond(status=e.status) data = slack_request.data # Actions list may be empty when receiving a dialog response action_list = data.get("actions", []) action_option = action_list and action_list[0].get("value", "") # if a user is just clicking our auto response in the messages tab we just return a 200 if action_option == "sentry_docs_link_clicked": return self.respond() channel_id = slack_request.channel_id user_id = slack_request.user_id integration = slack_request.integration response_url = data.get("response_url") if action_option in ["link", "ignore"]: analytics.record( "integrations.slack.chart_unfurl_action", organization_id=integration.organizations.all()[0].id, action=action_option, ) payload = {"delete_original": "true"} try: post(response_url, json=payload) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond(status=403) return self.respond() logging_data["channel_id"] = channel_id logging_data["slack_user_id"] = user_id logging_data["response_url"] = response_url logging_data["integration_id"] = integration.id # Determine the issue group action is being taken on group_id = slack_request.callback_data["issue"] logging_data["group_id"] = group_id try: group = Group.objects.select_related("project__organization").get( project__in=Project.objects.filter( organization__in=integration.organizations.all()), id=group_id, ) except Group.DoesNotExist: logger.info("slack.action.invalid-issue", extra=logging_data) return self.respond(status=403) logging_data["organization_id"] = group.organization.id # Determine the acting user by slack identity try: idp = IdentityProvider.objects.get( type="slack", external_id=slack_request.team_id) except IdentityProvider.DoesNotExist: logger.error("slack.action.invalid-team-id", extra=logging_data) return self.respond(status=403) try: identity = Identity.objects.select_related("user").get( idp=idp, external_id=user_id) except Identity.DoesNotExist: associate_url = build_linking_url(integration, group.organization, user_id, channel_id, response_url) return self.respond({ "response_type": "ephemeral", "replace_original": False, "text": LINK_IDENTITY_MESSAGE.format(associate_url=associate_url), }) # Handle status dialog submission if slack_request.type == "dialog_submission" and "resolve_type" in data[ "submission"]: # Masquerade a status action action = { "name": "status", "value": data["submission"]["resolve_type"] } try: self.on_status(request, identity, group, action, data, integration) except client.ApiError as e: if e.status_code == 403: text = UNLINK_IDENTITY_MESSAGE.format( associate_url=build_unlinking_url( integration.id, user_id, channel_id, response_url), user_email=identity.user, org_name=group.organization.name, ) else: text = DEFAULT_ERROR_MESSAGE return self.api_error(e, "status_dialog", logging_data, text) group = Group.objects.get(id=group.id) attachment = build_group_attachment(group, identity=identity, actions=[action]) body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"]) # use the original response_url to update the link attachment slack_client = SlackClient() try: slack_client.post( slack_request.callback_data["orig_response_url"], data=body, json=True) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond() # Usually we'll want to respond with the updated attachment including # the list of actions taken. However, when opening a dialog we do not # have anything to update the message with and will use the # response_url later to update it. defer_attachment_update = False # Handle interaction actions action_type = None try: for action in action_list: action_type = action["name"] if action_type == "status": self.on_status(request, identity, group, action, data, integration) elif action_type == "assign": self.on_assign(request, identity, group, action) elif action_type == "resolve_dialog": self.open_resolve_dialog(data, group, integration) defer_attachment_update = True except client.ApiError as e: if e.status_code == 403: text = UNLINK_IDENTITY_MESSAGE.format( associate_url=build_unlinking_url(integration.id, user_id, channel_id, response_url), user_email=identity.user, org_name=group.organization.name, ) else: text = DEFAULT_ERROR_MESSAGE return self.api_error(e, action_type, logging_data, text) if defer_attachment_update: return self.respond() # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) attachment = build_group_attachment(group, identity=identity, actions=action_list) body = self.construct_reply(attachment, is_message=self.is_message(data)) return self.respond(body)
def post(self, request: Request) -> Response: try: slack_request = SlackActionRequest(request) slack_request.validate() except SlackRequestError as e: return self.respond(status=e.status) action_option = slack_request.action_option if action_option in ["approve_member", "reject_member"]: return self.handle_member_approval(slack_request) # if a user is just clicking our auto response in the messages tab we just return a 200 if action_option == "sentry_docs_link_clicked": return self.respond() # Actions list may be empty when receiving a dialog response data = slack_request.data action_list_raw = data.get("actions", []) action_list = [ MessageAction(**action_data) for action_data in action_list_raw ] organizations = slack_request.integration.organizations.all() if action_option in ["link", "ignore"]: analytics.record( "integrations.slack.chart_unfurl_action", organization_id=organizations[0].id, action=action_option, ) payload = {"delete_original": "true"} try: requests_.post(slack_request.response_url, json=payload) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond(status=403) return self.respond() # Determine the issue group action is being taken on group_id = slack_request.callback_data["issue"] logging_data = {**slack_request.logging_data, "group_id": group_id} try: group = Group.objects.select_related("project__organization").get( project__in=Project.objects.filter( organization__in=organizations), id=group_id, ) except Group.DoesNotExist: logger.info("slack.action.invalid-issue", extra={**logging_data}) return self.respond(status=403) logging_data["organization_id"] = group.organization.id # Determine the acting user by slack identity try: identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: return self.respond(status=403) if not identity: associate_url = build_linking_url( integration=slack_request.integration, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.respond_ephemeral( LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)) # Handle status dialog submission if slack_request.type == "dialog_submission" and "resolve_type" in data[ "submission"]: # Masquerade a status action action = MessageAction( name="status", value=data["submission"]["resolve_type"], ) try: self.on_status(request, identity, group, action, data, slack_request.integration) except client.ApiError as error: return self.api_error(slack_request, group, identity, error, "status_dialog") group = Group.objects.get(id=group.id) attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=[action]).build() body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"]) # use the original response_url to update the link attachment slack_client = SlackClient() try: slack_client.post( slack_request.callback_data["orig_response_url"], data=body, json=True) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond() # Usually we'll want to respond with the updated attachment including # the list of actions taken. However, when opening a dialog we do not # have anything to update the message with and will use the # response_url later to update it. defer_attachment_update = False # Handle interaction actions for action in action_list: action_type = action.name try: if action_type == "status": self.on_status(request, identity, group, action, data, slack_request.integration) elif action_type == "assign": self.on_assign(request, identity, group, action) elif action_type == "resolve_dialog": self.open_resolve_dialog(data, group, slack_request.integration) defer_attachment_update = True except client.ApiError as error: return self.api_error(slack_request, group, identity, error, action_type) if defer_attachment_update: return self.respond() # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=action_list).build() body = self.construct_reply(attachment, is_message=self.is_message(data)) return self.respond(body)