Dev to main (#1129)

Release v1.1.16
Co-authored-by: Ieva <ieva.vasiljeva@grafana.com>
Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
Co-authored-by: Rares Mardare <40542072+teodosii@users.noreply.github.com>
Co-authored-by: Tom Mitchell <klaue@live.com>
This commit is contained in:
Innokentii Konstantinov 2023-01-12 18:40:47 +08:00 committed by GitHub
parent 49789098d8
commit 6d4e6f0108
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 224 additions and 48 deletions

View file

@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.1.16 (2023-01-12)
### Fixed
- Minor bug fix in how the value of `Organization.is_rbac_permissions_enabled` is determined
- Helm chart: default values file and documentation now reflect the correct key to set for the Slack
slash command name, `oncall.slack.commandName`.
## v1.1.15 (2023-01-10)
### Changed

View file

@ -30,6 +30,14 @@ class GrafanaUserWithPermissions(GrafanaUser):
permissions: List[GrafanaAPIPermission]
class GCOMInstanceInfoConfigFeatureToggles(TypedDict):
accessControlOnCall: str
class GCOMInstanceInfoConfig(TypedDict):
feature_toggles: GCOMInstanceInfoConfigFeatureToggles
class GCOMInstanceInfo(TypedDict):
id: int
orgId: int
@ -38,6 +46,7 @@ class GCOMInstanceInfo(TypedDict):
orgName: str
url: str
status: str
config: Optional[GCOMInstanceInfoConfig]
class APIClient:
@ -190,10 +199,26 @@ class GcomAPIClient(APIClient):
def __init__(self, api_token: str):
super().__init__(settings.GRAFANA_COM_API_URL, api_token)
def get_instance_info(self, stack_id: str) -> Optional[GCOMInstanceInfo]:
data, _ = self.api_get(f"instances/{stack_id}")
def get_instance_info(self, stack_id: str, include_config_query_param: bool = False) -> Optional[GCOMInstanceInfo]:
"""
NOTE: in order to use ?config=true, an "Admin" GCOM token must be used to make the API call
"""
url = f"instances/{stack_id}"
if include_config_query_param:
url += "?config=true"
data, _ = self.api_get(url)
return data
def is_rbac_enabled_for_stack(self, stack_id: str) -> bool:
"""
NOTE: must use an "Admin" GCOM token when calling this method
"""
instance_info = self.get_instance_info(stack_id, True)
if not instance_info:
return False
return instance_info.get("config", {}).get("feature_toggles", {}).get("accessControlOnCall", "false") == "true"
def get_instances(self, query: str):
return self.api_get(query)

View file

@ -0,0 +1,29 @@
from unittest.mock import patch
import pytest
from apps.grafana_plugin.helpers.client import GcomAPIClient
class TestIsRbacEnabledForStack:
@pytest.mark.parametrize(
"gcom_api_response,expected",
[
(None, False),
({}, False),
({"config": {}}, False),
({"config": {"feature_toggles": {}}}, False),
({"config": {"feature_toggles": {"accessControlOnCall": "false"}}}, False),
({"config": {"feature_toggles": {"accessControlOnCall": "true"}}}, True),
],
)
@patch("apps.grafana_plugin.helpers.client.GcomAPIClient.api_get")
def test_it_returns_based_on_feature_toggle_value(
self, mocked_gcom_api_client_api_get, gcom_api_response, expected
):
stack_id = 5
mocked_gcom_api_client_api_get.return_value = (gcom_api_response, {"status_code": 200})
api_client = GcomAPIClient("someFakeApiToken")
assert api_client.is_rbac_enabled_for_stack(stack_id) == expected
assert mocked_gcom_api_client_api_get.called_once_with(f"instances/{stack_id}?config=true")

View file

@ -15,7 +15,7 @@ class TestGetUsersPermissions:
permissions = api_client.get_users_permissions(False)
assert len(permissions.keys()) == 0
@patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient.api_get")
@patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.api_get")
def test_api_call_returns_none(self, mocked_grafana_api_client_api_get):
mocked_grafana_api_client_api_get.return_value = (None, "dfkjfdkj")
@ -24,7 +24,7 @@ class TestGetUsersPermissions:
permissions = api_client.get_users_permissions(True)
assert len(permissions.keys()) == 0
@patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient.api_get")
@patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.api_get")
def test_it_properly_transforms_the_data(self, mocked_grafana_api_client_api_get):
mocked_grafana_api_client_api_get.return_value = (
{"1": {"grafana-oncall-app.alert-groups:read": [""], "grafana-oncall-app.alert-groups:write": [""]}},
@ -50,7 +50,7 @@ class TestIsRbacEnabledForOrganization:
(status.HTTP_404_NOT_FOUND, False),
],
)
@patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient.api_head")
@patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.api_head")
def test_it_returns_based_on_status_code_of_head_call(
self, mocked_grafana_api_client_api_head, grafana_api_status_code, expected
):

