Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prepare OnCall for Unified Slack App #4232

Merged
merged 65 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
6ee54a4
Add chatops-proxy routing data to AlertGroup Buttons
Konstantinov-Innokentii Apr 9, 2024
3a699c4
Make "Responders" button work
Konstantinov-Innokentii Apr 10, 2024
9581552
Make "Attach to" work
Konstantinov-Innokentii Apr 11, 2024
8bd9ef3
Make "Resolution notes" btn work
Konstantinov-Innokentii Apr 11, 2024
2dda654
Start work on the slack installation from the chatops-proxy
Konstantinov-Innokentii Apr 16, 2024
b187082
Support slack installation via chatops-proxy.
Konstantinov-Innokentii Apr 24, 2024
c97a994
Fix get_installation_link_from_chatops_proxy util
Konstantinov-Innokentii Apr 24, 2024
97d140a
Add SELF_HOSTED_STACK_ID env var
Konstantinov-Innokentii Apr 26, 2024
7103c5c
draft event receiver
Konstantinov-Innokentii Apr 26, 2024
0131c47
Merge branch 'slack_add_tenant_id' into handle_chatops_proxy_broadcast
Konstantinov-Innokentii Apr 29, 2024
4cf952c
Reorganize chatops-event handler
Konstantinov-Innokentii Apr 29, 2024
5cf0bb7
Fix types
Konstantinov-Innokentii Apr 29, 2024
0215e8c
Fixes
Konstantinov-Innokentii Apr 29, 2024
70f0e53
Move common.oncall_gateway to apps.chatops-proxy
Konstantinov-Innokentii Apr 30, 2024
62204f2
Introduce UNIFIED_SLACK_APP_ENABLED env var
Konstantinov-Innokentii Apr 30, 2024
ed0f6de
Fix registering oncall tenant
Konstantinov-Innokentii Apr 30, 2024
58b8376
Add api/v3/slack urls
Konstantinov-Innokentii May 3, 2024
616e6e9
fix get_installation_link_from_chatops_proxy
vadimkerr May 3, 2024
ee58525
fix AcceptShiftSwapRequestStep
vadimkerr May 10, 2024
f051bad
fix AcknowledgeConfirmationStep
vadimkerr May 10, 2024
281b419
fix EditScheduleShiftNotifyStep
vadimkerr May 10, 2024
a20fabd
fix UpdateAppearanceStep
vadimkerr May 10, 2024
c9b9abe
fix OnPagingChange steps
vadimkerr May 10, 2024
25bf434
fix OnPagingItemActionChange
vadimkerr May 10, 2024
6a8c33b
fix FinishDirectPaging
vadimkerr May 10, 2024
9fba9f6
Merge branch 'dev' into handle_chatops_proxy_broadcast
vadimkerr May 10, 2024
d126d03
fix responders tests
vadimkerr May 10, 2024
9c440e2
fix tests
vadimkerr May 10, 2024
cd9f325
simplify
vadimkerr May 10, 2024
0b9c414
remove user.organization
vadimkerr May 10, 2024
61cbbd2
add celery routes
vadimkerr May 10, 2024
b1df185
fix mypy
vadimkerr May 10, 2024
d99a046
fix mypy
vadimkerr May 10, 2024
ee4439f
Install slack from chatops-proxy
Konstantinov-Innokentii May 15, 2024
5d2f566
removed otel, added makeRequestRaw to return actual response, check s…
teodosii May 16, 2024
0abc787
check for 200
teodosii May 16, 2024
9235ede
lint
teodosii May 16, 2024
b265b59
Remove change which makes no sense
Konstantinov-Innokentii May 20, 2024
779c352
Add bunch of comments
Konstantinov-Innokentii May 20, 2024
56ce18d
Better comment for ChatopsEventsHandler
Konstantinov-Innokentii May 20, 2024
408b751
Better logging for Chatops events
Konstantinov-Innokentii May 20, 2024
4df9f69
Merge branch 'refs/heads/dev' into handle_chatops_proxy_broadcast
Konstantinov-Innokentii May 20, 2024
6d2edc7
Bump kindest/node image
Konstantinov-Innokentii May 20, 2024
8073e3d
Fix integration tests
Konstantinov-Innokentii May 20, 2024
252b268
Merge remote-tracking branch 'refs/remotes/origin/rares/support-slack…
Konstantinov-Innokentii May 21, 2024
1fa0469
Bring back unlink_slack if UNIFIED SLACK APP isn't enabled
Konstantinov-Innokentii May 21, 2024
52e8771
Rename OauthInstallation to OAuthInstallation
Konstantinov-Innokentii May 23, 2024
ae2eeca
Add logging if there is no handler for chatops-event
Konstantinov-Innokentii May 23, 2024
8ddd8a1
Remove outdated comment
Konstantinov-Innokentii May 23, 2024
b2ddd02
Add tests for ChatopsEventsView
Konstantinov-Innokentii May 23, 2024
a650357
Fix test_event_handler
Konstantinov-Innokentii May 23, 2024
e717174
Add tests for SlackInstallationHandler
Konstantinov-Innokentii May 23, 2024
9d8b129
Add tests for install_slack_integration
Konstantinov-Innokentii May 23, 2024
a5eb5fd
Add tests for uninstall_slack_integration
Konstantinov-Innokentii May 23, 2024
3afcb4f
Simplify test_root_event_handler
Konstantinov-Innokentii May 24, 2024
705e117
Correct test data
Konstantinov-Innokentii May 24, 2024
bede2ad
Merge branch 'dev' into handle_chatops_proxy_broadcast
teodosii May 24, 2024
89a3c0c
Add tests for start slack installation via social auth
Konstantinov-Innokentii May 24, 2024
f3a951d
lint
teodosii May 24, 2024
e3dd665
Merge branch 'handle_chatops_proxy_broadcast' of https://github.com/g…
teodosii May 24, 2024
82f3695
Merge branch 'dev' into handle_chatops_proxy_broadcast
Konstantinov-Innokentii May 24, 2024
6e6e120
Merge branch 'dev' into handle_chatops_proxy_broadcast
Konstantinov-Innokentii May 27, 2024
8811757
Merge branch 'dev' into handle_chatops_proxy_broadcast
Konstantinov-Innokentii May 29, 2024
8d438a3
Verify chatops-proxy events signature
Konstantinov-Innokentii Jun 3, 2024
aae84ab
Merge branch 'dev' into handle_chatops_proxy_broadcast
Konstantinov-Innokentii Jun 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/kind-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ registry: ctlptl-registry
kindV1Alpha4Cluster:
nodes:
- role: control-plane
image: kindest/node:v1.24.7
image: kindest/node:v1.27.3
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind was not starting for me on this version

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters import AlertSlackTemplater
from apps.slack.chatops_proxy_routing import make_value
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
from apps.slack.scenarios.scenario_step import ScenarioStep
from apps.slack.types import Block
Expand Down Expand Up @@ -436,9 +437,8 @@ def _alert_group_action_value(self, **kwargs):

