diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad938561a..5ced1ea95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Allow mobile app to access status endpoint @mderynck ([#2791](https://github.com/grafana/oncall/pull/2791)) + ## v1.3.26 (2023-08-22) ### Changed diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 462315a515..aee403f545 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -54,7 +54,13 @@ def authenticate_credentials(self, token): return auth_token.user, auth_token -class PluginAuthentication(BaseAuthentication): +class BasePluginAuthentication(BaseAuthentication): + """ + Authentication used by grafana-plugin app where we tolerate user not being set yet due to being in + a state of initialization, Only validates that the plugin should be talking to the backend. Outside of + this app PluginAuthentication should be used since it also checks the user. + """ + def authenticate_header(self, request): # Check parent's method comments return "Bearer" @@ -90,6 +96,28 @@ def authenticate_credentials(self, token_string: str, request: Request) -> Tuple user = self._get_user(request, auth_token.organization) return user, auth_token + @staticmethod + def _get_user(request: Request, organization: Organization) -> User: + try: + context = dict(json.loads(request.headers.get("X-Grafana-Context"))) + except (ValueError, TypeError): + return None + + if "UserId" not in context and "UserID" not in context: + return None + + try: + user_id = context["UserId"] + except KeyError: + user_id = context["UserID"] + + try: + return organization.users.get(user_id=user_id) + except User.DoesNotExist: + return None + + +class PluginAuthentication(BasePluginAuthentication): @staticmethod def _get_user(request: Request, organization: Organization) -> User: try: @@ -111,14 +139,6 @@ def _get_user(request: Request, organization: Organization) -> User: logger.debug(f"Could not get user from grafana request. Context {context}") raise exceptions.AuthenticationFailed("Non-existent or anonymous user.") - @classmethod - def is_user_from_request_present_in_organization(cls, request: Request, organization: Organization) -> User: - try: - cls._get_user(request, organization) - return True - except exceptions.AuthenticationFailed: - return False - class GrafanaIncidentUser(AnonymousUser): @property diff --git a/engine/apps/grafana_plugin/permissions.py b/engine/apps/grafana_plugin/permissions.py deleted file mode 100644 index 47bb8bf271..0000000000 --- a/engine/apps/grafana_plugin/permissions.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -import logging - -from django.views import View -from rest_framework import permissions -from rest_framework.authentication import get_authorization_header -from rest_framework.request import Request - -from apps.auth_token.exceptions import InvalidToken -from apps.grafana_plugin.helpers.gcom import check_token - -logger = logging.getLogger(__name__) - - -class PluginTokenVerified(permissions.BasePermission): - # The grafana plugin can either use a token from gcom or one generated internally by oncall - # Tokens from gcom will be prefixed with gcom: otherwise they will be treated as local - def has_permission(self, request: Request, view: View) -> bool: - token_string = get_authorization_header(request).decode() - context = json.loads(request.headers.get("X-Instance-Context")) - try: - auth_token = check_token(token_string, context) - if auth_token: - return True - except InvalidToken: - logger.warning(f"Invalid token used: {context}") - - return False diff --git a/engine/apps/grafana_plugin/tests/test_status.py b/engine/apps/grafana_plugin/tests/test_status.py index 74e74a11c2..966d370835 100644 --- a/engine/apps/grafana_plugin/tests/test_status.py +++ b/engine/apps/grafana_plugin/tests/test_status.py @@ -1,48 +1,52 @@ -from unittest.mock import patch - import pytest from django.test import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.user_management.models import Organization + GRAFANA_TOKEN = "TESTTOKEN" GRAFANA_URL = "hello.com" LICENSE = "asdfasdf" VERSION = "asdfasdfasdf" +BASE_URL = "http://asdasdqweqweqw.com/oncall" GRAFANA_CONTEXT_DATA = {"IsAnonymous": False} -SETTINGS = {"LICENSE": LICENSE, "VERSION": VERSION} +SETTINGS = {"LICENSE": LICENSE, "VERSION": VERSION, "BASE_URL": BASE_URL} + + +def _check_status_response(auth_headers, client): + response = client.post(reverse("grafana-plugin:status"), format="json", **auth_headers) + response_data = response.data + assert response.status_code == status.HTTP_200_OK + assert response_data["token_ok"] is True + assert response_data["is_installed"] is True + assert response_data["allow_signup"] is True + assert response_data["is_user_anonymous"] is False + assert response_data["license"] == LICENSE + assert response_data["version"] == VERSION + assert response_data["api_url"] == BASE_URL @pytest.mark.django_db @override_settings(**SETTINGS) -@patch("apps.grafana_plugin.views.status.GrafanaAPIClient") def test_token_ok_is_based_on_grafana_api_check_token_response( - mocked_grafana_api_client, make_organization_and_user_with_plugin_token, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - mocked_grafana_api_client.return_value.check_token.return_value = (None, {"connected": True}) - organization, user, token = make_organization_and_user_with_plugin_token() organization.grafana_url = GRAFANA_URL - organization.save(update_fields=["grafana_url"]) + organization.api_token_status = Organization.API_TOKEN_STATUS_OK + organization.save(update_fields=["grafana_url", "api_token_status"]) client = APIClient() + url = reverse("grafana-plugin:status") + response = client.post(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + auth_headers = make_user_auth_headers( user, token, grafana_token=GRAFANA_TOKEN, grafana_context_data=GRAFANA_CONTEXT_DATA ) - response = client.get(reverse("grafana-plugin:status"), format="json", **auth_headers) - response_data = response.data - - assert response.status_code == status.HTTP_200_OK - assert response_data["token_ok"] is True - assert response_data["is_installed"] is True - assert response_data["allow_signup"] is True - assert response_data["is_user_anonymous"] is False - assert response_data["license"] == LICENSE - assert response_data["version"] == VERSION - - assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_URL, api_token=GRAFANA_TOKEN) - assert mocked_grafana_api_client.return_value.check_token.called_once_with() + _check_status_response(auth_headers, client) @pytest.mark.django_db @@ -59,8 +63,29 @@ def test_allow_signup(make_organization_and_user_with_plugin_token, make_user_au ) response = client.get(reverse("grafana-plugin:status"), format="json", **auth_headers) - # if the org doesn't exist this will never return 200 due to - # the PluginTokenVerified permission class.. # should consider removing the DynamicSetting logic because technically this # condition will never be reached in the code... assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@override_settings(**SETTINGS) +def test_status_mobile_app_auth_token( + make_organization_and_user_with_mobile_app_auth_token, + make_user_auth_headers, +): + organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token() + organization.grafana_url = GRAFANA_URL + organization.api_token_status = Organization.API_TOKEN_STATUS_OK + organization.save(update_fields=["grafana_url", "api_token_status"]) + + client = APIClient() + url = reverse("grafana-plugin:status") + response = client.post(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + auth_headers = make_user_auth_headers( + user, + auth_token, + ) + _check_status_response(auth_headers, client) diff --git a/engine/apps/grafana_plugin/urls.py b/engine/apps/grafana_plugin/urls.py index 8cdd4ca596..0b3e76c807 100644 --- a/engine/apps/grafana_plugin/urls.py +++ b/engine/apps/grafana_plugin/urls.py @@ -1,12 +1,6 @@ from django.urls import re_path -from apps.grafana_plugin.views import ( - InstallView, - PluginSyncView, - SelfHostedInstallView, - StatusView, - SyncOrganizationView, -) +from apps.grafana_plugin.views import InstallView, SelfHostedInstallView, StatusView, SyncOrganizationView app_name = "grafana-plugin" @@ -15,5 +9,4 @@ re_path(r"status/?", StatusView().as_view(), name="status"), re_path(r"install/?", InstallView().as_view(), name="install"), re_path(r"sync_organization/?", SyncOrganizationView().as_view(), name="sync-organization"), - re_path(r"sync/?", PluginSyncView().as_view(), name="sync"), ] diff --git a/engine/apps/grafana_plugin/views/__init__.py b/engine/apps/grafana_plugin/views/__init__.py index 3b3f1a3ac4..23e2437334 100644 --- a/engine/apps/grafana_plugin/views/__init__.py +++ b/engine/apps/grafana_plugin/views/__init__.py @@ -1,5 +1,4 @@ from .install import InstallView # noqa: F401 from .self_hosted_install import SelfHostedInstallView # noqa: F401 from .status import StatusView # noqa: F401 -from .sync import PluginSyncView # noqa: F401 from .sync_organization import SyncOrganizationView # noqa: F401 diff --git a/engine/apps/grafana_plugin/views/install.py b/engine/apps/grafana_plugin/views/install.py index f13f2f95b8..44fefaecde 100644 --- a/engine/apps/grafana_plugin/views/install.py +++ b/engine/apps/grafana_plugin/views/install.py @@ -3,14 +3,14 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.grafana_plugin.permissions import PluginTokenVerified +from apps.auth_token.auth import BasePluginAuthentication from apps.user_management.models import Organization from apps.user_management.sync import sync_organization from common.api_helpers.mixins import GrafanaHeadersMixin class InstallView(GrafanaHeadersMixin, APIView): - permission_classes = (PluginTokenVerified,) + authentication_classes = (BasePluginAuthentication,) def post(self, request: Request) -> Response: stack_id = self.instance_context["stack_id"] diff --git a/engine/apps/grafana_plugin/views/status.py b/engine/apps/grafana_plugin/views/status.py index 70a4b6adcf..7e32de3065 100644 --- a/engine/apps/grafana_plugin/views/status.py +++ b/engine/apps/grafana_plugin/views/status.py @@ -1,20 +1,21 @@ from django.conf import settings -from django.http import JsonResponse from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from apps.auth_token.auth import PluginAuthentication +from apps.auth_token.auth import BasePluginAuthentication from apps.base.models import DynamicSetting -from apps.grafana_plugin.helpers import GrafanaAPIClient -from apps.grafana_plugin.permissions import PluginTokenVerified from apps.grafana_plugin.tasks.sync import plugin_sync_organization_async +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import Organization from common.api_helpers.mixins import GrafanaHeadersMixin class StatusView(GrafanaHeadersMixin, APIView): - permission_classes = (PluginTokenVerified,) + authentication_classes = ( + MobileAppAuthTokenAuthentication, + BasePluginAuthentication, + ) def post(self, request: Request) -> Response: """ @@ -24,8 +25,8 @@ def post(self, request: Request) -> Response: """ # Check if the plugin is currently undergoing maintenance, and return response without querying db if settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE: - return JsonResponse( - { + return Response( + data={ "currently_undergoing_maintenance_message": settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE, } ) @@ -36,22 +37,21 @@ def post(self, request: Request) -> Response: is_installed = False token_ok = False allow_signup = True + api_url = settings.BASE_URL # Check if organization is in OnCall database if organization := Organization.objects.get(stack_id=stack_id, org_id=org_id): is_installed = True token_ok = organization.api_token_status == Organization.API_TOKEN_STATUS_OK + if organization.is_moved: + api_url = organization.migration_destination.oncall_backend_url else: allow_signup = DynamicSetting.objects.get_or_create( name="allow_plugin_organization_signup", defaults={"boolean_value": True} )[0].boolean_value - # Check if current user is in OnCall database - user_is_present_in_org = PluginAuthentication.is_user_from_request_present_in_organization( - request, organization - ) # If user is not present in OnCall database, set token_ok to False, which will trigger reinstall - if not user_is_present_in_org: + if not request.user: token_ok = False organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING organization.save(update_fields=["api_token_status"]) @@ -64,40 +64,11 @@ def post(self, request: Request) -> Response: "is_installed": is_installed, "token_ok": token_ok, "allow_signup": allow_signup, - "is_user_anonymous": self.grafana_context["IsAnonymous"], + "is_user_anonymous": self.grafana_context.get("IsAnonymous", request.user is None), "license": settings.LICENSE, "version": settings.VERSION, "recaptcha_site_key": settings.RECAPTCHA_V3_SITE_KEY, "currently_undergoing_maintenance_message": settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE, - } - ) - - def get(self, _request: Request) -> Response: - """Deprecated. May be used for the plugins with versions < 1.3.17""" - stack_id = self.instance_context["stack_id"] - org_id = self.instance_context["org_id"] - is_installed = False - token_ok = False - allow_signup = True - - if organization := Organization.objects.filter(stack_id=stack_id, org_id=org_id).first(): - is_installed = True - _, resp = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token).check_token() - token_ok = resp["connected"] - else: - from apps.base.models import DynamicSetting - - allow_signup = DynamicSetting.objects.get_or_create( - name="allow_plugin_organization_signup", defaults={"boolean_value": True} - )[0].boolean_value - - return Response( - data={ - "is_installed": is_installed, - "token_ok": token_ok, - "allow_signup": allow_signup, - "is_user_anonymous": self.grafana_context["IsAnonymous"], - "license": settings.LICENSE, - "version": settings.VERSION, + "api_url": api_url, } ) diff --git a/engine/apps/grafana_plugin/views/sync.py b/engine/apps/grafana_plugin/views/sync.py deleted file mode 100644 index 6c17f352cd..0000000000 --- a/engine/apps/grafana_plugin/views/sync.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging - -from django.conf import settings -from rest_framework import status -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication -from apps.grafana_plugin.permissions import PluginTokenVerified -from apps.grafana_plugin.tasks.sync import plugin_sync_organization_async -from apps.user_management.models import Organization -from common.api_helpers.mixins import GrafanaHeadersMixin - -logger = logging.getLogger(__name__) - - -class PluginSyncView(GrafanaHeadersMixin, APIView): - permission_classes = (PluginTokenVerified,) - - def post(self, request: Request) -> Response: - """Deprecated. May be used for the plugins with versions < 1.3.17""" - stack_id = self.instance_context["stack_id"] - org_id = self.instance_context["org_id"] - is_installed = False - allow_signup = True - - try: - # Check if organization is in OnCall database - organization = Organization.objects.get(stack_id=stack_id, org_id=org_id) - if organization.api_token_status == Organization.API_TOKEN_STATUS_OK: - is_installed = True - - user_is_present_in_org = PluginAuthentication.is_user_from_request_present_in_organization( - request, organization - ) - if not user_is_present_in_org: - organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING - organization.save(update_fields=["api_token_status"]) - - if not organization: - from apps.base.models import DynamicSetting - - allow_signup = DynamicSetting.objects.get_or_create( - name="allow_plugin_organization_signup", defaults={"boolean_value": True} - )[0].boolean_value - - plugin_sync_organization_async.apply_async((organization.pk,)) - except Organization.DoesNotExist: - logger.info(f"Organization for stack {stack_id} org {org_id} was not found") - - return Response( - status=status.HTTP_202_ACCEPTED, - data={ - "is_installed": is_installed, - "is_user_anonymous": self.grafana_context["IsAnonymous"], - "allow_signup": allow_signup, - }, - ) - - def get(self, _request: Request) -> Response: - """Deprecated. May be used for the plugins with versions < 1.3.17""" - stack_id = self.instance_context["stack_id"] - org_id = self.instance_context["org_id"] - token_ok = False - - try: - organization = Organization.objects.get(stack_id=stack_id, org_id=org_id) - if organization.api_token_status == Organization.API_TOKEN_STATUS_PENDING: - return Response(status=status.HTTP_202_ACCEPTED) - elif organization.api_token_status == Organization.API_TOKEN_STATUS_OK: - token_ok = True - except Organization.DoesNotExist: - logger.info(f"Organization for stack {stack_id} org {org_id} was not found") - - return Response( - status=status.HTTP_200_OK, - data={ - "token_ok": token_ok, - "license": settings.LICENSE, - "version": settings.VERSION, - "recaptcha_site_key": settings.RECAPTCHA_V3_SITE_KEY, - }, - ) diff --git a/engine/apps/grafana_plugin/views/sync_organization.py b/engine/apps/grafana_plugin/views/sync_organization.py index faf3b5cac2..6d5d3b6fcd 100644 --- a/engine/apps/grafana_plugin/views/sync_organization.py +++ b/engine/apps/grafana_plugin/views/sync_organization.py @@ -5,14 +5,14 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.grafana_plugin.permissions import PluginTokenVerified +from apps.auth_token.auth import BasePluginAuthentication from apps.user_management.models import Organization from apps.user_management.sync import sync_organization from common.api_helpers.mixins import GrafanaHeadersMixin class SyncOrganizationView(GrafanaHeadersMixin, APIView): - permission_classes = (PluginTokenVerified,) + authentication_classes = (BasePluginAuthentication,) def post(self, request: Request) -> Response: stack_id = self.instance_context["stack_id"] diff --git a/engine/apps/mobile_app/auth.py b/engine/apps/mobile_app/auth.py index 5b1d249789..72d0646adb 100644 --- a/engine/apps/mobile_app/auth.py +++ b/engine/apps/mobile_app/auth.py @@ -4,7 +4,6 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_header from apps.auth_token.exceptions import InvalidToken -from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException from apps.user_management.models import User from .models import MobileAppAuthToken, MobileAppVerificationToken @@ -43,9 +42,4 @@ def authenticate_credentials(self, token_string: str) -> Tuple[Optional[User], O except InvalidToken: return None, None - if auth_token.organization.is_moved: - raise OrganizationMovedException(auth_token.organization) - if auth_token.organization.deleted_at: - raise OrganizationDeletedException(auth_token.organization) - return auth_token.user, auth_token