From 59f727d4f5757a176439a4cec2eaffb7f121a0cd Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 2 Apr 2024 14:59:03 -0400 Subject: [PATCH] Google OAuth2 flow + fetch Google Calendar OOO events (#4067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What this PR does The following is deployed under a feature flag. **How it works** 1. The user clicks on the "Connect using your Google account" button in the user profile settings modal 2. The UI makes a call to `GET /api/internal/v1/login/google-oauth2`. The backend has now been configured to add `apps.social_auth.backends.GoogleOAuth2` as a "`social_auth` backend". 3. The backend will respond w/ a URL which points to the Google OAuth2 consent screen. The frontend then proceeds by sending the user to this page. This URL includes the following query parameters (amongst others): - `redirect_uri` - this will send the user back to `/api/internal/v1/complete/google-oauth2` (ie. make another API call to the OnCall backend to finalize the Google OAuth2 flow) - `state` - this represents an `apps.auth_token.models.GoogleOAuth2Token` token. This allows us to identify the OnCall user once they've linked their Google account. 4. Once redirected back to `/api/internal/v1/complete/google-oauth2`, this will complete the OAuth2 flow. At this point, the backend has access to several pieces of information about the Google user, including their `access_token` and `refresh_token`. We persist these (encrypted) for future use to fetch the user's out-of-office calendar events 5. The response from the API call in 4 above ☝️ is HTTP 302 (redirect) to `/a/grafana-oncall-app/users/me` (ie. open the user profile settings modal). At this point the user will see that their account has been connected and they can further configure the settings ![image](https://github.com/grafana/oncall/assets/9406895/c7673055-8485-4f9a-98df-b4f7347229ce) ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall-private/issues/2584 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - will be done in https://github.com/grafana/oncall-private/issues/2591 - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. - will be done in https://github.com/grafana/oncall-private/issues/2591 --------- Co-authored-by: Dominik Co-authored-by: Maxim Mordasov --- engine/apps/api/alert_group_table_columns.py | 3 +- engine/apps/api/serializers/user.py | 23 +++ engine/apps/api/tests/test_auth.py | 58 ++++++- engine/apps/api/tests/test_user.py | 44 +++++ engine/apps/api/urls.py | 9 +- .../api/views/alert_group_table_settings.py | 3 +- engine/apps/api/views/auth.py | 33 ++-- engine/apps/api/views/features.py | 4 + engine/apps/auth_token/auth.py | 77 +++++---- engine/apps/auth_token/constants.py | 6 + .../migrations/0006_googleoauth2token.py | 32 ++++ engine/apps/auth_token/models/__init__.py | 1 + .../auth_token/models/google_oauth2_token.py | 56 +++++++ .../auth_token/models/slack_auth_token.py | 4 +- engine/apps/google/__init__.py | 0 engine/apps/google/client.py | 76 +++++++++ engine/apps/google/migrations/0001_initial.py | 29 ++++ engine/apps/google/migrations/__init__.py | 0 engine/apps/google/models/__init__.py | 1 + .../apps/google/models/google_oauth2_user.py | 14 ++ engine/apps/google/tasks.py | 26 +++ engine/apps/google/types.py | 47 ++++++ engine/apps/social_auth/backends.py | 35 +++- engine/apps/social_auth/pipeline/__init__.py | 0 engine/apps/social_auth/pipeline/common.py | 27 +++ engine/apps/social_auth/pipeline/google.py | 27 +++ .../{pipeline.py => pipeline/slack.py} | 12 -- engine/apps/social_auth/types.py | 14 ++ engine/apps/user_management/constants.py | 14 +- .../0021_user_google_calendar_settings.py | 18 ++ .../user_management/models/organization.py | 2 +- engine/apps/user_management/models/user.py | 38 ++++- .../apps/user_management/tests/test_sync.py | 4 + engine/apps/user_management/types.py | 20 +++ engine/conftest.py | 16 +- engine/pyproject.toml | 2 + engine/requirements.in | 3 + engine/requirements.txt | 17 +- engine/settings/base.py | 54 +++++- engine/settings/celery_task_routes.py | 2 + .../e2e-tests/insights/insights.test.ts | 7 +- .../userProfile.googleConnectionTab.test.ts | 21 +++ .../containers/UserSettings/UserSettings.tsx | 3 + .../UserSettings/UserSettings.types.ts | 1 + .../UserSettings/parts/UserSettingsParts.tsx | 13 ++ .../parts/connectors/GoogleConnector.tsx | 50 ++++++ .../tabs/GoogleCalendar/GoogleCalendar.tsx | 139 ++++++++++++++++ .../tabs/UserInfoTab/UserInfoTab.module.css | 9 +- .../parts/tabs/UserInfoTab/UserInfoTab.tsx | 8 + .../src/icons/GoogleCalendarLogo.tsx | 53 ++++++ grafana-plugin/src/icons/GoogleLogo.tsx | 26 +++ .../src/models/user/user.helpers.tsx | 5 + grafana-plugin/src/models/user/user.ts | 6 + .../oncall-api/autogenerated-api.types.d.ts | 154 ++++++++++++++++++ grafana-plugin/src/state/features.ts | 1 + 55 files changed, 1254 insertions(+), 93 deletions(-) create mode 100644 engine/apps/auth_token/migrations/0006_googleoauth2token.py create mode 100644 engine/apps/auth_token/models/google_oauth2_token.py create mode 100644 engine/apps/google/__init__.py create mode 100644 engine/apps/google/client.py create mode 100644 engine/apps/google/migrations/0001_initial.py create mode 100644 engine/apps/google/migrations/__init__.py create mode 100644 engine/apps/google/models/__init__.py create mode 100644 engine/apps/google/models/google_oauth2_user.py create mode 100644 engine/apps/google/tasks.py create mode 100644 engine/apps/google/types.py create mode 100644 engine/apps/social_auth/pipeline/__init__.py create mode 100644 engine/apps/social_auth/pipeline/common.py create mode 100644 engine/apps/social_auth/pipeline/google.py rename engine/apps/social_auth/{pipeline.py => pipeline/slack.py} (91%) create mode 100644 engine/apps/social_auth/types.py create mode 100644 engine/apps/user_management/migrations/0021_user_google_calendar_settings.py create mode 100644 engine/apps/user_management/types.py create mode 100644 grafana-plugin/e2e-tests/users/userProfile.googleConnectionTab.test.ts create mode 100644 grafana-plugin/src/containers/UserSettings/parts/connectors/GoogleConnector.tsx create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar.tsx create mode 100644 grafana-plugin/src/icons/GoogleCalendarLogo.tsx create mode 100644 grafana-plugin/src/icons/GoogleLogo.tsx diff --git a/engine/apps/api/alert_group_table_columns.py b/engine/apps/api/alert_group_table_columns.py index 8fb33811..44d77976 100644 --- a/engine/apps/api/alert_group_table_columns.py +++ b/engine/apps/api/alert_group_table_columns.py @@ -1,6 +1,7 @@ import typing -from apps.user_management.constants import AlertGroupTableColumns, default_columns +from apps.user_management.constants import default_columns +from apps.user_management.types import AlertGroupTableColumns if typing.TYPE_CHECKING: from apps.user_management.models import User diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index a64188a9..8ae48492 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -31,6 +31,25 @@ class UserPermissionSerializer(serializers.Serializer): action = serializers.CharField(read_only=True) +class GoogleCalendarSettingsSerializer(serializers.Serializer): + # # TODO: figure out how to get OrganizationFilteredPrimaryKeyRelatedField to work with many=True + # oncall_schedules_to_consider_for_shift_swaps = + # oncall_schedules_to_consider_for_shift_swaps = serializers.ListField( + # child=OrganizationFilteredPrimaryKeyRelatedField( + # queryset=OnCallSchedule.objects, + # required=False, + # allow_null=True, + # ), + # required=False, + # allow_null=True, + # ) + oncall_schedules_to_consider_for_shift_swaps = serializers.ListField( + child=serializers.CharField(), + required=False, + allow_null=True, + ) + + class NotificationChainVerbal(typing.TypedDict): default: str important: str @@ -93,6 +112,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "notification_chain_verbal", "cloud_connection_status", "hide_phone_number", + "has_google_oauth2_connected", ] read_only_fields = [ "email", @@ -100,6 +120,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "name", "role", "verified_phone_number", + "has_google_oauth2_connected", ] def validate_working_hours(self, working_hours): @@ -169,10 +190,12 @@ class UserSerializer(ListUserSerializer): context: UserSerializerContext is_currently_oncall = serializers.SerializerMethodField() + google_calendar_settings = GoogleCalendarSettingsSerializer(required=False) class Meta(ListUserSerializer.Meta): fields = ListUserSerializer.Meta.fields + [ "is_currently_oncall", + "google_calendar_settings", ] read_only_fields = ListUserSerializer.Meta.read_only_fields + [ "is_currently_oncall", diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index a0eee1f3..20e8c655 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest from django.contrib.auth import REDIRECT_FIELD_NAME from django.http import HttpResponse +from django.test.utils import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -31,7 +32,7 @@ def test_complete_slack_auth_redirect_ok( client = APIClient() url = ( - reverse("api-internal:complete-slack-auth", kwargs={"backend": backend_name}) + reverse("api-internal:complete-social-auth", kwargs={"backend": backend_name}) + f"?{SLACK_AUTH_TOKEN_NAME}={slack_token}" ) @@ -55,7 +56,7 @@ def test_complete_slack_auth_redirect_error( client = APIClient() url = ( - reverse("api-internal:complete-slack-auth", kwargs={"backend": "slack-login"}) + reverse("api-internal:complete-social-auth", kwargs={"backend": "slack-login"}) + f"?{SLACK_AUTH_TOKEN_NAME}={slack_token}" ) @@ -68,3 +69,56 @@ def test_complete_slack_auth_redirect_error( assert response.status_code == status.HTTP_302_FOUND assert response.url == "some-url" + + +@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")) +@override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE=["https://www.googleapis.com/auth/calendar.events.readonly"]) +@override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_KEY="ouath2_key") +def test_google_start_auth_redirect_ok( + _mock_create_google_oauth2_auth_token, + mock_google_oauth2_backend_get_redirect_uri, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + redirect_uri = "http://testserver" + mock_google_oauth2_backend_get_redirect_uri.return_value = redirect_uri + + _, user, token = make_organization_and_user_with_plugin_token() + + client = APIClient() + url = reverse("api-internal:social-auth", kwargs={"backend": "google-oauth2"}) + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == ( + "https://accounts.google.com/o/oauth2/auth?client_id=ouath2_key" + f"&redirect_uri={redirect_uri}&response_type=code" + "&state=token_string&scope=https://www.googleapis.com/auth/calendar.events.readonly+openid+email+profile" + "&access_type=offline&approval_prompt=auto" + ) + + +@pytest.mark.django_db +@patch("apps.api.views.auth.do_complete", return_value=None) +def test_google_complete_auth_redirect_ok( + _mock_do_complete, + make_organization, + make_user_for_organization, + make_google_oauth2_token_for_user, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, google_oauth2_token = make_google_oauth2_token_for_user(admin) + + client = APIClient() + url = ( + reverse("api-internal:complete-social-auth", kwargs={"backend": "google-oauth2"}) + + f"?state={google_oauth2_token}" + ) + + response = client.get(url) + + assert response.status_code == status.HTTP_302_FOUND + assert response.url == "/a/grafana-oncall-app/users/me" diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 597c259e..b0f80b94 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -63,6 +63,8 @@ def test_current_user( "slack_user_identity": None, "avatar": user.avatar_url, "avatar_full": user.avatar_full_url, + "has_google_oauth2_connected": False, + "google_calendar_settings": None, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -126,6 +128,44 @@ def test_update_user( assert response.json()["current_team"] == data["current_team"] +@pytest.mark.parametrize("oncall_schedules_to_consider_for_shift_swaps", [True, False]) +@pytest.mark.django_db +def test_update_user_google_calendar_settings( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + oncall_schedules_to_consider_for_shift_swaps, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + schedule1 = make_schedule(organization, schedule_class=OnCallScheduleWeb) + schedule2 = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) + + schedule_public_primary_keys = ( + [schedule1.public_primary_key, schedule2.public_primary_key] + if oncall_schedules_to_consider_for_shift_swaps + else [] + ) + data = { + "google_calendar_settings": { + "oncall_schedules_to_consider_for_shift_swaps": schedule_public_primary_keys, + }, + } + + response = client.put(url, data, format="json", **make_user_auth_headers(admin, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["google_calendar_settings"] == { + "oncall_schedules_to_consider_for_shift_swaps": schedule_public_primary_keys, + } + + @override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) @pytest.mark.django_db def test_update_user_cant_change_email_and_username( @@ -171,6 +211,8 @@ def test_update_user_cant_change_email_and_username( "slack_user_identity": None, "avatar": admin.avatar_url, "avatar_full": admin.avatar_full_url, + "has_google_oauth2_connected": False, + "google_calendar_settings": None, } response = client.put(url, data, format="json", **make_user_auth_headers(admin, token)) assert response.status_code == status.HTTP_200_OK @@ -222,6 +264,7 @@ def test_list_users( "avatar": admin.avatar_url, "avatar_full": admin.avatar_full_url, "cloud_connection_status": None, + "has_google_oauth2_connected": False, }, { "pk": editor.public_primary_key, @@ -247,6 +290,7 @@ def test_list_users( "avatar": editor.avatar_url, "avatar_full": editor.avatar_full_url, "cloud_connection_status": None, + "has_google_oauth2_connected": False, }, ], "current_page_number": 1, diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 41df5f17..0f3b8e95 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -106,9 +106,12 @@ urlpatterns = [ urlpatterns += [ # For some reason frontend is using url without / at the end. Hacking here to avoid 301's :( - path(r"login/", auth.overridden_login_slack_auth, name="slack-auth-with-no-slash"), - path(r"login//", auth.overridden_login_slack_auth, name="slack-auth"), - path(r"complete//", auth.overridden_complete_slack_auth, name="complete-slack-auth"), + # TODO: I'm fairly certain this is not needed anymore, it looks like the frontend instead + # makes calls w/ a trailing slash.. we can probably get rid of social-auth-with-no-slash + path(r"login/", auth.overridden_login_social_auth, name="social-auth-with-no-slash"), + path(r"login//", auth.overridden_login_social_auth, name="social-auth"), + path(r"complete//", auth.overridden_complete_social_auth, name="complete-social-auth"), + path(r"disconnect/", auth.overridden_disconnect_social_auth, name="disconnect-social-auth"), ] urlpatterns += [ diff --git a/engine/apps/api/views/alert_group_table_settings.py b/engine/apps/api/views/alert_group_table_settings.py index 3b0a35f4..53859f1b 100644 --- a/engine/apps/api/views/alert_group_table_settings.py +++ b/engine/apps/api/views/alert_group_table_settings.py @@ -12,7 +12,8 @@ from apps.api.serializers.alert_group_table_settings import ( ) from apps.api.views.labels import LabelsFeatureFlagViewSet from apps.auth_token.auth import PluginAuthentication -from apps.user_management.constants import AlertGroupTableColumn, default_columns +from apps.user_management.constants import default_columns +from apps.user_management.types import AlertGroupTableColumn class AlertGroupTableColumnsViewSet(LabelsFeatureFlagViewSet): diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index f9eb862c..7ae7d2e3 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -7,12 +7,14 @@ from django.http import HttpResponse, HttpResponseRedirect from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, authentication_classes +from rest_framework.request import Request from rest_framework.response import Response -from social_core.actions import do_auth, do_complete +from social_core.actions import do_auth, do_complete, do_disconnect +from social_core.backends.google import GoogleOAuth2 from social_django.utils import psa from social_django.views import _do_login -from apps.auth_token.auth import PluginAuthentication, SlackTokenAuthentication +from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication from apps.social_auth.backends import LoginSlackOAuth2V2 logger = logging.getLogger(__name__) @@ -22,32 +24,33 @@ logger = logging.getLogger(__name__) @authentication_classes([PluginAuthentication]) @never_cache @psa("social:complete") -def overridden_login_slack_auth(request, backend): +def overridden_login_social_auth(request: Request, backend: str) -> Response: # 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. - if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED: + if "slack" in backend and settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED: return Response( "Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace", status=400, ) - url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url + url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url return Response(url_to_redirect_to, 200) @api_view(["GET"]) -@authentication_classes([SlackTokenAuthentication]) +@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication]) @never_cache @csrf_exempt @psa("social:complete") -def overridden_complete_slack_auth(request, backend, *args, **kwargs): +def overridden_complete_social_auth(request: Request, backend: str, *args, **kwargs) -> Response: """Authentication complete view""" - # InstallSlackOAuth2V2 backend - redirect_to = "/a/grafana-oncall-app/chat-ops" - if isinstance(request.backend, LoginSlackOAuth2V2): + if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2)): # if this was a user login/linking account, redirect to profile redirect_to = "/a/grafana-oncall-app/users/me" + else: + # InstallSlackOAuth2V2 backend + redirect_to = "/a/grafana-oncall-app/chat-ops" kwargs.update( user=request.user, @@ -71,3 +74,13 @@ def overridden_complete_slack_auth(request, backend, *args, **kwargs): # We build the frontend url using org url since multiple stacks could be connected to one backend. return_to = urljoin(request.user.organization.grafana_url, redirect_to) return HttpResponseRedirect(return_to) + + +@api_view(["GET"]) +@authentication_classes([PluginAuthentication]) +@never_cache +@psa("social:disconnect") +def overridden_disconnect_social_auth(request: Request, backend: str) -> Response: + if backend == "google-oauth2": + do_disconnect(request.backend, request.user) + return Response("ok", 200) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 282cce5b..cad151b8 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -24,6 +24,7 @@ class Feature(enum.StrEnum): # On OnCall side it do nothing, just indicating if OnCall API is ready to that integration. GRAFANA_ALERTING_V2 = "grafana_alerting_v2" LABELS = "labels" + GOOGLE_OAUTH2 = "google_oauth2" class FeaturesAPIView(APIView): @@ -64,4 +65,7 @@ class FeaturesAPIView(APIView): if is_labels_feature_enabled(self.request.auth.organization): enabled_features.append(Feature.LABELS) + if settings.FEATURE_GOOGLE_OAUTH2_ENABLED: + enabled_features.append(Feature.GOOGLE_OAUTH2) + return enabled_features diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index c9e26102..755947c3 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -1,6 +1,6 @@ import json import logging -from typing import Tuple +import typing from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -16,15 +16,24 @@ from apps.user_management.models import User from apps.user_management.models.organization import Organization from settings.base import SELF_HOSTED_SETTINGS -from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME +from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME from .exceptions import InvalidToken from .grafana.grafana_auth_token import get_service_account_token_permissions -from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken -from .models.integration_backsync_auth_token import IntegrationBacksyncAuthToken +from .models import ( + ApiAuthToken, + GoogleOAuth2Token, + IntegrationBacksyncAuthToken, + PluginAuthToken, + ScheduleExportAuthToken, + SlackAuthToken, + UserScheduleExportAuthToken, +) logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) +T = typing.TypeVar("T") + class ServerUser(AnonymousUser): @property @@ -77,7 +86,7 @@ class BasePluginAuthentication(BaseAuthentication): # Check parent's method comments return "Bearer" - def authenticate(self, request: Request) -> Tuple[User, PluginAuthToken]: + def authenticate(self, request: Request) -> typing.Tuple[User, PluginAuthToken]: token_string = get_authorization_header(request).decode() if not token_string: @@ -85,7 +94,7 @@ class BasePluginAuthentication(BaseAuthentication): return self.authenticate_credentials(token_string, request) - def authenticate_credentials(self, token_string: str, request: Request) -> Tuple[User, PluginAuthToken]: + def authenticate_credentials(self, token_string: str, request: Request) -> typing.Tuple[User, PluginAuthToken]: context_string = request.headers.get("X-Instance-Context") if not context_string: raise exceptions.AuthenticationFailed("No instance context provided.") @@ -184,7 +193,7 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication): # Check parent's method comments return "Bearer" - def authenticate(self, request: Request) -> Tuple[GrafanaIncidentUser, None]: + def authenticate(self, request: Request) -> typing.Tuple[GrafanaIncidentUser, None]: token_string = get_authorization_header(request).decode() if ( @@ -198,7 +207,7 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication): return self.authenticate_credentials(token_string, request) - def authenticate_credentials(self, token_string: str, request: Request) -> Tuple[GrafanaIncidentUser, None]: + def authenticate_credentials(self, token_string: str, request: Request) -> typing.Tuple[GrafanaIncidentUser, None]: try: user = GrafanaIncidentUser() except InvalidToken: @@ -207,29 +216,41 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication): return user, None -class SlackTokenAuthentication(BaseAuthentication): +class _SocialAuthTokenAuthentication(BaseAuthentication, typing.Generic[T]): + def authenticate(self, request) -> typing.Optional[typing.Tuple[User, T]]: + """ + If you don't return `None`, the authenticate will raise an `APIException`, so the next authentication class + will not be called. + https://stackoverflow.com/a/61623607/3902555 + + This is useful for the social_auth views where we want to use multiple authentication classes + for the same view. + """ + auth = request.query_params.get(self.token_query_param_name) + if not auth: + return None + + try: + auth_token = self.model.validate_token_string(auth) + return auth_token.user, auth_token + except InvalidToken: + return None + + +class SlackTokenAuthentication(_SocialAuthTokenAuthentication[SlackAuthToken]): + token_query_param_name = SLACK_AUTH_TOKEN_NAME model = SlackAuthToken - def authenticate(self, request) -> Tuple[User, SlackAuthToken]: - auth = request.query_params.get(SLACK_AUTH_TOKEN_NAME) - if not auth: - raise exceptions.AuthenticationFailed("Invalid token.") - user, auth_token = self.authenticate_credentials(auth) - return user, auth_token - def authenticate_credentials(self, token_string: str) -> Tuple[User, SlackAuthToken]: - try: - auth_token = self.model.validate_token_string(token_string) - except InvalidToken: - raise exceptions.AuthenticationFailed("Invalid token.") - - return auth_token.user, auth_token +class GoogleTokenAuthentication(_SocialAuthTokenAuthentication[GoogleOAuth2Token]): + token_query_param_name = GOOGLE_OAUTH2_AUTH_TOKEN_NAME + model = GoogleOAuth2Token class ScheduleExportAuthentication(BaseAuthentication): model = ScheduleExportAuthToken - def authenticate(self, request) -> Tuple[User, ScheduleExportAuthToken]: + def authenticate(self, request) -> typing.Tuple[User, ScheduleExportAuthToken]: auth = request.query_params.get(SCHEDULE_EXPORT_TOKEN_NAME) public_primary_key = request.parser_context.get("kwargs", {}).get("pk") if not auth: @@ -240,7 +261,7 @@ class ScheduleExportAuthentication(BaseAuthentication): def authenticate_credentials( self, token_string: str, public_primary_key: str - ) -> Tuple[User, ScheduleExportAuthToken]: + ) -> typing.Tuple[User, ScheduleExportAuthToken]: try: auth_token = self.model.validate_token_string(token_string) except InvalidToken: @@ -263,7 +284,7 @@ class ScheduleExportAuthentication(BaseAuthentication): class UserScheduleExportAuthentication(BaseAuthentication): model = UserScheduleExportAuthToken - def authenticate(self, request) -> Tuple[User, UserScheduleExportAuthToken]: + def authenticate(self, request) -> typing.Tuple[User, UserScheduleExportAuthToken]: auth = request.query_params.get(SCHEDULE_EXPORT_TOKEN_NAME) public_primary_key = request.parser_context.get("kwargs", {}).get("pk") @@ -275,7 +296,7 @@ class UserScheduleExportAuthentication(BaseAuthentication): def authenticate_credentials( self, token_string: str, public_primary_key: str - ) -> Tuple[User, UserScheduleExportAuthToken]: + ) -> typing.Tuple[User, UserScheduleExportAuthToken]: try: auth_token = self.model.validate_token_string(token_string) except InvalidToken: @@ -363,7 +384,7 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication): class IntegrationBacksyncAuthentication(BaseAuthentication): model = IntegrationBacksyncAuthToken - def authenticate(self, request) -> Tuple[ServerUser, IntegrationBacksyncAuthToken]: + def authenticate(self, request) -> typing.Tuple[ServerUser, IntegrationBacksyncAuthToken]: token = get_authorization_header(request).decode("utf-8") if not token: @@ -371,7 +392,7 @@ class IntegrationBacksyncAuthentication(BaseAuthentication): return self.authenticate_credentials(token) - def authenticate_credentials(self, token_string: str) -> Tuple[ServerUser, IntegrationBacksyncAuthToken]: + def authenticate_credentials(self, token_string: str) -> typing.Tuple[ServerUser, IntegrationBacksyncAuthToken]: try: auth_token = self.model.validate_token_string(token_string) except InvalidToken: diff --git a/engine/apps/auth_token/constants.py b/engine/apps/auth_token/constants.py index 6ea64f67..4e016932 100644 --- a/engine/apps/auth_token/constants.py +++ b/engine/apps/auth_token/constants.py @@ -5,6 +5,12 @@ DIGEST_LENGTH = 128 MAX_PUBLIC_API_TOKENS_PER_USER = 5 SLACK_AUTH_TOKEN_NAME = "slack_login_token" +GOOGLE_OAUTH2_AUTH_TOKEN_NAME = "state" +""" +We must use the `state` query param, otherwise Google returns a 400 error. + +https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response +""" SCHEDULE_EXPORT_TOKEN_NAME = "token" SCHEDULE_EXPORT_TOKEN_CHARACTER_LENGTH = 32 diff --git a/engine/apps/auth_token/migrations/0006_googleoauth2token.py b/engine/apps/auth_token/migrations/0006_googleoauth2token.py new file mode 100644 index 00000000..5b98ca81 --- /dev/null +++ b/engine/apps/auth_token/migrations/0006_googleoauth2token.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.10 on 2024-03-19 10:27 + +import apps.auth_token.models.google_oauth2_token +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0020_organization_is_grafana_labels_enabled'), + ('auth_token', '0005_integrationauthtoken'), + ] + + operations = [ + migrations.CreateModel( + name='GoogleOAuth2Token', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_key', models.CharField(db_index=True, max_length=8)), + ('digest', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(null=True)), + ('expire_date', models.DateTimeField(default=apps.auth_token.models.google_oauth2_token.get_expire_date)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_auth_token_set', to='user_management.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_auth_token_set', to='user_management.user')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/auth_token/models/__init__.py b/engine/apps/auth_token/models/__init__.py index 418b89f4..272adbda 100644 --- a/engine/apps/auth_token/models/__init__.py +++ b/engine/apps/auth_token/models/__init__.py @@ -1,5 +1,6 @@ from .api_auth_token import ApiAuthToken # noqa: F401 from .base_auth_token import BaseAuthToken # noqa: F401 +from .google_oauth2_token import GoogleOAuth2Token # noqa: F401 from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401 from .plugin_auth_token import PluginAuthToken # noqa: F401 from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401 diff --git a/engine/apps/auth_token/models/google_oauth2_token.py b/engine/apps/auth_token/models/google_oauth2_token.py new file mode 100644 index 00000000..debc9670 --- /dev/null +++ b/engine/apps/auth_token/models/google_oauth2_token.py @@ -0,0 +1,56 @@ +from typing import Tuple + +from django.db import models +from django.utils import timezone + +from apps.auth_token import constants, crypto +from apps.auth_token.models import BaseAuthToken +from apps.user_management.models import Organization, User +from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS + + +def get_expire_date(): + return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS) + + +class GoogleOAuth2TokenQueryset(models.QuerySet): + def filter(self, *args, **kwargs): + now = timezone.now() + return super().filter(*args, **kwargs, revoked_at=None, expire_date__gte=now) + + def delete(self): + self.update(revoked_at=timezone.now()) + + +class GoogleOAuth2Token(BaseAuthToken): + """ + Not to be confused with `apps.google.models.GoogleOAuth2User` which is a model for storing user/token data that is + received from Google OAuth2 when the user completes the OAuth2 flow. + + This model is primarly used for storing a token during the OAuth2 redirect flow to allow us to identify the user + after they've been redirected back to us. + """ + + objects = GoogleOAuth2TokenQueryset.as_manager() + user = models.ForeignKey( + "user_management.User", + related_name="google_oauth2_auth_token_set", + on_delete=models.CASCADE, + ) + organization = models.ForeignKey( + "user_management.Organization", related_name="google_oauth2_auth_token_set", on_delete=models.CASCADE + ) + expire_date = models.DateTimeField(default=get_expire_date) + + @classmethod + def create_auth_token(cls, user: User, organization: Organization) -> Tuple["GoogleOAuth2Token", str]: + token_string = crypto.generate_token_string() + digest = crypto.hash_token_string(token_string) + + instance = cls.objects.create( + token_key=token_string[: constants.TOKEN_KEY_LENGTH], + digest=digest, + user=user, + organization=organization, + ) + return instance, token_string diff --git a/engine/apps/auth_token/models/slack_auth_token.py b/engine/apps/auth_token/models/slack_auth_token.py index 6e144230..2c8bfb70 100644 --- a/engine/apps/auth_token/models/slack_auth_token.py +++ b/engine/apps/auth_token/models/slack_auth_token.py @@ -6,11 +6,11 @@ from django.utils import timezone from apps.auth_token import constants, crypto from apps.auth_token.models import BaseAuthToken from apps.user_management.models import Organization, User -from settings.base import SLACK_AUTH_TOKEN_TIMEOUT_SECONDS +from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS def get_expire_date(): - return timezone.now() + timezone.timedelta(seconds=SLACK_AUTH_TOKEN_TIMEOUT_SECONDS) + return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS) class SlackAuthTokenQueryset(models.QuerySet): diff --git a/engine/apps/google/__init__.py b/engine/apps/google/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/google/client.py b/engine/apps/google/client.py new file mode 100644 index 00000000..3815fba7 --- /dev/null +++ b/engine/apps/google/client.py @@ -0,0 +1,76 @@ +import datetime +import logging +import typing + +from django.conf import settings +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build + +from apps.google.types import GoogleCalendarEvent + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class GoogleCalendarAPIClient: + MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250 + """ + By default the value is 250 events. The page size can never be larger than 2500 events + """ + + CALENDAR_ID = "primary" + """ + for right now we only consider the user's primary calendar. If in the future we + want to allow the user to specify a different calendar, we'd need to [retrieve all their calendars](https://developers.google.com/calendar/v3/reference/calendarList/list) + , display this list to them + perist their choice + + See `calendarId` under the "Parameters" section [here](https://developers.google.com/calendar/api/v3/reference/events/list) + """ + + def __init__(self, access_token: str, refresh_token: str): + """ + https://developers.google.com/calendar/api/quickstart/python + https://google-auth.readthedocs.io/en/stable/reference/google.oauth2.credentials.html + """ + credentials = Credentials( + token=access_token, + refresh_token=refresh_token, + token_uri="https://www.googleapis.com/oauth2/v3/token", + client_id=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, + client_secret=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET, + ) + + self.service = build("calendar", "v3", credentials=credentials) + + def fetch_out_of_office_events(self) -> typing.List[GoogleCalendarEvent]: + """ + https://developers.google.com/calendar/api/v3/reference/events/list + """ + + def _format_datetime_arg(dt: datetime.datetime) -> str: + """ + https://stackoverflow.com/a/17159470/3902555 + """ + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + now = _format_datetime_arg(datetime.datetime.now(datetime.UTC)) + + logger.info( + f"GoogleCalendarAPIClient - Getting the upcoming {self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH} " + "out of office events" + ) + + events_result = ( + self.service.events() + .list( + calendarId=self.CALENDAR_ID, + timeMin=now, + # timeMax= TODO: should we only fetch out of office events for next X amount of time? + maxResults=self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH, + singleEvents=True, + orderBy="startTime", + eventTypes="outOfOffice", + ) + .execute() + ) + return events_result.get("items", []) diff --git a/engine/apps/google/migrations/0001_initial.py b/engine/apps/google/migrations/0001_initial.py new file mode 100644 index 00000000..09f50374 --- /dev/null +++ b/engine/apps/google/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-03-17 19:54 + +from django.db import migrations, models +import django.db.models.deletion +import mirage.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_management', '0020_organization_is_grafana_labels_enabled'), + ] + + operations = [ + migrations.CreateModel( + name='GoogleOAuth2User', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('google_user_id', models.CharField(max_length=100)), + ('access_token', mirage.fields.EncryptedCharField(max_length=300)), + ('refresh_token', mirage.fields.EncryptedCharField(max_length=300)), + ('oauth_scope', models.TextField(max_length=30000)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_user', to='user_management.user')), + ], + ), + ] diff --git a/engine/apps/google/migrations/__init__.py b/engine/apps/google/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/google/models/__init__.py b/engine/apps/google/models/__init__.py new file mode 100644 index 00000000..2537b123 --- /dev/null +++ b/engine/apps/google/models/__init__.py @@ -0,0 +1 @@ +from .google_oauth2_user import GoogleOAuth2User # noqa: F401 diff --git a/engine/apps/google/models/google_oauth2_user.py b/engine/apps/google/models/google_oauth2_user.py new file mode 100644 index 00000000..268acbe4 --- /dev/null +++ b/engine/apps/google/models/google_oauth2_user.py @@ -0,0 +1,14 @@ +from django.db import models +from mirage import fields as mirage_fields + + +class GoogleOAuth2User(models.Model): + id = models.AutoField(primary_key=True) + user = models.OneToOneField( + to="user_management.User", null=False, blank=False, related_name="google_oauth2_user", on_delete=models.CASCADE + ) + google_user_id = models.CharField(max_length=100) + access_token = mirage_fields.EncryptedCharField(max_length=300) + refresh_token = mirage_fields.EncryptedCharField(max_length=300) + oauth_scope = models.TextField(max_length=30000) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/engine/apps/google/tasks.py b/engine/apps/google/tasks.py new file mode 100644 index 00000000..4572dd91 --- /dev/null +++ b/engine/apps/google/tasks.py @@ -0,0 +1,26 @@ +import logging + +from celery.utils.log import get_task_logger + +from apps.google.client import GoogleCalendarAPIClient +from apps.google.models import GoogleOAuth2User +from common.custom_celery_tasks import shared_dedicated_queue_retry_task + +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + + +@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True) +def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> None: + google_oauth2_user = GoogleOAuth2User.objects.get(pk=google_oauth2_user_pk) + google_api_client = GoogleCalendarAPIClient(google_oauth2_user.access_token, google_oauth2_user.refresh_token) + + # NOTE: shift swap request generation will be done in https://github.com/grafana/oncall-private/issues/2590 + # QUESTION: will we need to persist any information about these calendar events in our database? + google_api_client.fetch_out_of_office_events() + + +@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True) +def sync_out_of_office_calendar_events_for_all_users() -> None: + for google_oauth2_user in GoogleOAuth2User.objects.all(): + sync_out_of_office_calendar_events_for_user.apply_async(args=(google_oauth2_user.pk,)) diff --git a/engine/apps/google/types.py b/engine/apps/google/types.py new file mode 100644 index 00000000..fb431fe4 --- /dev/null +++ b/engine/apps/google/types.py @@ -0,0 +1,47 @@ +import typing + + +class GoogleCalendarEventDate(typing.TypedDict): + date: typing.NotRequired[str] + """ + The date, in the format "yyyy-mm-dd", if this is an all-day event. + """ + + dateTime: typing.NotRequired[str] + """ + The time, as a combined date-time value (formatted according to RFC3339). + A time zone offset is required unless a time zone is explicitly specified in timeZone. + """ + + timeZone: typing.NotRequired[str] + """ + The time zone in which the time is specified. (Formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich".) + For recurring events this field is required and specifies the time zone in which the recurrence is expanded. + For single events this field is optional and indicates a custom time zone for the event start/end. + """ + + +class GoogleCalendarEvent(typing.TypedDict): + """ + https://developers.google.com/calendar/api/v3/reference/events#resource + """ + + id: str + """ + Opaque identifier of the event + """ + + start: GoogleCalendarEventDate + """ + The (inclusive) start time of the event. For a recurring event, this is the start time of the first instance. + """ + + end: GoogleCalendarEventDate + """ + The (exclusive) end time of the event. For a recurring event, this is the end time of the first instance. + """ + + summary: str + """ + Title of the event + """ diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index 0e515b6e..9043db50 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -1,10 +1,11 @@ from urllib.parse import urljoin +from social_core.backends.google import GoogleOAuth2 as BaseGoogleOAuth2 from social_core.backends.slack import SlackOAuth2 from social_core.utils import handle_http_errors from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME -from apps.auth_token.models import SlackAuthToken +from apps.auth_token.models import GoogleOAuth2Token, SlackAuthToken # Scopes for slack user token. # It is main purpose - retrieve user data in SlackOAuth2V2 but we are using it in legacy code or weird Slack api cases. @@ -39,11 +40,37 @@ BOT_SCOPE = [ "users:write", ] -# Reference to Slack tokens: https://api.slack.com/authentication/token-types + +class GoogleOAuth2(BaseGoogleOAuth2): + REDIRECT_STATE = False + """ + Remove redirect state because we lose session during redirects + """ + STATE_PARAMETER = False + """ + keep `False` to avoid having `BaseGoogleOAuth2` check the `state` query param against a session value + """ + + def auth_params(self, state=None): + """ + Override to generate `GoogleOAuth2Token` token to include as `state` query parameter. + + https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response + """ + + params = super().auth_params(state) + + _, token_string = GoogleOAuth2Token.create_auth_token( + self.strategy.request.user, self.strategy.request.auth.organization + ) + params["state"] = token_string + return params class SlackOAuth2V2(SlackOAuth2): """ + Reference to Slack tokens: https://api.slack.com/authentication/token-types + Slack app with granular permissions require using SlackOauth2.0 V2. SlackOAuth2V2 and its inheritors tune SlackOAuth2 implementation from social core to fit new endpoints and response shapes. @@ -54,8 +81,10 @@ class SlackOAuth2V2(SlackOAuth2): ACCESS_TOKEN_URL = "https://slack.com/api/oauth.v2.access" AUTH_TOKEN_NAME = SLACK_AUTH_TOKEN_NAME - # Remove redirect state because we lose session during redirects REDIRECT_STATE = False + """ + Remove redirect state because we lose session during redirects + """ STATE_PARAMETER = False EXTRA_DATA = [("id", "id"), ("name", "name"), ("real_name", "real_name"), ("team", "team")] diff --git a/engine/apps/social_auth/pipeline/__init__.py b/engine/apps/social_auth/pipeline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/social_auth/pipeline/common.py b/engine/apps/social_auth/pipeline/common.py new file mode 100644 index 00000000..bf33377b --- /dev/null +++ b/engine/apps/social_auth/pipeline/common.py @@ -0,0 +1,27 @@ +import typing + +from django.http import HttpResponse +from rest_framework import status +from social_core.backends.base import BaseAuth +from social_core.exceptions import AuthForbidden +from social_core.strategy import BaseStrategy + +from apps.user_management.models import Organization, User + + +class UserOrganizationKwargsResponse(typing.TypedDict): + user: User + organization: Organization + + +def set_user_and_organization_from_request( + backend: typing.Type[BaseAuth], strategy: typing.Type[BaseStrategy], *args, **kwargs +) -> UserOrganizationKwargsResponse: + user = strategy.request.user + organization = strategy.request.auth.organization + if user is None or organization is None: + return HttpResponse(str(AuthForbidden(backend)), status=status.HTTP_401_UNAUTHORIZED) + return { + "user": user, + "organization": organization, + } diff --git a/engine/apps/social_auth/pipeline/google.py b/engine/apps/social_auth/pipeline/google.py new file mode 100644 index 00000000..065a69db --- /dev/null +++ b/engine/apps/social_auth/pipeline/google.py @@ -0,0 +1,27 @@ +import logging +import typing + +from rest_framework.response import Response +from social_core.backends.base import BaseAuth + +from apps.user_management.models import User + +logger = logging.getLogger(__name__) + + +def persist_access_and_refresh_tokens(backend: typing.Type[BaseAuth], response: Response, user: User, *args, **kwargs): + """ + TODO: what happens if `refresh_token` is not present? what are the scenarios that lead to this? + + NOTE: I think it's only included when the user initially grants access to our Google OAuth2 app + on subsequent logins, the refresh_token is not included in the response, only access_token.. what to do here? + + https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 + """ + user.finish_google_oauth2_connection_flow(response) + + +def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: User, *args, **kwargs): + # 2nd argument, uid, is not needed for GoogleOauth2 backend + backend.revoke_token(user.google_oauth2_user.access_token, "") + user.finish_google_oauth2_disconnection_flow() diff --git a/engine/apps/social_auth/pipeline.py b/engine/apps/social_auth/pipeline/slack.py similarity index 91% rename from engine/apps/social_auth/pipeline.py rename to engine/apps/social_auth/pipeline/slack.py index 42c8b846..3be18bfa 100644 --- a/engine/apps/social_auth/pipeline.py +++ b/engine/apps/social_auth/pipeline/slack.py @@ -5,7 +5,6 @@ from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.http import HttpResponse from rest_framework import status -from social_core.exceptions import AuthForbidden from apps.slack.tasks import populate_slack_channels_for_team, populate_slack_usergroups_for_team from apps.social_auth.exceptions import InstallMultiRegionSlackException @@ -16,17 +15,6 @@ from common.oncall_gateway import can_link_slack_team_wrapper, link_slack_team_w logger = logging.getLogger(__name__) -def set_user_and_organization_from_request(backend, strategy, *args, **kwargs): - user = strategy.request.user - organization = strategy.request.auth.organization - if user is None or organization is None: - return HttpResponse(str(AuthForbidden(backend)), status=status.HTTP_401_UNAUTHORIZED) - return { - "user": user, - "organization": organization, - } - - def connect_user_to_slack(response, backend, strategy, user, organization, *args, **kwargs): from apps.slack.models import SlackUserIdentity diff --git a/engine/apps/social_auth/types.py b/engine/apps/social_auth/types.py new file mode 100644 index 00000000..5abc4d30 --- /dev/null +++ b/engine/apps/social_auth/types.py @@ -0,0 +1,14 @@ +import typing + + +class GoogleOauth2Response(typing.TypedDict): + sub: str + scope: str + access_token: str + refresh_token: typing.Optional[str] + """ + NOTE: I think `refresh_token` is only included when the user initially grants access to our Google OAuth2 app + on subsequent logins, the `refresh_token` is not included in the response, only `access_token` + + https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 + """ diff --git a/engine/apps/user_management/constants.py b/engine/apps/user_management/constants.py index 16c3b4ff..af61e3f2 100644 --- a/engine/apps/user_management/constants.py +++ b/engine/apps/user_management/constants.py @@ -2,6 +2,8 @@ import typing from django.db.models import TextChoices +from apps.user_management.types import AlertGroupTableColumn + class AlertGroupTableDefaultColumnChoices(TextChoices): STATUS = "status", "Status" @@ -20,18 +22,6 @@ class AlertGroupTableColumnTypeChoices(TextChoices): LABEL = "label" -class AlertGroupTableColumn(typing.TypedDict): - id: str - name: str - type: str - - -class AlertGroupTableColumns(typing.TypedDict): - visible: typing.List[AlertGroupTableColumn] - hidden: typing.List[AlertGroupTableColumn] - default: bool - - def default_columns() -> typing.List[AlertGroupTableColumn]: return [ {"name": column.label, "id": column.value, "type": AlertGroupTableColumnTypeChoices.DEFAULT.value} diff --git a/engine/apps/user_management/migrations/0021_user_google_calendar_settings.py b/engine/apps/user_management/migrations/0021_user_google_calendar_settings.py new file mode 100644 index 00000000..00a9aab3 --- /dev/null +++ b/engine/apps/user_management/migrations/0021_user_google_calendar_settings.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-18 22:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0020_organization_is_grafana_labels_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='google_calendar_settings', + field=models.JSONField(default=None, null=True), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 139fc358..8564a195 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -11,8 +11,8 @@ from django.utils import timezone from mirage import fields as mirage_fields from apps.alerts.models import MaintainableObject -from apps.user_management.constants import AlertGroupTableColumn from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy +from apps.user_management.types import AlertGroupTableColumn from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log from common.oncall_gateway import ( register_oncall_tenant_wrapper, diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 14c0f533..d121719e 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -7,6 +7,7 @@ from urllib.parse import urljoin import pytz from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MinLengthValidator from django.db import models, transaction from django.db.models import Q @@ -20,8 +21,9 @@ from apps.api.permissions import ( RBACPermission, user_is_authorized, ) +from apps.google.models import GoogleOAuth2User from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization -from apps.user_management.constants import AlertGroupTableColumn +from apps.user_management.types import AlertGroupTableColumn, GoogleCalendarSettings from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: @@ -31,6 +33,7 @@ if typing.TYPE_CHECKING: from apps.auth_token.models import ApiAuthToken, ScheduleExportAuthToken, UserScheduleExportAuthToken from apps.base.models import UserNotificationPolicy from apps.slack.models import SlackUserIdentity + from apps.social_auth.types import GoogleOauth2Response from apps.user_management.models import Organization, Team logger = logging.getLogger(__name__) @@ -174,6 +177,7 @@ class User(models.Model): auth_tokens: "RelatedManager['ApiAuthToken']" current_team: typing.Optional["Team"] escalation_policy_notify_queues: "RelatedManager['EscalationPolicy']" + google_oauth2_user: typing.Optional[GoogleOAuth2User] last_notified_in_escalation_policies: "RelatedManager['EscalationPolicy']" notification_policies: "RelatedManager['UserNotificationPolicy']" organization: "Organization" @@ -239,6 +243,8 @@ class User(models.Model): alert_group_table_selected_columns: list[AlertGroupTableColumn] | None = models.JSONField(default=None, null=True) + google_calendar_settings: GoogleCalendarSettings | None = models.JSONField(default=None, null=True) + def __str__(self): return f"{self.pk}: {self.username}" @@ -252,6 +258,14 @@ class User(models.Model): def is_authenticated(self): return True + @property + def has_google_oauth2_connected(self) -> bool: + try: + # https://stackoverflow.com/a/35005034/3902555 + return self.google_oauth2_user is not None + except ObjectDoesNotExist: + return False + @property def avatar_full_url(self): return urljoin(self.organization.grafana_url, self.avatar_url) @@ -458,6 +472,28 @@ class User(models.Model): self.alert_group_table_selected_columns = columns self.save(update_fields=["alert_group_table_selected_columns"]) + def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None: + _obj, created = GoogleOAuth2User.objects.update_or_create( + user=self, + defaults={ + "google_user_id": google_oauth2_response.get("sub"), + "access_token": google_oauth2_response.get("access_token"), + "refresh_token": google_oauth2_response.get("refresh_token"), + "oauth_scope": google_oauth2_response.get("scope"), + }, + ) + if created: + self.google_calendar_settings = { + "oncall_schedules_to_consider_for_shift_swaps": [], + } + self.save(update_fields=["google_calendar_settings"]) + + def finish_google_oauth2_disconnection_flow(self) -> None: + GoogleOAuth2User.objects.filter(user=self).delete() + + self.google_calendar_settings = None + self.save(update_fields=["google_calendar_settings"]) + # TODO: check whether this signal can be moved to save method of the model @receiver(post_save, sender=User) diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index d32119cc..f24edaac 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -518,6 +518,8 @@ def test_sync_organization_lock(make_organization): @dataclass class TestSyncGrafanaLabelsPluginParams: + __test__ = False + response: tuple expected_result: bool @@ -548,6 +550,8 @@ def test_sync_grafana_labels_plugin(make_organization, test_params: TestSyncGraf @dataclass class TestSyncGrafanaIncidentParams: + __test__ = False + response: tuple expected_flag: bool expected_url: Optional[str] diff --git a/engine/apps/user_management/types.py b/engine/apps/user_management/types.py new file mode 100644 index 00000000..8f286e54 --- /dev/null +++ b/engine/apps/user_management/types.py @@ -0,0 +1,20 @@ +import typing + + +class AlertGroupTableColumn(typing.TypedDict): + id: str + name: str + type: str + + +class AlertGroupTableColumns(typing.TypedDict): + visible: typing.List[AlertGroupTableColumn] + hidden: typing.List[AlertGroupTableColumn] + default: bool + + +class GoogleCalendarSettings(typing.TypedDict): + oncall_schedules_to_consider_for_shift_swaps: typing.Optional[typing.List[str]] + """ + `public_primary_key` of specific OnCall schedules that should be considered for autogenerated shift swap requests. + """ diff --git a/engine/conftest.py b/engine/conftest.py index 5135cc7b..ad222572 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -45,7 +45,13 @@ from apps.api.permissions import ( LegacyAccessControlRole, RBACPermission, ) -from apps.auth_token.models import ApiAuthToken, IntegrationBacksyncAuthToken, PluginAuthToken, SlackAuthToken +from apps.auth_token.models import ( + ApiAuthToken, + GoogleOAuth2Token, + IntegrationBacksyncAuthToken, + PluginAuthToken, + SlackAuthToken, +) from apps.base.models.user_notification_policy_log_record import ( UserNotificationPolicyLogRecord, listen_for_usernotificationpolicylogrecord_model_save, @@ -283,6 +289,14 @@ def make_slack_token_for_user(): return _make_slack_token_for_user +@pytest.fixture +def make_google_oauth2_token_for_user(): + def _make_google_oauth2_token_for_user(user): + return GoogleOAuth2Token.create_auth_token(organization=user.organization, user=user) + + return _make_google_oauth2_token_for_user + + @pytest.fixture def make_public_api_token(): def _make_public_api_token(user, organization, name="test_api_token"): diff --git a/engine/pyproject.toml b/engine/pyproject.toml index c5f95775..f2ea3e21 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -57,6 +57,8 @@ module = [ "factory.*", "fcm_django.*", "firebase_admin.*", + "googleapiclient.discovery.*", + "google.oauth2.credentials.*", "httpretty.*", "humanize.*", "ipware.*", diff --git a/engine/requirements.in b/engine/requirements.in index 696a3ca4..a1ea6ad7 100644 --- a/engine/requirements.in +++ b/engine/requirements.in @@ -61,3 +61,6 @@ twilio~=6.37.0 urllib3==1.26.18 uwsgi==2.0.21 whitenoise==5.3.0 +google-api-python-client==2.122.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==1.2.0 diff --git a/engine/requirements.txt b/engine/requirements.txt index f9e84e12..aa7f32b1 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -168,17 +168,24 @@ google-api-core==2.17.0 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.118.0 - # via firebase-admin +google-api-python-client==2.122.0 + # via + # -r ./engine/requirements.in + # firebase-admin google-auth==2.27.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 + # google-auth-oauthlib # google-cloud-core # google-cloud-storage google-auth-httplib2==0.2.0 - # via google-api-python-client + # via + # -r ./engine/requirements.in + # google-api-python-client +google-auth-oauthlib==1.2.0 + # via -r ./engine/requirements.in google-cloud-core==2.4.1 # via # google-cloud-firestore @@ -397,7 +404,9 @@ requests==2.31.0 # social-auth-core # twilio requests-oauthlib==1.3.1 - # via social-auth-core + # via + # google-auth-oauthlib + # social-auth-core rpds-py==0.18.0 # via # jsonschema diff --git a/engine/settings/base.py b/engine/settings/base.py index 729d3ef7..0c3f0c72 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -71,6 +71,7 @@ GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATION FEATURE_LABELS_ENABLED_FOR_ALL = getenv_boolean("FEATURE_LABELS_ENABLED_FOR_ALL", default=False) # Enable labels feature for organizations from the list. Use OnCall organization ID, for this flag FEATURE_LABELS_ENABLED_PER_ORG = getenv_list("FEATURE_LABELS_ENABLED_PER_ORG", default=list()) +FEATURE_GOOGLE_OAUTH2_ENABLED = getenv_boolean("FEATURE_GOOGLE_OAUTH2_ENABLED", default=False) TWILIO_API_KEY_SID = os.environ.get("TWILIO_API_KEY_SID") TWILIO_API_KEY_SECRET = os.environ.get("TWILIO_API_KEY_SECRET") @@ -280,6 +281,7 @@ INSTALLED_APPS = [ "django_dbconn_retry", "apps.phone_notifications", "drf_spectacular", + "apps.google", ] REST_FRAMEWORK = { @@ -320,6 +322,10 @@ SPECTACULAR_INCLUDED_PATHS = [ "/alert_receive_channels", "/users", "/labels", + # social auth routes + "/login", + "/complete", + "/disconnect", ] MIDDLEWARE = [ @@ -642,8 +648,27 @@ AUTHENTICATION_BACKENDS = [ "apps.social_auth.backends.InstallSlackOAuth2V2", "apps.social_auth.backends.LoginSlackOAuth2V2", "django.contrib.auth.backends.ModelBackend", + "apps.social_auth.backends.GoogleOAuth2", ] +if FEATURE_GOOGLE_OAUTH2_ENABLED: + CELERY_BEAT_SCHEDULE["sync_google_calendar_out_of_office_events_for_all_users"] = { + "task": "apps.google.tasks.sync_out_of_office_calendar_events_for_all_users", + "schedule": crontab(minute="*/30"), # every 30 minutes + "args": (), + } + +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") +# NOTE: for right now we probably only need the calendar.events.readonly scope +# however, if we want to write events back to the user's calendar +# we'll probably need to change this to the calendar.events scope +# (not sure how hard this is to migrate to in the future?) +# https://developers.google.com/identity/protocols/oauth2/scopes#calendar +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = getenv_list( + "SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE", default=["https://www.googleapis.com/auth/calendar.events.readonly"] +) + SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET") SLACK_SIGNING_SECRET_LIVE = os.environ.get("SLACK_SIGNING_SECRET_LIVE", "") @@ -673,13 +698,32 @@ SOCIAL_AUTH_SLACK_INSTALL_FREE_CUSTOM_SCOPE = [ ] SOCIAL_AUTH_PIPELINE = ( - "apps.social_auth.pipeline.set_user_and_organization_from_request", + "apps.social_auth.pipeline.common.set_user_and_organization_from_request", "social_core.pipeline.social_auth.social_details", - "apps.social_auth.pipeline.connect_user_to_slack", - "apps.social_auth.pipeline.populate_slack_identities", - "apps.social_auth.pipeline.delete_slack_auth_token", + "apps.social_auth.pipeline.slack.connect_user_to_slack", + "apps.social_auth.pipeline.slack.populate_slack_identities", + "apps.social_auth.pipeline.slack.delete_slack_auth_token", ) +SOCIAL_AUTH_GOOGLE_OAUTH2_PIPELINE = ( + "apps.social_auth.pipeline.common.set_user_and_organization_from_request", + "apps.social_auth.pipeline.google.persist_access_and_refresh_tokens", +) + +SOCIAL_AUTH_GOOGLE_OAUTH2_DISCONNECT_PIPELINE = ( + "apps.social_auth.pipeline.common.set_user_and_organization_from_request", + "apps.social_auth.pipeline.google.disconnect_user_google_oauth2_settings", +) + +# https://python-social-auth.readthedocs.io/en/latest/use_cases.html#re-prompt-google-oauth2-users-to-refresh-the-refresh-token +# https://developers.google.com/identity/protocols/oauth2/web-server +SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = { + # Indicates whether your application can refresh access tokens when the user is not present at the browser. + # Valid parameter values are online, which is the default value, and offline. + "access_type": "offline", + "approval_prompt": "auto", +} + SOCIAL_AUTH_FIELDS_STORED_IN_SESSION: typing.List[str] = [] SOCIAL_AUTH_REDIRECT_IS_HTTPS = getenv_boolean("SOCIAL_AUTH_REDIRECT_IS_HTTPS", default=True) SOCIAL_AUTH_SLUGIFY_USERNAMES = True @@ -689,7 +733,7 @@ PUBLIC_PRIMARY_KEY_MIN_LENGTH = 12 PUBLIC_PRIMARY_KEY_ALLOWED_CHARS = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789" AUTH_LINK_TIMEOUT_SECONDS = 300 -SLACK_AUTH_TOKEN_TIMEOUT_SECONDS = 300 +AUTH_TOKEN_TIMEOUT_SECONDS = 300 SLACK_INSTALL_RETURN_REDIRECT_HOST = os.environ.get("SLACK_INSTALL_RETURN_REDIRECT_HOST", None) diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index b349cf2e..ec28312c 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -113,6 +113,8 @@ CELERY_TASK_ROUTES = { "apps.base.tasks.process_failed_to_invoke_celery_tasks": {"queue": "critical"}, "apps.base.tasks.process_failed_to_invoke_celery_tasks_batch": {"queue": "critical"}, "apps.email.tasks.notify_user_async": {"queue": "critical"}, + "apps.google.tasks.sync_out_of_office_calendar_events_for_all_users": {"queue": "critical"}, + "apps.google.tasks.sync_out_of_office_calendar_events_for_user": {"queue": "critical"}, "apps.integrations.tasks.create_alert": {"queue": "critical"}, "apps.integrations.tasks.create_alertmanager_alerts": {"queue": "critical"}, "apps.integrations.tasks.start_notify_about_integration_ratelimit": {"queue": "critical"}, diff --git a/grafana-plugin/e2e-tests/insights/insights.test.ts b/grafana-plugin/e2e-tests/insights/insights.test.ts index 9320d290..14d2feec 100644 --- a/grafana-plugin/e2e-tests/insights/insights.test.ts +++ b/grafana-plugin/e2e-tests/insights/insights.test.ts @@ -20,7 +20,12 @@ test.skip( 'Insights is only available in Grafana 10.0.0 and above' ); -test.describe('Insights', () => { +/** + * skipping as these tests are currently flaky + * see this Slack conversation for more details: + * https://raintank-corp.slack.com/archives/C04JCU51NF8/p1712069772861909 + */ +test.describe.skip('Insights', () => { test.beforeAll(async ({ adminRolePage: { page } }) => { const DATASOURCE_NAME = 'OnCall Prometheus'; const DATASOURCE_URL = 'http://oncall-dev-prometheus-server.default.svc.cluster.local'; diff --git a/grafana-plugin/e2e-tests/users/userProfile.googleConnectionTab.test.ts b/grafana-plugin/e2e-tests/users/userProfile.googleConnectionTab.test.ts new file mode 100644 index 00000000..e287396d --- /dev/null +++ b/grafana-plugin/e2e-tests/users/userProfile.googleConnectionTab.test.ts @@ -0,0 +1,21 @@ +import { AppFeature } from 'state/features'; + +import { test, expect } from '../fixtures'; +import { goToOnCallPage } from '../utils/navigation'; + +test('Google Calendar connector and Google Calendar tab are visible if google_oauth2 feature enabled', async ({ + adminRolePage: { page }, +}) => { + goToOnCallPage(page, 'users/me'); + + const featuresResponse = await page.waitForResponse((resp) => { + return resp.url().includes('/features/') && resp.status() === 200; + }); + + const features = await featuresResponse.json(); + + if (features.includes(AppFeature.GoogleOauth2)) { + await expect(page.getByTestId('google-calendar-connector-title')).toBeVisible(); + await expect(page.getByTestId('google-calendar-tab')).toBeVisible(); + } +}); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index da5d3258..3e704c8f 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -55,12 +55,14 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn showTelegramConnectionTab, showMobileAppConnectionTab, showMsTeamsConnectionTab, + showGoogleCalendarTab, ] = [ !isDesktopOrLaptop, isCurrent && organizationStore.currentOrganization?.slack_team_identity && !storeUser.slack_user_identity, isCurrent && store.hasFeature(AppFeature.Telegram) && !storeUser.telegram_configuration, isCurrent, store.hasFeature(AppFeature.MsTeams) && !storeUser.messaging_backends.MSTEAMS, + isCurrent && store.hasFeature(AppFeature.GoogleOauth2), ]; const title = ( @@ -81,6 +83,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn showTelegramConnectionTab={showTelegramConnectionTab} showMobileAppConnectionTab={showMobileAppConnectionTab} showMsTeamsConnectionTab={showMsTeamsConnectionTab} + showGoogleCalendarTab={showGoogleCalendarTab} /> diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts index eb29b95f..6dfd1d9a 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts @@ -1,6 +1,7 @@ export enum UserSettingsTab { UserInfo, NotificationSettings, + GoogleCalendar, PhoneVerification, SlackInfo, TelegramInfo, diff --git a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx index b5cb8a0d..a6262e58 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx @@ -10,6 +10,7 @@ import { MobileAppConnectionTab } from 'containers/MobileAppConnection/MobileApp import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab'; import { CloudPhoneSettings } from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings'; +import { GoogleCalendar } from 'containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar'; import { MSTeamsInfo } from 'containers/UserSettings/parts/tabs/MSTeamsInfo/MSTeamsInfo'; import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab'; import { PhoneVerification } from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification'; @@ -29,6 +30,7 @@ interface TabsProps { onTabChange: (tab: UserSettingsTab) => void; showNotificationSettingsTab: boolean; showMobileAppConnectionTab: boolean; + showGoogleCalendarTab: boolean; showSlackConnectionTab: boolean; showTelegramConnectionTab: boolean; showMsTeamsConnectionTab: boolean; @@ -38,6 +40,7 @@ export const Tabs = ({ activeTab, onTabChange, showNotificationSettingsTab, + showGoogleCalendarTab, showMobileAppConnectionTab, showSlackConnectionTab, showTelegramConnectionTab, @@ -70,6 +73,15 @@ export const Tabs = ({ data-testid="tab-notification-settings" /> )} + {showGoogleCalendarTab && ( + + )} ))} {activeTab === UserSettingsTab.NotificationSettings && } + {activeTab === UserSettingsTab.GoogleCalendar && } {activeTab === UserSettingsTab.PhoneVerification && (store.hasFeature(AppFeature.CloudNotifications) ? ( diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/GoogleConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/GoogleConnector.tsx new file mode 100644 index 00000000..4199750c --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/GoogleConnector.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { Button, HorizontalGroup, InlineField } from '@grafana/ui'; +import { observer } from 'mobx-react'; + +import { WithConfirm } from 'components/WithConfirm/WithConfirm'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { UserHelper } from 'models/user/user.helpers'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useStore } from 'state/useStore'; +import { UserActions } from 'utils/authorization/authorization'; + +interface GoogleConnectorProps { + id: ApiSchemas['User']['pk']; +} + +export const GoogleConnector = observer((props: GoogleConnectorProps) => { + const { id } = props; + + const store = useStore(); + const { userStore } = store; + + const storeUser = userStore.items[id]; + + const isCurrentUser = id === store.userStore.currentUserPk; + + return ( +
+ + {storeUser.has_google_oauth2_connected ? ( + + + + + + + + ) : ( + + + + )} + +
+ ); +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar.tsx new file mode 100644 index 00000000..1dc44487 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useState } from 'react'; + +import { css } from '@emotion/css'; +import { Button, HorizontalGroup, InlineSwitch, VerticalGroup, useStyles2 } from '@grafana/ui'; +import { observer } from 'mobx-react'; + +import { Block } from 'components/GBlock/Block'; +import { Text } from 'components/Text/Text'; +import { WithConfirm } from 'components/WithConfirm/WithConfirm'; +import { GSelect } from 'containers/GSelect/GSelect'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import GoogleCalendarLogo from 'icons/GoogleCalendarLogo'; +import { Schedule } from 'models/schedule/schedule.types'; +import { UserHelper } from 'models/user/user.helpers'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useStore } from 'state/useStore'; +import { UserActions } from 'utils/authorization/authorization'; + +const GoogleCalendar: React.FC<{ id: ApiSchemas['User']['pk'] }> = observer(({ id }) => { + const { userStore, scheduleStore } = useStore(); + + const styles = useStyles2(getStyles); + + const user = userStore.items[id]; + const [googleCalendarSettings, setGoogleCalendarSettings] = useState(user?.google_calendar_settings); + const [showSchedulesDropdown, setShowSchedulesDropdown] = useState( + user.google_calendar_settings?.oncall_schedules_to_consider_for_shift_swaps?.length > 0 + ); + + const handleShowSchedulesDropdownChange = (event: React.ChangeEvent) => { + const value = event.target.checked; + setShowSchedulesDropdown(value); + + if (!value) { + handleSchedulesChange([]); + } + }; + + useEffect(() => { + if (user) { + setGoogleCalendarSettings(user.google_calendar_settings); + } + }, [user]); + + const handleSchedulesChange = (value) => { + setGoogleCalendarSettings((v) => ({ ...v, oncall_schedules_to_consider_for_shift_swaps: value })); + + userStore.updateCurrentUser({ + google_calendar_settings: { ...googleCalendarSettings, oncall_schedules_to_consider_for_shift_swaps: value }, + }); + }; + + return ( + + + + {user.has_google_oauth2_connected ? ( + + + + + Google calendar is connected + + + + + + + + + ) : ( + + + +
+ Connect your Google Calendar + + This connection allows Grafana OnCall to read your Out of Office events and autogenerate Shift Swap + Requests + +
+
+ + + +
+ )} + + {user.has_google_oauth2_connected && ( + + + + + {showSchedulesDropdown && ( +
+ + + isMulti + showSearch + allowClear + disabled={false} + items={scheduleStore.items} + fetchItemsFn={scheduleStore.updateItems} + fetchItemFn={scheduleStore.updateItem} + getSearchResult={scheduleStore.getSearchResult} + displayField="name" + valueField="id" + placeholder="Select Schedules" + value={googleCalendarSettings?.oncall_schedules_to_consider_for_shift_swaps} + onChange={handleSchedulesChange} + /> + +
+ )} +
+ )} +
+
+
+ ); +}); + +export const getStyles = () => ({ + root: css({ + width: '100%', + }), +}); + +export { GoogleCalendar }; diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.module.css index b03c2abc..628f717f 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.module.css +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.module.css @@ -1,7 +1,4 @@ -.user-value { - font-size: 16px; -} - -.user-item { - margin-bottom: 15px; +.external-link-style { + margin-right: 4px; + align-self: baseline; } diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx index d267316b..329a28b2 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx @@ -5,7 +5,9 @@ import { InlineField, Input, Legend } from '@grafana/ui'; import { GrafanaTeamSelect } from 'containers/GrafanaTeamSelect/GrafanaTeamSelect'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { Connectors } from 'containers/UserSettings/parts/connectors/Connectors'; +import { GoogleConnector } from 'containers/UserSettings/parts/connectors/GoogleConnector'; import { ApiSchemas } from 'network/oncall-api/api.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; interface UserInfoTabProps { @@ -55,6 +57,12 @@ export const UserInfoTab = (props: UserInfoTabProps) => { }} /> + {store.hasFeature(AppFeature.GoogleOauth2) && ( + <> + Google Calendar + + + )} Notification channels diff --git a/grafana-plugin/src/icons/GoogleCalendarLogo.tsx b/grafana-plugin/src/icons/GoogleCalendarLogo.tsx new file mode 100644 index 00000000..a0bce3b5 --- /dev/null +++ b/grafana-plugin/src/icons/GoogleCalendarLogo.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +const GoogleCalendarLogo = ({ width, height }) => ( + + + + + + + + + + + + + + + +); + +export default GoogleCalendarLogo; diff --git a/grafana-plugin/src/icons/GoogleLogo.tsx b/grafana-plugin/src/icons/GoogleLogo.tsx new file mode 100644 index 00000000..1651b063 --- /dev/null +++ b/grafana-plugin/src/icons/GoogleLogo.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const GoogleLogo = ({ width, height }) => ( + + + + + + + + +); + +export default GoogleLogo; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index 7c73dfa4..27f124ac 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -107,4 +107,9 @@ export class UserHelper { }) ).data; } + + static async handleConnectGoogle() { + const { data } = await onCallApi().GET('/login/{backend}', { params: { path: { backend: 'google-oauth2' } } }); + window.location = data; + } } diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index c7e74e6b..a1a1251b 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -143,6 +143,12 @@ export class UserStore { this.loadCurrentUser(); } + async disconnectGoogle() { + await onCallApi().GET('/disconnect/{backend}', { params: { path: { backend: 'google-oauth2' } } }); + + this.loadCurrentUser(); + } + async updateUser(data: Partial) { const user = ( await onCallApi().PUT('/users/{id}/', { diff --git a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts index 8eb916a5..50a42b8e 100644 --- a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts +++ b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts @@ -849,6 +849,39 @@ export interface paths { patch?: never; trace?: never; }; + '/complete/{backend}/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Authentication complete view */ + get: operations['complete_retrieve']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/disconnect/{backend}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['disconnect_retrieve']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/features/': { parameters: { query?: never; @@ -954,6 +987,38 @@ export interface paths { patch?: never; trace?: never; }; + '/login/{backend}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['login_retrieve']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/login/{backend}/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['login_retrieve_2']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/users/': { parameters: { query?: never; @@ -1732,6 +1797,9 @@ export interface components { value: string; display_name: string; }; + GoogleCalendarSettings: { + oncall_schedules_to_consider_for_shift_swaps?: string[] | null; + }; /** @description Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details. */ IntegrationAlertGroupLabels: { inheritable: { @@ -1887,6 +1955,7 @@ export interface components { }; readonly cloud_connection_status: components['schemas']['CloudConnectionStatusEnum'] | null; hide_phone_number?: boolean; + readonly has_google_oauth2_connected: boolean; }; /** * @description * `0` - Debug @@ -2018,7 +2087,9 @@ export interface components { }; readonly cloud_connection_status?: components['schemas']['CloudConnectionStatusEnum'] | null; hide_phone_number?: boolean; + readonly has_google_oauth2_connected?: boolean; readonly is_currently_oncall?: boolean; + google_calendar_settings?: components['schemas']['GoogleCalendarSettings']; }; PreviewTemplateRequest: { template_body?: string | null; @@ -2143,7 +2214,9 @@ export interface components { }; readonly cloud_connection_status: components['schemas']['CloudConnectionStatusEnum'] | null; hide_phone_number?: boolean; + readonly has_google_oauth2_connected: boolean; readonly is_currently_oncall: boolean; + google_calendar_settings?: components['schemas']['GoogleCalendarSettings']; }; UserExportTokenGetResponse: { /** Format: date-time */ @@ -3741,6 +3814,46 @@ export interface operations { }; }; }; + complete_retrieve: { + parameters: { + query?: never; + header?: never; + path: { + backend: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + disconnect_retrieve: { + parameters: { + query?: never; + header?: never; + path: { + backend: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; features_retrieve: { parameters: { query?: never; @@ -3764,6 +3877,7 @@ export interface operations { | 'grafana_cloud_connection' | 'grafana_alerting_v2' | 'labels' + | 'google_oauth2' )[]; }; }; @@ -3938,6 +4052,46 @@ export interface operations { }; }; }; + login_retrieve: { + parameters: { + query?: never; + header?: never; + path: { + backend: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + login_retrieve_2: { + parameters: { + query?: never; + header?: never; + path: { + backend: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; users_list: { parameters: { query?: { diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index 4749f41c..a5c5af26 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -6,4 +6,5 @@ export enum AppFeature { CloudConnection = 'grafana_cloud_connection', Labels = 'labels', MsTeams = 'msteams', + GoogleOauth2 = 'google_oauth2', }