data = {
"organization_id": self.alert_group.channel.organization_id,
"alert_group_pk": self.alert_group.pk,
# eventually replace using alert_group_pk with alert_group_public_pk in slack payload
"alert_group_ppk": self.alert_group.public_primary_key,
**kwargs,
}
return json.dumps(data) # Slack block elements allow to pass value as string only (max 2000 chars)

return make_value(data, self.alert_group.channel.organization)
77 changes: 75 additions & 2 deletions engine/apps/api/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import Mock, patch

import pytest
from django.contrib.auth import REDIRECT_FIELD_NAME
Expand All @@ -9,14 +9,16 @@
from rest_framework.test import APIClient

from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE


@pytest.mark.django_db
@pytest.mark.parametrize(
"backend_name,expected_url",
(
("slack-login", "/a/grafana-oncall-app/users/me"),
("slack-install-free", "/a/grafana-oncall-app/chat-ops"),
(SLACK_INSTALLATION_BACKEND, "/a/grafana-oncall-app/chat-ops"),
),
)
def test_complete_slack_auth_redirect_ok(
Expand Down Expand Up @@ -71,6 +73,77 @@ def _custom_do_complete(backend, *args, **kwargs):
assert response.url == "some-url"


@override_settings(UNIFIED_SLACK_APP_ENABLED=False)
@pytest.mark.django_db
def test_start_slack_ok(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
"""
Covers the case when user starts Slack integration installation via Grafana OnCall
"""
_, user, token = make_organization_and_user_with_plugin_token()

client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})

mock_do_auth_return = Mock()
mock_do_auth_return.url = "https://slack_oauth_redirect.com"
with patch("apps.api.views.auth.do_auth", return_value=mock_do_auth_return) as mock_do_auth:
response = client.get(url, **make_user_auth_headers(user, token))
assert mock_do_auth.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == "https://slack_oauth_redirect.com"


@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
@pytest.mark.django_db
def test_start_unified_slack_ok(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
"""
Covers the case when user starts Unified Slack integration installation
"""
_, user, token = make_organization_and_user_with_plugin_token()

client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})

with patch(
"apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value="https://slack_oauth_redirect.com"
):
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == "https://slack_oauth_redirect.com"


