diff --git a/CHANGELOG.md b/CHANGELOG.md index b203ff40..37f51682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 44316e22..7a49d077 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -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) diff --git a/engine/apps/grafana_plugin/tests/test_gcom_api_client.py b/engine/apps/grafana_plugin/tests/test_gcom_api_client.py new file mode 100644 index 00000000..cac834d8 --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_gcom_api_client.py @@ -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") diff --git a/engine/apps/grafana_plugin/tests/test_grafana_api_client.py b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py index ef4af42e..67e9e575 100644 --- a/engine/apps/grafana_plugin/tests/test_grafana_api_client.py +++ b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py @@ -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 ): diff --git a/engine/apps/mobile_app/fcm_relay.py b/engine/apps/mobile_app/fcm_relay.py index f50f2434..8fdeb716 100644 --- a/engine/apps/mobile_app/fcm_relay.py +++ b/engine/apps/mobile_app/fcm_relay.py @@ -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, + ) + ) + ) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 04bbe5b7..d3198adb 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -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 diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index fbf554f4..b65b1837 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -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() diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 7bbba71f..c9636cc6 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -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 diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 5a340875..e35739ed 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -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 diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx index 3a305949..7b7db75a 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/SlackTab/SlackTab.tsx @@ -25,7 +25,7 @@ export const SlackTab = () => { - 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. To setup personal Slack click the button below, choose workspace and click Allow. diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 51055b7b..b69e9435 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -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" }, diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index 76ffef88..20d414c1 100644 --- a/helm/oncall/Chart.yaml +++ b/helm/oncall/Chart.yaml @@ -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 diff --git a/helm/oncall/README.md b/helm/oncall/README.md index 172758fe..994a9426 100644 --- a/helm/oncall/README.md +++ b/helm/oncall/README.md @@ -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.commandName` is used for changing default bot slash command, +`oncall`. In slack, it could be called via `/`. To set up Telegram tokem and webhook url use: diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index 9abb14fc..612e0c49 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -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/ -> Basic Information -> App Credentials -> Client ID clientId: ~