Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Return API URL as part of status #2791

Merged
merged 10 commits into from
Aug 22, 2023
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))

### Fixed

- Ignore ical cancelled events when calculating shifts ([#2776](https://github.com/grafana/oncall/pull/2776))
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
Loading