From eb777f5415c0d59b3a0deb453a4f3d8a905ee4ae Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 14 Aug 2024 18:02:34 -0400 Subject: [PATCH] address Google OAuth2 issues where user didn't grant us the `https://www.googleapis.com/auth/calendar.events.readonly` scope (#4802) # What this PR does Follow up PR to https://github.com/grafana/oncall/pull/4792 Basically if when communicating with Google Calendar's API we encounter an HTTP 403, or the Google client throws a `google.auth.exceptions.RefreshError` this means one of three things: 1. the refresh token we have persisted for the user is missing the `https://www.googleapis.com/auth/calendar.events.readonly` scope (HTTP 403) 2. the Google user has been deleted (`google.auth.exceptions.RefreshError`) 3. the refresh token has expired (`google.auth.exceptions.RefreshError`) To prevent scenario 1 above from happening in the future we now will check that the token has been granted the required scopes. If the user doesn't grant us all the necessary scopes, we will show them an error message in the UI: https://www.loom.com/share/0055ef03192b4154b894c2221cecbd5f For tokens that were granted prior to this PR and which are missing the required scope, we will show the user a dismissible warning banner in the UI letting them know that they will need to reconnect their account and grant us the missing permissions (see [this second demo video](https://www.loom.com/share/bf2ee8b840864a64893165370a892bcd) showing this). ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [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. --------- Co-authored-by: Dominik --- engine/apps/api/serializers/user.py | 8 +- engine/apps/api/tests/test_auth.py | 27 ++ engine/apps/api/tests/test_user.py | 1 + engine/apps/api/views/user.py | 1 + engine/apps/google/client.py | 29 +- engine/apps/google/constants.py | 3 + engine/apps/google/tasks.py | 39 +- ..._out_of_office_calendar_events_for_user.py | 100 ++++- engine/apps/google/tests/test_utils.py | 18 + engine/apps/google/utils.py | 8 + engine/apps/social_auth/exceptions.py | 3 + engine/apps/social_auth/pipeline/google.py | 45 ++- engine/apps/user_management/models/user.py | 9 +- .../apps/user_management/tests/test_user.py | 34 ++ engine/conftest.py | 6 +- engine/settings/base.py | 4 +- .../src/containers/Alerts/Alerts.tsx | 60 ++- .../UserSettings/UserSettings.module.css | 15 + .../containers/UserSettings/UserSettings.tsx | 56 ++- grafana-plugin/src/models/user/user.ts | 4 +- .../oncall-api/autogenerated-api.types.d.ts | 342 +++++++++++++----- 21 files changed, 670 insertions(+), 142 deletions(-) create mode 100644 engine/apps/google/tests/test_utils.py diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index b428aefb..2133b80f 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -222,12 +222,14 @@ class UserSerializer(ListUserSerializer): class CurrentUserSerializer(UserSerializer): rbac_permissions = UserPermissionSerializer(read_only=True, many=True, source="permissions") - class Meta: - model = User + class Meta(UserSerializer.Meta): fields = UserSerializer.Meta.fields + [ "rbac_permissions", + "google_oauth2_token_is_missing_scopes", + ] + read_only_fields = UserSerializer.Meta.read_only_fields + [ + "google_oauth2_token_is_missing_scopes", ] - read_only_fields = UserSerializer.Meta.read_only_fields class UserHiddenFieldsSerializer(ListUserSerializer): diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index 7ba330f9..85a15554 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -195,3 +195,30 @@ def test_google_complete_auth_redirect_ok( assert response.status_code == status.HTTP_302_FOUND assert response.url == "/a/grafana-oncall-app/users/me" + + +@pytest.mark.django_db +def test_complete_google_auth_redirect_error( + 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}" + ) + + def _custom_do_complete(backend, *args, **kwargs): + backend.strategy.session[REDIRECT_FIELD_NAME] = "some-url" + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + with patch("apps.api.views.auth.do_complete", side_effect=_custom_do_complete): + response = client.get(url) + + assert response.status_code == status.HTTP_302_FOUND + assert response.url == "some-url" diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index fbe8ebb9..a4468f89 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -66,6 +66,7 @@ def test_current_user( "avatar_full": user.avatar_full_url, "has_google_oauth2_connected": False, "google_calendar_settings": None, + "google_oauth2_token_is_missing_scopes": False, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 3bbc50ac..d1cd14c7 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -127,6 +127,7 @@ class CachedSchedulesContextMixin: return context +@extend_schema(responses={status.HTTP_200_OK: CurrentUserSerializer}) class CurrentUserView(APIView, CachedSchedulesContextMixin): authentication_classes = (MobileAppAuthTokenAuthentication, PluginAuthentication) permission_classes = (IsAuthenticated,) diff --git a/engine/apps/google/client.py b/engine/apps/google/client.py index bf7f9818..f4383bcc 100644 --- a/engine/apps/google/client.py +++ b/engine/apps/google/client.py @@ -104,25 +104,44 @@ class GoogleCalendarAPIClient: ) except HttpError as e: if e.status_code == 403: - # this scenario can be encountered when, for some reason, the OAuth2 token that we have + # this scenario can be encountered when, the OAuth2 token that we have # does not contain the https://www.googleapis.com/auth/calendar.events.readonly scope # example error: # # noqa: E501 + # + # this should really only occur for tokens granted prior to this commit (which wrote this comment). + # Before then we didn't handle the scenario where the Google oauth consent screen could potentially + # have checkboxes and users would have to actively check the checkbox to grant this scope. We now + # handle this scenario. + # + # References + # https://jpassing.com/2022/08/01/dealing-with-partial-consent-in-google-oauth-clients/ + # https://raintank-corp.slack.com/archives/C05AMEGMLCT/p1723556508149689 + # https://raintank-corp.slack.com/archives/C04JCU51NF8/p1723493330369349 logger.error(f"GoogleCalendarAPIClient - HttpError 403 when fetching out of office events: {e}") raise GoogleCalendarUnauthorizedHTTPError(e) logger.error(f"GoogleCalendarAPIClient - HttpError when fetching out of office events: {e}") raise GoogleCalendarGenericHTTPError(e) except RefreshError as e: - # TODO: come back and solve this properly once we get better logging output - # it seems like right now we are seeing RefreshError in two different scenarios: + # we see RefreshError in two different scenarios: # 1. RefreshError('invalid_grant: Account has been deleted', {'error': 'invalid_grant', 'error_description': 'Account has been deleted'}) # 2. RefreshError('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'}) + # # https://stackoverflow.com/a/49024030/3902555 + # + # in both of these cases the granted token is no longer good and we should delete it + + try: + error_details = e.args[1] # should be a dict like in the comment above + except IndexError: + error_details = None # catch this just in case + + error_description = error_details.get("error_description") if error_details else None + logger.error( f"GoogleCalendarAPIClient - RefreshError when fetching out of office events: {e} " - # NOTE: remove e.args after debugging how to dig into the error details - f"args={e.args}" + f"error_description={error_description}" ) raise GoogleCalendarRefreshError(e) diff --git a/engine/apps/google/constants.py b/engine/apps/google/constants.py index f0f58bae..b2b2bb40 100644 --- a/engine/apps/google/constants.py +++ b/engine/apps/google/constants.py @@ -1,3 +1,5 @@ +from django.conf import settings + EVENT_SUMMARY_IGNORE_KEYWORD = "#grafana-oncall-ignore" GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" @@ -6,3 +8,4 @@ https://stackoverflow.com/a/17159470/3902555 """ DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS = 30 +REQUIRED_OAUTH_SCOPES = settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE diff --git a/engine/apps/google/tasks.py b/engine/apps/google/tasks.py index b8efc5bc..7c0f4b4d 100644 --- a/engine/apps/google/tasks.py +++ b/engine/apps/google/tasks.py @@ -1,6 +1,7 @@ import logging from celery.utils.log import get_task_logger +from django.db.models import Q from apps.google import constants from apps.google.client import ( @@ -39,15 +40,24 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N try: out_of_office_events = google_api_client.fetch_out_of_office_events() except GoogleCalendarUnauthorizedHTTPError: - # this happens because the user's access token is (somehow) missing the - # https://www.googleapis.com/auth/calendar.events.readonly scope - # they will need to reconnect their Google account and grant us the necessary scopes, retrying will not help - logger.exception(f"Failed to fetch out of office events for user {user_id} due to an unauthorized HTTP error") - # TODO: come back and solve this properly once we get better logging output - # user.reset_google_oauth2_settings() + # this means the user's current token is missing the required scopes, don't delete their token for now + # we'll notify them via the plugin UI and ask them to reauth and grant us the missing scope + logger.warning( + f"Failed to fetch out of office events for user {user_id} due to missing required scopes. " + "Safe to skip for now" + ) return - except (GoogleCalendarRefreshError, GoogleCalendarGenericHTTPError): - logger.exception(f"Failed to fetch out of office events for user {user_id}") + except GoogleCalendarRefreshError: + # in this scenarios there's really not much we can do with the refresh/access token that we + # have available. The user will need to re-connect with Google so lets delete their persisted token + + logger.exception( + f"Failed to fetch out of office events for user {user_id} due to an invalid access and/or refresh token" + ) + user.reset_google_oauth2_settings() + return + except GoogleCalendarGenericHTTPError: + logger.exception(f"Failed to fetch out of office events for user {user_id} due to a generic HTTP error") return for out_of_office_event in out_of_office_events: @@ -118,5 +128,16 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N @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.filter(user__organization__deleted_at__isnull=True): + # some existing tokens may not have all the required scopes, lets skip these + tokens_containing_required_scopes = GoogleOAuth2User.objects.filter( + *[Q(oauth_scope__contains=scope) for scope in constants.REQUIRED_OAUTH_SCOPES], + user__organization__deleted_at__isnull=True, + ) + + logger.info( + f"Google OAuth2 tokens with the required scopes - " + f"{tokens_containing_required_scopes.count()}/{GoogleOAuth2User.objects.count()}" + ) + + for google_oauth2_user in tokens_containing_required_scopes: sync_out_of_office_calendar_events_for_user.apply_async(args=(google_oauth2_user.pk,)) diff --git a/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py b/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py index e4692fbe..77ad912f 100644 --- a/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py +++ b/engine/apps/google/tests/test_sync_out_of_office_calendar_events_for_user.py @@ -1,11 +1,13 @@ import datetime -from unittest.mock import patch +from unittest.mock import call, patch import pytest from django.utils import timezone +from google.auth.exceptions import RefreshError from googleapiclient.errors import HttpError from apps.google import constants, tasks +from apps.google.models import GoogleOAuth2User from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest @@ -148,19 +150,61 @@ class MockResponse: @patch("apps.google.client.build") +@pytest.mark.parametrize( + "ErrorClass,http_status,should_reset_user_google_oauth2_settings,task_should_raise_exception", + [ + (RefreshError, None, True, False), + (HttpError, 401, False, False), + (HttpError, 500, False, False), + (HttpError, 403, False, False), + (Exception, None, False, True), + ], +) @pytest.mark.django_db -def test_sync_out_of_office_calendar_events_for_user_httperror(mock_google_api_client_build, test_setup): - mock_response = MockResponse(reason="forbidden", status=403) - mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.side_effect = HttpError( - resp=mock_response, content=b"error" - ) +def test_sync_out_of_office_calendar_events_for_user_error_scenarios( + mock_google_api_client_build, + ErrorClass, + http_status, + should_reset_user_google_oauth2_settings, + task_should_raise_exception, + test_setup, +): + if ErrorClass == HttpError: + mock_response = MockResponse(reason="forbidden", status=http_status) + exception = ErrorClass(resp=mock_response, content=b"error") + elif ErrorClass == RefreshError: + exception = ErrorClass( + "invalid_grant: Token has been expired or revoked.", + {"error": "invalid_grant", "error_description": "Token has been expired or revoked."}, + ) + else: + exception = ErrorClass() + + mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.side_effect = exception google_oauth2_user, schedule = test_setup([]) user = google_oauth2_user.user - tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk) + assert user.google_calendar_settings is not None - assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 0 + if task_should_raise_exception: + with pytest.raises(ErrorClass): + tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk) + else: + tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk) + + assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 0 + + user.refresh_from_db() + + google_oauth2_user_count = GoogleOAuth2User.objects.filter(user=user).count() + + if should_reset_user_google_oauth2_settings: + assert user.google_calendar_settings is None + assert google_oauth2_user_count == 0 + else: + assert user.google_calendar_settings is not None + assert google_oauth2_user_count == 1 @patch("apps.google.client.build") @@ -374,14 +418,50 @@ def test_sync_out_of_office_calendar_events_for_user_preexisting_shift_swap_requ assert _fetch_shift_swap_requests().count() == 1 +REQUIRED_SCOPE_1 = "https://www.googleapis.com/test/foo" +REQUIRED_SCOPE_2 = "https://www.googleapis.com/test/bar" + + +@patch("apps.google.tasks.constants.REQUIRED_OAUTH_SCOPES", [REQUIRED_SCOPE_1, REQUIRED_SCOPE_2]) @patch("apps.google.tasks.sync_out_of_office_calendar_events_for_user.apply_async") @pytest.mark.django_db -def test_sync_out_of_office_calendar_events_for_all_users( +def test_sync_out_of_office_calendar_events_for_all_users_only_called_for_tokens_having_all_required_scopes( + mock_sync_out_of_office_calendar_events_for_user, + make_organization_and_user, + make_user_for_organization, + make_google_oauth2_user_for_user, +): + organization, user1 = make_organization_and_user() + user2 = make_user_for_organization(organization) + user3 = make_user_for_organization(organization) + + missing_a_scope = f"{REQUIRED_SCOPE_1} foo_bar" + has_all_scopes = f"{REQUIRED_SCOPE_1} {REQUIRED_SCOPE_2} foo_bar" + + _ = make_google_oauth2_user_for_user(user1, oauth_scope=missing_a_scope) + user2_google_oauth2_user = make_google_oauth2_user_for_user(user2, oauth_scope=has_all_scopes) + user3_google_oauth2_user = make_google_oauth2_user_for_user(user3, oauth_scope=has_all_scopes) + + tasks.sync_out_of_office_calendar_events_for_all_users() + + assert len(mock_sync_out_of_office_calendar_events_for_user.mock_calls) == 2 + mock_sync_out_of_office_calendar_events_for_user.assert_has_calls( + [ + call(args=(user2_google_oauth2_user.pk,)), + call(args=(user3_google_oauth2_user.pk,)), + ], + any_order=True, + ) + + +@patch("apps.google.tasks.sync_out_of_office_calendar_events_for_user.apply_async") +@pytest.mark.django_db +def test_sync_out_of_office_calendar_events_for_all_users_filters_out_users_from_deleted_organizations( mock_sync_out_of_office_calendar_events_for_user, make_organization_and_user, make_google_oauth2_user_for_user, ): - organization, user = make_organization_and_user() + _, user = make_organization_and_user() google_oauth2_user = make_google_oauth2_user_for_user(user) deleted_organization, deleted_user = make_organization_and_user() diff --git a/engine/apps/google/tests/test_utils.py b/engine/apps/google/tests/test_utils.py new file mode 100644 index 00000000..038c3b8a --- /dev/null +++ b/engine/apps/google/tests/test_utils.py @@ -0,0 +1,18 @@ +import pytest + +from apps.google import utils + +SCOPES_ALWAYS_GRANTED = ( + "openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email" +) + + +@pytest.mark.parametrize( + "granted_scopes,expected", + ( + (SCOPES_ALWAYS_GRANTED, False), + (f"{SCOPES_ALWAYS_GRANTED} https://www.googleapis.com/auth/calendar.events.readonly", True), + ), +) +def test_user_granted_all_required_scopes(granted_scopes, expected): + assert utils.user_granted_all_required_scopes(granted_scopes) == expected diff --git a/engine/apps/google/utils.py b/engine/apps/google/utils.py index ce30cd99..7498d81a 100644 --- a/engine/apps/google/utils.py +++ b/engine/apps/google/utils.py @@ -3,6 +3,14 @@ import datetime from apps.google import constants +def user_granted_all_required_scopes(user_granted_scopes: str) -> bool: + """ + `user_granted_scopes` should be a space-separated string of scopes + """ + granted_scopes = user_granted_scopes.split(" ") + return all(scope in granted_scopes for scope in constants.REQUIRED_OAUTH_SCOPES) + + def datetime_strftime(dt: datetime.datetime) -> str: return dt.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT) diff --git a/engine/apps/social_auth/exceptions.py b/engine/apps/social_auth/exceptions.py index 23835ccb..8912c9a5 100644 --- a/engine/apps/social_auth/exceptions.py +++ b/engine/apps/social_auth/exceptions.py @@ -1,2 +1,5 @@ class InstallMultiRegionSlackException(Exception): pass + + +GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR = "missing_granted_scope" diff --git a/engine/apps/social_auth/pipeline/google.py b/engine/apps/social_auth/pipeline/google.py index bed5996b..d79a4bae 100644 --- a/engine/apps/social_auth/pipeline/google.py +++ b/engine/apps/social_auth/pipeline/google.py @@ -1,24 +1,51 @@ import logging import typing +from urllib.parse import urljoin import requests -from rest_framework.response import Response +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.http import HttpResponse +from rest_framework import status from social_core.backends.base import BaseAuth -from apps.user_management.models import User +from apps.google.utils import user_granted_all_required_scopes +from apps.social_auth.exceptions import GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR +from apps.social_auth.types import GoogleOauth2Response +from apps.user_management.models import Organization, 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? +def connect_user_to_google( + strategy, + response: GoogleOauth2Response, + user: User, + organization: Organization, + *args, + **kwargs, +): + granted_scopes = response.get("scope", "") - 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? + if not user_granted_all_required_scopes(granted_scopes): + logger.warning( + f"User {user.pk} did not grant all required scopes, redirecting w/ error message " + f"granted_scopes={granted_scopes}" + ) + + base_url_to_redirect = urljoin(organization.grafana_url, "/a/grafana-oncall-app/users/me") + strategy.session[ + REDIRECT_FIELD_NAME + ] = f"{base_url_to_redirect}?google_error={GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR}" + + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + # at this point everything is correct and we can persist the Google OAuth2 token + generate any other relevant + # config for the user + # + # be sure to clear any pre-existing sessions, in case the user previously enecountered errors we want + # to be sure to clear these so they do not see them again + strategy.session.flush() - https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 - """ user.save_google_oauth2_settings(response) diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 97163883..376dd82a 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -20,6 +20,7 @@ from apps.api.permissions import ( RBACPermission, user_is_authorized, ) +from apps.google import utils as google_utils from apps.google.models import GoogleOAuth2User from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization from apps.user_management.types import AlertGroupTableColumn, GoogleCalendarSettings @@ -188,6 +189,12 @@ class User(models.Model): except ObjectDoesNotExist: return False + @property + def google_oauth2_token_is_missing_scopes(self) -> bool: + if not self.has_google_oauth2_connected: + return False + return not google_utils.user_granted_all_required_scopes(self.google_oauth2_user.oauth_scope) + @property def avatar_full_url(self): return urljoin(self.organization.grafana_url, self.avatar_url) @@ -386,7 +393,7 @@ class User(models.Model): logger.info( f"Saving Google OAuth2 settings for user {self.pk} " f"sub={google_oauth2_response.get('sub')} " - f"oauth_scope={google_oauth2_response.get('oauth_scope')}" + f"oauth_scope={google_oauth2_response.get('scope')}" ) _, created = GoogleOAuth2User.objects.update_or_create( diff --git a/engine/apps/user_management/tests/test_user.py b/engine/apps/user_management/tests/test_user.py index 54e95788..7d29770b 100644 --- a/engine/apps/user_management/tests/test_user.py +++ b/engine/apps/user_management/tests/test_user.py @@ -4,6 +4,7 @@ import pytest from django.utils import timezone from apps.api.permissions import LegacyAccessControlRole +from apps.google import constants as google_constants from apps.google.models import GoogleOAuth2User from apps.user_management.models import User @@ -120,6 +121,39 @@ def test_has_google_oauth2_connected(make_organization_and_user, make_google_oau assert user.has_google_oauth2_connected is True +@pytest.mark.django_db +def test_google_oauth2_token_is_missing_scopes(make_organization_and_user, make_google_oauth2_user_for_user): + initial_granted_scope = "foo bar baz" + initial_oauth_response = { + "access_token": "access", + "refresh_token": "refresh", + "sub": "google_user_id", + "scope": initial_granted_scope, + } + + _, user = make_organization_and_user() + + # false because the user hasn't yet connected their google account + assert user.google_oauth2_token_is_missing_scopes is False + + user.save_google_oauth2_settings(initial_oauth_response) + user.refresh_from_db() + + # true because we're missing a granted scope + assert user.google_oauth2_token_is_missing_scopes is True + + user.save_google_oauth2_settings( + { + **initial_oauth_response, + "scope": f"{initial_granted_scope} {' '.join(google_constants.REQUIRED_OAUTH_SCOPES)}", + } + ) + user.refresh_from_db() + + # False because we now have all the required scopes + assert user.google_oauth2_token_is_missing_scopes is False + + @pytest.mark.django_db def test_save_google_oauth2_settings(make_organization_and_user): oauth_response = { diff --git a/engine/conftest.py b/engine/conftest.py index c4de1730..df5fcb01 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -63,6 +63,7 @@ from apps.base.tests.factories import ( UserNotificationPolicyLogRecordFactory, ) from apps.email.tests.factories import EmailMessageFactory +from apps.google import constants as google_constants from apps.google.tests.factories import GoogleOAuth2UserFactory from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory from apps.labels.tests.factories import ( @@ -1075,8 +1076,9 @@ def make_webhook_label_association(make_label_key_and_value): @pytest.fixture def make_google_oauth2_user_for_user(): - def _make_google_oauth2_user_for_user(user): - return GoogleOAuth2UserFactory(user=user) + def _make_google_oauth2_user_for_user(user, **kwargs): + oauth_scope = kwargs.pop("oauth_scope", " ".join(google_constants.REQUIRED_OAUTH_SCOPES)) + return GoogleOAuth2UserFactory(user=user, oauth_scope=oauth_scope, **kwargs) return _make_google_oauth2_user_for_user diff --git a/engine/settings/base.py b/engine/settings/base.py index e19be784..0112f002 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -346,6 +346,8 @@ SPECTACULAR_INCLUDED_PATHS = [ "/features", "/alertgroups", "/alert_receive_channels", + # current user endpoint 👇, without trailing slash we pick-up /user_group endpoints, which we don't want for now + "/user/", "/users", "/labels", # social auth routes @@ -740,7 +742,7 @@ SOCIAL_AUTH_PIPELINE = ( 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", + "apps.social_auth.pipeline.google.connect_user_to_google", ) SOCIAL_AUTH_GOOGLE_OAUTH2_DISCONNECT_PIPELINE = ( diff --git a/grafana-plugin/src/containers/Alerts/Alerts.tsx b/grafana-plugin/src/containers/Alerts/Alerts.tsx index 3a5c44e0..5b6ccde8 100644 --- a/grafana-plugin/src/containers/Alerts/Alerts.tsx +++ b/grafana-plugin/src/containers/Alerts/Alerts.tsx @@ -3,11 +3,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Alert } from '@grafana/ui'; import cn from 'classnames/bind'; import { sanitize } from 'dompurify'; +import { observer } from 'mobx-react'; import { PluginLink } from 'components/PluginLink/PluginLink'; import { getSlackMessage } from 'containers/DefaultPageLayout/DefaultPageLayout.helpers'; import { SlackError } from 'containers/DefaultPageLayout/DefaultPageLayout.types'; import { getIfChatOpsConnected } from 'containers/DefaultPageLayout/helper'; +import { UserHelper } from 'models/user/user.helpers'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; @@ -24,9 +26,10 @@ const cx = cn.bind(styles); enum AlertID { CONNECTIVITY_WARNING = 'Connectivity Warning', + USER_GOOGLE_OAUTH2_TOKEN_MISSING_SCOPES = 'User Google OAuth2 token is missing scopes', } -export const Alerts = function () { +export const Alerts = observer(() => { const queryParams = useQueryParams(); const [showSlackInstallAlert, setShowSlackInstallAlert] = useState(); @@ -40,7 +43,7 @@ export const Alerts = function () { if (queryParams.get('slack_error')) { setShowSlackInstallAlert(queryParams.get('slack_error') as SlackError); - LocationHelper.update({ slack_error: undefined }, 'replace'); + LocationHelper.update({ slack_error: undefined }, 'partial'); } }, []); @@ -54,17 +57,24 @@ export const Alerts = function () { const store = useStore(); const { - userStore: { currentUser }, + userStore: { currentUser, currentUserPk }, organizationStore: { currentOrganization }, } = store; + const versionMismatchLocalStorageId = `version_mismatch_${store.backendVersion}_${plugin?.version}`; const isChatOpsConnected = getIfChatOpsConnected(currentUser); const isPhoneVerified = currentUser?.cloud_connection_status === 3 || currentUser?.verified_phone_number; const isDefaultNotificationsSet = currentUser?.notification_chain_verbal.default; const isImportantNotificationsSet = currentUser?.notification_chain_verbal.important; - if (!showSlackInstallAlert && !showBannerTeam() && !showMismatchWarning() && !showChannelWarnings()) { + if ( + !showSlackInstallAlert && + !showCurrentUserGoogleOAuth2TokenIsMissingScopes() && + !showBannerTeam() && + !showMismatchWarning() && + !showChannelWarnings() + ) { return null; } return ( @@ -79,6 +89,22 @@ export const Alerts = function () { {getSlackMessage(showSlackInstallAlert, currentOrganization, store.hasFeature(AppFeature.LiveSettings))} )} + {showCurrentUserGoogleOAuth2TokenIsMissingScopes() && ( + + Your Google OAuth2 token is missing some required permissions (you may have forgotten to check the necessary + checkboxes when connecting your Google account). To rectify this, please grant Grafana OnCall these + permissions by clicking{' '} + + here + {' '} + and re-connecting your Google account. + + )} {showBannerTeam() && ( Please make sure you have the same versions of the Grafana OnCall plugin and the Grafana OnCall engine, otherwise there could be issues with your Grafana OnCall installation! @@ -151,7 +177,7 @@ export const Alerts = function () { store.backendVersion && plugin?.version && store.backendVersion !== plugin?.version && - !getItem(`version_mismatch_${store.backendVersion}_${plugin?.version}`) + !getItem(versionMismatchLocalStorageId) ); } @@ -164,4 +190,24 @@ export const Alerts = function () { !getItem(AlertID.CONNECTIVITY_WARNING) ); } -}; + + /** + * tbh we don't really need the `currentUserPk` reference here... + * the only reason why it's here is to appease mobx. Without this reference, the `@computed` property + * on `UserStore.currentUser` doesn't recalculate and will just be stuck on returning `undefined`.. + * + * If we dereference `currentUserPk` here, even if we don't use it.. things just seem to work + * (what is this `mobx` wizardry?) + * + * Seems to be related to this https://stackoverflow.com/questions/77724466/mobx-computed-not-updating + */ + function showCurrentUserGoogleOAuth2TokenIsMissingScopes(): boolean { + return Boolean( + currentUserPk && + currentUser && + currentUser.has_google_oauth2_connected && + currentUser.google_oauth2_token_is_missing_scopes && + !getItem(AlertID.USER_GOOGLE_OAUTH2_TOKEN_MISSING_SCOPES) + ); + } +}); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.module.css b/grafana-plugin/src/containers/UserSettings/UserSettings.module.css index 389005b0..f30a20d1 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.module.css +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.module.css @@ -8,3 +8,18 @@ width: calc(100% - var(--spacing) * 2); /* allow lateral spacing */ max-width: 1100px; } + +.alerts-container { + display: flex; + flex-direction: column; + margin-bottom: 10px; + gap: 10px; + + &:empty { + display: none; + } +} + +.alert { + margin: 0; +} diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index 3e704c8f..2cc5bd78 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { HorizontalGroup, Modal } from '@grafana/ui'; +import { Alert, HorizontalGroup, Modal } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useMediaQuery } from 'react-responsive'; @@ -10,7 +10,9 @@ import { Tabs, TabsContent } from 'containers/UserSettings/parts/UserSettingsPar import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; +import { LocationHelper } from 'utils/LocationHelper'; import { BREAKPOINT_TABS } from 'utils/consts'; +import { useQueryParams } from 'utils/hooks'; import { UserSettingsTab } from './UserSettings.types'; @@ -18,6 +20,10 @@ import styles from './UserSettings.module.css'; const cx = cn.bind(styles); +enum GoogleError { + MISSING_GRANTED_SCOPE = 'missing_granted_scope', +} + interface UserFormProps { onHide: () => void; id: ApiSchemas['User']['pk'] | 'new'; @@ -26,6 +32,53 @@ interface UserFormProps { tab?: UserSettingsTab; } +function getGoogleMessage(googleError: GoogleError) { + if (googleError === GoogleError.MISSING_GRANTED_SCOPE) { + return ( + <> + Couldn't connect your Google account. You did not grant Grafana OnCall the necessary permissions. Please retry + and be sure to check any checkboxes which grant Grafana OnCall read access to your calendar events. + + ); + } + + return <>Couldn't connect your Google account.; +} + +const UserAlerts: React.FC = () => { + const queryParams = useQueryParams(); + const [showGoogleConnectAlert, setShowGoogleConnectAlert] = useState(); + + const handleCloseGoogleAlert = useCallback(() => { + setShowGoogleConnectAlert(undefined); + }, []); + + useEffect(() => { + if (queryParams.get('google_error')) { + setShowGoogleConnectAlert(queryParams.get('google_error') as GoogleError); + + LocationHelper.update({ google_error: undefined }, 'partial'); + } + }, []); + + if (!showGoogleConnectAlert) { + return null; + } + + return ( +
+ + {getGoogleMessage(showGoogleConnectAlert)} + +
+ ); +}; + export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: UserFormProps) => { const store = useStore(); const { userStore, organizationStore } = store; @@ -74,6 +127,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn return ( <> +
; readonly render_for_web: | { title: string; @@ -1370,22 +1405,22 @@ export interface components { source_link: string | null; } | Record; - dependent_alert_groups: components['schemas']['ShortAlertGroup'][]; + dependent_alert_groups: Array; root_alert_group: components['schemas']['ShortAlertGroup']; readonly status: number; /** @description Generate a link for AlertGroup to declare Grafana Incident by click */ readonly declare_incident_link: string; team: string | null; grafana_incident_id?: string | null; - readonly labels: components['schemas']['AlertGroupLabel'][]; + readonly labels: Array; readonly permalinks: { slack: string | null; slack_app: string | null; telegram: string | null; web: string; }; - readonly alerts: components['schemas']['Alert'][]; - readonly render_after_resolve_report_json: { + readonly alerts: Array; + readonly render_after_resolve_report_json: Array<{ time: string; action: string; /** @enum {string} */ @@ -1398,11 +1433,11 @@ export interface components { avatar: string; avatar_full: string; }; - }[]; + }>; readonly slack_permalink: string | null; /** Format: date-time */ readonly last_alert_at: string; - readonly paged_users: { + readonly paged_users: Array<{ id: number; username: string; name: string; @@ -1410,13 +1445,13 @@ export interface components { avatar: string; avatar_full: string; important: boolean; - }[]; - readonly external_urls: { + }>; + readonly external_urls: Array<{ integration: string; integration_type: string; external_id: string; url: string; - }[]; + }>; }; AlertGroupAttach: { root_alert_group_pk: string; @@ -1473,7 +1508,7 @@ export interface components { silenced_until?: string | null; /** Format: date-time */ readonly started_at: string; - readonly related_users: components['schemas']['UserShort'][]; + readonly related_users: Array; readonly render_for_web: | { title: string; @@ -1482,14 +1517,14 @@ export interface components { source_link: string | null; } | Record; - dependent_alert_groups: components['schemas']['ShortAlertGroup'][]; + dependent_alert_groups: Array; root_alert_group: components['schemas']['ShortAlertGroup']; readonly status: number; /** @description Generate a link for AlertGroup to declare Grafana Incident by click */ readonly declare_incident_link: string; team: string | null; grafana_incident_id?: string | null; - readonly labels: components['schemas']['AlertGroupLabel'][]; + readonly labels: Array; readonly permalinks: { slack: string | null; slack_app: string | null; @@ -1589,7 +1624,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1606,15 +1641,15 @@ export interface components { AlertReceiveChannelConnectedContactPoints: { uid: string; name: string; - contact_points: components['schemas']['AlertReceiveChannelConnectedContactPointsInner'][]; + contact_points: Array; }; AlertReceiveChannelConnectedContactPointsInner: { name: string; notification_connected: boolean; }; AlertReceiveChannelConnection: { - readonly source_alert_receive_channels: components['schemas']['AlertReceiveChannelSourceChannel'][]; - readonly connected_alert_receive_channels: components['schemas']['AlertReceiveChannelConnectedChannel'][]; + readonly source_alert_receive_channels: Array; + readonly connected_alert_receive_channels: Array; }; AlertReceiveChannelContactPoints: { uid: string; @@ -1656,7 +1691,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1741,7 +1776,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1749,6 +1784,45 @@ export interface components { }; /** @enum {integer} */ CloudConnectionStatusEnum: 0 | 1 | 2 | 3; + CurrentUser: { + readonly pk: string; + readonly organization: components['schemas']['FastOrganization']; + current_team?: string | null; + /** Format: email */ + readonly email: string; + readonly username: string; + readonly name: string; + readonly role: components['schemas']['RoleEnum']; + /** Format: uri */ + readonly avatar: string; + /** Format: uri */ + readonly avatar_full: string; + timezone?: string | null; + working_hours?: components['schemas']['WorkingHours']; + unverified_phone_number?: string | null; + /** @description Use property to highlight that _verified_phone_number should not be modified directly */ + readonly verified_phone_number: string | null; + readonly slack_user_identity: components['schemas']['SlackUserIdentity']; + readonly telegram_configuration: components['schemas']['TelegramToUserConnector']; + readonly messaging_backends: { + [key: string]: + | { + [key: string]: unknown; + } + | undefined; + }; + readonly notification_chain_verbal: { + default: string; + important: string; + }; + 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']; + readonly rbac_permissions: Array; + readonly google_oauth2_token_is_missing_scopes: boolean; + }; /** @description This serializer is consistent with apps.api.serializers.labels.LabelPairSerializer, but allows null for value ID. */ CustomLabel: { key: components['schemas']['CustomLabelKey']; @@ -1813,17 +1887,14 @@ export interface components { inheritable: { [key: string]: boolean | undefined; }; - custom: components['schemas']['CustomLabel'][]; + custom: Array; template: string | null; }; /** - * @description * `alertmanager` - Alertmanager - * * `legacy_alertmanager` - (Legacy) AlertManager - * * `grafana` - Grafana - * * `grafana_alerting` - Grafana Alerting - * * `legacy_grafana_alerting` - (Legacy) Grafana Alerting - * * `formatted_webhook` - Formatted webhook + * @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook + * * `alertmanager` - Alertmanager + * * `formatted_webhook` - Formatted webhook * * `kapacitor` - Kapacitor * * `elastalert` - Elastalert * * `heartbeat` - Heartbeat @@ -1833,6 +1904,9 @@ export interface components { * * `slack_channel` - Slack Channel * * `zabbix` - Zabbix * * `direct_paging` - Direct paging + * * `grafana` - Grafana Legacy Alerting + * * `legacy_alertmanager` - (Legacy) AlertManager + * * `legacy_grafana_alerting` - (Deprecated) Grafana Alerting * * `servicenow` - ServiceNow * * `amazon_sns` - Amazon SNS * * `stackdriver` - Stackdriver @@ -1852,13 +1926,10 @@ export interface components { * @enum {string} */ IntegrationEnum: - | 'alertmanager' - | 'legacy_alertmanager' - | 'grafana' | 'grafana_alerting' - | 'legacy_grafana_alerting' - | 'formatted_webhook' | 'webhook' + | 'alertmanager' + | 'formatted_webhook' | 'kapacitor' | 'elastalert' | 'heartbeat' @@ -1868,6 +1939,9 @@ export interface components { | 'slack_channel' | 'zabbix' | 'direct_paging' + | 'grafana' + | 'legacy_alertmanager' + | 'legacy_grafana_alerting' | 'servicenow' | 'amazon_sns' | 'stackdriver' @@ -1906,7 +1980,7 @@ export interface components { }; LabelCreate: { key: components['schemas']['LabelRepr']; - values: components['schemas']['LabelRepr'][]; + values: Array; }; LabelKey: { id: string; @@ -1916,7 +1990,7 @@ export interface components { }; LabelOption: { key: components['schemas']['LabelKey']; - values: components['schemas']['LabelValue'][]; + values: Array; }; LabelPair: { key: components['schemas']['LabelKey']; @@ -1981,14 +2055,22 @@ export interface components { /** @enum {unknown} */ NullEnum: null; PaginatedAlertGroupListList: { + /** + * Format: uri + * @example http://api.example.org/accounts/?cursor=cD00ODY%3D" + */ next?: string | null; + /** + * Format: uri + * @example http://api.example.org/accounts/?cursor=cj0xJnA9NDg3 + */ previous?: string | null; - results?: components['schemas']['AlertGroupList'][]; + results: Array; page_size?: number; }; PaginatedAlertReceiveChannelPolymorphicList: { /** @example 123 */ - count?: number; + count: number; /** * Format: uri * @example http://api.example.org/accounts/?page=4 @@ -1999,14 +2081,14 @@ export interface components { * @example http://api.example.org/accounts/?page=2 */ previous?: string | null; - results?: components['schemas']['AlertReceiveChannelPolymorphic'][]; + results: Array; page_size?: number; current_page_number?: number; total_pages?: number; }; PaginatedUserPolymorphicList: { /** @example 123 */ - count?: number; + count: number; /** * Format: uri * @example http://api.example.org/accounts/?page=4 @@ -2017,7 +2099,7 @@ export interface components { * @example http://api.example.org/accounts/?page=2 */ previous?: string | null; - results?: components['schemas']['UserPolymorphic'][]; + results: Array; page_size?: number; current_page_number?: number; total_pages?: number; @@ -2057,7 +2139,7 @@ export interface components { readonly is_based_on_alertmanager?: boolean; readonly inbound_email?: string; readonly is_legacy?: boolean; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at?: string | null; @@ -2240,6 +2322,21 @@ export interface components { created_at: string; export_url: string; }; + UserFilters: { + name: string; + type: string; + href?: string; + global?: boolean; + default?: { + [key: string]: unknown; + }; + description?: string; + options: components['schemas']['UserFiltersOptions']; + }; + UserFiltersOptions: { + value: string; + display_name: number; + }; UserGetTelegramVerificationCode: { telegram_code: string; bot_link: string; @@ -2251,9 +2348,12 @@ export interface components { avatar_full: string; name: string; readonly timezone: string | null; - readonly teams: components['schemas']['FastTeam'][]; + readonly teams: Array; readonly is_currently_oncall: boolean; }; + UserPermission: { + readonly action: string; + }; UserPolymorphic: | components['schemas']['FilterUser'] | components['schemas']['UserIsCurrentlyOnCall'] @@ -2288,16 +2388,16 @@ export interface components { readonly last_response_log: string; integration_filter?: string[]; preset?: string | null; - labels?: components['schemas']['LabelPair'][]; + labels?: Array; }; WorkingHours: { - monday: components['schemas']['WorkingHoursPeriod'][]; - tuesday: components['schemas']['WorkingHoursPeriod'][]; - wednesday: components['schemas']['WorkingHoursPeriod'][]; - thursday: components['schemas']['WorkingHoursPeriod'][]; - friday: components['schemas']['WorkingHoursPeriod'][]; - saturday: components['schemas']['WorkingHoursPeriod'][]; - sunday: components['schemas']['WorkingHoursPeriod'][]; + monday: Array; + tuesday: Array; + wednesday: Array; + thursday: Array; + friday: Array; + saturday: Array; + sunday: Array; }; WorkingHoursPeriod: { start: string; @@ -2316,13 +2416,10 @@ export interface operations { parameters: { query?: { id_ne?: string[]; - /** @description * `alertmanager` - Alertmanager - * * `legacy_alertmanager` - (Legacy) AlertManager - * * `grafana` - Grafana - * * `grafana_alerting` - Grafana Alerting - * * `legacy_grafana_alerting` - (Legacy) Grafana Alerting - * * `formatted_webhook` - Formatted webhook + /** @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook + * * `alertmanager` - Alertmanager + * * `formatted_webhook` - Formatted webhook * * `kapacitor` - Kapacitor * * `elastalert` - Elastalert * * `heartbeat` - Heartbeat @@ -2332,6 +2429,9 @@ export interface operations { * * `slack_channel` - Slack Channel * * `zabbix` - Zabbix * * `direct_paging` - Direct paging + * * `grafana` - Grafana Legacy Alerting + * * `legacy_alertmanager` - (Legacy) AlertManager + * * `legacy_grafana_alerting` - (Deprecated) Grafana Alerting * * `servicenow` - ServiceNow * * `amazon_sns` - Amazon SNS * * `stackdriver` - Stackdriver @@ -2348,7 +2448,7 @@ export interface operations { * * `jira` - Jira * * `zendesk` - Zendesk * * `appdynamics` - AppDynamics */ - integration?: ( + integration?: Array< | 'alertmanager' | 'amazon_sns' | 'appdynamics' @@ -2381,14 +2481,11 @@ export interface operations { | 'webhook' | 'zabbix' | 'zendesk' - )[]; - /** @description * `alertmanager` - Alertmanager - * * `legacy_alertmanager` - (Legacy) AlertManager - * * `grafana` - Grafana - * * `grafana_alerting` - Grafana Alerting - * * `legacy_grafana_alerting` - (Legacy) Grafana Alerting - * * `formatted_webhook` - Formatted webhook + >; + /** @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook + * * `alertmanager` - Alertmanager + * * `formatted_webhook` - Formatted webhook * * `kapacitor` - Kapacitor * * `elastalert` - Elastalert * * `heartbeat` - Heartbeat @@ -2398,6 +2495,9 @@ export interface operations { * * `slack_channel` - Slack Channel * * `zabbix` - Zabbix * * `direct_paging` - Direct paging + * * `grafana` - Grafana Legacy Alerting + * * `legacy_alertmanager` - (Legacy) AlertManager + * * `legacy_grafana_alerting` - (Deprecated) Grafana Alerting * * `servicenow` - ServiceNow * * `amazon_sns` - Amazon SNS * * `stackdriver` - Stackdriver @@ -2414,7 +2514,7 @@ export interface operations { * * `jira` - Jira * * `zendesk` - Zendesk * * `appdynamics` - AppDynamics */ - integration_ne?: ( + integration_ne?: Array< | 'alertmanager' | 'amazon_sns' | 'appdynamics' @@ -2447,10 +2547,10 @@ export interface operations { | 'webhook' | 'zabbix' | 'zendesk' - )[]; + >; /** @description * `0` - Debug * * `1` - Maintenance */ - maintenance_mode?: (0 | 1)[]; + maintenance_mode?: Array<0 | 1>; /** @description A page number within the paginated result set. */ page?: number; /** @description Number of results to return per page. */ @@ -2726,9 +2826,9 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['AlertReceiveChannelNewConnection'][]; - 'application/x-www-form-urlencoded': components['schemas']['AlertReceiveChannelNewConnection'][]; - 'multipart/form-data': components['schemas']['AlertReceiveChannelNewConnection'][]; + 'application/json': Array; + 'application/x-www-form-urlencoded': Array; + 'multipart/form-data': Array; }; }; responses: { @@ -2810,7 +2910,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelConnectedContactPoints'][]; + 'application/json': Array; }; }; }; @@ -3088,7 +3188,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Webhook'][]; + 'application/json': Array; }; }; }; @@ -3186,7 +3286,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelContactPoints'][]; + 'application/json': Array; }; }; }; @@ -3231,7 +3331,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelFilters'][]; + 'application/json': Array; }; }; }; @@ -3250,7 +3350,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertReceiveChannelIntegrationOptions'][]; + 'application/json': Array; }; }; }; @@ -3330,7 +3430,7 @@ export interface operations { * * `1` - Acknowledged * * `2` - Resolved * * `3` - Silenced */ - status?: (0 | 1 | 2 | 3)[]; + status?: Array<0 | 1 | 2 | 3>; with_resolution_note?: boolean; }; header?: never; @@ -3701,7 +3801,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertGroupBulkActionOptions'][]; + 'application/json': Array; }; }; }; @@ -3720,7 +3820,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertGroupFilters'][]; + 'application/json': Array; }; }; }; @@ -3760,7 +3860,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LabelKey'][]; + 'application/json': Array; }; }; }; @@ -3779,7 +3879,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['AlertGroupSilenceOptions'][]; + 'application/json': Array; }; }; }; @@ -3804,7 +3904,7 @@ export interface operations { * * `1` - Acknowledged * * `2` - Resolved * * `3` - Silenced */ - status?: (0 | 1 | 2 | 3)[]; + status?: Array<0 | 1 | 2 | 3>; with_resolution_note?: boolean; }; header?: never; @@ -3877,9 +3977,10 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': ( + 'application/json': Array< | 'msteams' | 'slack' + | 'unified_slack' | 'telegram' | 'live_settings' | 'grafana_cloud_notifications' @@ -3887,7 +3988,7 @@ export interface operations { | 'grafana_alerting_v2' | 'labels' | 'google_oauth2' - )[]; + >; }; }; }; @@ -3901,9 +4002,9 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['LabelCreate'][]; - 'application/x-www-form-urlencoded': components['schemas']['LabelCreate'][]; - 'multipart/form-data': components['schemas']['LabelCreate'][]; + 'application/json': Array; + 'application/x-www-form-urlencoded': Array; + 'multipart/form-data': Array; }; }; responses: { @@ -4056,7 +4157,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['LabelKey'][]; + 'application/json': Array; }; }; }; @@ -4101,6 +4202,44 @@ export interface operations { }; }; }; + user_retrieve: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CurrentUser']; + }; + }; + }; + }; + user_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CurrentUser']; + }; + }; + }; + }; users_list: { parameters: { query?: { @@ -4170,7 +4309,7 @@ export interface operations { * * `1` - EDITOR * * `2` - VIEWER * * `3` - NONE */ - roles?: (0 | 1 | 2 | 3)[]; + roles?: Array<0 | 1 | 2 | 3>; /** @description A search term. */ search?: string; team?: string[]; @@ -4591,7 +4730,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + 'application/json': Array<{ schedule_id: string; schedule_name: string; is_oncall: boolean; @@ -4601,7 +4740,7 @@ export interface operations { start: string; /** Format: date-time */ end: string; - users: { + users: Array<{ display_name: string; pk: string; email: string; @@ -4615,7 +4754,7 @@ export interface operations { avatar_full: string; } | null; } | null; - }[]; + }>; missing_users: string[]; priority_level: number | null; source: string | null; @@ -4633,7 +4772,7 @@ export interface operations { start: string; /** Format: date-time */ end: string; - users: { + users: Array<{ display_name: string; pk: string; email: string; @@ -4647,7 +4786,7 @@ export interface operations { avatar_full: string; } | null; } | null; - }[]; + }>; missing_users: string[]; priority_level: number | null; source: string | null; @@ -4659,7 +4798,7 @@ export interface operations { pk: string; }; } | null; - }[]; + }>; }; }; }; @@ -4687,6 +4826,25 @@ export interface operations { }; }; }; + users_filters_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': Array; + }; + }; + }; + }; users_timezone_options_retrieve: { parameters: { query?: never;