Skip to content

Commit

Permalink
Return API URL as part of status (#2791)
Browse files Browse the repository at this point in the history
# What this PR does
- Provide API URL when returning status to inform plugin or mobile app
if it should be talking to a different backend in case of migration.
- Add MobileAppAuthTokenAuthentication to status endpoint so that the
app can use it.
- Split PluginAuthentication (Checks user) and BasePluginAuthentication
(Does not check user) and use BasePluginAuthentication in grafana-plugin
app when getting status.
- Removed PluginTokenVerified since it can be handled by
BasePluginAuthentication.
- Removed deprecated endpoints from grafana-plugin app. 

## Which issue(s) this PR fixes

## 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] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
  • Loading branch information
mderynck authored Aug 22, 2023
1 parent 3783aea commit 0dfa882
Show file tree
Hide file tree
Showing 11 changed files with 100 additions and 206 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 29 additions & 9 deletions engine/apps/auth_token/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
28 changes: 0 additions & 28 deletions engine/apps/grafana_plugin/permissions.py

This file was deleted.

71 changes: 48 additions & 23 deletions engine/apps/grafana_plugin/tests/test_status.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
9 changes: 1 addition & 8 deletions engine/apps/grafana_plugin/urls.py
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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"),
]
1 change: 0 additions & 1 deletion engine/apps/grafana_plugin/views/__init__.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions engine/apps/grafana_plugin/views/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
57 changes: 14 additions & 43 deletions engine/apps/grafana_plugin/views/status.py
Original file line number Diff line number Diff line change
@@ -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:
"""
Expand All @@ -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,
}
)
Expand All @@ -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"])
Expand All @@ -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,
}
)
Loading

0 comments on commit 0dfa882

Please sign in to comment.