@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
@patch("apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value=None)
@patch("apps.api.views.auth.get_slack_oauth_response_from_chatops_proxy", return_value=SLACK_OAUTH_ACCESS_RESPONSE)
@patch("apps.api.views.auth.install_slack_integration", return_value=None)
@pytest.mark.django_db
def test_start_slack_ok_via_chatops_proxy_when_already_installed(
mock_install_slack_integration,
mock_get_slack_oauth_response_from_chatops_proxy,
mock_get_installation_link_from_chatops_proxy,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
"""
Covers the case when user starts Unified Slack integration installation, but it's already installed.
It might happen if integration was installed from Incident side, but OnCall missed the corresponding event
"""
org, user, token = make_organization_and_user_with_plugin_token()

client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})

response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_201_CREATED
assert mock_install_slack_integration.call_args.args == (org, user, SLACK_OAUTH_ACCESS_RESPONSE)


@pytest.mark.django_db
@patch("apps.social_auth.backends.GoogleOAuth2.get_redirect_uri")
@patch("apps.social_auth.backends.GoogleOAuth2Token.create_auth_token", return_value=("something", "token_string"))
Expand Down
32 changes: 30 additions & 2 deletions engine/apps/api/views/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
from social_django.views import _do_login

from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication
from apps.social_auth.backends import LoginSlackOAuth2V2
from apps.chatops_proxy.utils import (
get_installation_link_from_chatops_proxy,
get_slack_oauth_response_from_chatops_proxy,
)
from apps.slack.installation import install_slack_integration
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2

logger = logging.getLogger(__name__)

Expand All @@ -25,6 +30,10 @@
@never_cache
@psa("social:complete")
def overridden_login_social_auth(request: Request, backend: str) -> Response:
"""
overridden_login_social_auth starts the installation of integration which uses OAuth flow.
"""

# We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS.
# So frontend can't follow our redirect.
# So wrapping and returning URL to redirect as a string.
Expand All @@ -34,7 +43,26 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response:
status=400,
)

url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
if backend == SLACK_INSTALLATION_BACKEND and settings.UNIFIED_SLACK_APP_ENABLED:
"""
Install unified slack integration via chatops-proxy.
1. Get installation link from chatops-proxy
2. If link is not None – slack installation already exists on Chatops-Proxy - install using it's oauth response.
"""
try:
link = get_installation_link_from_chatops_proxy(request.user)
if link is not None:
return Response(link, 200)
else:
slack_oauth_response = get_slack_oauth_response_from_chatops_proxy(request.user.organization.stack_id)
install_slack_integration(request.user.organization, request.user, slack_oauth_response)
return Response("slack integration installed", 201)
except Exception as e:
logger.exception("overridden_login_social_auth: Failed to install slack via chatops-proxy: %s", e)
return Response({"error": "something went wrong, try again later"}, 500)
else:
# Otherwise use social-auth.
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
return Response(url_to_redirect_to, 200)


Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.conf import settings

SERVICE_TYPE_ONCALL = "oncall"
PROVIDER_TYPE_SLACK = "slack"


@dataclass
Expand All @@ -32,6 +33,15 @@ class Tenant:
msteams_links: List[MSTeamsLink] = field(default_factory=list)


@dataclass
class OAuthInstallation:
id: str
oauth_response: dict
stack_id: int
provider_type: str
provider_id: str


class ChatopsProxyAPIException(Exception):
"""A generic 400 or 500 level exception from the Chatops Proxy API"""

Expand All @@ -55,14 +65,15 @@ def __init__(self, url: str, token: str):