View file

@ -1,29 +1,72 @@
# from firebase_admin.messaging import Message
# from fcm_django.models import FCMDevice
import logging
from fcm_django.models import FCMDevice
from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
REQUIRED_FIELDS = {"registration_ids", "notification", "data"}
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# TODO: update thie
class FCMRelayView(APIView):
# TODO: use public API authentication (then it would be required to connect to a cloud instance to use the app)
authentication_classes = []
permission_classes = []
def post(self, request):
"""
This view accepts requests from OSS instances of Grafana OnCall and forwards these requests to FCM.
Requests will be sent with the FCM_API_KEY configured in server settings
(see PUSH_NOTIFICATIONS_SETTINGS in settings/base.py)
This view accepts push notifications from OSS instances and forwards these requests to FCM.
Requests to this endpoint come from OSS instances: apps.mobile_app.tasks.send_push_notification_to_fcm_relay
"""
if not REQUIRED_FIELDS.issubset(request.data.keys()):
try:
token = request.data["token"]
data = request.data["data"]
except KeyError:
return Response(status=status.HTTP_400_BAD_REQUEST)
# registration_ids = request.data["registration_ids"]
# data = {
# **request.data["data"],
# **request.data["notification"],
# }
message = Message(token=token, data=data, apns=get_apns(request.data))
# return FCMDevice.objects.send_message(Message(), False, ["registration_ids"])
return "TODO:"
logger.debug(f"Sending message to FCM: {message}")
result = FCMDevice(registration_id=token).send_message(message)
logger.debug(f"FCM response: {result}")
return Response(status=status.HTTP_200_OK)
def get_apns(data):
"""
Create APNSConfig object from JSON payload from OSS instance.
"""
aps = data.get("apns", {}).get("payload", {}).get("aps", {})
if not aps:
return None
thread_id = aps.get("thread-id")
badge = aps.get("badge")
alert = aps.get("alert")
if isinstance(alert, dict):
alert = ApsAlert(**alert)
sound = aps.get("sound")
if isinstance(sound, dict):
sound = CriticalSound(**sound)
# remove all keys from "aps" so it can be used for custom_data
for key in ["thread-id", "badge", "alert", "sound"]:
aps.pop(key, None)
return APNSConfig(
payload=APNSPayload(
aps=Aps(
thread_id=thread_id,
badge=badge,
alert=alert,
sound=sound,
custom_data=aps,
)
)
)

View file