# OnCall Tenant
def register_tenant(
self, service_tenant_id: str, cluster_slug: str, service_type: str
self, service_tenant_id: str, cluster_slug: str, service_type: str, stack_id: int
) -> tuple[Tenant, requests.models.Response]:
url = f"{self.api_base_url}/tenants/register"
d = {
"tenant": {
"service_tenant_id": service_tenant_id,
"cluster_slug": cluster_slug,
"service_type": service_type,
"stack_id": stack_id,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this value backfilled/updated somehow for already registered tenants?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be. Probably I'll add a new endpoint on ChatopsProxy, allowing to PUT tenants and set their stack ids.
Currently it will be just ignored by ChatopsProxy, since new API is not released yet.

}
}
response = requests.post(url=url, json=d, headers=self._headers)
Expand Down Expand Up @@ -131,6 +142,34 @@ def unlink_slack_team(
self._check_response(response)
return response.json()["removed"], response

def get_slack_oauth_link(
self, stack_id: int, grafana_user_id: int, app_redirect: str, app_type: str
) -> tuple[str, requests.models.Response]:
url = f"{self.api_base_url}/oauth2/start"
d = {
"stack_id": stack_id,
"grafana_user_id": grafana_user_id,
"app_redirect": app_redirect,
"app_type": app_type,
}
response = requests.post(url=url, json=d, headers=self._headers)
self._check_response(response)
return response.json()["install_link"], response

def get_oauth_installation(
self,
stack_id: int,
provider_type: str,
) -> tuple[OAuthInstallation, requests.models.Response]:
url = f"{self.api_base_url}/oauth_installations/get"
d = {
"stack_id": stack_id,
"provider_type": provider_type,
}
response = requests.post(url=url, json=d, headers=self._headers)
self._check_response(response)
return OAuthInstallation(**response.json()["oauth_installation"]), response

def _check_response(self, response: requests.models.Response):
"""
Wraps an exceptional response to ChatopsProxyAPIException
Expand Down
1 change: 1 addition & 0 deletions engine/apps/chatops_proxy/events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .root_handler import ChatopsEventsHandler # noqa
50 changes: 50 additions & 0 deletions engine/apps/chatops_proxy/events/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
import typing
from abc import ABC, abstractmethod

from apps.chatops_proxy.client import PROVIDER_TYPE_SLACK
from apps.slack.installation import SlackInstallationExc, install_slack_integration
from apps.user_management.models import Organization

from .types import INTEGRATION_INSTALLED_EVENT_TYPE, Event, IntegrationInstalledData

logger = logging.getLogger(__name__)


class Handler(ABC):
@classmethod
@abstractmethod
def match(cls, event: Event) -> bool:
pass

@classmethod
@abstractmethod
def handle(cls, event_data: dict) -> None:
pass


class SlackInstallationHandler(Handler):
@classmethod
def match(cls, event: Event) -> bool:
return (
event.get("event_type") == INTEGRATION_INSTALLED_EVENT_TYPE
and event.get("data", {}).get("provider_type") == PROVIDER_TYPE_SLACK
)

@classmethod
def handle(cls, data: dict) -> None:
data = typing.cast(IntegrationInstalledData, data)

stack_id = data.get("stack_id")
user_id = data.get("grafana_user_id")
payload = data.get("payload")

organization = Organization.objects.get(stack_id=stack_id)
user = organization.users.get(user_id=user_id)
try:
install_slack_integration(organization, user, payload)
except SlackInstallationExc as e:
logger.exception(
f'msg="SlackInstallationHandler: Failed to install Slack integration: %s" org_id={organization.id} stack_id={stack_id}',
e,
)
37 changes: 37 additions & 0 deletions engine/apps/chatops_proxy/events/root_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import logging
import typing

from .handlers import Handler, SlackInstallationHandler
from .types import Event

logger = logging.getLogger(__name__)


class ChatopsEventsHandler:
"""
ChatopsEventsHandler is a root handler which receives event from Chatops-Proxy and chooses the handler to process it.
"""

HANDLERS: typing.List[typing.Type[Handler]] = [SlackInstallationHandler]

def handle(self, event_data: Event) -> bool:
"""
handle iterates over all handlers and chooses the first one that matches the event.
Returns True if a handler was found and False otherwise.
"""
logger.info(f"msg=\"ChatopsEventsHandler: Handling\" event_type={event_data.get('event_type')}")
for h in self.HANDLERS:
if h.match(event_data):
logger.info(
f"msg=\"ChatopsEventsHandler: Found matching handler {h.__name__}\" event_type={event_data.get('event_type')}"
)
self._exec(h.handle, event_data.get("data", {}))
return True
logger.error(f"msg=\"ChatopsEventsHandler: No handler found\" event_type={event_data.get('event_type')}")
return False

def _exec(self, handlefunc: typing.Callable[[dict], None], data: dict):
"""
_exec is a helper method to execute a handler's handle method.
"""
return handlefunc(data)
36 changes: 36 additions & 0 deletions engine/apps/chatops_proxy/events/signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import base64
import binascii
import hashlib
import hmac


def hash(data):
hasher = hashlib.sha256()
hasher.update(data)
return base64.b64encode(hasher.digest()).decode()


def generate_signature(data, secret):
h = hmac.new(secret.encode(), data.encode(), hashlib.sha256)
return binascii.hexlify(h.digest()).decode()


def verify_signature(request, secret) -> bool:
header = request.META.get("HTTP_X_CHATOPS_SIGNATURE")
if not header:
return False

signatures = header.split(",")
s = dict(pair.split("=") for pair in signatures)
t = s.get("t")
v1 = s.get("v1")

payload = request.body
body_hash = hash(payload)
string_to_sign = f"{body_hash}:{t}:v1"
expected = generate_signature(string_to_sign, secret)

if expected != v1:
return False

return True
Loading
Loading