@ -1,3 +1,7 @@
import json
import logging
import requests
from celery.utils.log import get_task_logger
from django.conf import settings
from fcm_django.models import FCMDevice
@ -6,10 +10,12 @@ from firebase_admin.messaging import APNSConfig, APNSPayload, Aps, ApsAlert, Cri
from apps.alerts.models import AlertGroup
from apps.mobile_app.alert_rendering import get_push_notification_message
from apps.user_management.models import User
from common.api_helpers.utils import create_engine_url
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
MAX_RETRIES = 1 if settings.DEBUG else 10
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
@ -71,8 +77,6 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}"
# TODO: we should update this to check if FCM_RELAY is set and conditionally make a call here..
message = Message(
token=device_to_notify.registration_id,
data={
@ -109,10 +113,25 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
),
)
logger.info(f"Sending push notification with message: {message}; thread-id: {thread_id};")
logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};")
fcm_response = device_to_notify.send_message(message)
if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:
response = send_push_notification_to_fcm_relay(message)
logger.debug(f"FCM relay response: {response}")
else:
response = device_to_notify.send_message(message)
# NOTE: we may want to further handle the response from FCM, but for now lets simply log it out
# https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
logger.debug(f"FCM response: {response}")
# NOTE: we may want to further handle the response from FCM, but for now lets simply log it out
# https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
logger.info(f"FCM response was: {fcm_response}")
def send_push_notification_to_fcm_relay(message):
"""
Send push notification to FCM relay on cloud instance: apps.mobile_app.fcm_relay.FCMRelayView
"""
url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL)
response = requests.post(url, json=json.loads(str(message)))
response.raise_for_status()
return response

View file

@ -15,7 +15,7 @@ from apps.alerts.tasks import disable_maintenance
from apps.slack.utils import post_message_to_channel
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from common.insight_log import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.oncall_gateway import create_oncall_connector, delete_oncall_connector_async
from common.oncall_gateway import create_oncall_connector, delete_oncall_connector_async, delete_slack_connector_async
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
logger = logging.getLogger(__name__)
@ -51,6 +51,7 @@ class OrganizationQuerySet(models.QuerySet):
return instance
def delete(self):
# Be careful with deleting via queryset - it doesn't delete chatops-proxy connectors.
self.update(deleted_at=timezone.now())
def hard_delete(self):
@ -72,10 +73,12 @@ class Organization(MaintainableObject):
self.subscription_strategy = self._get_subscription_strategy()
def delete(self):
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
if settings.FEATURE_MULTIREGION_ENABLED:
delete_oncall_connector_async.apply_async((self.public_primary_key,))
if self.slack_team_identity:
delete_slack_connector_async.apply_async((self.slack_team_identity.slack_id,))
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def hard_delete(self):
super().delete()

View file

@ -12,18 +12,29 @@ logger.setLevel(logging.DEBUG)
def sync_organization(organization):
client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
# NOTE: checking whether or not RBAC is enabled depends on whether we are dealing with an open-source or cloud
# stack. For Cloud we should make a call to the GCOM API, using an admin API token, and get the list of
# feature_toggles enabled for the stack. For open-source, simply make a HEAD request to the grafana instance's API
# and consider RBAC enabled if the list RBAC permissions endpoint returns 200. We cannot simply rely on the HEAD
# call in cloud because if an instance is not active, the grafana gateway will still return 200 for the
# HEAD request.
if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
gcom_client = GcomAPIClient(settings.GRAFANA_COM_ADMIN_API_TOKEN)
rbac_is_enabled = gcom_client.is_rbac_enabled_for_stack(organization.stack_id)
else:
rbac_is_enabled = grafana_api_client.is_rbac_enabled_for_organization()
rbac_is_enabled = client.is_rbac_enabled_for_organization()
organization.is_rbac_permissions_enabled = rbac_is_enabled
_sync_instance_info(organization)
api_users = client.get_users(rbac_is_enabled)
api_users = grafana_api_client.get_users(rbac_is_enabled)
if api_users:
organization.api_token_status = Organization.API_TOKEN_STATUS_OK
sync_users_and_teams(client, api_users, organization)
sync_users_and_teams(grafana_api_client, api_users, organization)
else:
organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED

View file

@ -1,11 +1,12 @@
from unittest.mock import patch
import pytest
from django.conf import settings
from django.test import override_settings
from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient
from apps.user_management.models import Team, User
from apps.user_management.sync import cleanup_organization, sync_organization
from conftest import IS_RBAC_ENABLED
@pytest.mark.django_db
@ -133,7 +134,7 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
},
)
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=IS_RBAC_ENABLED):
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False):
with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response):
with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)):
with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)):
@ -153,8 +154,40 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat
assert team.users.count() == 1
assert team.users.get() == user
# check that the rbac flag is properly set on the org
assert organization.is_rbac_permissions_enabled == IS_RBAC_ENABLED
@pytest.mark.parametrize("grafana_api_response", [False, True])
@override_settings(LICENSE=settings.OPEN_SOURCE_LICENSE_NAME)
@pytest.mark.django_db
def test_sync_organization_is_rbac_permissions_enabled_open_source(make_organization, grafana_api_response):
organization = make_organization()
with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=grafana_api_response):
with patch.object(GrafanaAPIClient, "get_users", return_value=[]):
sync_organization(organization)
organization.refresh_from_db()
assert organization.is_rbac_permissions_enabled == grafana_api_response
@pytest.mark.parametrize("gcom_api_response", [False, True])
@patch("apps.user_management.sync.GcomAPIClient")
@override_settings(LICENSE=settings.CLOUD_LICENSE_NAME)
@override_settings(GRAFANA_COM_ADMIN_API_TOKEN="mockedToken")
@pytest.mark.django_db
def test_sync_organization_is_rbac_permissions_enabled_cloud(mocked_gcom_client, make_organization, gcom_api_response):
stack_id = 5
organization = make_organization(stack_id=stack_id)
mocked_gcom_client.return_value.is_rbac_enabled_for_stack.return_value = gcom_api_response
with patch.object(GrafanaAPIClient, "get_users", return_value=[]):
sync_organization(organization)
organization.refresh_from_db()
assert mocked_gcom_client.return_value.called_once_with("mockedToken")
assert mocked_gcom_client.return_value.is_rbac_enabled_for_stack.called_once_with(stack_id)
assert organization.is_rbac_permissions_enabled == gcom_api_response
@pytest.mark.django_db

View file

@ -25,7 +25,7 @@ export const SlackTab = () => {
<VerticalGroup align="center" spacing="lg">
<SlackNewIcon />
<Text>
Personal Slack connection will allow you to manage alert grouops in your connected team Internal Slack
Personal Slack connection will allow you to manage alert groups in your connected team's Internal Slack
workspace.
</Text>
<Text>To setup personal Slack click the button below, choose workspace and click Allow.</Text>

View file

@ -237,7 +237,7 @@
{
"role": {
"name": "Editor",
"description": "Similar to the Admin role, minus the abilities to: create Integrations, create Escalation Chains, create Schedules, create Outgoing Webhooks, update ChatOps settings, update other user's settings, and update general OnCall setings.",
"description": "Similar to the Admin role, minus the abilities to: create Integrations, create Escalation Chains, create Outgoing Webhooks, update ChatOps settings, update other user's settings, and update general OnCall setings.",
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },

View file

@ -7,7 +7,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.1.1
version: 1.1.2
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to

View file

@ -98,15 +98,19 @@ You can set up Slack connection via following variables:
oncall:
slack:
enabled: true
command: ~
commandName: oncall
clientId: ~
clientSecret: ~
apiToken: ~
apiTokenCommon: ~
signingSecret: ~
existingSecret: ""
clientIdKey: ""
clientSecretKey: ""
signingSecretKey: ""
redirectHost: ~
```
`oncall.slack.command` is used for changing default bot slash command,
`oncall`. In slack, it could be called via `/<oncall.slack.command>`.
`oncall.slack.commandName` is used for changing default bot slash command,
`oncall`. In slack, it could be called via `/<oncall.slack.commandName>`.
To set up Telegram tokem and webhook url use:

View file

@ -91,8 +91,8 @@ oncall:
slack:
# enabled enable the Slack ChatOps integration for the Oncall Engine.
enabled: false
# command sets the Slack bot slash-command
command: oncall
# commandName sets the Slack bot slash-command
commandName: oncall
# clientId configures the Slack app OAuth2 client ID.
# api.slack.com/apps/<yourApp> -> Basic Information -> App Credentials -> Client ID
clientId: ~