From 09bf0ee3d5f3cb299ae89c5c556176cae099ba19 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 28 Nov 2022 14:18:27 +0800 Subject: [PATCH 01/48] Add celery parameters to disable gossip, heartbeat, mingle (#907) --- engine/celery_with_exporter.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/engine/celery_with_exporter.sh b/engine/celery_with_exporter.sh index d1e020e9..abc33691 100755 --- a/engine/celery_with_exporter.sh +++ b/engine/celery_with_exporter.sh @@ -36,5 +36,14 @@ CELERY_ARGS=( if [[ $CELERY_WORKER_BEAT_ENABLED = True ]]; then CELERY_ARGS+=("--beat") fi +if [[ $CELERY_WORKER_WITHOUT_MINGLE = True ]]; then + CELERY_ARGS+=("--without-mingle") +fi +if [[ $CELERY_WORKER_WITHOUT_GOSSIP = True ]]; then + CELERY_ARGS+=("--without-gossip") +fi +if [[ $CELERY_WORKER_WITHOUT_HEARTBEAT = True ]]; then + CELERY_ARGS+=("--without-heartbeat") +fi celery "${CELERY_ARGS[@]}" From 54d14d10255722e0de095b6c758d7d386f619cdd Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Mon, 28 Nov 2022 12:50:58 +0000 Subject: [PATCH 02/48] Move MobileAppAuthToken view to mobile_app (#902) --- engine/apps/api/views/user.py | 5 ++-- engine/apps/mobile_app/urls.py | 14 ++++++---- engine/apps/mobile_app/views.py | 47 ++++++++++++++++++++++++++++++++- engine/engine/urls.py | 2 +- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index de0a8590..0126abaf 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -129,7 +129,6 @@ class UserView( "unlink_backend", "make_test_call", "export_token", - "mobile_app_verification_token", "mobile_app_auth_token", ), AnyRole: ("retrieve", "timezone_options"), @@ -149,7 +148,6 @@ class UserView( "unlink_backend", "make_test_call", "export_token", - "mobile_app_verification_token", "mobile_app_auth_token", ), } @@ -475,6 +473,9 @@ class UserView( authentication_classes=(MobileAppVerificationTokenAuthentication,), ) def mobile_app_auth_token(self, request): + """ + TODO: remove after hackathon app is deprecated (see apps.mobile_app.views.MobileAppAuthTokenAPIView) + """ DynamicSetting = apps.get_model("base", "DynamicSetting") if not settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 059503fb..ddb9700b 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -1,9 +1,13 @@ -from apps.mobile_app.views import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet -from common.api_helpers.optional_slash_router import OptionalSlashRouter +from apps.mobile_app.views import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path +app_name = "mobile_app" router = OptionalSlashRouter() -router.register("apns", APNSDeviceAuthorizedViewSet) -router.register("gcm", GCMDeviceAuthorizedViewSet) +router.register("apns", APNSDeviceAuthorizedViewSet, basename="apns") +router.register("gcm", GCMDeviceAuthorizedViewSet, basename="gcm") -urlpatterns = router.urls +urlpatterns = [ + *router.urls, + optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"), +] diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py index 9755b74d..694eeff3 100644 --- a/engine/apps/mobile_app/views.py +++ b/engine/apps/mobile_app/views.py @@ -1,7 +1,12 @@ from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet as BaseAPNSDeviceAuthorizedViewSet from push_notifications.api.rest_framework import GCMDeviceAuthorizedViewSet as BaseGCMDeviceAuthorizedViewSet +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.views import APIView -from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication +from apps.mobile_app.models import MobileAppAuthToken class APNSDeviceAuthorizedViewSet(BaseAPNSDeviceAuthorizedViewSet): @@ -10,3 +15,43 @@ class APNSDeviceAuthorizedViewSet(BaseAPNSDeviceAuthorizedViewSet): class GCMDeviceAuthorizedViewSet(BaseGCMDeviceAuthorizedViewSet): authentication_classes = (MobileAppAuthTokenAuthentication,) + + +class MobileAppAuthTokenAPIView(APIView): + authentication_classes = (MobileAppVerificationTokenAuthentication,) + + def get(self, request): + try: + token = MobileAppAuthToken.objects.get(user=self.request.user) + except MobileAppAuthToken.DoesNotExist: + raise NotFound + + response = { + "token_id": token.id, + "user_id": token.user_id, + "organization_id": token.organization_id, + "created_at": token.created_at, + "revoked_at": token.revoked_at, + } + return Response(response, status=status.HTTP_200_OK) + + def post(self, request): + # If token already exists revoke it + try: + token = MobileAppAuthToken.objects.get(user=self.request.user) + token.delete() + except MobileAppAuthToken.DoesNotExist: + pass + + instance, token = MobileAppAuthToken.create_auth_token(self.request.user, self.request.user.organization) + data = {"id": instance.pk, "token": token, "created_at": instance.created_at} + return Response(data, status=status.HTTP_201_CREATED) + + def delete(self, request): + try: + token = MobileAppAuthToken.objects.get(user=self.request.user) + token.delete() + except MobileAppAuthToken.DoesNotExist: + raise NotFound + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index ab6934d8..a29c11c5 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -53,7 +53,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: urlpatterns += [ - path("mobile_app/", include("apps.mobile_app.urls")), + path("mobile_app/v1/", include("apps.mobile_app.urls", namespace="mobile_app")), ] From 69f1218bab5b0f833c0a86a92f815ab33364e3c8 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 10 Nov 2022 10:49:21 -0300 Subject: [PATCH 03/48] Handle error when updating ical cache from deleted web schedule --- .../apps/schedules/models/on_call_schedule.py | 13 +++++-- .../schedules/tests/test_on_call_schedule.py | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 7b04d6ae..512a39aa 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -8,6 +8,7 @@ from django.apps import apps from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models +from django.db.utils import DatabaseError from django.utils import timezone from django.utils.functional import cached_property from icalendar.cal import Calendar @@ -637,7 +638,11 @@ class OnCallScheduleWeb(OnCallSchedule): """Return cached ical file with iCal events from custom on-call shifts.""" if self.cached_ical_file_primary is None: self.cached_ical_file_primary = self._generate_ical_file_primary() - self.save(update_fields=["cached_ical_file_primary"]) + try: + self.save(update_fields=["cached_ical_file_primary"]) + except DatabaseError: + # schedule may have been deleted from db + return return self.cached_ical_file_primary def _refresh_primary_ical_file(self): @@ -650,7 +655,11 @@ class OnCallScheduleWeb(OnCallSchedule): """Return cached ical file with iCal events from custom on-call overrides shifts.""" if self.cached_ical_file_overrides is None: self.cached_ical_file_overrides = self._generate_ical_file_overrides() - self.save(update_fields=["cached_ical_file_overrides"]) + try: + self.save(update_fields=["cached_ical_file_overrides"]) + except DatabaseError: + # schedule may have been deleted from db + return return self.cached_ical_file_overrides def _refresh_overrides_ical_file(self): diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 32a1d826..181a2523 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -924,3 +924,37 @@ def test_schedule_related_users(make_organization, make_user_for_organization, m schedule.refresh_from_db() users = schedule.related_users() assert users == set(u.public_primary_key for u in [user_a, user_d, user_e]) + + +@pytest.mark.django_db(transaction=True) +def test_filter_events_none_cache_unchanged( + make_organization, make_user_for_organization, make_schedule, make_on_call_shift +): + organization = make_organization() + user = make_user_for_organization(organization) + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + start_date = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + # add shift + data = { + "start": start_date + timezone.timedelta(hours=36), + "rotation_start": start_date + timezone.timedelta(hours=36), + "duration": timezone.timedelta(hours=2), + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + # schedule is removed from db + schedule.delete() + + events = schedule.filter_events("UTC", start_date, days=5, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY) + expected = [] + assert events == expected From dc6fcf5c05dcf4ac8c3500b8de37675091f8aee2 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Mon, 28 Nov 2022 15:52:31 +0000 Subject: [PATCH 04/48] Add internal API fields for the mobile app (#910) * add permalinks list to internal API alertgroup view * add user's name and full avatar URL to the user view * make avatar_full_url a property * fix tests * fix user connection criteria --- engine/apps/alerts/models/alert_group.py | 12 ++++++------ engine/apps/api/serializers/alert_group.py | 3 ++- engine/apps/api/serializers/user.py | 5 ++++- engine/apps/api/tests/test_user.py | 6 ++++++ engine/apps/mobile_app/backend.py | 7 +++---- engine/apps/user_management/models/user.py | 5 +++++ engine/apps/user_management/tests/test_sync.py | 6 ++++-- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index cb08e8e1..06df3a2b 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -1,7 +1,6 @@ import logging -import typing from collections import namedtuple -from typing import Optional +from typing import Optional, TypedDict from urllib.parse import urljoin from uuid import uuid1 @@ -46,8 +45,9 @@ def generate_public_primary_key_for_alert_group(): return new_public_primary_key -class Permalinks(typing.TypedDict): - slack: str +class Permalinks(TypedDict): + slack: Optional[str] + telegram: Optional[str] class AlertGroupQuerySet(models.QuerySet): @@ -401,12 +401,12 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. raise NotImplementedError @property - def slack_permalink(self): + def slack_permalink(self) -> Optional[str]: if self.slack_message is not None: return self.slack_message.permalink @property - def telegram_permalink(self) -> typing.Optional[str]: + def telegram_permalink(self) -> Optional[str]: """ This property will attempt to access an attribute, `prefetched_telegram_messages`, representing a list of prefetched telegram messages. If this attribute does not exist, it falls back to performing a query. diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index a71cfde2..5c2e9517 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -132,7 +132,8 @@ class AlertGroupSerializer(AlertGroupListSerializer): fields = AlertGroupListSerializer.Meta.fields + [ "alerts", "render_after_resolve_report_json", - "slack_permalink", + "slack_permalink", # TODO: make plugin frontend use "permalinks" field to get Slack link + "permalinks", "last_alert_at", ] diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 5f428d87..e3a0d784 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -36,7 +36,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): timezone = serializers.CharField(allow_null=True, required=False) avatar = serializers.URLField(source="avatar_url", read_only=True) - + avatar_full = serializers.URLField(source="avatar_full_url", read_only=True) permissions = serializers.SerializerMethodField() notification_chain_verbal = serializers.SerializerMethodField() cloud_connection_status = serializers.SerializerMethodField() @@ -51,8 +51,10 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "current_team", "email", "username", + "name", "role", "avatar", + "avatar_full", "timezone", "working_hours", "unverified_phone_number", @@ -68,6 +70,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): read_only_fields = [ "email", "username", + "name", "role", "verified_phone_number", ] diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 54a79ad7..ee7f9809 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -68,6 +68,7 @@ def test_update_user_cant_change_email_and_username( "email": admin.email, "hide_phone_number": False, "username": admin.username, + "name": admin.name, "role": admin.role, "timezone": None, "working_hours": default_working_hours(), @@ -84,6 +85,7 @@ def test_update_user_cant_change_email_and_username( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, + "avatar_full": admin.avatar_full_url, } response = client.put(url, data, format="json", **make_user_auth_headers(admin, token)) assert response.status_code == status.HTTP_200_OK @@ -117,6 +119,7 @@ def test_list_users( "email": admin.email, "hide_phone_number": False, "username": admin.username, + "name": admin.name, "role": admin.role, "timezone": None, "working_hours": default_working_hours(), @@ -132,6 +135,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, + "avatar_full": admin.avatar_full_url, "cloud_connection_status": 0, }, { @@ -141,6 +145,7 @@ def test_list_users( "email": editor.email, "hide_phone_number": False, "username": editor.username, + "name": editor.name, "role": editor.role, "timezone": None, "working_hours": default_working_hours(), @@ -156,6 +161,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": editor.avatar_url, + "avatar_full": editor.avatar_full_url, "cloud_connection_status": 0, }, ], diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py index 82187250..f95633ee 100644 --- a/engine/apps/mobile_app/backend.py +++ b/engine/apps/mobile_app/backend.py @@ -1,5 +1,3 @@ -from push_notifications.models import APNSDevice - from apps.base.messaging import BaseMessagingBackend from apps.mobile_app.tasks import notify_user_async @@ -28,8 +26,9 @@ class MobileAppBackend(BaseMessagingBackend): token.delete() def serialize_user(self, user): - # TODO: add Android support using GCMDevice - return {"connected": APNSDevice.objects.filter(user_id=user.pk).exists()} + from apps.mobile_app.models import MobileAppAuthToken + + return {"connected": MobileAppAuthToken.objects.filter(user=user).exists()} def notify_user(self, user, alert_group, notification_policy, critical=False): notify_user_async.delay( diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 63c3ce0e..81af4e70 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urljoin from django.apps import apps from django.conf import settings @@ -161,6 +162,10 @@ class User(models.Model): def is_authenticated(self): return True + @property + def avatar_full_url(self): + return urljoin(self.organization.grafana_url, self.avatar_url) + @property def verified_phone_number(self): """ diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index d6a6d922..c7e03bb9 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -10,7 +10,7 @@ from apps.user_management.sync import cleanup_organization, sync_organization @pytest.mark.django_db def test_sync_users_for_organization(make_organization, make_user_for_organization): - organization = make_organization() + organization = make_organization(grafana_url="https://test.test") users = tuple(make_user_for_organization(organization, user_id=user_id) for user_id in (1, 2)) api_users = tuple( @@ -20,7 +20,7 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati "name": "Test", "login": "test", "role": "admin", - "avatarUrl": "test.test/test", + "avatarUrl": "/test/1234", } for user_id in (2, 3) ) @@ -37,12 +37,14 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati assert updated_user is not None assert updated_user.name == api_users[0]["name"] assert updated_user.email == api_users[0]["email"] + assert updated_user.avatar_full_url == "https://test.test/test/1234" # check that missing users are created created_user = organization.users.filter(user_id=api_users[1]["userId"]).first() assert created_user is not None assert created_user.user_id == api_users[1]["userId"] assert created_user.name == api_users[1]["name"] + assert created_user.avatar_full_url == "https://test.test/test/1234" @pytest.mark.django_db From 3582f9b08f94c380dadfe96ea2174efe684f018b Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 28 Nov 2022 16:46:51 +0000 Subject: [PATCH 05/48] Improve Jinja Template feedback and error handling (#884) * Improve feedback so template errors are given to user * Add security error logging * Add limits for templates, payloads, results * Show popup error notification for webhook errors and template errors that don't have a result * Update tests * Split exceptions into warnings/errors to give more control when previewing, rendering, saving templates * Limit title lengths * Make TypeError a warning * Adjust title length limit * Remove length limiting on urlize since it is being done on template render * Fix tests * Add KeyError and ValueError to warnings * No longer enforcing json result when saving webhook in case it is dependent on payload * Add tests for expected exceptions coming from apply_jinja_template * Update changelog * Send raw post if template result is not JSON --- CHANGELOG.md | 8 +++ .../templaters/alert_templater.py | 15 ++++- engine/apps/alerts/models/alert.py | 8 +-- engine/apps/alerts/models/custom_button.py | 24 ++++--- .../tasks/alert_group_web_title_cache.py | 2 +- .../api/serializers/alert_receive_channel.py | 10 +-- engine/apps/api/serializers/custom_button.py | 37 +++-------- engine/apps/api/tests/test_alert_group.py | 7 +- engine/apps/api/tests/test_custom_button.py | 18 +---- .../views/alert_receive_channel_template.py | 9 ++- engine/common/api_helpers/mixins.py | 11 ++-- .../jinja_templater/apply_jinja_template.py | 44 +++++++++++-- .../jinja_templater/jinja_template_env.py | 7 ++ .../common/tests/test_apply_jinja_template.py | 65 +++++++++++++++++++ engine/settings/base.py | 3 + .../AlertTemplates/AlertTemplatesForm.tsx | 1 - .../AlertTemplatesFormContainer.tsx | 12 ++-- .../TemplatePreview/TemplatePreview.tsx | 11 +++- 18 files changed, 204 insertions(+), 88 deletions(-) create mode 100644 engine/common/tests/test_apply_jinja_template.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2d3f90..51e987ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.6 (TBD) + +### Fixed +- Got 500 error when saving Outgoing Webhook ([#890](https://github.com/grafana/oncall/issues/890)) + +### Changed +- When editing templates for alert group presentation or outgoing webhooks, errors and warnings are now displayed in the UI as notification popups or displayed in the preview. +- Errors and warnings that occur when rendering templates during notification or webhooks will now render and display the error/warning as the result. ## v1.1.5 (2022-11-24) ### Fixed diff --git a/engine/apps/alerts/incident_appearance/templaters/alert_templater.py b/engine/apps/alerts/incident_appearance/templaters/alert_templater.py index 052313c4..176e82b3 100644 --- a/engine/apps/alerts/incident_appearance/templaters/alert_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/alert_templater.py @@ -1,9 +1,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from django.conf import settings + from apps.base.messaging import get_messaging_backend_from_id from apps.slack.slack_formatter import SlackFormatter from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning class TemplateLoader: @@ -172,9 +175,15 @@ class AlertTemplater(ABC): "amixr_incident_id": self.incident_id, # TODO: decide on variable names "amixr_link": self.link, # TODO: decide on variable names } - templated_attr, success = apply_jinja_template(attr_template, data, **context) - if success: - return templated_attr + try: + if attr == "title": + return apply_jinja_template( + attr_template, data, result_length_limit=settings.JINJA_RESULT_TITLE_MAX_LENGTH, **context + ) + else: + return apply_jinja_template(attr_template, data, **context) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return e.fallback_message return None diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index b3355b51..9a6c383e 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -189,12 +189,12 @@ class Alert(models.Model): # set web_title_cache to web title to allow alert group searching based on web_title_cache web_title_template = template_manager.get_attr_template("title", alert_receive_channel, render_for="web") if web_title_template: - web_title_cache = apply_jinja_template(web_title_template, raw_request_data)[0] or None + web_title_cache = apply_jinja_template(web_title_template, raw_request_data) else: web_title_cache = None if grouping_id_template is not None: - group_distinction, _ = apply_jinja_template(grouping_id_template, raw_request_data) + group_distinction = apply_jinja_template(grouping_id_template, raw_request_data) # Insert random uuid to prevent grouping of demo alerts or alerts with group_distinction=None if is_demo or not group_distinction: @@ -204,13 +204,13 @@ class Alert(models.Model): group_distinction = hashlib.md5(str(group_distinction).encode()).hexdigest() if resolve_condition_template is not None: - is_resolve_signal, _ = apply_jinja_template(resolve_condition_template, payload=raw_request_data) + is_resolve_signal = apply_jinja_template(resolve_condition_template, payload=raw_request_data) if isinstance(is_resolve_signal, str): is_resolve_signal = is_resolve_signal.strip().lower() in ["1", "true", "ok"] else: is_resolve_signal = False if acknowledge_condition_template is not None: - is_acknowledge_signal, _ = apply_jinja_template(acknowledge_condition_template, payload=raw_request_data) + is_acknowledge_signal = apply_jinja_template(acknowledge_condition_template, payload=raw_request_data) if isinstance(is_acknowledge_signal, str): is_acknowledge_signal = is_acknowledge_signal.strip().lower() in ["1", "true", "ok"] else: diff --git a/engine/apps/alerts/models/custom_button.py b/engine/apps/alerts/models/custom_button.py index 4b83adbc..9cbee4a5 100644 --- a/engine/apps/alerts/models/custom_button.py +++ b/engine/apps/alerts/models/custom_button.py @@ -1,6 +1,7 @@ import json import logging import re +from json import JSONDecodeError from django.conf import settings from django.core.validators import MinLengthValidator @@ -9,7 +10,8 @@ from django.db.models import F from django.utils import timezone from requests.auth import HTTPBasicAuth -from common.jinja_templater import jinja_template_env +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) @@ -103,13 +105,19 @@ class CustomButton(models.Model): if self.forward_whole_payload: post_kwargs["json"] = alert.raw_request_data elif self.data: - rendered_data = jinja_template_env.from_string(self.data).render( - { - "alert_payload": self._escape_alert_payload(alert.raw_request_data), - "alert_group_id": alert.group.public_primary_key, - } - ) - post_kwargs["json"] = json.loads(rendered_data) + try: + rendered_data = apply_jinja_template( + self.data, + alert_payload=self._escape_alert_payload(alert.raw_request_data), + alert_group_id=alert.group.public_primary_key, + ) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + post_kwargs["json"] = {"error": e.fallback_message} + + try: + post_kwargs["json"] = json.loads(rendered_data) + except JSONDecodeError: + post_kwargs["data"] = rendered_data return post_kwargs def _escape_alert_payload(self, payload: dict): diff --git a/engine/apps/alerts/tasks/alert_group_web_title_cache.py b/engine/apps/alerts/tasks/alert_group_web_title_cache.py index 5afc6234..963965f4 100644 --- a/engine/apps/alerts/tasks/alert_group_web_title_cache.py +++ b/engine/apps/alerts/tasks/alert_group_web_title_cache.py @@ -76,7 +76,7 @@ def update_web_title_cache(alert_receive_channel_pk, alert_group_pks): if web_title_template: if alert_group.pk in first_alert_map: raw_request_data = first_alert_map[alert_group.pk]["raw_request_data"] - web_title_cache = apply_jinja_template(web_title_template, raw_request_data)[0] or None + web_title_cache = apply_jinja_template(web_title_template, raw_request_data) else: web_title_cache = None else: diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 91823581..c8df5ad3 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -20,7 +20,8 @@ from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, Writabl from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import IMAGE_URL, TEMPLATE_NAMES_ONLY_WITH_NOTIFICATION_CHANNEL, EagerLoadingMixin from common.api_helpers.utils import CurrentTeamDefault -from common.jinja_templater import jinja_template_env +from common.jinja_templater import apply_jinja_template, jinja_template_env +from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning from .integration_heartbeat import IntegrationHeartBeatSerializer @@ -28,9 +29,10 @@ from .integration_heartbeat import IntegrationHeartBeatSerializer def valid_jinja_template_for_serializer_method_field(template): for _, val in template.items(): try: - jinja_template_env.from_string(val) - except TemplateSyntaxError: - raise serializers.ValidationError("invalid template") + apply_jinja_template(val, payload={}) + except JinjaTemplateWarning: + # Suppress warnings, template may be valid with payload + pass class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializer): diff --git a/engine/apps/api/serializers/custom_button.py b/engine/apps/api/serializers/custom_button.py index 86ee2def..d0026b9f 100644 --- a/engine/apps/api/serializers/custom_button.py +++ b/engine/apps/api/serializers/custom_button.py @@ -1,15 +1,14 @@ -import json from collections import defaultdict from django.core.validators import URLValidator, ValidationError -from jinja2 import TemplateError from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from apps.alerts.models import CustomButton from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault -from common.jinja_templater import jinja_template_env +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning class CustomButtonSerializer(serializers.ModelSerializer): @@ -53,32 +52,12 @@ class CustomButtonSerializer(serializers.ModelSerializer): return None try: - template = jinja_template_env.from_string(data) - except TemplateError: - raise serializers.ValidationError("Data has incorrect template") - - try: - rendered = template.render( - { - # Validate that the template can be rendered with a JSON-ish alert payload. - # We don't know what the actual payload will be, so we use a defaultdict - # so that attribute access within a template will never fail - # (provided it's only one level deep - we won't accept templates that attempt - # to do nested attribute access). - # Every attribute access should return a string to ensure that users are - # correctly using `tojson` or wrapping fields in strings. - # If we instead used a `defaultdict(dict)` or `defaultdict(lambda: 1)` we - # would accidentally accept templates such as `{"name": {{ alert_payload.name }}}` - # which would then fail at the true render time due to the - # lack of explicit quotes around the template variable; this would render - # as `{"name": some_alert_name}` which is not valid JSON. - "alert_payload": defaultdict(str), - "alert_group_id": "abcd", - } - ) - json.loads(rendered) - except ValueError: - raise serializers.ValidationError("Data has incorrect format") + apply_jinja_template(data, alert_payload=defaultdict(str), alert_group_id="abcd") + except JinjaTemplateError as e: + raise serializers.ValidationError(e.fallback_message) + except JinjaTemplateWarning: + # Suppress render exceptions since we do not have a representative payload to test with + pass return data diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 4ef75494..0b236b7d 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -1446,8 +1446,9 @@ def test_alert_group_preview_body_non_existent_template_var( data = {"template_name": "testonly_title_template", "template_body": "foobar: {{ foobar.does_not_exist }}"} response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + # Return errors as preview body instead of None assert response.status_code == status.HTTP_200_OK - assert response.json()["preview"] is None + assert response.json()["preview"] == "Template Warning: 'foobar' is undefined" @pytest.mark.django_db @@ -1468,4 +1469,6 @@ def test_alert_group_preview_body_invalid_template_syntax( data = {"template_name": "testonly_title_template", "template_body": "{{'' if foo is None else foo}}"} response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST + # Errors now returned preview content + assert response.status_code == status.HTTP_200_OK + assert response.data["preview"] == "Template Error: No test named 'None' found." diff --git a/engine/apps/api/tests/test_custom_button.py b/engine/apps/api/tests/test_custom_button.py index 2d519d83..f45acb77 100644 --- a/engine/apps/api/tests/test_custom_button.py +++ b/engine/apps/api/tests/test_custom_button.py @@ -236,23 +236,7 @@ def test_create_invalid_data_custom_button(custom_button_internal_api_setup, mak data = { "name": "amixr_button_invalid_data", "webhook": TEST_URL, - "data": "invalid_json", - } - response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.django_db -def test_create_invalid_templated_data_custom_button(custom_button_internal_api_setup, make_user_auth_headers): - user, token, custom_button = custom_button_internal_api_setup - client = APIClient() - url = reverse("api-internal:custom_button-list") - - data = { - "name": "amixr_button_invalid_data", - "webhook": TEST_URL, - # This would need a `| tojson` or some double quotes around it to pass validation. - "data": "{{ alert_payload.name }}", + "data": "{{%", } response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/engine/apps/api/views/alert_receive_channel_template.py b/engine/apps/api/views/alert_receive_channel_template.py index ff8cd923..c6800ee5 100644 --- a/engine/apps/api/views/alert_receive_channel_template.py +++ b/engine/apps/api/views/alert_receive_channel_template.py @@ -1,5 +1,6 @@ -from rest_framework import mixins, viewsets +from rest_framework import mixins, status, viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin @@ -7,6 +8,7 @@ from apps.api.serializers.alert_receive_channel import AlertReceiveChannelTempla from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.insight_log import EntityEvent, write_resource_insight_log +from common.jinja_templater.apply_jinja_template import JinjaTemplateError class AlertReceiveChannelTemplateView( @@ -36,7 +38,10 @@ class AlertReceiveChannelTemplateView( def update(self, request, *args, **kwargs): instance = self.get_object() prev_state = instance.insight_logs_serialized - result = super().update(request, *args, **kwargs) + try: + result = super().update(request, *args, **kwargs) + except JinjaTemplateError as e: + return Response(e.fallback_message, status.HTTP_400_BAD_REQUEST) instance = self.get_object() new_state = instance.insight_logs_serialized write_resource_insight_log( diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 7a2f84c0..c3a0991d 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -4,7 +4,6 @@ import math from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django.utils.functional import cached_property -from jinja2.exceptions import TemplateRuntimeError from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import NotFound, Throttled @@ -23,6 +22,7 @@ from apps.base.messaging import get_messaging_backends from apps.user_management.models import Team from common.api_helpers.exceptions import BadRequest from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning class UpdateSerializerMixin: @@ -324,13 +324,16 @@ class PreviewTemplateMixin: templater.template_manager = PreviewTemplateLoader() try: templated_alert = templater.render() - except TemplateRuntimeError: - raise BadRequest(detail={"template_body": "Invalid template syntax"}) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return Response({"preview": e.fallback_message}, status.HTTP_200_OK) templated_attr = getattr(templated_alert, attr_name) elif attr_name in TEMPLATE_NAMES_WITHOUT_NOTIFICATION_CHANNEL: - templated_attr, _ = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data) + try: + templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return Response({"preview": e.fallback_message}, status.HTTP_200_OK) else: templated_attr = None response = {"preview": templated_attr} diff --git a/engine/common/jinja_templater/apply_jinja_template.py b/engine/common/jinja_templater/apply_jinja_template.py index fb00ab31..3a640e25 100644 --- a/engine/common/jinja_templater/apply_jinja_template.py +++ b/engine/common/jinja_templater/apply_jinja_template.py @@ -1,12 +1,42 @@ -from jinja2 import TemplateSyntaxError, UndefinedError +import logging + +from django.conf import settings +from jinja2 import TemplateAssertionError, TemplateSyntaxError, UndefinedError +from jinja2.exceptions import SecurityError from .jinja_template_env import jinja_template_env +logger = logging.getLogger(__name__) + + +class JinjaTemplateError(Exception): + def __init__(self, fallback_message): + self.fallback_message = f"Template Error: {fallback_message}" + + +class JinjaTemplateWarning(Exception): + def __init__(self, fallback_message): + self.fallback_message = f"Template Warning: {fallback_message}" + + +def apply_jinja_template(template, payload=None, result_length_limit=settings.JINJA_RESULT_MAX_LENGTH, **kwargs): + if len(template) > settings.JINJA_TEMPLATE_MAX_LENGTH: + raise JinjaTemplateError( + f"Template exceeds length limit ({len(template)} > {settings.JINJA_TEMPLATE_MAX_LENGTH})" + ) -def apply_jinja_template(template, payload=None, **kwargs): try: - template = jinja_template_env.from_string(template) - result = template.render(payload=payload, **kwargs) - return result, True - except (UndefinedError, TypeError, ValueError, KeyError, TemplateSyntaxError): - return None, False + compiled_template = jinja_template_env.from_string(template) + result = compiled_template.render(payload=payload, **kwargs) + except SecurityError as e: + logger.warning(f"SecurityError process template={template} payload={payload}") + raise JinjaTemplateError(str(e)) + except (TemplateAssertionError, TemplateSyntaxError) as e: + raise JinjaTemplateError(str(e)) + except (TypeError, KeyError, ValueError, UndefinedError) as e: + raise JinjaTemplateWarning(str(e)) + except Exception as e: + logger.error(f"Unexpected template error: {str(e)} template={template} payload={payload}") + raise JinjaTemplateError(str(e)) + + return (result[:result_length_limit] + "..") if len(result) > result_length_limit else result diff --git a/engine/common/jinja_templater/jinja_template_env.py b/engine/common/jinja_templater/jinja_template_env.py index 41e915aa..747b010b 100644 --- a/engine/common/jinja_templater/jinja_template_env.py +++ b/engine/common/jinja_templater/jinja_template_env.py @@ -1,13 +1,20 @@ from django.utils import timezone from jinja2 import BaseLoader +from jinja2.exceptions import SecurityError from jinja2.sandbox import SandboxedEnvironment from .filters import datetimeformat, iso8601_to_time, regex_replace, to_pretty_json + +def raise_security_exception(name): + raise SecurityError(f"use of '{name}' is restricted") + + jinja_template_env = SandboxedEnvironment(loader=BaseLoader()) jinja_template_env.filters["datetimeformat"] = datetimeformat jinja_template_env.filters["iso8601_to_time"] = iso8601_to_time jinja_template_env.filters["tojson_pretty"] = to_pretty_json jinja_template_env.globals["time"] = timezone.now +jinja_template_env.globals["range"] = lambda *args: raise_security_exception("range") jinja_template_env.filters["regex_replace"] = regex_replace diff --git a/engine/common/tests/test_apply_jinja_template.py b/engine/common/tests/test_apply_jinja_template.py new file mode 100644 index 00000000..aed42af1 --- /dev/null +++ b/engine/common/tests/test_apply_jinja_template.py @@ -0,0 +1,65 @@ +import json + +import pytest +from django.conf import settings + +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning + + +def test_apply_jinja_template(): + payload = {"name": "test"} + rendered = apply_jinja_template("{{ payload | tojson_pretty }}", payload) + result = json.loads(rendered) + assert payload == result + + +def test_apply_jinja_template_bad_syntax_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{{%", payload={}) + + +def test_apply_jinja_template_unknown_filter_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{{ payload | to_json_pretty }}", payload={}) + + +def test_apply_jinja_template_unsafe_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{{ payload.__init__() }}", payload={}) + + +def test_apply_jinja_template_restricted_error(): + with pytest.raises(JinjaTemplateError): + apply_jinja_template("{% for n in range(100) %}Hello{% endfor %}", payload={}) + + +def test_apply_jinja_template_restricted_inside_conditional(): + template = "{% if 'blabla' in payload %}{% for n in range(100) %}Hello{% endfor %}{% endif %}" + # No exception when condition == False + apply_jinja_template(template, payload={}) + with pytest.raises(JinjaTemplateError): + apply_jinja_template(template, payload={"blabla": "test"}) + + +def test_apply_jinja_template_missing_field_warning(): + with pytest.raises(JinjaTemplateWarning): + apply_jinja_template("{{ payload.field.name }}", payload={}) + + +def test_apply_jinja_template_type_warning(): + with pytest.raises(JinjaTemplateWarning): + apply_jinja_template("{{ payload.name + 25 }}", payload={"name": "test"}) + + +def test_apply_jinja_template_too_large(): + template = "test" * 20000 + with pytest.raises(JinjaTemplateError): + apply_jinja_template(template, payload={}) + + +def test_apply_jinja_template_result_truncate(): + payload = {"value": "test" * 20000} + result = apply_jinja_template("{{ payload.value }}", payload) + # Length == Limit + 2 to account for '..' appended to end + assert len(result) == settings.JINJA_RESULT_MAX_LENGTH + 2 diff --git a/engine/settings/base.py b/engine/settings/base.py index 49036059..1b1e9412 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -574,6 +574,9 @@ SELF_HOSTED_SETTINGS = { GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) DATA_UPLOAD_MAX_MEMORY_SIZE = getenv_integer("DATA_UPLOAD_MAX_MEMORY_SIZE", 1_048_576) # 1mb by default +JINJA_TEMPLATE_MAX_LENGTH = 50000 +JINJA_RESULT_TITLE_MAX_LENGTH = 500 +JINJA_RESULT_MAX_LENGTH = 50000 # Log inbound/outbound calls as slow=1 if they exceed threshold SLOW_THRESHOLD_SECONDS = 2.0 diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 71c38142..4166833d 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -27,7 +27,6 @@ const cx = cn.bind(styles); interface AlertTemplatesFormProps { templates: any; onUpdateTemplates: (values: any) => void; - errors: any; alertReceiveChannelId: AlertReceiveChannel['id']; alertGroupId?: Alert['pk']; demoAlertEnabled: boolean; diff --git a/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx b/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx index f1ef2f51..bff57aff 100644 --- a/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx +++ b/grafana-plugin/src/containers/AlertTemplatesFormContainer/AlertTemplatesFormContainer.tsx @@ -6,7 +6,7 @@ import AlertTemplatesForm from 'components/AlertTemplates/AlertTemplatesForm'; import { AlertReceiveChannel } from 'models/alert_receive_channel'; import { Alert } from 'models/alertgroup/alertgroup.types'; import { useStore } from 'state/useStore'; -import { openNotification } from 'utils'; +import { openErrorNotification, openNotification } from 'utils'; interface TeamEditContainerProps { onHide: () => void; @@ -24,7 +24,6 @@ const AlertTemplatesFormContainer = observer((props: TeamEditContainerProps) => const store = useStore(); const [templatesRefreshing, setTemplatesRefreshing] = useState(false); - const [errors, setErrors] = useState({}); useEffect(() => { store.alertReceiveChannelStore.updateItem(alertReceiveChannelId); @@ -41,8 +40,12 @@ const AlertTemplatesFormContainer = observer((props: TeamEditContainerProps) => onUpdateTemplates(); } }) - .catch((data) => { - setErrors(data.response.data); + .catch((err) => { + if (err.response?.data?.length > 0) { + openErrorNotification(err.response.data); + } else { + openErrorNotification(err.message); + } }); }, [alertReceiveChannelId, onUpdateTemplates, store.alertReceiveChannelStore] @@ -64,7 +67,6 @@ const AlertTemplatesFormContainer = observer((props: TeamEditContainerProps) => { (alertGroupId ? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody) : alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody) - ).then(setResult); + ) + .then(setResult) + .catch((err) => { + if (err.response?.data?.length > 0) { + openErrorNotification(err.response.data); + } else { + openErrorNotification(err.message); + } + }); }, 1000); useEffect(handleTemplateBodyChange, [templateBody]); From 5a4fc90fa43e6feaa4a8e455f4e1224665e4361b Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 22 Nov 2022 16:57:15 +0100 Subject: [PATCH 06/48] fetch/render mobile app QR code in user settings modal --- CHANGELOG.md | 7 +- engine/apps/mobile_app/backend.py | 12 +- grafana-plugin/package.json | 1 + .../src/assets/img/brand/apple-logo.svg | 3 + .../src/assets/img/brand/play-store-logo.svg | 6 + .../{Block.module.css => Block.module.scss} | 16 +- .../src/components/GBlock/Block.tsx | 20 +- .../MobileAppVerification.module.css | 8 - .../MobileAppVerification.test.tsx | 182 + .../MobileAppVerification.tsx | 169 +- .../MobileAppVerification.test.tsx.snap | 5191 +++++++++++++++++ .../DisconnectButton.test.tsx | 28 + .../DisconnectButton.test.tsx.snap | 16 + .../parts/DisconnectButton/index.tsx | 20 + .../DownloadIcons/DownloadIcons.module.scss | 16 + .../DownloadIcons/DownloadIcons.test.tsx | 12 + .../__snapshots__/DownloadIcons.test.tsx.snap | 74 + .../parts/DownloadIcons/index.tsx | 36 + .../parts/QRCode/QRCode.test.tsx | 12 + .../QRCode/__snapshots__/QRCode.test.tsx.snap | 2221 +++++++ .../parts/QRCode/index.tsx | 17 + .../ConfigurationForm.test.tsx.snap | 2 +- .../containers/UserSettings/UserSettings.tsx | 20 +- .../containers/UserSettings/parts/index.tsx | 33 +- grafana-plugin/src/models/user/user.test.ts | 56 + grafana-plugin/src/models/user/user.ts | 14 +- grafana-plugin/src/style/vars.css | 1 + grafana-plugin/yarn.lock | 13 + 28 files changed, 8067 insertions(+), 139 deletions(-) create mode 100644 grafana-plugin/src/assets/img/brand/apple-logo.svg create mode 100644 grafana-plugin/src/assets/img/brand/play-store-logo.svg rename grafana-plugin/src/components/GBlock/{Block.module.css => Block.module.scss} (61%) delete mode 100644 grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.module.css create mode 100644 grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx create mode 100644 grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap create mode 100644 grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/index.tsx create mode 100644 grafana-plugin/src/models/user/user.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2d3f90..02530b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## v1.1.5 (2022-11-24) +### Added + +- Added a QR code in the "Mobile App Verification" tab on the user settings modal to connect the mobile application to your OnCall instance + +## v1.1.5 (2022-11-24) + ### Fixed - UI bug fixes for Grafana 9.3 ([#860](https://github.com/grafana/oncall/pull/860)) - Bug fix for saving source link template ([#898](https://github.com/grafana/oncall/pull/898)) - ## v1.1.4 (2022-11-23) ### Fixed diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py index f95633ee..1acf67b9 100644 --- a/engine/apps/mobile_app/backend.py +++ b/engine/apps/mobile_app/backend.py @@ -1,3 +1,7 @@ +import json + +from django.conf import settings + from apps.base.messaging import BaseMessagingBackend from apps.mobile_app.tasks import notify_user_async @@ -9,7 +13,6 @@ class MobileAppBackend(BaseMessagingBackend): available_for_use = True template_fields = ["title"] - # TODO: add QR code generation (base64 encode?) def generate_user_verification_code(self, user): from apps.mobile_app.models import MobileAppVerificationToken @@ -17,7 +20,12 @@ class MobileAppBackend(BaseMessagingBackend): MobileAppVerificationToken.objects.filter(user=user).delete() _, token = MobileAppVerificationToken.create_auth_token(user, user.organization) - return token + return json.dumps( + { + "token": token, + "oncall_api_url": settings.BASE_URL, + } + ) def unlink_user(self, user): from apps.mobile_app.models import MobileAppAuthToken diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 46d3cc39..78b76592 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -117,6 +117,7 @@ "react-draggable": "^4.4.5", "react-emoji-render": "^1.2.4", "react-modal": "^3.15.1", + "react-qr-code": "^2.0.8", "react-responsive": "^8.1.0", "react-router-dom": "^5.2.0", "react-sortable-hoc": "^1.11.0", diff --git a/grafana-plugin/src/assets/img/brand/apple-logo.svg b/grafana-plugin/src/assets/img/brand/apple-logo.svg new file mode 100644 index 00000000..acfaa37e --- /dev/null +++ b/grafana-plugin/src/assets/img/brand/apple-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/grafana-plugin/src/assets/img/brand/play-store-logo.svg b/grafana-plugin/src/assets/img/brand/play-store-logo.svg new file mode 100644 index 00000000..e4e5150d --- /dev/null +++ b/grafana-plugin/src/assets/img/brand/play-store-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/grafana-plugin/src/components/GBlock/Block.module.css b/grafana-plugin/src/components/GBlock/Block.module.scss similarity index 61% rename from grafana-plugin/src/components/GBlock/Block.module.css rename to grafana-plugin/src/components/GBlock/Block.module.scss index 49f4ed2d..b83e6c79 100644 --- a/grafana-plugin/src/components/GBlock/Block.module.css +++ b/grafana-plugin/src/components/GBlock/Block.module.scss @@ -1,6 +1,18 @@ .root { padding: 16px; border-radius: 2px; + + &--withBackGround { + background: var(--secondary-background); + } + + &--fullWidth { + width: 100%; + } + + &--hover:hover { + background: var(--hover-selected); + } } :global(.theme-dark) .root_bordered { @@ -14,7 +26,3 @@ :global(.theme-dark) .root_shadowed { box-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); } - -.root_with-background { - background: var(--secondary-background); -} diff --git a/grafana-plugin/src/components/GBlock/Block.tsx b/grafana-plugin/src/components/GBlock/Block.tsx index 16fd7260..f3a4dc0e 100644 --- a/grafana-plugin/src/components/GBlock/Block.tsx +++ b/grafana-plugin/src/components/GBlock/Block.tsx @@ -2,25 +2,39 @@ import React, { FC, HTMLAttributes } from 'react'; import cn from 'classnames/bind'; -import styles from './Block.module.css'; +import styles from './Block.module.scss'; interface BlockProps extends HTMLAttributes { bordered?: boolean; shadowed?: boolean; withBackground?: boolean; + hover?: boolean; + fullWidth?: boolean; } const cx = cn.bind(styles); const Block: FC = (props) => { - const { children, style, className, bordered = false, shadowed = false, withBackground = false, ...rest } = props; + const { + children, + style, + className, + bordered = false, + fullWidth = false, + hover = false, + shadowed = false, + withBackground = false, + ...rest + } = props; return (
>; + +const mockUseStore = (rest?: any, connected = false) => { + const store = { + userStore: { + currentUser: { + messaging_backends: { + MOBILE_APP: { connected }, + }, + } as unknown as User, + ...(rest ? rest : {}), + } as unknown as UserStore, + } as unknown as RootStore; + + useStore.mockReturnValue(store); + + return store; +}; + +const USER_PK = '8585'; +const BACKEND = 'MOBILE_APP'; + +describe('MobileAppVerification', () => { + test('it shows a loading message if it is currently fetching the QR code', async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + + test('it shows a message when the mobile app is already connected', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }, + true + ); + + const component = render(); + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0); + }); + + test('it shows an error message if there was an error fetching the QR code', async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockRejectedValueOnce('dfd'), + }); + + const component = render(); + await screen.findByText(/.*error fetching your QR code.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + + test("it shows a QR code if the app isn't already connected", async () => { + const { userStore } = mockUseStore({ + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + }); + + const component = render(); + await screen.findByText(/.*the QR code is only valid for one minute.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + + test('if we disconnect the app, it disconnects and fetches a new QR code', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockResolvedValueOnce('asdfadsfafds'), + }, + true + ); + + const component = render(); + + const user = userEvent.setup(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await user.click(button); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + await screen.findByText(/.*the QR code is only valid for one minute.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + + test('it shows a loading message if it is currently disconnecting', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockResolvedValueOnce('aaa'), + }, + true + ); + + const component = render(); + + const user = userEvent.setup(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await user.click(button); + // click the confirm button within the modal, which actually triggers the callback + // this is maybe a bit "hacky" but by not awaiting the below promise it allows us to check the loading state.. + user.click(screen.getByText('Remove')); + + // wait for loading state + await screen.findByText(/.*Loading.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(1); + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledWith(USER_PK, BACKEND); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); + + test('it shows an error message if there was an error disconnecting the mobile app', async () => { + const { userStore } = mockUseStore( + { + sendBackendConfirmationCode: jest.fn().mockResolvedValueOnce('dfd'), + unlinkBackend: jest.fn().mockRejectedValueOnce('asdfadsfafds'), + }, + true + ); + + const component = render(); + + const user = userEvent.setup(); + const button = await screen.findByRole('button'); + + // click the disconnect button, which opens the modal + await user.click(button); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + await screen.findByText(/.*error disconnecting your mobile app.*/); + + expect(component.container).toMatchSnapshot(); + + expect(userStore.sendBackendConfirmationCode).toHaveBeenCalledTimes(0); + + expect(userStore.unlinkBackend).toHaveBeenCalledTimes(1); + expect(userStore.unlinkBackend).toHaveBeenCalledWith(USER_PK, BACKEND); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx index 5413c270..cdaf5af2 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -1,104 +1,107 @@ -import React, { HTMLAttributes, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; -import { Button, LoadingPlaceholder } from '@grafana/ui'; -import cn from 'classnames/bind'; +import { HorizontalGroup, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { observer } from 'mobx-react'; +import Block from 'components/GBlock/Block'; import Text from 'components/Text/Text'; -import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; -import styles from './MobileAppVerification.module.css'; +import DisconnectButton from './parts/DisconnectButton'; +import DownloadIcons from './parts/DownloadIcons'; +import QRCode from './parts/QRCode'; -const cx = cn.bind(styles); +type Props = { + userPk: User['pk']; +}; -interface MobileAppVerificationProps extends HTMLAttributes { - userPk?: User['pk']; - phone?: string; -} +const BACKEND = 'MOBILE_APP'; -const MobileAppVerification = observer((props: MobileAppVerificationProps) => { - const { userPk: propsUserPk } = props; +const MobileAppVerification = observer(({ userPk }: Props) => { + const { userStore } = useStore(); - const store = useStore(); - const { userStore } = store; + const [mobileAppIsCurrentlyConnected, setMobileAppIsCurrentlyConnected] = useState( + userStore.currentUser.messaging_backends[BACKEND]?.connected === true + ); - const userPk = (propsUserPk || userStore.currentUserPk) as User['pk']; - const user = userStore.items[userPk as User['pk']]; - const isCurrent = userStore.currentUserPk === user.pk; - const action = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const [fetchingQRCode, setFetchingQRCode] = useState(!mobileAppIsCurrentlyConnected); + const [QRCodeValue, setQRCodeValue] = useState(null); + const [errorFetchingQRCode, setErrorFetchingQRCode] = useState(null); - const [showMobileAppVerificationToken, setShowMobileAppVerificationToken] = useState(undefined); - const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState(false); - const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState(true); + const [disconnectingMobileApp, setDisconnectingMobileApp] = useState(false); + const [errorDisconnectingMobileApp, setErrorDisconnectingMobileApp] = useState(null); - const handleCreateMobileAppVerificationToken = async () => { - setIsMobileAppVerificationTokenExisting(true); - await userStore - .sendBackendConfirmationCode(userPk, 'MOBILE_APP') - .then((res) => setShowMobileAppVerificationToken(res)); - }; + const fetchQRCode = useCallback(async () => { + setFetchingQRCode(true); + try { + // backend verification code that we receive is a JSON object that has been "stringified" + const qrCodeContent = await userStore.sendBackendConfirmationCode(userPk, BACKEND); + setQRCodeValue(qrCodeContent); + } catch (e) { + setErrorFetchingQRCode('There was an error fetching your QR code. Please try again.'); + } + setFetchingQRCode(false); + }, [userPk]); - useEffect(() => { - handleCreateMobileAppVerificationToken().then(() => { - setMobileAppVerificationTokenLoading(false); - }); + const resetState = useCallback(() => { + setErrorDisconnectingMobileApp(null); + setMobileAppIsCurrentlyConnected(false); + setQRCodeValue(null); }, []); + const disconnectMobileApp = useCallback(async () => { + setDisconnectingMobileApp(true); + + try { + await userStore.unlinkBackend(userPk, BACKEND); + resetState(); + } catch (e) { + setErrorDisconnectingMobileApp('There was an error disconnecting your mobile app. Please try again.'); + } + setDisconnectingMobileApp(false); + }, [userPk, resetState]); + + useEffect(() => { + if (!mobileAppIsCurrentlyConnected) { + fetchQRCode(); + } + }, [mobileAppIsCurrentlyConnected]); + + let content: React.ReactNode = null; + + if (fetchingQRCode || disconnectingMobileApp) { + content = ; + } else if (errorFetchingQRCode || errorDisconnectingMobileApp) { + content = {errorFetchingQRCode || errorDisconnectingMobileApp}; + } else if (mobileAppIsCurrentlyConnected) { + content = ( + + Your mobile app is currently connected. Click below to disconnect. + + + ); + } else if (QRCodeValue) { + content = ( + + + + Note: the QR code is only valid for one minute. If you have issues connecting your mobile app, try refreshing + this page to generate a new code. + + + ); + } + return ( -
- {MobileAppVerificationTokenLoading ? ( - - ) : ( - <> -

- Open Grafana OnCall mobile application and enter the following code to add the new device: -

- {isMobileAppVerificationTokenExisting ? ( - <> - {showMobileAppVerificationToken !== undefined ? ( - <> -

{showMobileAppVerificationToken}

-

- * This code is active only for a minute -

-

- - - -

- - ) : ( - <> - )} - - ) : ( -

- - - -

- )} -

- * Only iOS is currently supported -

- - )} -
+ + + {content} + + + + + ); }); diff --git a/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap new file mode 100644 index 00000000..bb4670cf --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap @@ -0,0 +1,5191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 1`] = ` +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Note: the QR code is only valid for one minute. If you have issues connecting your mobile app, try refreshing this page to generate a new code. + +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a QR code if the app isn't already connected 1`] = ` +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Note: the QR code is only valid for one minute. If you have issues connecting your mobile app, try refreshing this page to generate a new code. + +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = ` +
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently fetching the QR code 1`] = ` +
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a message when the mobile app is already connected 1`] = ` +
+
+
+
+
+
+ + Your mobile app is currently connected. Click below to disconnect. + +
+
+ +
+
+
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 1`] = ` +
+
+
+
+ + There was an error disconnecting your mobile app. Please try again. + +
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; + +exports[`MobileAppVerification it shows an error message if there was an error fetching the QR code 1`] = ` +
+
+
+
+ + There was an error fetching your QR code. Please try again. + +
+
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx new file mode 100644 index 00000000..b660d5af --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DisconnectButton from '.'; + +describe('DisconnectButton', () => { + test('it renders properly', () => { + const component = render( {}} />); + expect(component.container).toMatchSnapshot(); + }); + + test('It calls the onClick handler when clicked', async () => { + const mockedOnClick = jest.fn(); + + const user = userEvent.setup(); + render(); + + // click the button, which opens the modal + await user.click(screen.getByRole('button')); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + expect(mockedOnClick).toHaveBeenCalledWith(); + expect(mockedOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap new file mode 100644 index 00000000..88ffb190 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DisconnectButton it renders properly 1`] = ` +
+ +
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx new file mode 100644 index 00000000..ec108f47 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +import { Button } from '@grafana/ui'; + +import WithConfirm from 'components/WithConfirm/WithConfirm'; + +type Props = { + onClick: () => void; +}; + +// TODO: right now this shows a confirmation pop-up modal on top of the user settings modal, do we want to maybe change this? +const DisconnectButton: FC = ({ onClick }) => ( + + + +); + +export default DisconnectButton; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss new file mode 100644 index 00000000..6f41181e --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.module.scss @@ -0,0 +1,16 @@ +.icon { + width: 25px; + height: auto; + margin-right: 12px; +} + +.icon-text, +.icon { + cursor: default; +} + +.icon-block { + display: flex; + align-items: center; + min-height: 80px; +} diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx new file mode 100644 index 00000000..bc2011e3 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/DownloadIcons.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import DownloadIcons from './'; + +describe('DownloadIcons', () => { + test('it renders properly', () => { + const component = render(); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap new file mode 100644 index 00000000..63cd0899 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DownloadIcons it renders properly 1`] = ` +
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx new file mode 100644 index 00000000..79b54f2e --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DownloadIcons/index.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; + +import { VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import AppleLogoSVG from 'assets/img/brand/apple-logo.svg'; +import PlayStoreLogoSVG from 'assets/img/brand/play-store-logo.svg'; +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; + +import styles from './DownloadIcons.module.scss'; + +const cx = cn.bind(styles); + +const DownloadIcons: FC = () => ( + + Download + The Grafana IRM app is available on both the App Store and Google Play Store. + + + Apple + + iOS + + + + Play Store + + Android + + + + +); + +export default DownloadIcons; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx new file mode 100644 index 00000000..8c3e1554 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import QRCode from './'; + +describe('QRCode', () => { + test('it renders properly', () => { + const component = render(); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap new file mode 100644 index 00000000..511496e9 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/__snapshots__/QRCode.test.tsx.snap @@ -0,0 +1,2221 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QRCode it renders properly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/index.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/index.tsx new file mode 100644 index 00000000..98ec3561 --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/index.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react'; + +import QRCodeBase from 'react-qr-code'; + +import Block from 'components/GBlock/Block'; + +type Props = { + value: string; +}; + +const QRCode: FC = ({ value }) => ( + + + +); + +export default QRCode; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap index 08fcbc9e..8d36ea17 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap @@ -209,7 +209,7 @@ exports[`ConfigurationForm It shows an error message if the self hosted plugin A
@@ -75,7 +79,7 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U showNotificationSettingsTab={showNotificationSettingsTab} showSlackConnectionTab={showSlackConnectionTab} showTelegramConnectionTab={showTelegramConnectionTab} - showMobileAppVerificationTab={showMobileAppVerificationTab} + showMobileAppVerificationTab={true} />
diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 24221f4b..9c486a58 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -30,16 +30,14 @@ interface TabsProps { showTelegramConnectionTab: boolean; } -export const Tabs = (props: TabsProps) => { - const { - activeTab, - onTabChange, - showNotificationSettingsTab, - showMobileAppVerificationTab, - showSlackConnectionTab, - showTelegramConnectionTab, - } = props; - +export const Tabs = ({ + activeTab, + onTabChange, + showNotificationSettingsTab, + showMobileAppVerificationTab, + showSlackConnectionTab, + showTelegramConnectionTab, +}: TabsProps) => { const getTabClickHandler = useCallback( (tab: UserSettingsTab) => { return () => { @@ -106,17 +104,13 @@ interface TabsContentProps { isDesktopOrLaptop: boolean; } -export const TabsContent = observer((props: TabsContentProps) => { - const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; +export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLaptop }: TabsContentProps) => { + const store = useStore(); + useEffect(() => { store.updateFeatures(); }, []); - const store = useStore(); - const { userStore } = store; - - const storeUser = userStore.items[id]; - return ( {activeTab === UserSettingsTab.UserInfo && @@ -139,9 +133,8 @@ export const TabsContent = observer((props: TabsContentProps) => { ) : ( ))} - {activeTab === UserSettingsTab.MobileAppVerification && ( - - )} + {/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */} + {activeTab === UserSettingsTab.MobileAppVerification && } {activeTab === UserSettingsTab.SlackInfo && } {activeTab === UserSettingsTab.TelegramInfo && } diff --git a/grafana-plugin/src/models/user/user.test.ts b/grafana-plugin/src/models/user/user.test.ts new file mode 100644 index 00000000..a8f0b681 --- /dev/null +++ b/grafana-plugin/src/models/user/user.test.ts @@ -0,0 +1,56 @@ +import { makeRequest as makeRequestOriginal } from 'network'; +import { RootStore } from 'state'; + +import { UserStore } from './user'; + +const makeRequest = makeRequestOriginal as jest.Mock>; + +jest.mock('network'); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('UserStore.sendBackendConfirmationCode', () => { + const rootStore = new RootStore(); + const userStore = new UserStore(rootStore); + + const userPk = '5'; + const backend = 'dfkjfdjkfdkjfdaaa'; + const mockedQrCode = 'dfkjfdkjfdkjfdjk'; + + test('it makes the proper API call and returns the response', async () => { + makeRequest.mockResolvedValueOnce(mockedQrCode); + + expect(await userStore.sendBackendConfirmationCode(userPk, backend)).toEqual(mockedQrCode); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, { + method: 'GET', + }); + }); +}); + +describe('UserStore.unlinkBackend', () => { + const rootStore = new RootStore(); + const userStore = new UserStore(rootStore); + + const userPk = '5'; + const backend = 'dfkjfdjkfdkjfdaaa'; + + test('it makes the proper API call and returns the response', async () => { + makeRequest.mockResolvedValueOnce('hello'); + + userStore.loadCurrentUser = jest.fn(); + + await userStore.unlinkBackend(userPk, backend); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`/users/${userPk}/unlink_backend/?backend=${backend}`, { + method: 'POST', + }); + + expect(userStore.loadCurrentUser).toHaveBeenCalledTimes(1); + expect(userStore.loadCurrentUser).toHaveBeenCalledWith(); + }); +}); diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 8d7ea002..edcea94d 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -82,11 +82,6 @@ export class UserStore extends BaseStore { }; } - @action - getCurrentUser() { - return this.items[this.currentUserPk as User['pk']]; - } - @action async updateItem(userPk: User['pk']) { if (this.itemsCurrentlyUpdating[userPk]) { @@ -144,10 +139,6 @@ export class UserStore extends BaseStore { return await makeRequest(`/users/${userPk}/get_telegram_verification_code/`, {}); }; - sendBackendConfirmationCode = async (userPk: User['pk'], backend: string) => { - return await makeRequest(`/users/${userPk}/get_backend_verification_code/?backend=${backend}`, {}); - }; - @action unlinkSlack = async (userPk: User['pk']) => { await makeRequest(`/users/${userPk}/unlink_slack/`, { @@ -176,6 +167,11 @@ export class UserStore extends BaseStore { }; }; + sendBackendConfirmationCode = (userPk: User['pk'], backend: string) => + makeRequest(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, { + method: 'GET', + }); + @action unlinkBackend = async (userPk: User['pk'], backend: string) => { await makeRequest(`/users/${userPk}/unlink_backend/?backend=${backend}`, { diff --git a/grafana-plugin/src/style/vars.css b/grafana-plugin/src/style/vars.css index 8204231a..bb0f2853 100644 --- a/grafana-plugin/src/style/vars.css +++ b/grafana-plugin/src/style/vars.css @@ -34,6 +34,7 @@ --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); --oncall-icon-stroke-color: #fff; + --hover-selected: #f4f5f5; --background-canvas: #f4f5f5; --background-primary: #fff; --background-secondary: #f4f5f5; diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index a11f7b61..1d3a045f 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -10597,6 +10597,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ== + query-string@*: version "7.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1" @@ -11062,6 +11067,14 @@ react-popper@2.3.0, react-popper@^2.3.0: react-fast-compare "^3.0.1" warning "^4.0.2" +react-qr-code@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.8.tgz#d34a766fb5b664a40dbdc7020f7ac801bacb2851" + integrity sha512-zYO9EAPQU8IIeD6c6uAle7NlKOiVKs8ji9hpbWPTGxO+FLqBN2on+XCXQvnhm91nrRd306RvNXUkUNcXXSfhWA== + dependencies: + prop-types "^15.8.1" + qr.js "0.0.0" + react-redux@^7.2.0: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" From 3198612c651e5b9bb628ea2f2dde148e1814ccfc Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Tue, 29 Nov 2022 11:16:42 +0800 Subject: [PATCH 07/48] Add flag to debug logs (#912) --- dev/README.md | 7 ++++++- engine/celery_with_exporter.sh | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/dev/README.md b/dev/README.md index 9f858e3c..48603d48 100644 --- a/dev/README.md +++ b/dev/README.md @@ -75,7 +75,12 @@ By default everything runs inside Docker. If you would like to run the backend s 1. Create a Python 3.9 virtual environment using a method of your choosing (ex. [venv](https://docs.python.org/3.9/library/venv.html) or [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv)). Make sure the virtualenv is "activated". 2. `postgres` is a dependency on some of our Python dependencies (notably `psycopg2` ([docs](https://www.psycopg.org/docs/install.html#prerequisites))). Please visit [here](https://www.postgresql.org/download/) for installation instructions. 3. `make backend-bootstrap` - installs all backend dependencies -4. Modify your `.env.dev` by copying the contents of one of `.env.mysql.dev`, `.env.postgres.dev`, or `.env.sqlite.dev` into `.env.dev` (you should exclude the `GF_` prefixed environment variables). In most cases where you are running stateful services via `docker-compose` and backend services outside of docker you will simply need to change the database host to `localhost` (or in the case of `sqlite` update the file-path to your `sqlite` database file). +4. Modify your `.env.dev` by copying the contents of one of `.env.mysql.dev`, `.env.postgres.dev`, +or `.env.sqlite.dev` into `.env.dev` (you should exclude the `GF_` prefixed environment variables). + > In most cases where you are running stateful services via `docker-compose` + and backend services outside of docker you will simply need to change the database host to `localhost` + (or in the case of `sqlite` update the file-path to your `sqlite` database file). You will need to change + the broker host to `localhost` as well. 5. `make backend-migrate` - runs necessary database migrations 6. Open two separate shells and then run the following: diff --git a/engine/celery_with_exporter.sh b/engine/celery_with_exporter.sh index abc33691..8d7da630 100755 --- a/engine/celery_with_exporter.sh +++ b/engine/celery_with_exporter.sh @@ -28,7 +28,6 @@ CELERY_ARGS=( "--quiet" # --quite parameter removes pointless banner when celery starts "-A" "engine" "worker" - "-l" "info" "--concurrency=$CELERY_WORKER_CONCURRENCY" "--max-tasks-per-child=$CELERY_WORKER_MAX_TASKS_PER_CHILD" "-Q" "$CELERY_WORKER_QUEUE" @@ -45,5 +44,10 @@ fi if [[ $CELERY_WORKER_WITHOUT_HEARTBEAT = True ]]; then CELERY_ARGS+=("--without-heartbeat") fi +if [[ $CELERY_WORKER_DEBUG_LOGS = True ]]; then + CELERY_ARGS+=("-l" "debug") +else + CELERY_ARGS+=("-l" "info") +fi celery "${CELERY_ARGS[@]}" From 132cf1da7f7c15a8cf4e183ee150c9f4b4d007d5 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Tue, 29 Nov 2022 16:20:41 +0800 Subject: [PATCH 08/48] Add celery profiling (#913) --- engine/engine/celery.py | 25 +++++++++++++++++++++++-- engine/requirements.txt | 1 + engine/settings/base.py | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/engine/engine/celery.py b/engine/engine/celery.py index d21e968c..c023eb6e 100644 --- a/engine/engine/celery.py +++ b/engine/engine/celery.py @@ -1,13 +1,20 @@ +import logging import os +import time import celery from celery.app.log import TaskFormatter +from celery.utils.debug import memdump, sample_mem +from celery.utils.log import get_task_logger +from django.conf import settings -# set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.prod") from django.db import connection # noqa: E402 +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + connection.cursor() from celery import Celery # noqa: E402 @@ -38,6 +45,20 @@ def on_after_setup_logger(logger, **kwargs): for handler in logger.handlers: handler.setFormatter( TaskFormatter( - "%(asctime)s source=engine:celery task_id=%(task_id)s task_name=%(task_name)s name=%(name)s level=%(levelname)s %(message)s" + "%(asctime)s source=engine:celery worker=%(processName)s task_id=%(task_id)s task_name=%(task_name)s name=%(name)s level=%(levelname)s %(message)s" ) ) + + +if settings.DEBUG_CELERY_TASKS_PROFILING: + + @celery.signals.task_prerun.connect + def start_task_timer(task_id=None, task=None, *a, **kw): + logger.info("started: {} of {} with cpu={} at {}".format(task_id, task.name, time.perf_counter(), time.time())) + sample_mem() + + @celery.signals.task_postrun.connect + def finish_task_timer(task_id=None, task=None, *a, **kw): + logger.info("ended: {} of {} with cpu={} at {}".format(task_id, task.name, time.perf_counter(), time.time())) + sample_mem() + memdump() diff --git a/engine/requirements.txt b/engine/requirements.txt index 3c5463f1..5e41b01d 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -41,3 +41,4 @@ psycopg2-binary==2.9.3 emoji==1.7.0 apns2==0.7.2 regex==2021.11.2 +psutil==5.9.4 diff --git a/engine/settings/base.py b/engine/settings/base.py index 1b1e9412..bbd17936 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -39,6 +39,8 @@ MIRAGE_CIPHER_MODE = "CBC" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False +DEBUG_CELERY_TASKS_PROFILING = getenv_boolean("DEBUG_CELERY_TASKS_PROFILING", False) + ALLOWED_HOSTS = [item.strip() for item in os.environ.get("ALLOWED_HOSTS", "*").split(",")] # TODO: update link to up-to-date docs From 9e598385f492df6d7fd336c0ef441762fc4f3a72 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 29 Nov 2022 09:41:56 +0100 Subject: [PATCH 09/48] Add RBAC Support (#777) * Modify plugin.json to support RBAC role registration * defines 26 new custom roles in plugin.json. The main roles are: - Admin: read/write access to everything in OnCall - Reader: read access to everything in OnCall - OnCaller : read access to everything in OnCall + edit access to Alert Groups and Schedules - Editor: read/write access to everything related to - Reader: read access for - User Settings Admin: read/write access to all user's settings, not just own settings. This is in comparison to User Settings Editor which can only read/write own settings * update changelog and documentation (#686) * implement RBAC for OnCall backend This commit refactors backend authorization. It trys to use RBAC authorization if the org's grafana instance supports it, otherwise it falls back to basic role authorization. * update RBAC backend tests * add tests for RBAC changes - run backend tests as matrix where RBAC is enabled/disabled. When RBAC is enabled, the permissions granted are read from the role grants in the frontend's plugin.json file (instead of relying what we specify in RBACPermission.Permissions) - remove --reuse-db --nomigrations flags from engine/tox.ini - minor autoformatting changes to docker-compose-developer.yml * remove --ds=settings.ci-test from pytest CI command DJANGO_SETTINGS_MODULE is already specified as an env var so this is just unecessary duplication * update gitignore * update github action job name for "test" * RBAC frontend changes * refactors the use of basic roles (ex. Viewer, Editor, Admin) use RBAC permissions (when supported), or falling back to basic roles when RBAC is not supported. - updates the UserAction enum in grafana-plugin/src/state/userAction.ts. Previously this was hardcoded to a list of strings that were being returned by the OnCall API. Now the values here correspond to the permissions in plugin.json (plus a fallback role) * changes per Gabriel's comments: - get rid of group attribute in rbac roles - remove displayName role attribute - remove hidden role attribute - add back role to includes section * don't try to update user timezone if they don't have permission --- .github/workflows/ci.yml | 22 +- CHANGELOG.md | 9 +- docker-compose-developer.yml | 3 + engine/apps/alerts/tasks/notify_user.py | 10 +- engine/apps/alerts/tests/test_alert_group.py | 5 +- .../tests/test_escalation_policy_snapshot.py | 14 +- engine/apps/alerts/tests/test_notify_user.py | 26 +- engine/apps/api/permissions/__init__.py | 299 ++++- engine/apps/api/permissions/actions.py | 27 - engine/apps/api/permissions/constants.py | 14 - engine/apps/api/permissions/methods.py | 12 - engine/apps/api/permissions/owner.py | 24 - engine/apps/api/permissions/roles.py | 49 - .../apps/api/permissions/test_permissions.py | 428 +++++++ engine/apps/api/serializers/user.py | 23 +- engine/apps/api/tests/test_alert_group.py | 229 ++-- .../api/tests/test_alert_receive_channel.py | 72 +- .../test_alert_receive_channel_template.py | 30 +- engine/apps/api/tests/test_channel_filter.py | 45 +- engine/apps/api/tests/test_custom_button.py | 33 +- .../apps/api/tests/test_escalation_chain.py | 2 +- .../apps/api/tests/test_escalation_policy.py | 50 +- engine/apps/api/tests/test_gitops.py | 18 +- .../api/tests/test_integration_heartbeat.py | 83 +- engine/apps/api/tests/test_maintenance.py | 8 +- engine/apps/api/tests/test_oncall_shift.py | 88 +- engine/apps/api/tests/test_organization.py | 56 +- .../api/tests/test_postmortem_messages.py | 42 +- .../apps/api/tests/test_public_api_tokens.py | 115 ++ engine/apps/api/tests/test_schedule_export.py | 38 +- engine/apps/api/tests/test_schedules.py | 84 +- .../api/tests/test_set_general_log_channel.py | 13 +- engine/apps/api/tests/test_slack_channels.py | 25 +- .../api/tests/test_slack_team_settings.py | 34 +- engine/apps/api/tests/test_subscription.py | 8 +- engine/apps/api/tests/test_team.py | 18 +- .../apps/api/tests/test_telegram_channel.py | 39 +- .../apps/api/tests/test_terraform_renderer.py | 2 +- engine/apps/api/tests/test_user.py | 1032 +++++++---------- engine/apps/api/tests/test_user_groups.py | 13 +- .../tests/test_user_notification_policy.py | 8 +- .../api/tests/test_user_schedule_export.py | 69 +- engine/apps/api/views/alert_group.py | 46 +- .../apps/api/views/alert_receive_channel.py | 32 +- .../views/alert_receive_channel_template.py | 13 +- engine/apps/api/views/channel_filter.py | 18 +- engine/apps/api/views/custom_button.py | 16 +- engine/apps/api/views/escalation_chain.py | 16 +- engine/apps/api/views/escalation_policy.py | 24 +- .../apps/api/views/integration_heartbeat.py | 17 +- engine/apps/api/views/live_setting.py | 11 +- engine/apps/api/views/maintenance.py | 18 +- engine/apps/api/views/on_call_shifts.py | 19 +- engine/apps/api/views/organization.py | 27 +- engine/apps/api/views/public_api_tokens.py | 13 +- engine/apps/api/views/resolution_note.py | 15 +- engine/apps/api/views/schedule.py | 38 +- engine/apps/api/views/slack_team_settings.py | 10 +- engine/apps/api/views/telegram_channels.py | 13 +- engine/apps/api/views/user.py | 85 +- .../api/views/user_notification_policy.py | 43 +- engine/apps/auth_token/auth.py | 4 +- engine/apps/base/constants.py | 23 - .../user_notification_policy_log_record.py | 8 +- engine/apps/grafana_plugin/helpers/client.py | 106 +- engine/apps/grafana_plugin/helpers/gcom.py | 7 +- engine/apps/grafana_plugin/tasks/sync.py | 4 +- .../tests/test_grafana_api_client.py | 60 + .../tests/test_self_hosted_install.py | 8 + engine/apps/grafana_plugin/tests/test_sync.py | 4 +- .../views/self_hosted_install.py | 6 +- .../models/cloud_connector.py | 3 +- .../views/cloud_connection.py | 8 +- .../oss_installation/views/cloud_heartbeat.py | 7 +- .../oss_installation/views/cloud_users.py | 38 +- engine/apps/public_api/serializers/users.py | 16 +- engine/apps/public_api/tests/test_users.py | 15 +- engine/apps/public_api/views/users.py | 6 +- engine/apps/schedules/ical_utils.py | 11 +- .../apps/schedules/tests/test_ical_utils.py | 16 +- .../schedules/tests/test_on_call_schedule.py | 6 +- .../apps/slack/models/slack_team_identity.py | 9 +- engine/apps/slack/models/slack_usergroup.py | 6 +- .../slack/scenarios/alertgroup_appearance.py | 4 +- .../apps/slack/scenarios/distribute_alerts.py | 27 +- engine/apps/slack/scenarios/scenario_step.py | 10 - engine/apps/slack/scenarios/step_mixins.py | 10 +- engine/apps/slack/tests/test_reset_slack.py | 12 +- engine/apps/slack/views.py | 8 +- .../updates/update_handlers/button_press.py | 5 +- .../migrations/0005_rbac_permissions.py | 23 + .../user_management/models/organization.py | 1 + engine/apps/user_management/models/user.py | 59 +- engine/apps/user_management/sync.py | 34 +- ...t_free_public_beta_subcription_strategy.py | 14 +- .../apps/user_management/tests/test_sync.py | 24 +- .../apps/user_management/tests/test_user.py | 16 +- engine/common/constants/role.py | 11 - engine/conftest.py | 121 +- engine/tox.ini | 2 +- grafana-plugin/package.json | 2 +- .../src/__mocks__/grafana/app/core/core.ts | 3 - .../AlertTemplates/AlertTemplatesForm.tsx | 6 +- .../NewScheduleSelector.tsx | 4 +- .../components/Policy/EscalationPolicy.tsx | 28 +- .../components/Policy/NotificationPolicy.tsx | 2 +- .../Unauthorized/Unauthorized.module.css | 9 + .../Unauthorized/Unauthorized.test.tsx | 48 + .../__snapshots__/Unauthorized.test.tsx.snap | 241 ++++ .../src/components/Unauthorized/index.tsx | 40 + .../src/components/UserGroups/UserGroups.tsx | 2 +- .../components/UsersFilters/UsersFilters.tsx | 42 +- .../src/containers/AlertRules/AlertRules.tsx | 32 +- .../parts/connectors/SlackConnector.tsx | 15 +- .../parts/connectors/TelegramConnector.tsx | 6 +- .../ApiTokenSettings/ApiTokenSettings.tsx | 8 +- .../AttachIncidentForm/AttachIncidentForm.tsx | 4 +- .../DefaultPageLayout/DefaultPageLayout.tsx | 4 +- .../EscalationChainSteps.tsx | 4 +- .../GrafanaTeamSelect/GrafanaTeamSelect.tsx | 4 +- .../HeartbeatModal/HeartbeatForm.tsx | 6 +- .../IntegrationSettings/parts/Autoresolve.tsx | 4 +- .../MaintenanceForm/MaintenanceForm.tsx | 4 +- .../OutgoingWebhookForm.tsx | 4 +- .../PersonalNotificationSettings.tsx | 4 +- .../src/containers/Rotations/Rotations.tsx | 4 +- .../Rotations/ScheduleOverrides.tsx | 4 +- .../containers/ScheduleForm/ScheduleForm.tsx | 4 +- .../SlackIntegrationButton.tsx | 6 +- .../TelegramIntegrationButton.tsx | 4 +- .../UserSettings/UserSettings.types.ts | 3 +- .../parts/connectors/ICalConnector.tsx | 6 +- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 4 +- .../PhoneVerification/PhoneVerification.tsx | 6 +- .../parts/tabs/UserInfoTab/UserInfoTab.tsx | 7 +- .../WithPermissionControl.tsx | 2 +- .../WithPermissionControl.tsx | 2 +- grafana-plugin/src/index.d.ts | 8 +- grafana-plugin/src/models/team/team.types.ts | 2 - grafana-plugin/src/models/user.ts | 6 - grafana-plugin/src/models/user/user.config.ts | 10 - .../src/models/user/user.helpers.tsx | 28 +- grafana-plugin/src/models/user/user.ts | 9 +- grafana-plugin/src/models/user/user.types.ts | 9 - .../escalation-chains/EscalationChains.tsx | 10 +- .../src/pages/incident/Incident.helpers.tsx | 12 +- .../src/pages/incident/Incident.tsx | 6 +- .../src/pages/incidents/Incidents.tsx | 10 +- .../pages/incidents/parts/SilenceDropdown.tsx | 4 +- grafana-plugin/src/pages/index.tsx | 16 +- .../src/pages/integrations/Integrations.tsx | 6 +- .../src/pages/maintenance/Maintenance.tsx | 6 +- .../outgoing_webhooks/OutgoingWebhooks.tsx | 10 +- .../src/pages/schedule/Schedule.tsx | 4 +- .../src/pages/schedules/Schedules.tsx | 8 +- .../src/pages/settings/SettingsPage.tsx | 9 +- .../tabs/SlackSettings/SlackSettings.tsx | 14 +- .../pages/settings/tabs/Cloud/CloudPage.tsx | 1 + .../tabs/LiveSettings/LiveSettingsPage.tsx | 6 +- .../tabs/MainSettings/MainSettings.tsx | 4 +- grafana-plugin/src/pages/test/Test.tsx | 4 +- .../src/pages/users/Users.helpers.ts | 14 +- grafana-plugin/src/pages/users/Users.tsx | 38 +- grafana-plugin/src/plugin.json | 420 +++++++ .../src/plugin/GrafanaPluginRootPage.tsx | 10 +- .../src/state/rootBaseStore/index.ts | 14 +- .../state/rootBaseStore/rootBaseStore.test.ts | 27 +- grafana-plugin/src/state/userAction.ts | 23 - grafana-plugin/src/types.ts | 7 +- .../utils/authorization/authorization.test.ts | 76 ++ .../src/utils/authorization/index.ts | 156 +++ grafana-plugin/yarn.lock | 608 +++++++--- 172 files changed, 4424 insertions(+), 2194 deletions(-) delete mode 100644 engine/apps/api/permissions/actions.py delete mode 100644 engine/apps/api/permissions/constants.py delete mode 100644 engine/apps/api/permissions/methods.py delete mode 100644 engine/apps/api/permissions/owner.py delete mode 100644 engine/apps/api/permissions/roles.py create mode 100644 engine/apps/api/permissions/test_permissions.py create mode 100644 engine/apps/api/tests/test_public_api_tokens.py delete mode 100644 engine/apps/base/constants.py create mode 100644 engine/apps/grafana_plugin/tests/test_grafana_api_client.py create mode 100644 engine/apps/user_management/migrations/0005_rbac_permissions.py delete mode 100644 engine/common/constants/role.py delete mode 100644 grafana-plugin/src/__mocks__/grafana/app/core/core.ts create mode 100644 grafana-plugin/src/components/Unauthorized/Unauthorized.module.css create mode 100644 grafana-plugin/src/components/Unauthorized/Unauthorized.test.tsx create mode 100644 grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap create mode 100644 grafana-plugin/src/components/Unauthorized/index.tsx delete mode 100644 grafana-plugin/src/models/user/user.config.ts delete mode 100644 grafana-plugin/src/state/userAction.ts create mode 100644 grafana-plugin/src/utils/authorization/authorization.test.ts create mode 100644 grafana-plugin/src/utils/authorization/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8eda99f..908f4d48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: | pre-commit run --all-files - test: + test-frontend: runs-on: ubuntu-latest container: python:3.9 steps: @@ -56,8 +56,12 @@ jobs: docker run -v ${PWD}/docs/sources:/hugo/content/docs/oncall/latest -e HUGO_REFLINKSERRORLEVEL=ERROR --rm grafana/docs-base:latest /bin/bash -c 'make hugo' unit-test-backend-mysql-rabbitmq: + name: "Backend Tests: MySQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest container: python:3.9 + strategy: + matrix: + rbac_enabled: ["True", "False"] env: DJANGO_SETTINGS_MODULE: settings.ci-test SLACK_CLIENT_OAUTH_ID: 1 @@ -72,7 +76,6 @@ jobs: env: MYSQL_DATABASE: oncall_local_dev MYSQL_ROOT_PASSWORD: local_dev_pwd - steps: - uses: actions/checkout@v2 - name: Unit Test Backend @@ -80,11 +83,15 @@ jobs: apt-get update && apt-get install -y netcat cd engine/ pip install -r requirements.txt - ./wait_for_test_mysql_start.sh && pytest --ds=settings.ci-test -x + ./wait_for_test_mysql_start.sh && ONCALL_TESTING_RBAC_ENABLED=${{ matrix.rbac_enabled }} pytest -x unit-test-backend-postgresql-rabbitmq: + name: "Backend Tests: PostgreSQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest container: python:3.9 + strategy: + matrix: + rbac_enabled: ["True", "False"] env: DATABASE_TYPE: postgresql DJANGO_SETTINGS_MODULE: settings.ci-test @@ -112,11 +119,15 @@ jobs: run: | cd engine/ pip install -r requirements.txt - pytest --ds=settings.ci-test -x + ONCALL_TESTING_RBAC_ENABLED=${{ matrix.rbac_enabled }} pytest -x unit-test-backend-sqlite-redis: + name: "Backend Tests: SQLite + Redis (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest container: python:3.9 + strategy: + matrix: + rbac_enabled: ["True", "False"] env: DATABASE_TYPE: sqlite3 BROKER_TYPE: redis @@ -131,7 +142,6 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - uses: actions/checkout@v2 - name: Unit Test Backend @@ -139,7 +149,7 @@ jobs: apt-get update && apt-get install -y netcat cd engine/ pip install -r requirements.txt - pytest --ds=settings.ci-test -x + ONCALL_TESTING_RBAC_ENABLED=${{ matrix.rbac_enabled }} pytest -x docker-build: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index e635768e..9ca8d4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,21 @@ 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.6 (TBD) +## v1.2.0 (TBD) + +### Added + +- RBAC permission support ### Fixed + - Got 500 error when saving Outgoing Webhook ([#890](https://github.com/grafana/oncall/issues/890)) ### Changed + - When editing templates for alert group presentation or outgoing webhooks, errors and warnings are now displayed in the UI as notification popups or displayed in the preview. - Errors and warnings that occur when rendering templates during notification or webhooks will now render and display the error/warning as the result. + ## v1.1.5 (2022-11-24) ### Added diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index e8208bbb..df98806c 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -13,6 +13,9 @@ x-oncall-volumes: &oncall-volumes # https://stackoverflow.com/a/60456034 - ${ENTERPRISE_ENGINE:-/dev/null}:/etc/app/extensions/engine_enterprise - ${SQLITE_DB_FILE:-/dev/null}:/var/lib/oncall/oncall.db + # this is mounted for testing purposes. Some of the authorization tests + # reference this file + - ./grafana-plugin/src/plugin.json:/etc/grafana-plugin/src/plugin.json x-env-files: &oncall-env-files - ./dev/.env.dev diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index b841aa3f..3fbca1af 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -53,13 +53,13 @@ def notify_user_task( organization = alert_group.channel.organization if not user.is_notification_allowed: - task_logger.info(f"notify_user_task: user {user.pk} notification is not allowed for role {user.role}") + task_logger.info(f"notify_user_task: user {user.pk} notification is not allowed") UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - reason=f"notification is not allowed for user with role {user.role}", + reason=f"notification is not allowed for user", alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN, ).save() return @@ -252,9 +252,9 @@ def perform_notification(log_record_pk): UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - reason=f"notification is not allowed for user with role {user.role}", + reason=f"notification is not allowed for user", alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN, ).save() return diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 16b3daf3..272ef0b1 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -4,7 +4,6 @@ from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertG from apps.alerts.models import AlertGroup from apps.alerts.tasks.delete_alert_group import delete_alert_group from apps.slack.models import SlackMessage -from common.constants.role import Role @pytest.mark.django_db @@ -14,7 +13,7 @@ def test_render_for_phone_call( make_alert_group, make_alert, ): - organization, slack_team_identity = make_organization_with_slack_team_identity() + organization, _ = make_organization_with_slack_team_identity() alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") alert_group = make_alert_group(alert_receive_channel) @@ -59,7 +58,7 @@ def test_delete( organization, slack_team_identity = make_organization_with_slack_team_identity() slack_channel = make_slack_channel(slack_team_identity, name="general", slack_id="CWER1ASD") - user = make_user(organization=organization, role=Role.ADMIN) + user = make_user(organization=organization) alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") diff --git a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py index 6a4e1630..e84050b2 100644 --- a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py @@ -8,9 +8,9 @@ from apps.alerts.escalation_snapshot.serializers.escalation_policy_snapshot impo from apps.alerts.escalation_snapshot.snapshot_classes import EscalationPolicySnapshot from apps.alerts.escalation_snapshot.utils import eta_for_escalation_step_notify_if_time from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import list_users_to_notify_from_ical from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar -from common.constants.role import Role def get_escalation_policy_snapshot_from_model(escalation_policy): @@ -213,8 +213,8 @@ def test_escalation_step_notify_on_call_schedule_viewer_user( make_schedule, make_on_call_shift, ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup - viewer = make_user_for_organization(organization=organization, role=Role.VIEWER) + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup + viewer = make_user_for_organization(organization=organization, role=LegacyAccessControlRole.VIEWER) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) # create on_call_shift with user to notify @@ -263,7 +263,7 @@ def test_escalation_step_notify_user_group( make_slack_user_group, make_escalation_policy, ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup slack_team_identity = make_slack_team_identity() organization.slack_team_identity = slack_team_identity organization.save() @@ -295,7 +295,7 @@ def test_escalation_step_notify_if_time( escalation_step_test_setup, make_escalation_policy, ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + _, _, _, channel_filter, alert_group, reason = escalation_step_test_setup # current time is not between from_time and to_time, step returns eta now = timezone.now() @@ -358,7 +358,7 @@ def test_escalation_step_notify_if_time( def test_escalation_step_notify_if_num_alerts_in_window( mocked_execute_tasks, escalation_step_test_setup, make_escalation_policy, make_alert ): - organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + _, _, _, channel_filter, alert_group, reason = escalation_step_test_setup make_alert(alert_group=alert_group, raw_request_data={}) make_alert(alert_group=alert_group, raw_request_data={}) @@ -419,7 +419,7 @@ def test_escalation_step_trigger_custom_button( make_custom_action, make_escalation_policy, ): - organization, _, alert_receive_channel, channel_filter, alert_group, reason = escalation_step_test_setup + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup custom_button = make_custom_action(organization=organization) diff --git a/engine/apps/alerts/tests/test_notify_user.py b/engine/apps/alerts/tests/test_notify_user.py index 0f43305b..e6cffe1c 100644 --- a/engine/apps/alerts/tests/test_notify_user.py +++ b/engine/apps/alerts/tests/test_notify_user.py @@ -3,9 +3,11 @@ from unittest.mock import patch import pytest from apps.alerts.tasks.notify_user import notify_user_task, perform_notification +from apps.api.permissions import LegacyAccessControlRole from apps.base.models.user_notification_policy import UserNotificationPolicy from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord -from common.constants.role import Role + +NOTIFICATION_UNAUTHORIZED_MSG = "notification is not allowed for user" @pytest.mark.django_db @@ -131,7 +133,9 @@ def test_notify_user_perform_notification_error_if_viewer( make_user_notification_policy_log_record, ): organization = make_organization() - user_1 = make_user(organization=organization, role=Role.VIEWER, _verified_phone_number="1234567890") + user_1 = make_user( + organization=organization, role=LegacyAccessControlRole.VIEWER, _verified_phone_number="1234567890" + ) user_notification_policy = make_user_notification_policy( user=user_1, step=UserNotificationPolicy.Step.NOTIFY, @@ -150,11 +154,8 @@ def test_notify_user_perform_notification_error_if_viewer( error_log_record = UserNotificationPolicyLogRecord.objects.last() assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED - assert error_log_record.reason == f"notification is not allowed for user with role {user_1.role}" - assert ( - error_log_record.notification_error_code - == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE - ) + assert error_log_record.reason == NOTIFICATION_UNAUTHORIZED_MSG + assert error_log_record.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN @pytest.mark.django_db @@ -165,7 +166,9 @@ def test_notify_user_error_if_viewer( make_alert_group, ): organization = make_organization() - user_1 = make_user(organization=organization, role=Role.VIEWER, _verified_phone_number="1234567890") + user_1 = make_user( + organization=organization, role=LegacyAccessControlRole.VIEWER, _verified_phone_number="1234567890" + ) alert_receive_channel = make_alert_receive_channel(organization=organization) alert_group = make_alert_group(alert_receive_channel=alert_receive_channel) @@ -173,8 +176,5 @@ def test_notify_user_error_if_viewer( error_log_record = UserNotificationPolicyLogRecord.objects.last() assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED - assert error_log_record.reason == f"notification is not allowed for user with role {user_1.role}" - assert ( - error_log_record.notification_error_code - == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE - ) + assert error_log_record.reason == NOTIFICATION_UNAUTHORIZED_MSG + assert error_log_record.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN diff --git a/engine/apps/api/permissions/__init__.py b/engine/apps/api/permissions/__init__.py index d3f1d47e..2287db47 100644 --- a/engine/apps/api/permissions/__init__.py +++ b/engine/apps/api/permissions/__init__.py @@ -1,5 +1,294 @@ -from .actions import ActionPermission # noqa: F401 -from .constants import ALL_BASE_ACTIONS, MODIFY_ACTIONS, READ_ACTIONS # noqa: F401 -from .methods import MethodPermission # noqa: F401 -from .owner import IsOwner, IsOwnerOrAdmin, IsOwnerOrAdminOrEditor # noqa: F401 -from .roles import AnyRole, IsAdmin, IsAdminOrEditor, IsEditor, IsStaff, IsViewer # noqa: F401 +import enum +import typing + +from rest_framework import permissions +from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from rest_framework.request import Request +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSet, ViewSetMixin + +from common.utils import getattrd + +ACTION_PREFIX = "grafana-oncall-app" +RBAC_PERMISSIONS_ATTR = "rbac_permissions" +RBAC_OBJECT_PERMISSIONS_ATTR = "rbac_object_permissions" + +ViewSetOrAPIView = typing.Union[ViewSet, APIView] + + +class GrafanaAPIPermission(typing.TypedDict): + action: str + + +class Resources(enum.Enum): + ALERT_GROUPS = "alert-groups" + INTEGRATIONS = "integrations" + ESCALATION_CHAINS = "escalation-chains" + SCHEDULES = "schedules" + CHATOPS = "chatops" + OUTGOING_WEBHOOKS = "outgoing-webhooks" + MAINTENANCE = "maintenance" + API_KEYS = "api-keys" + NOTIFICATIONS = "notifications" + + NOTIFICATION_SETTINGS = "notification-settings" + USER_SETTINGS = "user-settings" + OTHER_SETTINGS = "other-settings" + + +class Actions(enum.Enum): + READ = "read" + WRITE = "write" + ADMIN = "admin" + TEST = "test" + EXPORT = "export" + UPDATE_SETTINGS = "update-settings" + + +class LegacyAccessControlRole(enum.IntEnum): + ADMIN = 0 + EDITOR = 1 + VIEWER = 2 + + @classmethod + def choices(cls): + return tuple((option.value, option.name) for option in cls) + + +class LegacyAccessControlCompatiblePermission: + def __init__(self, resource: Resources, action: Actions, fallback_role: LegacyAccessControlRole) -> None: + self.value = f"{ACTION_PREFIX}.{resource.value}:{action.value}" + self.fallback_role = fallback_role + + +def get_most_authorized_role( + permissions: typing.List[LegacyAccessControlCompatiblePermission], +) -> LegacyAccessControlRole: + if not permissions: + return LegacyAccessControlRole.VIEWER + + # ex. Admin is 0, Viewer is 2, thereby min makes sense here + return min({p.fallback_role for p in permissions}, key=lambda r: r.value) + + +def user_is_authorized(user, required_permissions: typing.List[LegacyAccessControlCompatiblePermission]) -> bool: + if user.organization.is_rbac_permissions_enabled: + user_permissions = [u["action"] for u in user.permissions] + required_permissions = [p.value for p in required_permissions] + return all(permission in user_permissions for permission in required_permissions) + return user.role <= get_most_authorized_role(required_permissions).value + + +class RBACPermission(permissions.BasePermission): + class Permissions: + ALERT_GROUPS_READ = LegacyAccessControlCompatiblePermission( + Resources.ALERT_GROUPS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + ALERT_GROUPS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.ALERT_GROUPS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + INTEGRATIONS_READ = LegacyAccessControlCompatiblePermission( + Resources.INTEGRATIONS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + INTEGRATIONS_TEST = LegacyAccessControlCompatiblePermission( + Resources.INTEGRATIONS, Actions.TEST, LegacyAccessControlRole.EDITOR + ) + INTEGRATIONS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.INTEGRATIONS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + ESCALATION_CHAINS_READ = LegacyAccessControlCompatiblePermission( + Resources.ESCALATION_CHAINS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + ESCALATION_CHAINS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.ESCALATION_CHAINS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + SCHEDULES_READ = LegacyAccessControlCompatiblePermission( + Resources.SCHEDULES, Actions.READ, LegacyAccessControlRole.VIEWER + ) + SCHEDULES_WRITE = LegacyAccessControlCompatiblePermission( + Resources.SCHEDULES, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + SCHEDULES_EXPORT = LegacyAccessControlCompatiblePermission( + Resources.SCHEDULES, Actions.EXPORT, LegacyAccessControlRole.EDITOR + ) + + CHATOPS_READ = LegacyAccessControlCompatiblePermission( + Resources.CHATOPS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + CHATOPS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.CHATOPS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + CHATOPS_UPDATE_SETTINGS = LegacyAccessControlCompatiblePermission( + Resources.CHATOPS, Actions.UPDATE_SETTINGS, LegacyAccessControlRole.ADMIN + ) + + OUTGOING_WEBHOOKS_READ = LegacyAccessControlCompatiblePermission( + Resources.OUTGOING_WEBHOOKS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + OUTGOING_WEBHOOKS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.OUTGOING_WEBHOOKS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + MAINTENANCE_READ = LegacyAccessControlCompatiblePermission( + Resources.MAINTENANCE, Actions.READ, LegacyAccessControlRole.VIEWER + ) + MAINTENANCE_WRITE = LegacyAccessControlCompatiblePermission( + Resources.MAINTENANCE, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + API_KEYS_READ = LegacyAccessControlCompatiblePermission( + Resources.API_KEYS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + API_KEYS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.API_KEYS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + NOTIFICATIONS_READ = LegacyAccessControlCompatiblePermission( + Resources.NOTIFICATIONS, Actions.READ, LegacyAccessControlRole.EDITOR + ) + + NOTIFICATION_SETTINGS_READ = LegacyAccessControlCompatiblePermission( + Resources.NOTIFICATION_SETTINGS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + NOTIFICATION_SETTINGS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.NOTIFICATION_SETTINGS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + + USER_SETTINGS_READ = LegacyAccessControlCompatiblePermission( + Resources.USER_SETTINGS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + USER_SETTINGS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.USER_SETTINGS, Actions.WRITE, LegacyAccessControlRole.EDITOR + ) + USER_SETTINGS_ADMIN = LegacyAccessControlCompatiblePermission( + Resources.USER_SETTINGS, Actions.ADMIN, LegacyAccessControlRole.ADMIN + ) + + OTHER_SETTINGS_READ = LegacyAccessControlCompatiblePermission( + Resources.OTHER_SETTINGS, Actions.READ, LegacyAccessControlRole.VIEWER + ) + OTHER_SETTINGS_WRITE = LegacyAccessControlCompatiblePermission( + Resources.OTHER_SETTINGS, Actions.WRITE, LegacyAccessControlRole.ADMIN + ) + + @staticmethod + def _get_view_action(request: Request, view: ViewSetOrAPIView) -> str: + """ + For right now this needs to support being used in both a ViewSet as well as APIView, we use both interchangably + + Note: `request.method` is returned uppercase + """ + return view.action if isinstance(view, ViewSetMixin) else request.method.lower() + + def has_permission(self, request: Request, view: ViewSetOrAPIView) -> bool: + action = self._get_view_action(request, view) + + rbac_permissions: RBACPermissionsAttribute = getattr(view, RBAC_PERMISSIONS_ATTR, None) + + # first check that the rbac_permissions dict attribute is defined + assert ( + rbac_permissions is not None + ), f"Must define a {RBAC_PERMISSIONS_ATTR} dict on the ViewSet that is consuming the RBACPermission class" + + action_required_permissions: typing.Union[None, typing.List] = rbac_permissions.get(action, None) + + # next check that the action in question is defined within the rbac_permissions dict attribute + assert ( + action_required_permissions is not None + ), f"""Each action must be defined within the {RBAC_PERMISSIONS_ATTR} dict on the ViewSet. +\nIf an action requires no permissions, its value should explicitly be set to an empty list""" + + return user_is_authorized(request.user, action_required_permissions) + + def has_object_permission(self, request: Request, view: ViewSetOrAPIView, obj: typing.Any) -> bool: + rbac_object_permissions: RBACObjectPermissionsAttribute = getattr(view, RBAC_OBJECT_PERMISSIONS_ATTR, None) + + if rbac_object_permissions: + action = self._get_view_action(request, view) + + for permission_class, actions in rbac_object_permissions.items(): + if action in actions: + return permission_class.has_object_permission(request, view, obj) + return False + + # has_object_permission is called after has_permission, so return True if in view there is not + # RBAC_OBJECT_PERMISSIONS_ATTR attr which mean no additional check involving object required + return True + + +class IsOwner(permissions.BasePermission): + def __init__(self, ownership_field: typing.Optional[str] = None) -> None: + self.ownership_field = ownership_field + + def has_object_permission(self, request: Request, _view: ViewSet, obj: typing.Any) -> bool: + owner = obj if self.ownership_field is None else getattrd(obj, self.ownership_field) + return owner == request.user + + +class HasRBACPermissions(permissions.BasePermission): + def __init__(self, required_permissions: typing.List[LegacyAccessControlCompatiblePermission]) -> None: + self.required_permissions = required_permissions + + def has_object_permission(self, request: Request, _view: ViewSetOrAPIView, _obj: typing.Any) -> bool: + return user_is_authorized(request.user, self.required_permissions) + + +class IsOwnerOrHasRBACPermissions(permissions.BasePermission): + def __init__( + self, + required_permissions: typing.List[LegacyAccessControlCompatiblePermission], + ownership_field: typing.Optional[str] = None, + ) -> None: + self.IsOwner = IsOwner(ownership_field) + self.HasRBACPermissions = HasRBACPermissions(required_permissions) + + def has_object_permission(self, request: Request, view: ViewSetOrAPIView, obj: typing.Any) -> bool: + return self.IsOwner.has_object_permission(request, view, obj) or self.HasRBACPermissions.has_object_permission( + request, view, obj + ) + + +class IsStaff(permissions.BasePermission): + STAFF_AUTH_CLASSES = [BasicAuthentication, SessionAuthentication] + + def has_permission(self, request: Request, _view: ViewSet) -> bool: + user = request.user + if not any(isinstance(request._authenticator, x) for x in self.STAFF_AUTH_CLASSES): + return False + if user and user.is_authenticated: + return user.is_staff + return False + + +RBACPermissionsAttribute = typing.Dict[str, typing.List[LegacyAccessControlCompatiblePermission]] +RBACObjectPermissionsAttribute = typing.Dict[permissions.BasePermission, typing.List[str]] + + +# The below is legacy, it is only needed currently for backward compatibility w/ users running +# older "pinned" version of Grafana in Grafana Cloud +_DONT_USE_LEGACY_VIEWER_PERMISSIONS = [] +_DONT_USE_LEGACY_EDITOR_PERMISSIONS = ["update_incidents", "update_own_settings", "view_other_users"] +_DONT_USE_LEGACY_ADMIN_PERMISSIONS = _DONT_USE_LEGACY_EDITOR_PERMISSIONS + [ + "update_alert_receive_channels", + "update_escalation_policies", + "update_notification_policies", + "update_general_log_channel_id", + "update_other_users_settings", + "update_integrations", + "update_schedules", + "update_custom_actions", + "update_api_tokens", + "update_teams", + "update_maintenances", + "update_global_settings", + "send_demo_alert", +] + +DONT_USE_LEGACY_PERMISSION_MAPPING: typing.Dict[LegacyAccessControlRole, typing.List[str]] = { + LegacyAccessControlRole.VIEWER: _DONT_USE_LEGACY_VIEWER_PERMISSIONS, + LegacyAccessControlRole.EDITOR: _DONT_USE_LEGACY_EDITOR_PERMISSIONS, + LegacyAccessControlRole.ADMIN: _DONT_USE_LEGACY_ADMIN_PERMISSIONS, +} diff --git a/engine/apps/api/permissions/actions.py b/engine/apps/api/permissions/actions.py deleted file mode 100644 index 74136e12..00000000 --- a/engine/apps/api/permissions/actions.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any - -from rest_framework import permissions -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - - -class ActionPermission(permissions.BasePermission): - def has_permission(self, request: Request, view: ViewSet) -> bool: - for permission, actions in getattr(view, "action_permissions", {}).items(): - if view.action in actions: - return permission().has_permission(request, view) - - return False - - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - # action_object_permissions attr should be used in case permission check require lookup - # for some object's properties e.g. team. - if getattr(view, "action_object_permissions", None): - for permission, actions in getattr(view, "action_object_permissions", {}).items(): - if view.action in actions: - return permission().has_object_permission(request, view, obj) - return False - else: - # has_object_permission is called after has_permission, so return True if in view there is not - # action_object_permission attr which mean no additional check involving object required - return True diff --git a/engine/apps/api/permissions/constants.py b/engine/apps/api/permissions/constants.py deleted file mode 100644 index 29e828ce..00000000 --- a/engine/apps/api/permissions/constants.py +++ /dev/null @@ -1,14 +0,0 @@ -READ_ACTIONS = ( - "list", - "retrieve", - "metadata", -) - -MODIFY_ACTIONS = ( - "create", - "update", - "partial_update", - "destroy", -) - -ALL_BASE_ACTIONS = READ_ACTIONS + MODIFY_ACTIONS diff --git a/engine/apps/api/permissions/methods.py b/engine/apps/api/permissions/methods.py deleted file mode 100644 index 6ff1b110..00000000 --- a/engine/apps/api/permissions/methods.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework import permissions -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - - -class MethodPermission(permissions.BasePermission): - def has_permission(self, request: Request, view: ViewSet) -> bool: - for permission, methods in getattr(view, "method_permissions", {}).items(): - if request.method in methods: - return permission().has_permission(request, view) - - return False diff --git a/engine/apps/api/permissions/owner.py b/engine/apps/api/permissions/owner.py deleted file mode 100644 index 4a4fc69e..00000000 --- a/engine/apps/api/permissions/owner.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Any - -from rest_framework import permissions -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - -from apps.api.permissions.roles import IsAdmin, IsEditor -from common.utils import getattrd - - -class IsOwner(permissions.BasePermission): - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - ownership_field = getattr(view, "ownership_field", None) - if ownership_field is None: - owner = obj - else: - owner = getattrd(obj, ownership_field) - - return owner == request.user - - -IsOwnerOrAdmin = IsOwner | IsAdmin - -IsOwnerOrAdminOrEditor = IsOwner | IsAdmin | IsEditor diff --git a/engine/apps/api/permissions/roles.py b/engine/apps/api/permissions/roles.py deleted file mode 100644 index 3ae9d548..00000000 --- a/engine/apps/api/permissions/roles.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Any - -from rest_framework import permissions -from rest_framework.authentication import BasicAuthentication, SessionAuthentication -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet - -from common.constants.role import Role - - -class RolePermission(permissions.BasePermission): - ROLE = None - - def has_permission(self, request: Request, view: ViewSet) -> bool: - return request.user.role == type(self).ROLE - - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - return self.has_permission(request, view) - - -class IsAdmin(RolePermission): - ROLE = Role.ADMIN - - -class IsEditor(RolePermission): - ROLE = Role.EDITOR - - -class IsViewer(RolePermission): - ROLE = Role.VIEWER - - -IsAdminOrEditor = IsAdmin | IsEditor -AnyRole = IsAdmin | IsEditor | IsViewer - - -class IsStaff(permissions.BasePermission): - STAFF_AUTH_CLASSES = [BasicAuthentication, SessionAuthentication] - - def has_permission(self, request: Request, view: ViewSet) -> bool: - user = request.user - if not any(isinstance(request._authenticator, x) for x in self.STAFF_AUTH_CLASSES): - return False - if user and user.is_authenticated: - return user.is_staff - return False - - def has_object_permission(self, request: Request, view: ViewSet, obj: Any) -> bool: - return self.has_permission(request, view) diff --git a/engine/apps/api/permissions/test_permissions.py b/engine/apps/api/permissions/test_permissions.py new file mode 100644 index 00000000..e0d39b10 --- /dev/null +++ b/engine/apps/api/permissions/test_permissions.py @@ -0,0 +1,428 @@ +import typing + +import pytest +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSetMixin + +from . import ( + RBAC_PERMISSIONS_ATTR, + GrafanaAPIPermission, + HasRBACPermissions, + IsOwner, + IsOwnerOrHasRBACPermissions, + LegacyAccessControlCompatiblePermission, + RBACObjectPermissionsAttribute, + RBACPermission, + RBACPermissionsAttribute, + get_most_authorized_role, + user_is_authorized, +) + + +class MockedOrg: + def __init__(self, org_has_rbac_enabled: bool) -> None: + self.is_rbac_permissions_enabled = org_has_rbac_enabled + + +class MockedUser: + def __init__( + self, permissions: typing.List[LegacyAccessControlCompatiblePermission], org_has_rbac_enabled=True + ) -> None: + self.permissions = [GrafanaAPIPermission(action=perm.value) for perm in permissions] + self.role = get_most_authorized_role(permissions) + self.organization = MockedOrg(org_has_rbac_enabled) + + +class MockedSchedule: + def __init__(self, user: MockedUser) -> None: + self.user = user + + +class MockedRequest: + def __init__(self, user: typing.Optional[MockedUser] = None, method: typing.Optional[str] = None) -> None: + if user: + self.user = user + if method: + self.method = method + + +class MockedViewSet(ViewSetMixin): + def __init__( + self, + action: str, + rbac_permissions: typing.Optional[RBACPermissionsAttribute] = None, + rbac_object_permissions: typing.Optional[RBACObjectPermissionsAttribute] = None, + ) -> None: + super().__init__() + self.action = action + + if rbac_permissions: + self.rbac_permissions = rbac_permissions + if rbac_object_permissions: + self.rbac_object_permissions = rbac_object_permissions + + +class MockedAPIView(APIView): + def __init__( + self, + rbac_permissions: typing.Optional[RBACPermissionsAttribute] = None, + rbac_object_permissions: typing.Optional[RBACObjectPermissionsAttribute] = None, + ) -> None: + super().__init__() + + if rbac_permissions: + self.rbac_permissions = rbac_permissions + if rbac_object_permissions: + self.rbac_object_permissions = rbac_object_permissions + + +@pytest.mark.parametrize( + "user_permissions,required_permissions,org_has_rbac_enabled,expected_result", + [ + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + True, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + False, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + True, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + False, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + True, + False, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + False, + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + False, + False, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + True, + False, + ), + ], +) +def test_user_is_authorized(user_permissions, required_permissions, org_has_rbac_enabled, expected_result) -> None: + user = MockedUser(user_permissions, org_has_rbac_enabled=org_has_rbac_enabled) + assert user_is_authorized(user, required_permissions) == expected_result + + +@pytest.mark.parametrize( + "permissions,expected_role", + [ + ([RBACPermission.Permissions.ALERT_GROUPS_READ], RBACPermission.Permissions.ALERT_GROUPS_READ.fallback_role), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + RBACPermission.Permissions.ALERT_GROUPS_WRITE.fallback_role, + ), + ( + [ + RBACPermission.Permissions.USER_SETTINGS_READ, + RBACPermission.Permissions.USER_SETTINGS_WRITE, + RBACPermission.Permissions.USER_SETTINGS_ADMIN, + ], + RBACPermission.Permissions.USER_SETTINGS_ADMIN.fallback_role, + ), + ], +) +def test_get_most_authorized_role(permissions, expected_role) -> None: + assert get_most_authorized_role(permissions) == expected_role + + +class TestRBACPermission: + def test_get_view_action(self) -> None: + viewset_action = "viewset_action" + viewset = MockedViewSet(viewset_action) + + apiview = MockedAPIView() + + method = "APIVIEW_ACTION" + request = MockedRequest(method=method) + + assert RBACPermission._get_view_action(request, viewset) == viewset_action, "it works with a ViewSet" + assert RBACPermission._get_view_action(request, apiview) == method.lower(), "it works with an APIView" + + def test_has_permission_works_on_a_viewset_view(self) -> None: + required_permission = RBACPermission.Permissions.ALERT_GROUPS_READ + + action = "hello" + viewset = MockedViewSet( + action=action, + rbac_permissions={ + action: [required_permission], + }, + ) + + viewset_with_no_required_permissions = MockedViewSet( + action=action, + rbac_permissions={ + action: [], + }, + ) + + user_with_permission = MockedUser([required_permission]) + user_without_permission = MockedUser([RBACPermission.Permissions.ALERT_GROUPS_WRITE]) + + assert ( + RBACPermission().has_permission(MockedRequest(user_with_permission), viewset) is True + ), "it works on a viewset when the user does have permission" + + assert ( + RBACPermission().has_permission(MockedRequest(user_without_permission), viewset) is False + ), "it works on a viewset when the user does have permission" + + assert ( + RBACPermission().has_permission( + MockedRequest(user_without_permission), viewset_with_no_required_permissions + ) + is True + ), "it works on a viewset when the viewset action does not require permissions" + + def test_has_permission_works_on_an_apiview_view(self) -> None: + required_permission = RBACPermission.Permissions.ALERT_GROUPS_READ + + method = "hello" + apiview = MockedAPIView( + rbac_permissions={ + method: [required_permission], + } + ) + apiview_with_no_permissions = MockedAPIView( + rbac_permissions={ + method: [], + } + ) + + user1 = MockedUser([required_permission]) + user2 = MockedUser([RBACPermission.Permissions.ALERT_GROUPS_WRITE]) + + class Request(MockedRequest): + def __init__(self, user: typing.Optional[MockedUser] = None) -> None: + super().__init__(user, method) + + assert ( + RBACPermission().has_permission(Request(user1), apiview) is True + ), "it works on an APIView when the user has permission" + + assert ( + RBACPermission().has_permission(Request(user2), apiview) is False + ), "it works on an APIView when the user does not have permission" + + assert ( + RBACPermission().has_permission(Request(user2), apiview_with_no_permissions) is True + ), "it works on a viewset when the viewset action does not require permissions" + + def test_has_permission_throws_assertion_error_if_developer_forgets_to_specify_rbac_permissions(self) -> None: + action_slash_method = "hello" + error_msg = ( + f"Must define a {RBAC_PERMISSIONS_ATTR} dict on the ViewSet that is consuming the RBACPermission class" + ) + + viewset = MockedViewSet(action_slash_method) + apiview = MockedAPIView() + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(), viewset) + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(method=action_slash_method), apiview) + + def test_has_permission_throws_assertion_error_if_developer_forgets_to_specify_an_action_in_rbac_permissions( + self, + ) -> None: + action_slash_method = "hello" + other_action_rbac_permissions = {"bonjour": []} + error_msg = f"""Each action must be defined within the {RBAC_PERMISSIONS_ATTR} dict on the ViewSet. +\nIf an action requires no permissions, its value should explicitly be set to an empty list""" + + viewset = MockedViewSet(action_slash_method, other_action_rbac_permissions) + apiview = MockedAPIView(rbac_permissions=other_action_rbac_permissions) + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(), viewset) + + with pytest.raises(AssertionError, match=error_msg): + RBACPermission().has_permission(MockedRequest(method=action_slash_method), apiview) + + def test_has_object_permission_returns_true_if_rbac_object_permissions_not_specified(self) -> None: + request = MockedRequest() + assert RBACPermission().has_object_permission(request, MockedAPIView(), None) is True + assert RBACPermission().has_object_permission(request, MockedViewSet("potato"), None) is True + + def test_has_object_permission_works_if_no_permission_class_specified_for_action(self) -> None: + action = "hello" + + request = MockedRequest(None, action) + apiview = MockedAPIView(rbac_object_permissions={}) + viewset = MockedViewSet(action, rbac_object_permissions={}) + + assert RBACPermission().has_object_permission(request, apiview, None) is True + assert RBACPermission().has_object_permission(request, viewset, None) is True + + def test_has_object_permission_works_when_permission_class_specified_for_action(self) -> None: + action = "hello" + mocked_permission_class_response = "asdfasdfasdf" + + class MockedPermissionClass: + def has_object_permission(self, _req, _view, _obj) -> None: + return mocked_permission_class_response + + rbac_object_permissions = {MockedPermissionClass(): (action,)} + request = MockedRequest(None, action) + apiview = MockedAPIView(rbac_object_permissions=rbac_object_permissions) + viewset = MockedViewSet(action, rbac_object_permissions=rbac_object_permissions) + + assert RBACPermission().has_object_permission(request, apiview, None) == mocked_permission_class_response + assert RBACPermission().has_object_permission(request, viewset, None) == mocked_permission_class_response + + +class TestIsOwner: + def test_it_works_when_comparing_user_to_object(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser([]) + request = MockedRequest(user1) + IsUser = IsOwner() + + assert IsUser.has_object_permission(request, None, user1) is True + assert IsUser.has_object_permission(request, None, user2) is False + + def test_it_works_when_comparing_user_to_ownership_field_object(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser([]) + schedule = MockedSchedule(user1) + IsScheduleOwner = IsOwner("user") + + assert IsScheduleOwner.has_object_permission(MockedRequest(user1), None, schedule) is True + assert IsScheduleOwner.has_object_permission(MockedRequest(user2), None, schedule) is False + + def test_it_works_when_comparing_user_to_nested_ownership_field_object(self) -> None: + class Thingy: + def __init__(self, schedule: MockedSchedule) -> None: + self.schedule = schedule + + user1 = MockedUser([]) + user2 = MockedUser([]) + schedule = MockedSchedule(user1) + thingy = Thingy(schedule) + IsScheduleOwner = IsOwner("schedule.user") + + assert IsScheduleOwner.has_object_permission(MockedRequest(user1), None, thingy) is True + assert IsScheduleOwner.has_object_permission(MockedRequest(user2), None, thingy) is False + + +@pytest.mark.parametrize( + "user_permissions,required_permissions,expected_result", + [ + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + True, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + [RBACPermission.Permissions.ALERT_GROUPS_READ], + False, + ), + ( + [RBACPermission.Permissions.ALERT_GROUPS_READ], + [RBACPermission.Permissions.ALERT_GROUPS_READ, RBACPermission.Permissions.ALERT_GROUPS_WRITE], + False, + ), + ], +) +def test_HasRBACPermission(user_permissions, required_permissions, expected_result) -> None: + request = MockedRequest(MockedUser(user_permissions)) + assert HasRBACPermissions(required_permissions).has_object_permission(request, None, None) == expected_result + + +class TestIsOwnerOrHasRBACPermissions: + required_permission = RBACPermission.Permissions.SCHEDULES_READ + required_permissions = [required_permission] + + def test_it_works_when_user_is_owner_and_does_not_have_permissions(self) -> None: + user1 = MockedUser([]) + schedule = MockedSchedule(user1) + request = MockedRequest(user1) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is True + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is True + + def test_it_works_when_user_is_owner_and_has_permissions(self) -> None: + user1 = MockedUser(self.required_permissions) + schedule = MockedSchedule(user1) + request = MockedRequest(user1) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is True + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is True + + def test_it_works_when_user_is_not_owner_and_does_not_have_permissions(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser([]) + schedule = MockedSchedule(user1) + request = MockedRequest(user2) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is False + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is False + + def test_it_works_when_user_is_not_owner_and_has_permissions(self) -> None: + user1 = MockedUser([]) + user2 = MockedUser(self.required_permissions) + schedule = MockedSchedule(user1) + request = MockedRequest(user2) + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions) + assert PermClass.has_object_permission(request, None, user1) is True + + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "user") + assert PermClass.has_object_permission(request, None, schedule) is True + + class Thingy: + def __init__(self, schedule: MockedSchedule) -> None: + self.schedule = schedule + + thingy = Thingy(schedule) + PermClass = IsOwnerOrHasRBACPermissions(self.required_permissions, "schedule.user") + + assert PermClass.has_object_permission(request, None, thingy) is True + assert PermClass.has_object_permission(MockedRequest(MockedUser([])), None, thingy) is False diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index e3a0d784..3ed2ecda 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,12 +1,13 @@ import math import time +import typing import pytz from django.conf import settings from rest_framework import serializers +from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING from apps.api.serializers.telegram import TelegramToUserConnectorSerializer -from apps.base.constants import ADMIN_PERMISSIONS, ALL_ROLES_PERMISSIONS, EDITOR_PERMISSIONS from apps.base.messaging import get_messaging_backends from apps.base.models import UserNotificationPolicy from apps.base.utils import live_settings @@ -16,7 +17,6 @@ from apps.user_management.models import User from apps.user_management.models.user import default_working_hours from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin -from common.constants.role import Role from .custom_serializers import DynamicFieldsModelSerializer from .organization import FastOrganizationSerializer @@ -52,7 +52,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "email", "username", "name", - "role", + "role", # LEGACY.. this should get removed eventually "avatar", "avatar_full", "timezone", @@ -62,7 +62,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "slack_user_identity", "telegram_configuration", "messaging_backends", - "permissions", + "permissions", # LEGACY.. this should get removed eventually "notification_chain_verbal", "cloud_connection_status", "hide_phone_number", @@ -71,7 +71,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "email", "username", "name", - "role", + "role", # LEGACY.. this should get removed eventually "verified_phone_number", ] @@ -139,13 +139,8 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): serialized_data[backend_id] = backend.serialize_user(obj) return serialized_data - def get_permissions(self, obj): - if obj.role == Role.ADMIN: - return ADMIN_PERMISSIONS - elif obj.role == Role.EDITOR: - return EDITOR_PERMISSIONS - else: - return ALL_ROLES_PERMISSIONS + def get_permissions(self, obj) -> typing.List[str]: + return DONT_USE_LEGACY_PERMISSION_MAPPING[obj.role] def get_notification_chain_verbal(self, obj): default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj) @@ -180,7 +175,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): class UserHiddenFieldsSerializer(UserSerializer): - available_for_all_roles_fields = [ + fields_available_for_all_users = [ "pk", "organization", "current_team", @@ -196,7 +191,7 @@ class UserHiddenFieldsSerializer(UserSerializer): ret = super(UserSerializer, self).to_representation(instance) if instance.id != self.context["request"].user.id: for field in ret: - if field not in self.available_for_all_roles_fields: + if field not in self.fields_available_for_all_users: ret[field] = "******" ret["hidden_fields"] = True return ret diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 0b236b7d..ebc2f41c 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import AlertGroup, AlertGroupLogRecord -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole alert_raw_request_data = { "evalMatches": [ @@ -25,15 +25,6 @@ alert_raw_request_data = { } -# # This function is for creating token and do not to change fixture alert_group_internal_api_setup return values. -# # To create token amixr team is needed but in most tests using fixture alert_group_internal_api_setup team is redundant -# # So it just extract amixr team form alert_groups. -# def create_token_from_initial_test_data(make_func, alert_groups, role): -# organization = alert_groups[0].channel.organization -# _, token_user_role = make_func(organization, role) -# return token_user_role - - @pytest.fixture() def alert_group_internal_api_setup( make_organization_and_user_with_plugin_token, @@ -52,7 +43,7 @@ def alert_group_internal_api_setup( @pytest.mark.django_db def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_headers): - user, token, alert_groups = alert_group_internal_api_setup + user, token, _ = alert_group_internal_api_setup client = APIClient() url = reverse("api-internal:alertgroup-list") @@ -69,7 +60,7 @@ def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_he @pytest.mark.django_db def test_get_filter_resolved_at_alertgroup_empty_result(alert_group_internal_api_setup, make_user_auth_headers): client = APIClient() - user, token, alert_groups = alert_group_internal_api_setup + user, token, _ = alert_group_internal_api_setup url = reverse("api-internal:alertgroup-list") response = client.get( @@ -84,7 +75,7 @@ def test_get_filter_resolved_at_alertgroup_empty_result(alert_group_internal_api @pytest.mark.django_db def test_get_filter_resolved_at_alertgroup_invalid_format(alert_group_internal_api_setup, make_user_auth_headers): client = APIClient() - user, token, alert_groups = alert_group_internal_api_setup + user, token, _ = alert_group_internal_api_setup url = reverse("api-internal:alertgroup-list") response = client.get( @@ -660,19 +651,25 @@ def test_get_filter_with_resolution_note_after_delete_resolution_note( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_acknowledge_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() + url = reverse("api-internal:alertgroup-acknowledge", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -689,19 +686,24 @@ def test_alert_group_acknowledge_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unacknowledge_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unacknowledge", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -718,19 +720,24 @@ def test_alert_group_unacknowledge_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_resolve_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-resolve", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -747,19 +754,24 @@ def test_alert_group_resolve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unresolve_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unresolve", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -776,19 +788,24 @@ def test_alert_group_unresolve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_silence_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-silence", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -805,19 +822,24 @@ def test_alert_group_silence_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unsilence_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unsilence", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -834,19 +856,24 @@ def test_alert_group_unsilence_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_attach_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-attach", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -863,19 +890,24 @@ def test_alert_group_attach_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_unattach_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-unattach", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -892,19 +924,24 @@ def test_alert_group_unattach_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_list_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-list") with patch( @@ -921,19 +958,24 @@ def test_alert_group_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_stats_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-stats") with patch( @@ -950,19 +992,24 @@ def test_alert_group_stats_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_bulk_action_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-bulk-action") with patch( @@ -977,19 +1024,24 @@ def test_alert_group_bulk_action_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_filters_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-filters") with patch( @@ -1006,19 +1058,24 @@ def test_alert_group_filters_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_group_detail_permissions( - make_user_for_organization, alert_group_internal_api_setup, make_user_auth_headers, role, expected_status + alert_group_internal_api_setup, + make_user_for_organization, + make_user_auth_headers, + role, + expected_status, ): - client = APIClient() _, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups organization = new_alert_group.channel.organization user = make_user_for_organization(organization, role) + + client = APIClient() url = reverse("api-internal:alertgroup-detail", kwargs={"pk": new_alert_group.public_primary_key}) with patch( @@ -1032,10 +1089,7 @@ def test_alert_group_detail_permissions( @pytest.mark.django_db -def test_silence( - alert_group_internal_api_setup, - make_user_auth_headers, -): +def test_silence(alert_group_internal_api_setup, make_user_auth_headers): client = APIClient() user, token, alert_groups = alert_group_internal_api_setup _, _, new_alert_group, _ = alert_groups @@ -1396,9 +1450,9 @@ def test_alert_group_status_field( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_group_preview_template_permissions( @@ -1414,6 +1468,7 @@ def test_alert_group_preview_template_permissions( alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + client = APIClient() url = reverse("api-internal:alertgroup-preview-template", kwargs={"pk": alert_group.public_primary_key}) @@ -1436,7 +1491,7 @@ def test_alert_group_preview_body_non_existent_template_var( make_alert_group, make_alert, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) @@ -1459,7 +1514,7 @@ def test_alert_group_preview_body_invalid_template_syntax( make_alert_group, make_alert, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index f849cc5f..2ce07341 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel, EscalationPolicy -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.fixture() @@ -205,9 +205,9 @@ def test_integration_search( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_create_permissions( @@ -235,9 +235,9 @@ def test_alert_receive_channel_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_update_permissions( @@ -272,9 +272,9 @@ def test_alert_receive_channel_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_delete_permissions( @@ -303,7 +303,11 @@ def test_alert_receive_channel_delete_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_alert_receive_channel_list_permissions( make_organization_and_user_with_plugin_token, @@ -311,7 +315,7 @@ def test_alert_receive_channel_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:alert_receive_channel-list") @@ -330,7 +334,11 @@ def test_alert_receive_channel_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_alert_receive_channel_detail_permissions( make_organization_and_user_with_plugin_token, @@ -360,9 +368,9 @@ def test_alert_receive_channel_detail_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_send_demo_alert_permissions( @@ -395,9 +403,9 @@ def test_alert_receive_channel_send_demo_alert_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_integration_options_permissions( @@ -426,9 +434,9 @@ def test_alert_receive_channel_integration_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_preview_template_permissions( @@ -501,9 +509,9 @@ def test_alert_receive_channel_preview_template_require_notification_channel( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_change_team_permissions( @@ -597,9 +605,9 @@ def test_alert_receive_channel_change_team( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_counters_permissions( @@ -608,7 +616,7 @@ def test_alert_receive_channel_counters_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse( @@ -630,9 +638,9 @@ def test_alert_receive_channel_counters_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_counters_per_integration_permissions( diff --git a/engine/apps/api/tests/test_alert_receive_channel_template.py b/engine/apps/api/tests/test_alert_receive_channel_template.py index 16330810..0c2d658d 100644 --- a/engine/apps/api/tests/test_alert_receive_channel_template.py +++ b/engine/apps/api/tests/test_alert_receive_channel_template.py @@ -6,17 +6,17 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.base.messaging import BaseMessagingBackend -from common.constants.role import Role @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_template_update_permissions( @@ -48,9 +48,9 @@ def test_alert_receive_channel_template_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_alert_receive_channel_template_detail_permissions( @@ -83,7 +83,7 @@ def test_alert_receive_channel_template_include_additional_backend_templates( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel( organization, messaging_backends_templates={"TESTONLY": {"title": "the-title", "message": "the-message", "image_url": "url"}}, @@ -109,7 +109,7 @@ def test_alert_receive_channel_template_include_additional_backend_templates_usi make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -138,7 +138,7 @@ def test_update_alert_receive_channel_backend_template_invalid_template( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -160,7 +160,7 @@ def test_update_alert_receive_channel_backend_template_invalid_url( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -182,7 +182,7 @@ def test_update_alert_receive_channel_backend_template_empty_values_allowed( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization, messaging_backends_templates=None) client = APIClient() @@ -208,7 +208,7 @@ def test_update_alert_receive_channel_backend_template_update_values( make_user_auth_headers, make_alert_receive_channel, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel( organization, messaging_backends_templates={ @@ -249,7 +249,7 @@ def test_preview_alert_receive_channel_backend_templater( make_alert_group, make_alert, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter) @@ -280,7 +280,7 @@ def test_update_alert_receive_channel_templates( # set url here to pass *_url templates validation return "https://grafana.com" - organization, user, token = make_organization_and_user_with_plugin_token(role=Role.ADMIN) + organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel( organization, messaging_backends_templates={"TESTONLY": {"title": "the-title", "message": "the-message", "image_url": "url"}}, diff --git a/engine/apps/api/tests/test_channel_filter.py b/engine/apps/api/tests/test_channel_filter.py index f70c8956..fe02e97b 100644 --- a/engine/apps/api/tests/test_channel_filter.py +++ b/engine/apps/api/tests/test_channel_filter.py @@ -6,21 +6,20 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_create_permissions( make_organization_and_user_with_plugin_token, - make_alert_receive_channel, make_user_auth_headers, role, expected_status, @@ -45,9 +44,9 @@ def test_channel_filter_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_update_permissions( @@ -83,7 +82,11 @@ def test_channel_filter_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_channel_filter_list_permissions( make_organization_and_user_with_plugin_token, @@ -114,7 +117,11 @@ def test_channel_filter_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_channel_filter_retrieve_permissions( make_organization_and_user_with_plugin_token, @@ -146,9 +153,9 @@ def test_channel_filter_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_delete_permissions( @@ -181,9 +188,9 @@ def test_channel_filter_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_channel_filter_move_to_position_permissions( @@ -216,9 +223,9 @@ def test_channel_filter_move_to_position_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_alert_receive_channel_send_demo_alert_permissions( diff --git a/engine/apps/api/tests/test_custom_button.py b/engine/apps/api/tests/test_custom_button.py index f45acb77..3ed57a4f 100644 --- a/engine/apps/api/tests/test_custom_button.py +++ b/engine/apps/api/tests/test_custom_button.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import CustomButton -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole TEST_URL = "https://amixr.io" @@ -275,14 +275,13 @@ def test_delete_custom_button(custom_button_internal_api_setup, make_user_auth_h @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_custom_button_create_permissions( make_organization_and_user_with_plugin_token, - make_custom_action, make_user_auth_headers, role, expected_status, @@ -307,9 +306,9 @@ def test_custom_button_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_custom_button_update_permissions( @@ -343,7 +342,11 @@ def test_custom_button_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_custom_button_list_permissions( make_organization_and_user_with_plugin_token, @@ -372,7 +375,11 @@ def test_custom_button_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_custom_button_retrieve_permissions( make_organization_and_user_with_plugin_token, @@ -402,9 +409,9 @@ def test_custom_button_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_custom_button_delete_permissions( diff --git a/engine/apps/api/tests/test_escalation_chain.py b/engine/apps/api/tests/test_escalation_chain.py index ee88fa18..abe925f8 100644 --- a/engine/apps/api/tests/test_escalation_chain.py +++ b/engine/apps/api/tests/test_escalation_chain.py @@ -24,7 +24,7 @@ def test_delete_escalation_chain(escalation_chain_internal_api_setup, make_user_ @pytest.mark.django_db -def test_update_escalation_chain(escalation_chain_internal_api_setup, make_user_auth_headers, make_organization): +def test_update_escalation_chain(escalation_chain_internal_api_setup, make_user_auth_headers): user, token, escalation_chain = escalation_chain_internal_api_setup client = APIClient() url = reverse("api-internal:escalation_chain-detail", kwargs={"pk": escalation_chain.public_primary_key}) diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index f1e4c804..54fc2301 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import EscalationPolicy -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.fixture() @@ -93,9 +93,9 @@ def test_move_to_position(escalation_policy_internal_api_setup, make_user_auth_h @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_escalation_policy_create_permissions( @@ -130,9 +130,9 @@ def test_escalation_policy_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_escalation_policy_update_permissions( @@ -171,9 +171,9 @@ def test_escalation_policy_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_list_permissions( @@ -208,9 +208,9 @@ def test_escalation_policy_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_retrieve_permissions( @@ -245,9 +245,9 @@ def test_escalation_policy_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_escalation_policy_delete_permissions( @@ -282,9 +282,9 @@ def test_escalation_policy_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_escalation_options_permissions( @@ -319,9 +319,9 @@ def test_escalation_policy_escalation_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_delay_options_permissions( @@ -357,9 +357,9 @@ def test_escalation_policy_delay_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_escalation_policy_move_to_position_permissions( diff --git a/engine/apps/api/tests/test_gitops.py b/engine/apps/api/tests/test_gitops.py index ca196433..0152f5e3 100644 --- a/engine/apps/api/tests/test_gitops.py +++ b/engine/apps/api/tests/test_gitops.py @@ -3,16 +3,16 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_terraform_gitops_permissions( @@ -22,7 +22,7 @@ def test_terraform_gitops_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + organization, user, token = make_organization_and_user_with_plugin_token(role=role) make_escalation_chain(organization) client = APIClient() @@ -38,15 +38,15 @@ def test_terraform_gitops_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_terraform_state_permissions( make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status ): - _, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role=role) client = APIClient() url = reverse("api-internal:terraform_imports") diff --git a/engine/apps/api/tests/test_integration_heartbeat.py b/engine/apps/api/tests/test_integration_heartbeat.py index 048b5121..8954b74a 100644 --- a/engine/apps/api/tests/test_integration_heartbeat.py +++ b/engine/apps/api/tests/test_integration_heartbeat.py @@ -8,8 +8,8 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.heartbeat.models import IntegrationHeartBeat -from common.constants.role import Role MOCK_LAST_HEARTBEAT_TIME_VERBAL = "a moment" @@ -151,7 +151,7 @@ def test_create_empty_alert_receive_channel_integration_heartbeat( integration_heartbeat_internal_api_setup, make_user_auth_headers, ): - user, token, alert_receive_channel, integration_heartbeat = integration_heartbeat_internal_api_setup + user, token, _, _ = integration_heartbeat_internal_api_setup client = APIClient() url = reverse("api-internal:integration_heartbeat-list") @@ -185,9 +185,39 @@ def test_update_integration_heartbeat( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_integration_heartbeat_create_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + _, user, token = make_organization_and_user_with_plugin_token(role) + client = APIClient() + + url = reverse("api-internal:integration_heartbeat-list") + + with patch( + "apps.api.views.integration_heartbeat.IntegrationHeartBeatView.create", + return_value=Response( + status=status.HTTP_200_OK, + ), + ): + response = client.post(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_integration_heartbeat_update_permissions( @@ -223,7 +253,11 @@ def test_integration_heartbeat_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_integration_heartbeat_list_permissions( make_organization_and_user_with_plugin_token, @@ -255,9 +289,40 @@ def test_integration_heartbeat_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], +) +def test_integration_heartbeat_timeout_options_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + _, user, token = make_organization_and_user_with_plugin_token(role) + client = APIClient() + + url = reverse("api-internal:integration_heartbeat-timeout-options") + + with patch( + "apps.api.views.integration_heartbeat.IntegrationHeartBeatView.timeout_options", + return_value=Response( + status=status.HTTP_200_OK, + ), + ): + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_integration_heartbeat_retrieve_permissions( diff --git a/engine/apps/api/tests/test_maintenance.py b/engine/apps/api/tests/test_maintenance.py index dc140d67..6441d063 100644 --- a/engine/apps/api/tests/test_maintenance.py +++ b/engine/apps/api/tests/test_maintenance.py @@ -6,6 +6,8 @@ from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel from apps.user_management.models import Organization +# TODO: should probably modify these tests to take into account new rbac permissions + @pytest.fixture() def maintenance_internal_api_setup( @@ -23,7 +25,7 @@ def maintenance_internal_api_setup( def test_start_maintenance_integration( maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers ): - token, organization, user, alert_receive_channel = maintenance_internal_api_setup + token, _, user, alert_receive_channel = maintenance_internal_api_setup client = APIClient() url = reverse("api-internal:start_maintenance") @@ -50,7 +52,7 @@ def test_stop_maintenance_integration( mock_start_disable_maintenance_task, make_user_auth_headers, ): - token, organization, user, alert_receive_channel = maintenance_internal_api_setup + token, _, user, alert_receive_channel = maintenance_internal_api_setup client = APIClient() mode = AlertReceiveChannel.MAINTENANCE duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds @@ -161,7 +163,7 @@ def test_maintenances_list( def test_empty_maintenances_list( maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers ): - token, organization, user, alert_receive_channel = maintenance_internal_api_setup + token, _, user, alert_receive_channel = maintenance_internal_api_setup client = APIClient() url = reverse("api-internal:maintenance") response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index 8d5db17f..0235775d 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -7,8 +7,8 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb -from common.constants.role import Role @pytest.fixture() @@ -26,7 +26,7 @@ def on_call_shift_internal_api_setup( @pytest.mark.django_db def test_create_on_call_shift_rotation(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -58,7 +58,7 @@ def test_create_on_call_shift_rotation(on_call_shift_internal_api_setup, make_us @pytest.mark.django_db def test_create_on_call_shift_override(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -98,7 +98,7 @@ def test_get_on_call_shift( make_on_call_shift, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = timezone.now().replace(microsecond=0) @@ -144,7 +144,7 @@ def test_list_on_call_shift( make_on_call_shift, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = timezone.now().replace(microsecond=0) @@ -270,7 +270,7 @@ def test_update_future_on_call_shift( make_user_auth_headers, ): """Test updating the shift that has not started (rotation_start > now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() + timezone.timedelta(days=1)).replace(microsecond=0) @@ -337,7 +337,7 @@ def test_update_started_on_call_shift( ): """Test updating the shift that has started (rotation_start < now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0) @@ -409,7 +409,7 @@ def test_update_old_on_call_shift_with_future_version( make_user_auth_headers, ): """Test updating the shift that has the newer version (updated_shift is not None)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() now = timezone.now().replace(microsecond=0) @@ -498,7 +498,7 @@ def test_update_started_on_call_shift_title( ): """Test updating the title for the shift that has started (rotation_start < now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0) @@ -560,7 +560,7 @@ def test_delete_started_on_call_shift( ): """Test deleting the shift that has started (rotation_start < now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0) @@ -598,7 +598,7 @@ def test_delete_future_on_call_shift( ): """Test deleting the shift that has not started (rotation_start > now)""" - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() start_date = (timezone.now() + timezone.timedelta(days=1)).replace(microsecond=0) @@ -631,7 +631,7 @@ def test_create_on_call_shift_invalid_data_rotation_start( on_call_shift_internal_api_setup, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -660,7 +660,7 @@ def test_create_on_call_shift_invalid_data_rotation_start( @pytest.mark.django_db def test_create_on_call_shift_invalid_data_until(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -713,7 +713,7 @@ def test_create_on_call_shift_invalid_data_until(on_call_shift_internal_api_setu @pytest.mark.django_db def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -763,7 +763,7 @@ def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_set @pytest.mark.django_db def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -813,7 +813,7 @@ def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_s @pytest.mark.django_db def test_create_on_call_shift_invalid_data_shift_end(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -866,7 +866,7 @@ def test_create_on_call_shift_invalid_data_rolling_users( on_call_shift_internal_api_setup, make_user_auth_headers, ): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, user2, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -894,7 +894,7 @@ def test_create_on_call_shift_invalid_data_rolling_users( @pytest.mark.django_db def test_create_on_call_shift_override_invalid_data(on_call_shift_internal_api_setup, make_user_auth_headers): - token, user1, user2, organization, schedule = on_call_shift_internal_api_setup + token, user1, _, _, schedule = on_call_shift_internal_api_setup client = APIClient() url = reverse("api-internal:oncall_shifts-list") start_date = timezone.now().replace(microsecond=0, tzinfo=None) @@ -925,9 +925,9 @@ def test_create_on_call_shift_override_invalid_data(on_call_shift_internal_api_s @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_201_CREATED), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_create_permissions( @@ -936,7 +936,7 @@ def test_on_call_shift_create_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() @@ -957,9 +957,9 @@ def test_on_call_shift_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_update_permissions( @@ -1005,9 +1005,9 @@ def test_on_call_shift_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_list_permissions( @@ -1016,7 +1016,7 @@ def test_on_call_shift_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:oncall_shifts-list") @@ -1036,9 +1036,9 @@ def test_on_call_shift_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_retrieve_permissions( @@ -1079,9 +1079,9 @@ def test_on_call_shift_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_delete_permissions( @@ -1122,9 +1122,9 @@ def test_on_call_shift_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_frequency_options_permissions( @@ -1153,9 +1153,9 @@ def test_on_call_shift_frequency_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_on_call_shift_days_options_permissions( @@ -1184,9 +1184,9 @@ def test_on_call_shift_days_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_on_call_shift_preview_permissions( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index ed13fb2c..518c9f5a 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -6,30 +6,25 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_current_team_retrieve_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - org = make_organization() - tester = make_user_for_organization(org, role=role) - _, token = make_token_for_organization(org) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-current-team") @@ -48,23 +43,18 @@ def test_current_team_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_current_team_update_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - org = make_organization() - tester = make_user_for_organization(org, role=role) - _, token = make_token_for_organization(org) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-current-team") @@ -84,9 +74,9 @@ def test_current_team_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_current_team_get_telegram_verification_code_permissions( @@ -95,8 +85,7 @@ def test_current_team_get_telegram_verification_code_permissions( role, expected_status, ): - organization, tester, token = make_organization_and_user_with_plugin_token(role) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-get-telegram-verification-code") @@ -109,9 +98,9 @@ def test_current_team_get_telegram_verification_code_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_current_team_get_channel_verification_code_permissions( @@ -120,8 +109,7 @@ def test_current_team_get_channel_verification_code_permissions( role, expected_status, ): - organization, tester, token = make_organization_and_user_with_plugin_token(role) - + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:api-get-channel-verification-code") + "?backend=TESTONLY" @@ -135,8 +123,7 @@ def test_current_team_get_channel_verification_code_ok( make_organization_and_user_with_plugin_token, make_user_auth_headers, ): - organization, tester, token = make_organization_and_user_with_plugin_token(Role.ADMIN) - + organization, tester, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:api-get-channel-verification-code") + "?backend=TESTONLY" @@ -156,8 +143,7 @@ def test_current_team_get_channel_verification_code_invalid( make_organization_and_user_with_plugin_token, make_user_auth_headers, ): - organization, tester, token = make_organization_and_user_with_plugin_token(Role.ADMIN) - + _, tester, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:api-get-channel-verification-code") + "?backend=INVALID" diff --git a/engine/apps/api/tests/test_postmortem_messages.py b/engine/apps/api/tests/test_postmortem_messages.py index fe45ded0..e2877ce0 100644 --- a/engine/apps/api/tests/test_postmortem_messages.py +++ b/engine/apps/api/tests/test_postmortem_messages.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import ResolutionNote -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @@ -212,9 +212,9 @@ def test_delete_resolution_note( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_resolution_note_create_permissions( @@ -224,7 +224,7 @@ def test_resolution_note_create_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:resolution_note-list") @@ -245,9 +245,9 @@ def test_resolution_note_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_resolution_note_update_permissions( @@ -260,7 +260,7 @@ def test_resolution_note_update_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) resolution_note = make_resolution_note( @@ -289,9 +289,9 @@ def test_resolution_note_update_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_204_NO_CONTENT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_resolution_note_delete_permissions( @@ -304,7 +304,7 @@ def test_resolution_note_delete_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) resolution_note = make_resolution_note( @@ -331,9 +331,9 @@ def test_resolution_note_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_resolution_note_list_permissions( @@ -343,7 +343,7 @@ def test_resolution_note_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:resolution_note-list") @@ -363,9 +363,9 @@ def test_resolution_note_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_resolution_note_detail_permissions( @@ -378,7 +378,7 @@ def test_resolution_note_detail_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) resolution_note = make_resolution_note( diff --git a/engine/apps/api/tests/test_public_api_tokens.py b/engine/apps/api/tests/test_public_api_tokens.py new file mode 100644 index 00000000..54987e98 --- /dev/null +++ b/engine/apps/api/tests/test_public_api_tokens.py @@ -0,0 +1,115 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.api.permissions import LegacyAccessControlRole + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], +) +def test_public_api_tokens_retrieve_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, + role, + expected_status, +): + organization, user, plugin_token = make_organization_and_user_with_plugin_token(role) + api_token, _ = make_public_api_token(user, organization) + client = APIClient() + + url = reverse("api-internal:api_token-detail", kwargs={"pk": api_token.id}) + response = client.get(url, format="json", **make_user_auth_headers(user, plugin_token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], +) +def test_public_api_tokens_list_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, + role, + expected_status, +): + organization, user, plugin_token = make_organization_and_user_with_plugin_token(role) + make_public_api_token(user, organization) + client = APIClient() + + url = reverse("api-internal:api_token-list") + response = client.get(url, format="json", **make_user_auth_headers(user, plugin_token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_public_api_tokens_create_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + _, user, plugin_token = make_organization_and_user_with_plugin_token(role) + client = APIClient() + + url = reverse("api-internal:api_token-list") + response = client.post( + url, + data={ + "name": "helloooo", + }, + format="json", + **make_user_auth_headers(user, plugin_token), + ) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + ], +) +def test_public_api_tokens_delete_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_public_api_token, + role, + expected_status, +): + organization, user, plugin_token = make_organization_and_user_with_plugin_token(role) + api_token, _ = make_public_api_token(user, organization) + client = APIClient() + + url = reverse("api-internal:api_token-detail", kwargs={"pk": api_token.id}) + response = client.delete(url, format="json", **make_user_auth_headers(user, plugin_token)) + + assert response.status_code == expected_status diff --git a/engine/apps/api/tests/test_schedule_export.py b/engine/apps/api/tests/test_schedule_export.py index ecbdec7e..02dc6a5b 100644 --- a/engine/apps/api/tests/test_schedule_export.py +++ b/engine/apps/api/tests/test_schedule_export.py @@ -3,9 +3,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.models import ScheduleExportAuthToken from apps.schedules.models import OnCallScheduleICal -from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" # noqa @@ -14,9 +14,9 @@ ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_get_schedule_export_token( @@ -26,8 +26,7 @@ def test_get_schedule_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -50,9 +49,9 @@ def test_get_schedule_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_export_token_not_found( @@ -62,8 +61,7 @@ def test_schedule_export_token_not_found( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -84,9 +82,9 @@ def test_schedule_export_token_not_found( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_201_CREATED), - (Role.EDITOR, status.HTTP_201_CREATED), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_create_export_token( @@ -96,8 +94,7 @@ def test_schedule_create_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -118,9 +115,9 @@ def test_schedule_create_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_204_NO_CONTENT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_delete_export_token( @@ -130,8 +127,7 @@ def test_schedule_delete_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 9c73027d..61e241fb 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -10,6 +10,7 @@ from rest_framework.serializers import ValidationError from rest_framework.test import APIClient from apps.alerts.models import EscalationPolicy +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import memoized_users_in_ical from apps.schedules.models import ( CustomOnCallShift, @@ -18,7 +19,6 @@ from apps.schedules.models import ( OnCallScheduleICal, OnCallScheduleWeb, ) -from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -1062,7 +1062,7 @@ def test_merging_same_shift_events( user_a = make_user_for_organization(organization) user_b = make_user_for_organization(organization) - user_c = make_user_for_organization(organization, role=Role.VIEWER) + user_c = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) # clear users pks <-> organization cache (persisting between tests) memoized_users_in_ical.cache_clear() @@ -1158,9 +1158,9 @@ def test_filter_events_invalid_type( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_create_permissions( @@ -1170,7 +1170,7 @@ def test_schedule_create_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1196,9 +1196,9 @@ def test_schedule_create_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_update_permissions( @@ -1208,7 +1208,7 @@ def test_schedule_update_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1237,7 +1237,11 @@ def test_schedule_update_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_schedule_list_permissions( make_organization_and_user_with_plugin_token, @@ -1246,7 +1250,7 @@ def test_schedule_list_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1271,7 +1275,11 @@ def test_schedule_list_permissions( @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", - [(Role.ADMIN, status.HTTP_200_OK), (Role.EDITOR, status.HTTP_200_OK), (Role.VIEWER, status.HTTP_200_OK)], + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + ], ) def test_schedule_retrieve_permissions( make_organization_and_user_with_plugin_token, @@ -1280,7 +1288,7 @@ def test_schedule_retrieve_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1306,9 +1314,9 @@ def test_schedule_retrieve_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_schedule_delete_permissions( @@ -1318,7 +1326,7 @@ def test_schedule_delete_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1344,9 +1352,9 @@ def test_schedule_delete_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_events_permissions( @@ -1356,7 +1364,7 @@ def test_events_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1382,9 +1390,9 @@ def test_events_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_reload_ical_permissions( @@ -1394,7 +1402,7 @@ def test_reload_ical_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) schedule = make_schedule( organization, schedule_class=OnCallScheduleICal, @@ -1420,9 +1428,9 @@ def test_reload_ical_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_schedule_notify_oncall_shift_freq_options_permissions( @@ -1432,7 +1440,7 @@ def test_schedule_notify_oncall_shift_freq_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:schedule-notify-oncall-shift-freq-options") client = APIClient() response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -1444,9 +1452,9 @@ def test_schedule_notify_oncall_shift_freq_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_schedule_notify_empty_oncall_options_permissions( @@ -1456,7 +1464,7 @@ def test_schedule_notify_empty_oncall_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:schedule-notify-empty-oncall-options") client = APIClient() response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -1468,9 +1476,9 @@ def test_schedule_notify_empty_oncall_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_schedule_mention_options_permissions( @@ -1480,7 +1488,7 @@ def test_schedule_mention_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:schedule-mention-options") client = APIClient() response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_set_general_log_channel.py b/engine/apps/api/tests/test_set_general_log_channel.py index 703dd324..cdcce180 100644 --- a/engine/apps/api/tests/test_set_general_log_channel.py +++ b/engine/apps/api/tests/test_set_general_log_channel.py @@ -6,7 +6,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole # Testing permissions, not view itself. So mock is ok here @@ -14,13 +14,16 @@ from common.constants.role import Role @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_set_general_log_channel_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, ): _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() diff --git a/engine/apps/api/tests/test_slack_channels.py b/engine/apps/api/tests/test_slack_channels.py index 37d2c05c..70a083b1 100644 --- a/engine/apps/api/tests/test_slack_channels.py +++ b/engine/apps/api/tests/test_slack_channels.py @@ -6,20 +6,23 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_slack_channels_list_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, ): _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() @@ -40,13 +43,17 @@ def test_slack_channels_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_slack_channels_detail_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, make_slack_channel, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_slack_channel, + role, + expected_status, ): organization, user, token = make_organization_and_user_with_plugin_token(role) slack_channel = make_slack_channel(organization.slack_team_identity) diff --git a/engine/apps/api/tests/test_slack_team_settings.py b/engine/apps/api/tests/test_slack_team_settings.py index 31df5d83..9de0e2ba 100644 --- a/engine/apps/api/tests/test_slack_team_settings.py +++ b/engine/apps/api/tests/test_slack_team_settings.py @@ -6,16 +6,16 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_slack_settings_permissions( @@ -24,7 +24,7 @@ def test_get_slack_settings_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:slack-settings") @@ -43,9 +43,9 @@ def test_get_slack_settings_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_update_slack_settings_permissions( @@ -54,7 +54,7 @@ def test_update_slack_settings_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:slack-settings") @@ -73,9 +73,9 @@ def test_update_slack_settings_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_acknowledge_remind_options_permissions( @@ -84,7 +84,7 @@ def test_get_acknowledge_remind_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:acknowledge-reminder-options") @@ -103,9 +103,9 @@ def test_get_acknowledge_remind_options_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_unacknowledge_timeout_options_permissions( @@ -114,7 +114,7 @@ def test_get_unacknowledge_timeout_options_permissions( role, expected_status, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:unacknowledge-timeout-options") diff --git a/engine/apps/api/tests/test_subscription.py b/engine/apps/api/tests/test_subscription.py index ef61c949..2753784b 100644 --- a/engine/apps/api/tests/test_subscription.py +++ b/engine/apps/api/tests/test_subscription.py @@ -6,16 +6,16 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_subscription_retrieve_permissions( diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index 40df30d8..acdfaddb 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -3,9 +3,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import OnCallScheduleCalendar from apps.user_management.models import Team -from common.constants.role import Role GENERAL_TEAM = Team(public_primary_key=None, name="General", email=None, avatar_url=None) @@ -64,28 +64,24 @@ def test_list_teams_for_non_member( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_list_teams_permissions( - make_organization, - make_token_for_organization, - make_user_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - _, token = make_token_for_organization(organization) - user = make_user_for_organization(organization, role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:team-list") response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == expected_status @pytest.mark.django_db diff --git a/engine/apps/api/tests/test_telegram_channel.py b/engine/apps/api/tests/test_telegram_channel.py index 6bf26b9c..fa340ec4 100644 --- a/engine/apps/api/tests/test_telegram_channel.py +++ b/engine/apps/api/tests/test_telegram_channel.py @@ -3,14 +3,14 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db def test_not_authorized(make_organization_and_user_with_plugin_token, make_telegram_channel): client = APIClient() - organization, user, _ = make_organization_and_user_with_plugin_token() + organization, _, _ = make_organization_and_user_with_plugin_token() telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-list") @@ -34,9 +34,9 @@ def test_not_authorized(make_organization_and_user_with_plugin_token, make_teleg @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_list_telegram_channels_permissions( @@ -46,8 +46,7 @@ def test_list_telegram_channels_permissions( expected_status, ): client = APIClient() - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:telegram_channel-list") response = client.get(url, **make_user_auth_headers(user, token)) @@ -59,9 +58,9 @@ def test_list_telegram_channels_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_get_telegram_channels_permissions( @@ -72,8 +71,7 @@ def test_get_telegram_channels_permissions( expected_status, ): client = APIClient() - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-detail", kwargs={"pk": telegram_channel.public_primary_key}) @@ -86,9 +84,9 @@ def test_get_telegram_channels_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_delete_telegram_channels_permissions( @@ -100,7 +98,7 @@ def test_delete_telegram_channels_permissions( ): client = APIClient() - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-detail", kwargs={"pk": telegram_channel.public_primary_key}) @@ -113,9 +111,9 @@ def test_delete_telegram_channels_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_set_default_telegram_channels_permissions( @@ -127,8 +125,7 @@ def test_set_default_telegram_channels_permissions( ): client = APIClient() - organization, user, token = make_organization_and_user_with_plugin_token(role=role) - + organization, user, token = make_organization_and_user_with_plugin_token(role) telegram_channel = make_telegram_channel(organization=organization) url = reverse("api-internal:telegram_channel-set-default", kwargs={"pk": telegram_channel.public_primary_key}) diff --git a/engine/apps/api/tests/test_terraform_renderer.py b/engine/apps/api/tests/test_terraform_renderer.py index 16e5f654..ffa33155 100644 --- a/engine/apps/api/tests/test_terraform_renderer.py +++ b/engine/apps/api/tests/test_terraform_renderer.py @@ -18,7 +18,7 @@ def test_get_terraform_file( @pytest.mark.django_db def test_get_terraform_imports(make_organization_and_user_with_plugin_token, make_user_auth_headers): - organization, user, token = make_organization_and_user_with_plugin_token() + _, user, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:terraform_imports") response = client.get(url, format="text/plain", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index ee7f9809..ee8c74cc 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -8,10 +8,9 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from apps.base.constants import ADMIN_PERMISSIONS, EDITOR_PERMISSIONS +from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING, LegacyAccessControlRole from apps.base.models import UserNotificationPolicy from apps.user_management.models.user import default_working_hours -from common.constants.role import Role @pytest.mark.django_db @@ -81,7 +80,7 @@ def test_update_user_cant_change_email_and_username( } }, "cloud_connection_status": 0, - "permissions": ADMIN_PERMISSIONS, + "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, @@ -101,7 +100,7 @@ def test_list_users( ): organization = make_organization() admin = make_user_for_organization(organization) - editor = make_user_for_organization(organization, role=Role.EDITOR) + editor = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) _, token = make_token_for_organization(organization) client = APIClient() @@ -131,7 +130,7 @@ def test_list_users( "user": admin.username, } }, - "permissions": ADMIN_PERMISSIONS, + "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, @@ -157,7 +156,7 @@ def test_list_users( "user": editor.username, } }, - "permissions": EDITOR_PERMISSIONS, + "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[editor.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": editor.avatar_url, @@ -235,22 +234,18 @@ def test_notification_chain_verbal( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_update_self_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": tester.public_primary_key}) with patch( @@ -268,23 +263,20 @@ def test_user_update_self_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_update_other_permissions( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() + organization, tester, token = make_organization_and_user_with_plugin_token(role) admin = make_user_for_organization(organization) - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) @@ -299,22 +291,18 @@ def test_user_update_other_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_list_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-list") @@ -333,22 +321,18 @@ def test_user_list_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_user_detail_self_permissions( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": tester.public_primary_key}) @@ -367,23 +351,20 @@ def test_user_detail_self_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_detail_other_permissions( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() + organization, tester, token = make_organization_and_user_with_plugin_token(role) admin = make_user_for_organization(organization) - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) @@ -396,22 +377,18 @@ def test_user_detail_other_permissions( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_own_verification_code( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": tester.public_primary_key}) @@ -430,23 +407,20 @@ def test_user_get_own_verification_code( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_other_verification_code( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() + organization, tester, token = make_organization_and_user_with_plugin_token(role) admin = make_user_for_organization(organization) - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key}) @@ -460,22 +434,18 @@ def test_user_get_other_verification_code( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_verify_own_phone( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": tester.public_primary_key}) @@ -499,23 +469,20 @@ Tests below are outdated @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_verify_another_phone( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - other_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, tester, token = make_organization_and_user_with_plugin_token(role) + other_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": other_user.public_primary_key}) @@ -530,22 +497,18 @@ def test_user_verify_another_phone( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_own_telegram_verification_code( - make_organization, - make_user_for_organization, - make_token_for_organization, + make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - _, token = make_token_for_organization(organization) + _, tester, token = make_organization_and_user_with_plugin_token(role) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": tester.public_primary_key}) @@ -558,23 +521,20 @@ def test_user_get_own_telegram_verification_code( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_get_another_telegram_verification_code( - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=role) - other_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, tester, token = make_organization_and_user_with_plugin_token(role) + other_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": other_user.public_primary_key}) @@ -585,270 +545,16 @@ def test_user_get_another_telegram_verification_code( @pytest.mark.django_db def test_admin_can_update_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, ): - organization = make_organization() - tester = make_user_for_organization(organization, role=Role.ADMIN) - other_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) client = APIClient() data = { "email": "test@amixr.io", - "role": Role.ADMIN, - "username": "updated_test_username", - "unverified_phone_number": "+1234567890", - "slack_login": "", - } - url = reverse("api-internal:user-detail", kwargs={"pk": other_user.public_primary_key}) - response = client.put(url, format="json", data=data, **make_user_auth_headers(tester, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_update_himself( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - data = { - "email": "test@amixr.io", - "role": Role.ADMIN, - "username": "updated_test_username", - "unverified_phone_number": "+1234567890", - "slack_login": "", - } - - url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) - response = client.put(url, format="json", data=data, **make_user_auth_headers(admin, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_list_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - - url = reverse("api-internal:user-list") - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_detail_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - - url = reverse("api-internal:user-detail", kwargs={"pk": editor.public_primary_key}) - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) -@pytest.mark.django_db -def test_admin_can_get_own_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-verification-code", kwargs={"pk": admin.public_primary_key}) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) -@pytest.mark.django_db -def test_admin_can_get_another_user_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-verification-code", kwargs={"pk": editor.public_primary_key}) - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) -@pytest.mark.django_db -def test_admin_can_verify_own_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-verify-number", kwargs={"pk": admin.public_primary_key}) - - response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) -@pytest.mark.django_db -def test_admin_can_verify_another_user_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-verify-number", kwargs={"pk": editor.public_primary_key}) - - response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_get_own_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": admin.public_primary_key}) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_get_another_user_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": editor.public_primary_key}) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_get_another_user_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = ( - reverse("api-internal:user-get-backend-verification-code", kwargs={"pk": editor.public_primary_key}) - + "?backend=TESTONLY" - ) - - response = client.get(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_unlink_another_user_backend_account( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-unlink-backend", kwargs={"pk": editor.public_primary_key}) + "?backend=TESTONLY" - - response = client.post(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_admin_can_unlink_another_user_slack_account( - make_organization_with_slack_team_identity, - make_user_for_organization, - make_user_with_slack_user_identity, - make_token_for_organization, - make_user_auth_headers, -): - organization, slack_team_identity = make_organization_with_slack_team_identity() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_1", role=Role.EDITOR - ) - - _, token = make_token_for_organization(organization) - client = APIClient() - url = reverse("api-internal:user-unlink-slack", kwargs={"pk": editor.public_primary_key}) - - response = client.post(url, format="json", **make_user_auth_headers(admin, token)) - assert response.status_code == status.HTTP_200_OK - editor.refresh_from_db() - assert editor.slack_user_identity is None - - -"""Test user permissions""" - - -@pytest.mark.django_db -def test_user_cant_update_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) - - client = APIClient() - data = { - "email": "test@amixr.io", - "role": Role.ADMIN, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -856,21 +562,16 @@ def test_user_cant_update_user( url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) response = client.put(url, format="json", data=data, **make_user_auth_headers(second_user, token)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK @pytest.mark.django_db -def test_user_can_update_themself( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_admin_can_update_himself(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) client = APIClient() data = { "email": "test@amixr.io", - "role": Role.EDITOR, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -883,49 +584,267 @@ def test_user_can_update_themself( @pytest.mark.django_db -def test_user_can_list_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_admin_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) client = APIClient() url = reverse("api-internal:user-list") - response = client.get(url, format="json", **make_user_auth_headers(editor, token)) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_detail_users( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + + url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@pytest.mark.django_db +def test_admin_can_get_own_verification_code( + mock_verification_start, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) +@pytest.mark.django_db +def test_admin_can_get_another_user_verification_code( + mock_verification_start, + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-verification-code", kwargs={"pk": first_user.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@pytest.mark.django_db +def test_admin_can_verify_own_phone( + mocked_verification_check, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) + + response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) +@pytest.mark.django_db +def test_admin_can_verify_another_user_phone( + mocked_verification_check, + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-verify-number", kwargs={"pk": first_user.public_primary_key}) + + response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_get_own_telegram_verification_code( + make_organization_and_user_with_plugin_token, make_user_auth_headers +): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": user.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_get_another_user_telegram_verification_code( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": first_user.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_get_another_user_backend_verification_code( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = ( + reverse("api-internal:user-get-backend-verification-code", kwargs={"pk": first_user.public_primary_key}) + + "?backend=TESTONLY" + ) + + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_unlink_another_user_backend_account( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token() + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.ADMIN) + + client = APIClient() + url = ( + reverse("api-internal:user-unlink-backend", kwargs={"pk": first_user.public_primary_key}) + "?backend=TESTONLY" + ) + + response = client.post(url, format="json", **make_user_auth_headers(second_user, token)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_admin_can_unlink_another_user_slack_account( + make_organization_with_slack_team_identity, + make_user_for_organization, + make_user_with_slack_user_identity, + make_token_for_organization, + make_user_auth_headers, +): + organization, slack_team_identity = make_organization_with_slack_team_identity() + _, token = make_token_for_organization(organization) + + user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_2", role=LegacyAccessControlRole.ADMIN + ) + other_user = make_user_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-unlink-slack", kwargs={"pk": other_user.public_primary_key}) + + response = client.post(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + other_user.refresh_from_db() + assert other_user.slack_user_identity is None + + +"""Test user permissions""" + + +@pytest.mark.django_db +def test_user_cant_update_user( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, +): + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) + + client = APIClient() + data = { + "email": "test@amixr.io", + "username": "updated_test_username", + "unverified_phone_number": "+1234567890", + "slack_login": "", + } + url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) + response = client.put(url, format="json", data=data, **make_user_auth_headers(second_user, token)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_user_can_update_themself(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + + client = APIClient() + data = { + "email": "test@amixr.io", + "username": "updated_test_username", + "unverified_phone_number": "+1234567890", + "slack_login": "", + } + + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + response = client.put(url, format="json", data=data, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_user_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + + client = APIClient() + + url = reverse("api-internal:user-list") + response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @pytest.mark.django_db def test_user_can_detail_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - editor = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() - url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key}) + url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) - response = client.get(url, format="json", **make_user_auth_headers(editor, token)) + response = client.get(url, format="json", **make_user_auth_headers(second_user, token)) assert response.status_code == status.HTTP_403_FORBIDDEN @patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) @pytest.mark.django_db def test_user_can_get_own_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key}) @@ -938,15 +857,12 @@ def test_user_can_get_own_verification_code( @pytest.mark.django_db def test_user_cant_get_another_user_verification_code( mock_verification_start, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -958,15 +874,9 @@ def test_user_cant_get_another_user_verification_code( @patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) @pytest.mark.django_db def test_user_can_verify_own_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) @@ -979,15 +889,12 @@ def test_user_can_verify_own_phone( @pytest.mark.django_db def test_user_cant_verify_another_user_phone( mocked_verification_check, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": first_user.public_primary_key}) @@ -998,11 +905,9 @@ def test_user_cant_verify_another_user_phone( @pytest.mark.django_db def test_user_can_get_own_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": user.public_primary_key}) @@ -1013,12 +918,12 @@ def test_user_can_get_own_telegram_verification_code( @pytest.mark.django_db def test_user_cant_get_another_user_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -1029,11 +934,9 @@ def test_user_cant_get_another_user_telegram_verification_code( @pytest.mark.django_db def test_user_can_get_own_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = ( @@ -1054,12 +957,12 @@ def test_user_can_get_own_backend_verification_code( @pytest.mark.django_db def test_user_cant_get_another_user_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = ( @@ -1079,8 +982,8 @@ def test_user_can_unlink_own_slack_account( make_user_auth_headers, ): organization, slack_team_identity = make_organization_with_slack_team_identity() - user, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_1", role=Role.EDITOR + user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_2", role=LegacyAccessControlRole.EDITOR ) _, token = make_token_for_organization(organization) @@ -1094,12 +997,8 @@ def test_user_can_unlink_own_slack_account( @pytest.mark.django_db -def test_user_can_unlink_backend_own_account( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_user_can_unlink_backend_own_account(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=TESTONLY" @@ -1110,12 +1009,8 @@ def test_user_can_unlink_backend_own_account( @pytest.mark.django_db -def test_user_unlink_backend_invalid_backend_id( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_user_unlink_backend_invalid_backend_id(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=INVALID" @@ -1127,11 +1022,9 @@ def test_user_unlink_backend_invalid_backend_id( @pytest.mark.django_db def test_user_unlink_backend_backend_account_not_found( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=TESTONLY" @@ -1149,11 +1042,12 @@ def test_user_cant_unlink_slack_another_user( make_user_auth_headers, ): organization, slack_team_identity = make_organization_with_slack_team_identity() - first_user, slack_user_identity_1 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_1", role=Role.EDITOR + + first_user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_1", role=LegacyAccessControlRole.EDITOR ) - second_user, slack_user_identity_2 = make_user_with_slack_user_identity( - slack_team_identity, organization, slack_id="user_2", role=Role.EDITOR + second_user, _ = make_user_with_slack_user_identity( + slack_team_identity, organization, slack_id="user_2", role=LegacyAccessControlRole.EDITOR ) _, token = make_token_for_organization(organization) @@ -1168,12 +1062,10 @@ def test_user_cant_unlink_slack_another_user( @pytest.mark.django_db def test_user_cant_unlink_backend__another_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) client = APIClient() url = ( @@ -1187,40 +1079,16 @@ def test_user_cant_unlink_backend__another_user( """Test stakeholder permissions""" -@pytest.mark.django_db -def test_viewer_cant_create_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) - - client = APIClient() - url = reverse("api-internal:user-list") - data = { - "email": "test@amixr.io", - "role": Role.ADMIN, - "username": "test_username", - "unverified_phone_number": None, - "slack_login": "", - } - response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_403_FORBIDDEN - - @pytest.mark.django_db def test_viewer_cant_update_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) data = { "email": "test@amixr.io", - "role": Role.EDITOR, + "role": LegacyAccessControlRole.EDITOR, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -1234,16 +1102,12 @@ def test_viewer_cant_update_user( @pytest.mark.django_db -def test_viewer_cant_update_himself( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) +def test_viewer_cant_update_himself(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) data = { "email": "test@amixr.io", - "role": Role.VIEWER, + "role": LegacyAccessControlRole.VIEWER, "username": "updated_test_username", "unverified_phone_number": "+1234567890", "slack_login": "", @@ -1257,12 +1121,8 @@ def test_viewer_cant_update_himself( @pytest.mark.django_db -def test_viewer_cant_list_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) +def test_viewer_cant_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-list") @@ -1273,12 +1133,10 @@ def test_viewer_cant_list_users( @pytest.mark.django_db def test_viewer_cant_detail_users( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": first_user.public_primary_key}) @@ -1290,15 +1148,9 @@ def test_viewer_cant_detail_users( @patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock()) @pytest.mark.django_db def test_viewer_cant_get_own_verification_code( - mock_verification_start, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mock_verification_start, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key}) @@ -1311,15 +1163,12 @@ def test_viewer_cant_get_own_verification_code( @pytest.mark.django_db def test_viewer_cant_get_another_user_verification_code( mock_verification_start, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -1331,15 +1180,9 @@ def test_viewer_cant_get_another_user_verification_code( @patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None)) @pytest.mark.django_db def test_viewer_cant_verify_own_phone( - mocked_verification_check, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, + mocked_verification_check, make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key}) @@ -1352,15 +1195,12 @@ def test_viewer_cant_verify_own_phone( @pytest.mark.django_db def test_viewer_cant_verify_another_user_phone( mocked_verification_check, - make_organization, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-verify-number", kwargs={"pk": first_user.public_primary_key}) @@ -1371,11 +1211,9 @@ def test_viewer_cant_verify_another_user_phone( @pytest.mark.django_db def test_viewer_cant_get_own_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": user.public_primary_key}) @@ -1386,12 +1224,10 @@ def test_viewer_cant_get_own_telegram_verification_code( @pytest.mark.django_db def test_viewer_cant_get_another_user_telegram_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-get-telegram-verification-code", kwargs={"pk": first_user.public_primary_key}) @@ -1404,34 +1240,30 @@ def test_viewer_cant_get_another_user_telegram_verification_code( @pytest.mark.parametrize( "role,expected_status,initial_unverified_number,initial_verified_number", [ - (Role.ADMIN, status.HTTP_200_OK, "+1234567890", None), - (Role.EDITOR, status.HTTP_200_OK, "+1234567890", None), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), - (Role.ADMIN, status.HTTP_200_OK, None, "+1234567890"), - (Role.EDITOR, status.HTTP_200_OK, None, "+1234567890"), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, "+1234567890", None), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK, "+1234567890", None), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, None, "+1234567890"), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK, None, "+1234567890"), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), ], ) def test_forget_own_number( - make_organization, - make_team, + make_organization_and_user_with_plugin_token, make_user_for_organization, - make_token_for_organization, make_user_auth_headers, role, expected_status, initial_unverified_number, initial_verified_number, ): - organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) + organization, admin, token = make_organization_and_user_with_plugin_token() user = make_user_for_organization( organization, role=role, unverified_phone_number=initial_unverified_number, _verified_phone_number=initial_verified_number, ) - _, token = make_token_for_organization(organization) client = APIClient() url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key}) @@ -1456,17 +1288,16 @@ def test_forget_own_number( @pytest.mark.parametrize( "role,expected_status,initial_unverified_number,initial_verified_number", [ - (Role.ADMIN, status.HTTP_200_OK, "+1234567890", None), - (Role.EDITOR, status.HTTP_403_FORBIDDEN, "+1234567890", None), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), - (Role.ADMIN, status.HTTP_200_OK, None, "+1234567890"), - (Role.EDITOR, status.HTTP_403_FORBIDDEN, None, "+1234567890"), - (Role.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, "+1234567890", None), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN, "+1234567890", None), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, "+1234567890", None), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK, None, "+1234567890"), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN, None, "+1234567890"), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN, None, "+1234567890"), ], ) def test_forget_other_number( make_organization, - make_team, make_user_for_organization, make_token_for_organization, make_user_auth_headers, @@ -1476,26 +1307,26 @@ def test_forget_other_number( initial_verified_number, ): organization = make_organization() - user = make_user_for_organization( - organization, - role=Role.ADMIN, - unverified_phone_number=initial_unverified_number, - _verified_phone_number=initial_verified_number, - ) - other_user = make_user_for_organization(organization, role=role) _, token = make_token_for_organization(organization) + admin = make_user_for_organization( + organization, unverified_phone_number=initial_unverified_number, _verified_phone_number=initial_verified_number + ) + other_user = make_user_for_organization(organization, role=role) + admin_primary_key = admin.public_primary_key + client = APIClient() - url = reverse("api-internal:user-forget-number", kwargs={"pk": user.public_primary_key}) + url = reverse("api-internal:user-forget-number", kwargs={"pk": admin_primary_key}) with patch( "apps.twilioapp.phone_manager.PhoneManager.notify_about_changed_verified_phone_number", return_value=None ): response = client.put(url, None, format="json", **make_user_auth_headers(other_user, token)) assert response.status_code == expected_status - user_detail_url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) - response = client.get(user_detail_url, None, format="json", **make_user_auth_headers(user, token)) + user_detail_url = reverse("api-internal:user-detail", kwargs={"pk": admin_primary_key}) + response = client.get(user_detail_url, None, format="json", **make_user_auth_headers(admin, token)) assert response.status_code == status.HTTP_200_OK + if expected_status == status.HTTP_200_OK: assert not response.json()["unverified_phone_number"] assert not response.json()["verified_phone_number"] @@ -1506,11 +1337,9 @@ def test_forget_other_number( @pytest.mark.django_db def test_viewer_cant_get_own_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_auth_headers ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = ( @@ -1524,12 +1353,10 @@ def test_viewer_cant_get_own_backend_verification_code( @pytest.mark.django_db def test_viewer_cant_get_another_user_backend_verification_code( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = ( @@ -1542,13 +1369,8 @@ def test_viewer_cant_get_another_user_backend_verification_code( @pytest.mark.django_db -def test_viewer_cant_unlink_backend_own_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) - +def test_viewer_cant_unlink_backend_own_user(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-unlink-backend", kwargs={"pk": user.public_primary_key}) + "?backend=TESTONLY" @@ -1558,12 +1380,10 @@ def test_viewer_cant_unlink_backend_own_user( @pytest.mark.django_db def test_viewer_cant_unlink_backend_another_user( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers + make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_auth_headers ): - organization = make_organization() - first_user = make_user_for_organization(organization, role=Role.EDITOR) - second_user = make_user_for_organization(organization, role=Role.VIEWER) - _, token = make_token_for_organization(organization) + organization, first_user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + second_user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() url = ( @@ -1575,12 +1395,8 @@ def test_viewer_cant_unlink_backend_another_user( @pytest.mark.django_db -def test_change_timezone( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_change_timezone(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) @@ -1595,12 +1411,8 @@ def test_change_timezone( @pytest.mark.django_db @pytest.mark.parametrize("timezone", ["", 1, "NotATimezone"]) -def test_invalid_timezone( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers, timezone -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_invalid_timezone(make_organization_and_user_with_plugin_token, make_user_auth_headers, timezone): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) @@ -1612,12 +1424,8 @@ def test_invalid_timezone( @pytest.mark.django_db -def test_change_working_hours( - make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers -): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) +def test_change_working_hours(make_organization_and_user_with_plugin_token, make_user_auth_headers): + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) @@ -1651,15 +1459,9 @@ def test_change_working_hours( ], ) def test_invalid_working_hours( - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, - working_hours_extra, + make_organization_and_user_with_plugin_token, make_user_auth_headers, working_hours_extra ): - organization = make_organization() - user = make_user_for_organization(organization, role=Role.EDITOR) - _, token = make_token_for_organization(organization) + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) diff --git a/engine/apps/api/tests/test_user_groups.py b/engine/apps/api/tests/test_user_groups.py index ce7494a1..2e45e727 100644 --- a/engine/apps/api/tests/test_user_groups.py +++ b/engine/apps/api/tests/test_user_groups.py @@ -3,7 +3,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @@ -52,13 +52,16 @@ def test_usergroup_list_without_slack_installed( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), ], ) def test_usergroup_permissions( - make_organization_and_user_with_plugin_token, make_user_auth_headers, role, expected_status + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, ): _, user, token = make_organization_and_user_with_plugin_token(role) client = APIClient() diff --git a/engine/apps/api/tests/test_user_notification_policy.py b/engine/apps/api/tests/test_user_notification_policy.py index 1eb39e61..4bc9c306 100644 --- a/engine/apps/api/tests/test_user_notification_policy.py +++ b/engine/apps/api/tests/test_user_notification_policy.py @@ -6,8 +6,8 @@ from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.base.models import UserNotificationPolicy -from common.constants.role import Role DEFAULT_NOTIFICATION_CHANNEL = UserNotificationPolicy.NotificationChannel.SLACK @@ -17,7 +17,7 @@ def user_notification_policy_internal_api_setup( make_organization_and_user_with_plugin_token, make_user_for_organization, make_user_notification_policy ): organization, admin, token = make_organization_and_user_with_plugin_token() - user = make_user_for_organization(organization, Role.EDITOR) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) wait_notification_step = make_user_notification_policy( admin, UserNotificationPolicy.Step.WAIT, wait_delay=timezone.timedelta(minutes=15), important=False @@ -49,7 +49,7 @@ def user_notification_policy_internal_api_setup( @pytest.mark.django_db def test_create_notification_policy(user_notification_policy_internal_api_setup, make_user_auth_headers): - token, steps, users = user_notification_policy_internal_api_setup + token, _, users = user_notification_policy_internal_api_setup admin, _ = users client = APIClient() url = reverse("api-internal:notification_policy-list") @@ -69,7 +69,7 @@ def test_create_notification_policy(user_notification_policy_internal_api_setup, def test_admin_can_create_notification_policy_for_user( user_notification_policy_internal_api_setup, make_user_auth_headers ): - token, steps, users = user_notification_policy_internal_api_setup + token, _, users = user_notification_policy_internal_api_setup admin, user = users client = APIClient() url = reverse("api-internal:notification_policy-list") diff --git a/engine/apps/api/tests/test_user_schedule_export.py b/engine/apps/api/tests/test_user_schedule_export.py index a465a934..fd467477 100644 --- a/engine/apps/api/tests/test_user_schedule_export.py +++ b/engine/apps/api/tests/test_user_schedule_export.py @@ -3,8 +3,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.models import UserScheduleExportAuthToken -from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" # noqa @@ -13,9 +13,9 @@ ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_200_OK), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_get_user_schedule_export_token( @@ -24,8 +24,7 @@ def test_get_user_schedule_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user, @@ -45,9 +44,9 @@ def test_get_user_schedule_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_export_token_not_found( @@ -56,8 +55,7 @@ def test_user_schedule_export_token_not_found( role, expected_status, ): - - _, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:user-export-token", kwargs={"pk": user.public_primary_key}) @@ -72,9 +70,9 @@ def test_user_schedule_export_token_not_found( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_201_CREATED), - (Role.EDITOR, status.HTTP_201_CREATED), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_201_CREATED), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_create_export_token( @@ -83,8 +81,7 @@ def test_user_schedule_create_export_token( role, expected_status, ): - - _, user, token = make_organization_and_user_with_plugin_token(role=role) + _, user, token = make_organization_and_user_with_plugin_token(role) url = reverse("api-internal:user-export-token", kwargs={"pk": user.public_primary_key}) @@ -99,9 +96,9 @@ def test_user_schedule_create_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_409_CONFLICT), - (Role.EDITOR, status.HTTP_409_CONFLICT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_409_CONFLICT), + (LegacyAccessControlRole.EDITOR, status.HTTP_409_CONFLICT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_create_multiple_export_tokens_fails( @@ -110,8 +107,7 @@ def test_user_schedule_create_multiple_export_tokens_fails( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user, @@ -131,9 +127,9 @@ def test_user_schedule_create_multiple_export_tokens_fails( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_204_NO_CONTENT), - (Role.EDITOR, status.HTTP_204_NO_CONTENT), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_schedule_delete_export_token( @@ -142,8 +138,7 @@ def test_user_schedule_delete_export_token( role, expected_status, ): - - organization, user, token = make_organization_and_user_with_plugin_token(role=role) + organization, user, token = make_organization_and_user_with_plugin_token(role) instance, _ = UserScheduleExportAuthToken.create_auth_token( user=user, @@ -168,9 +163,9 @@ def test_user_schedule_delete_export_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_cannot_get_another_users_schedule_token( @@ -179,9 +174,8 @@ def test_user_cannot_get_another_users_schedule_token( role, expected_status, ): - - organization1, user1, _ = make_organization_and_user_with_plugin_token(role=role) - _, user2, token2 = make_organization_and_user_with_plugin_token(role=role) + organization1, user1, _ = make_organization_and_user_with_plugin_token(role) + _, user2, token2 = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user1, @@ -201,9 +195,9 @@ def test_user_cannot_get_another_users_schedule_token( @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_404_NOT_FOUND), - (Role.EDITOR, status.HTTP_404_NOT_FOUND), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.EDITOR, status.HTTP_404_NOT_FOUND), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_user_cannot_delete_another_users_schedule_token( @@ -212,9 +206,8 @@ def test_user_cannot_delete_another_users_schedule_token( role, expected_status, ): - - organization1, user1, _ = make_organization_and_user_with_plugin_token(role=role) - _, user2, token2 = make_organization_and_user_with_plugin_token(role=role) + organization1, user1, _ = make_organization_and_user_with_plugin_token(role) + _, user2, token2 = make_organization_and_user_with_plugin_token(role) UserScheduleExportAuthToken.create_auth_token( user=user1, diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 0af8fb9b..91a49579 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from apps.alerts.constants import ActionSource from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication @@ -160,29 +160,29 @@ class AlertGroupView( MobileAppAuthTokenAuthentication, PluginAuthentication, ) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdminOrEditor: ( - *MODIFY_ACTIONS, - "acknowledge", - "unacknowledge", - "resolve", - "unresolve", - "attach", - "unattach", - "silence", - "unsilence", - "bulk_action", - "preview_template", - ), - AnyRole: ( - *READ_ACTIONS, - "stats", - "filters", - "silence_options", - "bulk_action_options", - ), + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "stats": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "filters": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "silence_options": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "bulk_action_options": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "acknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unacknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "resolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unresolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "attach": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unattach": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "silence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unsilence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "bulk_action": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST], } http_method_names = ["get", "post"] diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 866620a8..e62599fc 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import AlertReceiveChannel -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.alert_receive_channel import ( AlertReceiveChannelSerializer, AlertReceiveChannelUpdateSerializer, @@ -66,19 +66,7 @@ class AlertReceiveChannelView( ModelViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "stop_maintenance", "start_maintenance", "change_team"), - IsAdminOrEditor: ("send_demo_alert", "preview_template"), - AnyRole: ( - *READ_ACTIONS, - "integration_options", - "maintenance_duration_options", - "maintenance_mode_options", - "counters", - "counters_per_integration", - ), - } + permission_classes = (IsAuthenticated, RBACPermission) model = AlertReceiveChannel serializer_class = AlertReceiveChannelSerializer @@ -90,6 +78,22 @@ class AlertReceiveChannelView( filterset_class = AlertReceiveChannelFilter + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "integration_options": [RBACPermission.Permissions.INTEGRATIONS_READ], + "counters": [RBACPermission.Permissions.INTEGRATIONS_READ], + "counters_per_integration": [RBACPermission.Permissions.INTEGRATIONS_READ], + "send_demo_alert": [RBACPermission.Permissions.INTEGRATIONS_TEST], + "preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "change_team": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } + def create(self, request, *args, **kwargs): if request.data["integration"] is not None and ( request.data["integration"] in AlertReceiveChannel.WEB_INTEGRATION_CHOICES diff --git a/engine/apps/api/views/alert_receive_channel_template.py b/engine/apps/api/views/alert_receive_channel_template.py index c6800ee5..d7963a4c 100644 --- a/engine/apps/api/views/alert_receive_channel_template.py +++ b/engine/apps/api/views/alert_receive_channel_template.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.alerts.models import AlertReceiveChannel -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.alert_receive_channel import AlertReceiveChannelTemplatesSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -18,11 +18,14 @@ class AlertReceiveChannelTemplateView( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: MODIFY_ACTIONS, - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } model = AlertReceiveChannel diff --git a/engine/apps/api/views/channel_filter.py b/engine/apps/api/views/channel_filter.py index efe397d1..b1664951 100644 --- a/engine/apps/api/views/channel_filter.py +++ b/engine/apps/api/views/channel_filter.py @@ -6,7 +6,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ChannelFilter -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.channel_filter import ( ChannelFilterCreateSerializer, ChannelFilterSerializer, @@ -23,11 +23,17 @@ from common.insight_log import EntityEvent, write_resource_insight_log class ChannelFilterView(PublicPrimaryKeyMixin, CreateSerializerMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "move_to_position"), - IsAdminOrEditor: ("send_demo_alert",), - AnyRole: READ_ACTIONS, + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "move_to_position": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "send_demo_alert": [RBACPermission.Permissions.INTEGRATIONS_TEST], } model = ChannelFilter diff --git a/engine/apps/api/views/custom_button.py b/engine/apps/api/views/custom_button.py index 09228d27..99037a85 100644 --- a/engine/apps/api/views/custom_button.py +++ b/engine/apps/api/views/custom_button.py @@ -4,7 +4,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet from apps.alerts.models import CustomButton -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.custom_button import CustomButtonSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin @@ -13,10 +13,16 @@ from common.insight_log import EntityEvent, write_resource_insight_log class CustomButtonView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: MODIFY_ACTIONS, - AnyRole: READ_ACTIONS, + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "metadata": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "list": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "retrieve": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "create": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "partial_update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], } model = CustomButton diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index 72c73d3a..05cdc216 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -7,7 +7,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.alerts.models import EscalationChain -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.escalation_chain import EscalationChainListSerializer, EscalationChainSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.exceptions import BadRequest @@ -17,11 +17,17 @@ from common.insight_log import EntityEvent, write_resource_insight_log class EscalationChainViewSet(TeamFilteringMixin, PublicPrimaryKeyMixin, ListSerializerMixin, viewsets.ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "copy"), - AnyRole: (*READ_ACTIONS, "details"), + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "list": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "retrieve": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "details": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "create": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "destroy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "copy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], } filter_backends = [SearchFilter] diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index c05a2f0e..a8090648 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationPolicy -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.escalation_policy import ( EscalationPolicyCreateSerializer, EscalationPolicySerializer, @@ -21,15 +21,19 @@ from common.insight_log import EntityEvent, write_resource_insight_log class EscalationPolicyView(PublicPrimaryKeyMixin, CreateSerializerMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "move_to_position"), - AnyRole: ( - *READ_ACTIONS, - "escalation_options", - "delay_options", - "num_minutes_in_window_options", - ), + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "list": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "retrieve": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "escalation_options": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "delay_options": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "num_minutes_in_window_options": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "create": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "partial_update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "destroy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "move_to_position": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], } model = EscalationPolicy diff --git a/engine/apps/api/views/integration_heartbeat.py b/engine/apps/api/views/integration_heartbeat.py index fa50e29b..0e1fa96d 100644 --- a/engine/apps/api/views/integration_heartbeat.py +++ b/engine/apps/api/views/integration_heartbeat.py @@ -3,7 +3,7 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.integration_heartbeat import IntegrationHeartBeatSerializer from apps.auth_token.auth import PluginAuthentication from apps.heartbeat.models import IntegrationHeartBeat @@ -20,10 +20,17 @@ class IntegrationHeartBeatView( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "activate", "deactivate"), - AnyRole: (*READ_ACTIONS, "timeout_options"), + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.INTEGRATIONS_READ], + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "timeout_options": [RBACPermission.Permissions.INTEGRATIONS_READ], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "activate": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "deactivate": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } model = IntegrationHeartBeat diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 57566f52..02ca047c 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -6,7 +6,7 @@ from rest_framework import status, viewsets from rest_framework.permissions import IsAuthenticated from telegram import error -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.live_setting import LiveSettingSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting @@ -21,7 +21,14 @@ from common.api_helpers.mixins import PublicPrimaryKeyMixin class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): serializer_class = LiveSettingSerializer authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "list": [RBACPermission.Permissions.OTHER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.OTHER_SETTINGS_READ], + "create": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "destroy": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def dispatch(self, request, *args, **kwargs): if not settings.FEATURE_LIVE_SETTINGS_ENABLED: diff --git a/engine/apps/api/views/maintenance.py b/engine/apps/api/views/maintenance.py index aa69dd9b..31fd8cd4 100644 --- a/engine/apps/api/views/maintenance.py +++ b/engine/apps/api/views/maintenance.py @@ -5,7 +5,7 @@ from rest_framework.views import APIView from apps.alerts.models import AlertReceiveChannel from apps.alerts.models.maintainable_object import MaintainableObject -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from common.api_helpers.exceptions import BadRequest from common.exceptions import MaintenanceCouldNotBeStartedError @@ -39,7 +39,11 @@ class GetObjectMixin: class MaintenanceAPIView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.MAINTENANCE_READ], + } def get(self, request): organization = self.request.auth.organization @@ -77,7 +81,10 @@ class MaintenanceAPIView(APIView): class MaintenanceStartAPIView(GetObjectMixin, APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "post": [RBACPermission.Permissions.MAINTENANCE_WRITE], + } def post(self, request): mode = request.data.get("mode", None) @@ -110,7 +117,10 @@ class MaintenanceStartAPIView(GetObjectMixin, APIView): class MaintenanceStopAPIView(GetObjectMixin, APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "post": [RBACPermission.Permissions.MAINTENANCE_WRITE], + } def post(self, request): instance = self.get_object(request) diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index 548a9df6..b7b6df75 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.on_call_shifts import OnCallShiftSerializer, OnCallShiftUpdateSerializer from apps.auth_token.auth import PluginAuthentication from apps.schedules.models import CustomOnCallShift @@ -18,11 +18,20 @@ from common.insight_log import EntityEvent, write_resource_insight_log class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "preview"), - AnyRole: (*READ_ACTIONS, "details", "frequency_options", "days_options"), + rbac_permissions = { + "metadata": [RBACPermission.Permissions.SCHEDULES_READ], + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "details": [RBACPermission.Permissions.SCHEDULES_READ], + "frequency_options": [RBACPermission.Permissions.SCHEDULES_READ], + "days_options": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + "preview": [RBACPermission.Permissions.SCHEDULES_WRITE], } model = CustomOnCallShift diff --git a/engine/apps/api/views/organization.py b/engine/apps/api/views/organization.py index 9545ffb3..86bae0bc 100644 --- a/engine/apps/api/views/organization.py +++ b/engine/apps/api/views/organization.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import AnyRole, IsAdmin, MethodPermission +from apps.api.permissions import RBACPermission from apps.api.serializers.organization import CurrentOrganizationSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.messaging import get_messaging_backend_from_id @@ -16,9 +16,12 @@ from common.insight_log import EntityEvent, write_resource_insight_log class CurrentOrganizationView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, MethodPermission) + permission_classes = (IsAuthenticated, RBACPermission) - method_permissions = {IsAdmin: ("PUT",), AnyRole: ("GET",)} + rbac_permissions = { + "get": [], + "put": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def get(self, request): organization = request.auth.organization @@ -46,7 +49,11 @@ class CurrentOrganizationView(APIView): class GetTelegramVerificationCode(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } def get(self, request): organization = request.auth.organization @@ -66,7 +73,11 @@ class GetTelegramVerificationCode(APIView): class GetChannelVerificationCode(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } def get(self, request): organization = request.auth.organization @@ -81,7 +92,11 @@ class GetChannelVerificationCode(APIView): class SetGeneralChannel(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "post": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + } def post(self, request): SlackChannel = apps.get_model("slack", "SlackChannel") diff --git a/engine/apps/api/views/public_api_tokens.py b/engine/apps/api/views/public_api_tokens.py index 55833ce7..2bded740 100644 --- a/engine/apps/api/views/public_api_tokens.py +++ b/engine/apps/api/views/public_api_tokens.py @@ -2,7 +2,7 @@ from rest_framework import mixins, status, viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.public_api_token import PublicApiTokenSerializer from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import MAX_PUBLIC_API_TOKENS_PER_USER @@ -19,9 +19,14 @@ class PublicApiTokenView( viewsets.GenericViewSet, ): authentication_classes = [PluginAuthentication] - permission_classes = [IsAuthenticated] - - action_permissions = {IsAdmin: (*MODIFY_ACTIONS, *READ_ACTIONS)} + permission_classes = [IsAuthenticated, RBACPermission] + rbac_permissions = { + "metadata": [RBACPermission.Permissions.API_KEYS_READ], + "list": [RBACPermission.Permissions.API_KEYS_READ], + "retrieve": [RBACPermission.Permissions.API_KEYS_READ], + "create": [RBACPermission.Permissions.API_KEYS_WRITE], + "destroy": [RBACPermission.Permissions.API_KEYS_WRITE], + } model = ApiAuthToken serializer_class = PublicApiTokenSerializer diff --git a/engine/apps/api/views/resolution_note.py b/engine/apps/api/views/resolution_note.py index 8400addd..02a77771 100644 --- a/engine/apps/api/views/resolution_note.py +++ b/engine/apps/api/views/resolution_note.py @@ -3,7 +3,7 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ResolutionNote from apps.alerts.tasks import send_update_resolution_note_signal -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.resolution_note import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMixin @@ -11,11 +11,16 @@ from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMix class ResolutionNoteView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdminOrEditor: MODIFY_ACTIONS, - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "partial_update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], } model = ResolutionNote diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 7ecebaf4..8dcf3632 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -14,7 +14,7 @@ from rest_framework.views import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationChain, EscalationPolicy -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor +from apps.api.permissions import RBACPermission from apps.api.serializers.schedule_base import ScheduleFastSerializer from apps.api.serializers.schedule_polymorphic import ( PolymorphicScheduleCreateSerializer, @@ -56,24 +56,26 @@ class ScheduleView( ModelViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - action_permissions = { - IsAdmin: ( - *MODIFY_ACTIONS, - "reload_ical", - ), - IsAdminOrEditor: ("export_token",), - AnyRole: ( - *READ_ACTIONS, - "events", - "filter_events", - "next_shifts_per_user", - "notify_empty_oncall_options", - "notify_oncall_shift_freq_options", - "mention_options", - "related_escalation_chains", - ), + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "metadata": [RBACPermission.Permissions.SCHEDULES_READ], + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "events": [RBACPermission.Permissions.SCHEDULES_READ], + "filter_events": [RBACPermission.Permissions.SCHEDULES_READ], + "next_shifts_per_user": [RBACPermission.Permissions.SCHEDULES_READ], + "notify_empty_oncall_options": [RBACPermission.Permissions.SCHEDULES_READ], + "notify_oncall_shift_freq_options": [RBACPermission.Permissions.SCHEDULES_READ], + "mention_options": [RBACPermission.Permissions.SCHEDULES_READ], + "related_escalation_chains": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + "reload_ical": [RBACPermission.Permissions.SCHEDULES_WRITE], + "export_token": [RBACPermission.Permissions.SCHEDULES_EXPORT], } + filter_backends = [SearchFilter] search_fields = ("name",) diff --git a/engine/apps/api/views/slack_team_settings.py b/engine/apps/api/views/slack_team_settings.py index 0da7525b..e52f250b 100644 --- a/engine/apps/api/views/slack_team_settings.py +++ b/engine/apps/api/views/slack_team_settings.py @@ -2,7 +2,7 @@ from rest_framework import views from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import AnyRole, IsAdmin, MethodPermission +from apps.api.permissions import RBACPermission from apps.api.serializers.organization_slack_settings import OrganizationSlackSettingsSerializer from apps.auth_token.auth import PluginAuthentication from apps.user_management.models import Organization @@ -11,11 +11,11 @@ from common.insight_log import EntityEvent, write_resource_insight_log class SlackTeamSettingsAPIView(views.APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, MethodPermission) + permission_classes = (IsAuthenticated, RBACPermission) - method_permissions = { - IsAdmin: ("PUT",), - AnyRole: ("GET",), + rbac_permissions = { + "get": [RBACPermission.Permissions.CHATOPS_READ], + "put": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], } serializer_class = OrganizationSlackSettingsSerializer diff --git a/engine/apps/api/views/telegram_channels.py b/engine/apps/api/views/telegram_channels.py index a8d5cdbb..7fd9975d 100644 --- a/engine/apps/api/views/telegram_channels.py +++ b/engine/apps/api/views/telegram_channels.py @@ -4,7 +4,7 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin +from apps.api.permissions import RBACPermission from apps.api.serializers.telegram import TelegramToOrganizationConnectorSerializer from apps.auth_token.auth import PluginAuthentication from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -19,11 +19,14 @@ class TelegramChannelViewSet( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdmin: (*MODIFY_ACTIONS, "set_default"), - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.CHATOPS_READ], + "list": [RBACPermission.Permissions.CHATOPS_READ], + "retrieve": [RBACPermission.Permissions.CHATOPS_READ], + "destroy": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + "set_default": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], } serializer_class = TelegramToOrganizationConnectorSerializer diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 0126abaf..be7ff475 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -16,12 +16,10 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.api.permissions import ( - MODIFY_ACTIONS, - READ_ACTIONS, - ActionPermission, - AnyRole, - IsAdminOrEditor, - IsOwnerOrAdmin, + IsOwnerOrHasRBACPermissions, + LegacyAccessControlRole, + RBACPermission, + user_is_authorized, ) from apps.api.serializers.team import TeamSerializer from apps.api.serializers.user import FilterUserSerializer, UserHiddenFieldsSerializer, UserSerializer @@ -41,7 +39,6 @@ from common.api_helpers.exceptions import Conflict from common.api_helpers.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator from common.api_helpers.utils import create_engine_url -from common.constants.role import Role from common.insight_log import ( ChatOpsEvent, ChatOpsType, @@ -51,6 +48,7 @@ from common.insight_log import ( ) logger = logging.getLogger(__name__) +IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions([RBACPermission.Permissions.USER_SETTINGS_ADMIN]) class CurrentUserView(APIView): @@ -89,7 +87,9 @@ class UserFilter(filters.FilterSet): """ email = filters.CharFilter(field_name="email", lookup_expr="icontains") - roles = filters.MultipleChoiceFilter(field_name="role", choices=Role.choices()) + roles = filters.MultipleChoiceFilter( + field_name="role", choices=LegacyAccessControlRole.choices() + ) # LEGACY.. this should get removed eventually class Meta: model = User @@ -109,35 +109,36 @@ class UserView( PluginAuthentication, ) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - # Non-admin users are allowed to list and retrieve users - # The overridden get_serializer_class will return - # another Serializer for non-admin users with sensitive information hidden - action_permissions = { - IsAdminOrEditor: ( - *MODIFY_ACTIONS, - "list", - "metadata", - "verify_number", - "forget_number", - "get_verification_code", - "get_backend_verification_code", - "get_telegram_verification_code", - "unlink_slack", - "unlink_telegram", - "unlink_backend", - "make_test_call", - "export_token", - "mobile_app_auth_token", - ), - AnyRole: ("retrieve", "timezone_options"), + rbac_permissions = { + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + "timezone_options": [RBACPermission.Permissions.USER_SETTINGS_READ], + "metadata": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "list": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "forget_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_backend_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "get_telegram_verification_code": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "unlink_slack": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "unlink_telegram": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "mobile_app_auth_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } - action_object_permissions = { - IsOwnerOrAdmin: ( - *MODIFY_ACTIONS, - *READ_ACTIONS, + rbac_object_permissions = { + IsOwnerOrHasUserSettingsAdminPermission: [ + "metadata", + "list", + "retrieve", + "update", + "partial_update", + "destroy", "verify_number", "forget_number", "get_verification_code", @@ -149,7 +150,7 @@ class UserView( "make_test_call", "export_token", "mobile_app_auth_token", - ), + ], } filter_serializer_class = FilterUserSerializer @@ -172,14 +173,18 @@ class UserView( filterset_class = UserFilter def get_serializer_class(self): - is_filters_request = self.request.query_params.get("filters", "false") == "true" + request = self.request + user = request.user + kwargs = self.kwargs + + is_filters_request = request.query_params.get("filters", "false") == "true" if self.action in ["list"] and is_filters_request: return self.get_filter_serializer_class() - is_users_own_data = ( - self.kwargs.get("pk") is not None and self.kwargs.get("pk") == self.request.user.public_primary_key - ) - if is_users_own_data or self.request.user.role == Role.ADMIN: + is_users_own_data = kwargs.get("pk") is not None and kwargs.get("pk") == user.public_primary_key + has_admin_permission = user_is_authorized(user, [RBACPermission.Permissions.USER_SETTINGS_ADMIN]) + + if is_users_own_data or has_admin_permission: return UserSerializer return UserHiddenFieldsSerializer diff --git a/engine/apps/api/views/user_notification_policy.py b/engine/apps/api/views/user_notification_policy.py index c1d7e553..e43622a0 100644 --- a/engine/apps/api/views/user_notification_policy.py +++ b/engine/apps/api/views/user_notification_policy.py @@ -6,14 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.api.permissions import ( - MODIFY_ACTIONS, - READ_ACTIONS, - ActionPermission, - AnyRole, - IsAdminOrEditor, - IsOwnerOrAdmin, -) +from apps.api.permissions import IsOwnerOrHasRBACPermissions, RBACPermission from apps.api.serializers.user_notification_policy import ( UserNotificationPolicySerializer, UserNotificationPolicyUpdateSerializer, @@ -31,18 +24,34 @@ from common.insight_log import EntityEvent, write_resource_insight_log class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - IsAdminOrEditor: (*MODIFY_ACTIONS, "move_to_position"), - AnyRole: (*READ_ACTIONS, "delay_options", "notify_by_options"), - } - action_object_permissions = { - IsOwnerOrAdmin: (*MODIFY_ACTIONS, "move_to_position"), - AnyRole: READ_ACTIONS, + rbac_permissions = { + "metadata": [RBACPermission.Permissions.USER_SETTINGS_READ], + "list": [RBACPermission.Permissions.USER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + "delay_options": [RBACPermission.Permissions.USER_SETTINGS_READ], + "notify_by_options": [RBACPermission.Permissions.USER_SETTINGS_READ], + "create": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "destroy": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "move_to_position": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } - ownership_field = "user" + IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions( + required_permissions=[RBACPermission.Permissions.USER_SETTINGS_ADMIN], ownership_field="user" + ) + + rbac_object_permissions = { + IsOwnerOrHasUserSettingsAdminPermission: [ + "create", + "update", + "partial_update", + "destroy", + "move_to_position", + ], + } model = UserNotificationPolicy serializer_class = UserNotificationPolicySerializer diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 47d8ece9..8b0da92a 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -8,11 +8,11 @@ from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request +from apps.api.permissions import RBACPermission, user_is_authorized from apps.grafana_plugin.helpers.gcom import check_token from apps.user_management.models import User from apps.user_management.models.organization import Organization from apps.user_management.models.region import OrganizationMovedException -from common.constants.role import Role from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME from .exceptions import InvalidToken @@ -29,7 +29,7 @@ class ApiTokenAuthentication(BaseAuthentication): auth = get_authorization_header(request).decode("utf-8") user, auth_token = self.authenticate_credentials(auth) - if user.role != Role.ADMIN: + if not user_is_authorized(user, [RBACPermission.Permissions.API_KEYS_WRITE]): raise exceptions.AuthenticationFailed( "Only users with Admin permissions are allowed to perform this action." ) diff --git a/engine/apps/base/constants.py b/engine/apps/base/constants.py deleted file mode 100644 index 3e719f8c..00000000 --- a/engine/apps/base/constants.py +++ /dev/null @@ -1,23 +0,0 @@ -# This is temporary solution to not to hardcode permissions on frontend -# Is should be removed with one which will collect permission from action_permission views' attribute -ALL_PERMISSIONS = [ - "update_incidents", - "update_alert_receive_channels", - "update_escalation_policies", - "update_notification_policies", - "update_general_log_channel_id", - "update_own_settings", - "update_other_users_settings", - "update_integrations", - "update_schedules", - "update_custom_actions", - "update_api_tokens", - "update_teams", - "update_maintenances", - "update_global_settings", - "send_demo_alert", - "view_other_users", -] -ADMIN_PERMISSIONS = ALL_PERMISSIONS -EDITOR_PERMISSIONS = ["update_incidents", "update_own_settings", "view_other_users"] -ALL_ROLES_PERMISSIONS = [] diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 3d9e366b..efd8d6e1 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -68,7 +68,7 @@ class UserNotificationPolicyLogRecord(models.Model): ERROR_NOTIFICATION_IN_SLACK_CHANNEL_IS_ARCHIVED, ERROR_NOTIFICATION_IN_SLACK_RATELIMIT, ERROR_NOTIFICATION_MESSAGING_BACKEND_ERROR, - ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + ERROR_NOTIFICATION_FORBIDDEN, ERROR_NOTIFICATION_TELEGRAM_USER_IS_DEACTIVATED, ) = range(27) @@ -258,10 +258,8 @@ class UserNotificationPolicyLogRecord(models.Model): result += f"failed to notify {user_verbal} in Slack, because channel is archived" elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK_RATELIMIT: result += f"failed to notify {user_verbal} in Slack due to Slack rate limit" - elif ( - self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE - ): - result += f"failed to notify {user_verbal}, not allowed role" + elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN: + result += f"failed to notify {user_verbal}, not allowed" elif ( self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_TELEGRAM_USER_IS_DEACTIVATED diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index c23c5f90..3aeaaa15 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -1,7 +1,7 @@ import json import logging import time -from typing import Optional, Tuple +from typing import Dict, List, Optional, Tuple, TypedDict from urllib.parse import urljoin import requests @@ -9,23 +9,51 @@ from django.conf import settings from rest_framework import status from rest_framework.response import Response +from apps.api.permissions import ACTION_PREFIX, GrafanaAPIPermission + logger = logging.getLogger(__name__) +class GrafanaUser(TypedDict): + orgId: int + userId: int + email: str + name: str + avatarUrl: str + login: str + role: str + lastSeenAt: str + lastSeenAtAge: str + + +class GrafanaUserWithPermissions(GrafanaUser): + permissions: List[GrafanaAPIPermission] + + +class GCOMInstanceInfo(TypedDict): + id: int + orgId: int + slug: str + orgSlug: str + orgName: str + url: str + status: str + + class APIClient: def __init__(self, api_url: str, api_token: str): self.api_url = api_url self.api_token = api_token + def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.head, body) + def api_get(self, endpoint: str) -> Tuple[Optional[Response], dict]: return self.call_api(endpoint, requests.get) def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: return self.call_api(endpoint, requests.post, body) - def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: - return self.call_api(endpoint, requests.head, body) - def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]: request_start = time.perf_counter() call_status = { @@ -72,30 +100,60 @@ class APIClient: class GrafanaAPIClient(APIClient): + USER_PERMISSION_ENDPOINT = f"api/access-control/users/permissions?actionPrefix={ACTION_PREFIX}" + def __init__(self, api_url: str, api_token: str): super().__init__(api_url, api_token) def check_token(self) -> Tuple[Optional[Response], dict]: return self.api_head("api/org") - def get_users(self) -> Tuple[Optional[Response], dict]: + def get_users_permissions(self, rbac_is_enabled_for_org: bool) -> Dict[str, List[GrafanaAPIPermission]]: """ - Response example: - [ - { - 'orgId': 1, - 'userId': 1, - 'email': 'user@example.com', - 'name': 'User User', - 'avatarUrl': '/avatar/79163f696e9e08958c0d3f73c160e2cc', - 'login': 'user', - 'role': 'Admin', - 'lastSeenAt': '2021-06-21T07:01:45Z', - 'lastSeenAtAge': '9m' - }, - ] + It is possible that this endpoint may not be available for certain Grafana orgs. + Ex: for Grafana Cloud orgs whom have pinned their Grafana version to an earlier version + where this endpoint is not available + + The response from the Grafana endpoint will look something like this: + { + "1": { + "grafana-oncall-app.alert-groups:read": [ + "" + ], + "grafana-oncall-app.alert-groups:write": [ + "" + ] + } + } """ - return self.api_get("api/org/users") + if not rbac_is_enabled_for_org: + return {} + data, _ = self.api_get(self.USER_PERMISSION_ENDPOINT) + if data is None: + return {} + + all_users_permissions = {} + for user_id, user_permissions in data.items(): + all_users_permissions[user_id] = [GrafanaAPIPermission(action=key) for key, _ in user_permissions.items()] + + return all_users_permissions + + def is_rbac_enabled_for_organization(self) -> bool: + _, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT) + return resp_status["status_code"] == status.HTTP_200_OK + + def get_users(self, rbac_is_enabled_for_org: bool) -> List[GrafanaUserWithPermissions]: + users, _ = self.api_get("api/org/users") + + if not users: + return [] + + user_permissions = self.get_users_permissions(rbac_is_enabled_for_org) + + # merge the users permissions response into the org users response + for user in users: + user["permissions"] = user_permissions.get(str(user["userId"]), []) + return users def get_teams(self): return self.api_get("api/teams/search?perpage=1000000") @@ -127,6 +185,7 @@ class GcomAPIClient(APIClient): ACTIVE_INSTANCE_QUERY = "instances?status=active" DELETED_INSTANCE_QUERY = "instances?status=deleted&includeDeleted=true" STACK_STATUS_DELETED = "deleted" + STACK_STATUS_ACTIVE = "active" def __init__(self, api_token: str): super().__init__(settings.GRAFANA_COM_API_URL, api_token) @@ -134,14 +193,15 @@ class GcomAPIClient(APIClient): def check_token(self): return self.api_post("api-keys/check", {"token": self.api_token}) - def get_instance_info(self, stack_id: str): - return self.api_get(f"instances/{stack_id}") + def get_instance_info(self, stack_id: str) -> Optional[GCOMInstanceInfo]: + data, _ = self.api_get(f"instances/{stack_id}?config=true") + return data def get_instances(self, query: str): return self.api_get(query) def is_stack_deleted(self, stack_id: str) -> bool: - instance_info, call_status = self.get_instance_info(stack_id) + instance_info = self.get_instance_info(stack_id) return instance_info and instance_info.get("status") == self.STACK_STATUS_DELETED def post_active_users(self, body): diff --git a/engine/apps/grafana_plugin/helpers/gcom.py b/engine/apps/grafana_plugin/helpers/gcom.py index 407f70e5..f4b5b47b 100644 --- a/engine/apps/grafana_plugin/helpers/gcom.py +++ b/engine/apps/grafana_plugin/helpers/gcom.py @@ -40,10 +40,12 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: logger.debug(f"Start authenticate by making request to gcom api for org={org_id}, stack_id={stack_id}") client = GcomAPIClient(token_string) - instance_info, status = client.get_instance_info(stack_id) + instance_info = client.get_instance_info(stack_id) if not instance_info or str(instance_info["orgId"]) != org_id: raise InvalidToken + rbac_is_enabled = client.is_rbac_enabled_for_organization() + if not organization: DynamicSetting = apps.get_model("base", "DynamicSetting") allow_signup = DynamicSetting.objects.get_or_create( @@ -60,6 +62,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: region_slug=instance_info["regionSlug"], gcom_token=token_string, gcom_token_org_last_time_synced=timezone.now(), + is_rbac_permissions_enabled=rbac_is_enabled, ) else: organization.stack_slug = instance_info["slug"] @@ -69,6 +72,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: organization.grafana_url = instance_info["url"] organization.gcom_token = token_string organization.gcom_token_org_last_time_synced = timezone.now() + organization.is_rbac_permissions_enabled = rbac_is_enabled organization.save( update_fields=[ "stack_slug", @@ -78,6 +82,7 @@ def check_gcom_permission(token_string: str, context) -> Optional["GcomToken"]: "grafana_url", "gcom_token", "gcom_token_org_last_time_synced", + "is_rbac_permissions_enabled", ] ) logger.debug(f"Finish authenticate by making request to gcom api for org={org_id}, stack_id={stack_id}") diff --git a/engine/apps/grafana_plugin/tasks/sync.py b/engine/apps/grafana_plugin/tasks/sync.py index ed58968d..204797d9 100644 --- a/engine/apps/grafana_plugin/tasks/sync.py +++ b/engine/apps/grafana_plugin/tasks/sync.py @@ -68,8 +68,8 @@ def run_organization_sync(organization_pk, force_sync): return if settings.GRAFANA_COM_API_TOKEN and settings.LICENSE == settings.CLOUD_LICENSE_NAME: client = GcomAPIClient(settings.GRAFANA_COM_API_TOKEN) - instance_info, status = client.get_instance_info(organization.stack_id) - if not instance_info or instance_info["status"] != "active": + instance_info = client.get_instance_info(organization.stack_id) + if not instance_info or instance_info["status"] != client.STACK_STATUS_ACTIVE: logger.debug(f"Canceling sync for Organization {organization_pk}, as it is no longer active.") return diff --git a/engine/apps/grafana_plugin/tests/test_grafana_api_client.py b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py new file mode 100644 index 00000000..ef4af42e --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +import pytest +from rest_framework import status + +from apps.grafana_plugin.helpers.client import GrafanaAPIClient + +API_URL = "/foo/bar" +API_TOKEN = "dfjkfdjkfd" + + +class TestGetUsersPermissions: + def test_rbac_is_not_enabled_for_org(self): + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + permissions = api_client.get_users_permissions(False) + assert len(permissions.keys()) == 0 + + @patch("apps.grafana_plugin.views.self_hosted_install.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") + + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + + permissions = api_client.get_users_permissions(True) + assert len(permissions.keys()) == 0 + + @patch("apps.grafana_plugin.views.self_hosted_install.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": [""]}}, + "asdfasdf", + ) + + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + + permissions = api_client.get_users_permissions(True) + assert permissions == { + "1": [ + {"action": "grafana-oncall-app.alert-groups:read"}, + {"action": "grafana-oncall-app.alert-groups:write"}, + ] + } + + +class TestIsRbacEnabledForOrganization: + @pytest.mark.parametrize( + "grafana_api_status_code,expected", + [ + (status.HTTP_200_OK, True), + (status.HTTP_404_NOT_FOUND, False), + ], + ) + @patch("apps.grafana_plugin.views.self_hosted_install.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 + ): + mocked_grafana_api_client_api_head.return_value = (None, {"status_code": grafana_api_status_code}) + + api_client = GrafanaAPIClient(API_URL, API_TOKEN) + assert api_client.is_rbac_enabled_for_organization() == expected diff --git a/engine/apps/grafana_plugin/tests/test_self_hosted_install.py b/engine/apps/grafana_plugin/tests/test_self_hosted_install.py index 62d835d5..244d8d4d 100644 --- a/engine/apps/grafana_plugin/tests/test_self_hosted_install.py +++ b/engine/apps/grafana_plugin/tests/test_self_hosted_install.py @@ -99,6 +99,7 @@ def test_if_organization_exists_it_is_updated( mocked_provision_plugin.return_value = provision_plugin_response mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": status.HTTP_200_OK}) + mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.return_value = True client = APIClient() url = reverse("grafana-plugin:self-hosted-install") @@ -106,6 +107,8 @@ def test_if_organization_exists_it_is_updated( assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) assert mocked_grafana_api_client.return_value.check_token.called_once_with() + assert mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.called_once_with() + assert mocked_sync_organization.called_once_with(organization) assert mocked_provision_plugin.called_once_with() assert mocked_revoke_plugin.called_once_with() @@ -117,6 +120,7 @@ def test_if_organization_exists_it_is_updated( assert organization.grafana_url == GRAFANA_API_URL assert organization.api_token == GRAFANA_TOKEN + assert organization.is_rbac_permissions_enabled is True @override_settings(SELF_HOSTED_SETTINGS=SELF_HOSTED_SETTINGS) @@ -136,6 +140,7 @@ def test_if_organization_does_not_exist_it_is_created( mocked_provision_plugin.return_value = provision_plugin_response mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": status.HTTP_200_OK}) + mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.return_value = True client = APIClient() url = reverse("grafana-plugin:self-hosted-install") @@ -146,6 +151,8 @@ def test_if_organization_does_not_exist_it_is_created( assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) assert mocked_grafana_api_client.return_value.check_token.called_once_with() + assert mocked_grafana_api_client.return_value.is_rbac_enabled_for_organization.called_once_with() + assert mocked_sync_organization.called_once_with(organization) assert mocked_provision_plugin.called_once_with() assert not mocked_revoke_plugin.called @@ -160,3 +167,4 @@ def test_if_organization_does_not_exist_it_is_created( assert organization.region_slug == REGION_SLUG assert organization.grafana_url == GRAFANA_API_URL assert organization.api_token == GRAFANA_TOKEN + assert organization.is_rbac_permissions_enabled is True diff --git a/engine/apps/grafana_plugin/tests/test_sync.py b/engine/apps/grafana_plugin/tests/test_sync.py index f37b2e41..7cf8f11f 100644 --- a/engine/apps/grafana_plugin/tests/test_sync.py +++ b/engine/apps/grafana_plugin/tests/test_sync.py @@ -26,6 +26,8 @@ class TestGcomAPIClient: info = None status = None + STACK_STATUS_ACTIVE = "active" + def reset(self): self.called = False self.info = None @@ -39,7 +41,7 @@ class TestGcomAPIClient: def get_instance_info(self, stack_id: str): self.called = True - return self.info, self.status + return self.info @pytest.mark.django_db diff --git a/engine/apps/grafana_plugin/views/self_hosted_install.py b/engine/apps/grafana_plugin/views/self_hosted_install.py index 7117cf63..8a40fed0 100644 --- a/engine/apps/grafana_plugin/views/self_hosted_install.py +++ b/engine/apps/grafana_plugin/views/self_hosted_install.py @@ -43,11 +43,14 @@ class SelfHostedInstallView(GrafanaHeadersMixin, APIView): return Response(data=provisioning_info, status=status.HTTP_400_BAD_REQUEST) organization = Organization.objects.filter(stack_id=stack_id, org_id=org_id).first() + rbac_is_enabled = grafana_api_client.is_rbac_enabled_for_organization() + if organization: organization.revoke_plugin() organization.grafana_url = grafana_url organization.api_token = grafana_api_token - organization.save(update_fields=["grafana_url", "api_token"]) + organization.is_rbac_permissions_enabled = rbac_is_enabled + organization.save(update_fields=["grafana_url", "api_token", "is_rbac_permissions_enabled"]) else: organization = Organization.objects.create( stack_id=stack_id, @@ -58,6 +61,7 @@ class SelfHostedInstallView(GrafanaHeadersMixin, APIView): region_slug=settings.SELF_HOSTED_SETTINGS["REGION_SLUG"], grafana_url=grafana_url, api_token=grafana_api_token, + is_rbac_permissions_enabled=rbac_is_enabled, ) sync_organization(organization) diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 07eb6724..3a828a51 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -8,7 +8,6 @@ from apps.base.utils import live_settings from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User from common.api_helpers.utils import create_engine_url -from common.constants.role import Role from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) @@ -61,7 +60,7 @@ class CloudConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.filter(role__in=(Role.ADMIN, Role.EDITOR)).values_list("email", flat=True)) + existing_emails = [user.email for user in User.objects.all() if user.is_notification_allowed] matching_users = [] users_url = create_engine_url("api/v1/users", override_base=GRAFANA_CLOUD_ONCALL_API_URL) diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index 21b6624c..de73343c 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting from apps.base.utils import live_settings @@ -13,7 +13,11 @@ from apps.oss_installation.models import CloudConnector, CloudHeartbeat class CloudConnectionView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "get": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + "delete": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def get(self, request): connector = CloudConnector.objects.first() diff --git a/engine/apps/oss_installation/views/cloud_heartbeat.py b/engine/apps/oss_installation/views/cloud_heartbeat.py index 932087c3..a3a2973e 100644 --- a/engine/apps/oss_installation/views/cloud_heartbeat.py +++ b/engine/apps/oss_installation/views/cloud_heartbeat.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import IsAdmin +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.cloud_heartbeat import get_heartbeat_link, setup_heartbeat_integration from apps.oss_installation.models import CloudConnector, CloudHeartbeat @@ -11,7 +11,10 @@ from apps.oss_installation.models import CloudConnector, CloudHeartbeat class CloudHeartbeatView(APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + rbac_permissions = { + "post": [RBACPermission.Permissions.OTHER_SETTINGS_WRITE], + } def post(self, request): connector = CloudConnector.objects.first() diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 3eb7685b..75d3886a 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import ActionPermission, AnyRole, IsAdmin, IsOwnerOrAdmin +from apps.api.permissions import IsOwnerOrHasRBACPermissions, RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer @@ -14,17 +14,26 @@ from apps.oss_installation.utils import cloud_user_identity_status from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator -from common.constants.role import Role + +PERMISSIONS = [RBACPermission.Permissions.OTHER_SETTINGS_WRITE] class CloudUsersView(HundredPageSizePaginator, APIView): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, IsAdmin) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": PERMISSIONS, + "post": PERMISSIONS, + } def get(self, request): organization = request.user.organization - queryset = User.objects.filter(organization=organization, role__in=[Role.ADMIN, Role.EDITOR]) + queryset = User.objects.filter( + organization=organization, + **User.build_permissions_query(RBACPermission.Permissions.NOTIFICATIONS_READ, organization), + ) if request.user.current_team is not None: queryset = queryset.filter(teams=request.user.current_team).distinct() @@ -81,15 +90,24 @@ class CloudUserView( viewsets.GenericViewSet, ): authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) + permission_classes = (IsAuthenticated, RBACPermission) - action_permissions = { - AnyRole: ("retrieve",), - IsAdmin: ("sync",), + rbac_permissions = { + "retrieve": PERMISSIONS, + "sync": PERMISSIONS, } - action_object_permissions = { - IsOwnerOrAdmin: ("retrieve", "sync"), + + IsOwnerOrHasUserSettingsAdminPermission = IsOwnerOrHasRBACPermissions( + [RBACPermission.Permissions.USER_SETTINGS_ADMIN] + ) + + rbac_object_permissions = { + IsOwnerOrHasUserSettingsAdminPermission: [ + "retrieve", + "sync", + ], } + serializer_class = CloudUserSerializer def get_queryset(self): diff --git a/engine/apps/public_api/serializers/users.py b/engine/apps/public_api/serializers/users.py index 8afdedaf..6b0f8f26 100644 --- a/engine/apps/public_api/serializers/users.py +++ b/engine/apps/public_api/serializers/users.py @@ -1,9 +1,9 @@ from rest_framework import serializers +from apps.api.permissions import LegacyAccessControlRole from apps.slack.models import SlackUserIdentity from apps.user_management.models import User from common.api_helpers.mixins import EagerLoadingMixin -from common.constants.role import Role class SlackUserIdentitySerializer(serializers.ModelSerializer): @@ -21,7 +21,7 @@ class SlackUserIdentitySerializer(serializers.ModelSerializer): class FastUserSerializer(serializers.ModelSerializer): id = serializers.ReadOnlyField(read_only=True, source="public_primary_key") email = serializers.EmailField(read_only=True) - role = serializers.SerializerMethodField() + role = serializers.SerializerMethodField() # LEGACY, should be removed eventually is_phone_number_verified = serializers.SerializerMethodField() class Meta: @@ -30,7 +30,10 @@ class FastUserSerializer(serializers.ModelSerializer): @staticmethod def get_role(obj): - return Role(obj.role).name.lower() + """ + LEGACY, should be removed eventually + """ + return LegacyAccessControlRole(obj.role).name.lower() def get_is_phone_number_verified(self, obj): return obj.verified_phone_number is not None @@ -39,8 +42,8 @@ class FastUserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer, EagerLoadingMixin): id = serializers.ReadOnlyField(read_only=True, source="public_primary_key") email = serializers.EmailField(read_only=True) - role = serializers.SerializerMethodField() slack = SlackUserIdentitySerializer(read_only=True, source="slack_user_identity") + role = serializers.SerializerMethodField() # LEGACY, should be removed eventually is_phone_number_verified = serializers.SerializerMethodField() SELECT_RELATED = [ @@ -54,7 +57,10 @@ class UserSerializer(serializers.ModelSerializer, EagerLoadingMixin): @staticmethod def get_role(obj): - return Role(obj.role).name.lower() + """ + LEGACY, should be removed eventually + """ + return LegacyAccessControlRole(obj.role).name.lower() def get_is_phone_number_verified(self, obj): return obj.verified_phone_number is not None diff --git a/engine/apps/public_api/tests/test_users.py b/engine/apps/public_api/tests/test_users.py index 892fbc38..0a0cf612 100644 --- a/engine/apps/public_api/tests/test_users.py +++ b/engine/apps/public_api/tests/test_users.py @@ -3,7 +3,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.fixture() @@ -20,7 +20,7 @@ def user_public_api_setup( def test_get_user( user_public_api_setup, ): - organization, user, token, slack_team_identity, slack_user_identity = user_public_api_setup + _, user, token, slack_team_identity, slack_user_identity = user_public_api_setup client = APIClient() @@ -93,7 +93,7 @@ def test_get_users_list_short( user_public_api_setup, make_user_for_organization, ): - organization, user_1, token, slack_team_identity, slack_user_identity = user_public_api_setup + organization, user_1, token, _, _ = user_public_api_setup user_2 = make_user_for_organization(organization) client = APIClient() @@ -145,13 +145,10 @@ def test_forbidden_access( @pytest.mark.django_db -def test_get_users_list_all_role_users( - user_public_api_setup, - make_user_for_organization, -): +def test_get_users_list_all_role_users(user_public_api_setup, make_user_for_organization): organization, admin, token, _, _ = user_public_api_setup - editor = make_user_for_organization(organization, role=Role.EDITOR) - viewer = make_user_for_organization(organization, role=Role.VIEWER) + editor = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) client = APIClient() diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 84851042..1eed6e1d 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -5,6 +5,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.views import Response from rest_framework.viewsets import ReadOnlyModelViewSet +from apps.api.permissions import LegacyAccessControlRole from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import FastUserSerializer, UserSerializer @@ -14,7 +15,6 @@ from apps.schedules.models import OnCallSchedule from apps.user_management.models import User from common.api_helpers.mixins import RateLimitHeadersMixin, ShortSerializerMixin from common.api_helpers.paginators import HundredPageSizePaginator -from common.constants.role import Role class UserFilter(filters.FilterSet): @@ -23,7 +23,9 @@ class UserFilter(filters.FilterSet): """ email = filters.CharFilter(field_name="email", lookup_expr="iexact") - roles = filters.MultipleChoiceFilter(field_name="role", choices=Role.choices()) + roles = filters.MultipleChoiceFilter( + field_name="role", choices=LegacyAccessControlRole.choices() + ) # LEGACY, should be removed eventually username = filters.CharFilter(field_name="username", lookup_expr="iexact") class Meta: diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 3e6756cd..77863e2b 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -13,6 +13,7 @@ from django.db.models import Q from django.utils import timezone from icalendar import Calendar +from apps.api.permissions import RBACPermission from apps.schedules.constants import ( ICAL_ATTENDEE, ICAL_DATETIME_END, @@ -25,7 +26,6 @@ from apps.schedules.constants import ( RE_PRIORITY, ) from apps.schedules.ical_events import ical_events -from common.constants.role import Role from common.utils import timed_lru_cache """ @@ -40,11 +40,16 @@ if TYPE_CHECKING: def users_in_ical(usernames_from_ical, organization, include_viewers=False): """ Parse ical file and return list of users found + NOTE: only grafana username will be used, consider adding grafana email and id """ - # Only grafana username will be used, consider adding grafana email and id + from apps.user_management.models import User + users_found_in_ical = organization.users if not include_viewers: - users_found_in_ical = users_found_in_ical.filter(role__in=(Role.ADMIN, Role.EDITOR)) + # TODO: this is a breaking change.... + users_found_in_ical = users_found_in_ical.filter( + **User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization) + ) user_emails = [v.lower() for v in usernames_from_ical] users_found_in_ical = users_found_in_ical.filter( diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index bb13cf5b..08d4ef44 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -5,6 +5,7 @@ import pytest import pytz from django.utils import timezone +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import ( list_of_oncall_shifts_from_ical, list_users_to_notify_from_ical, @@ -12,7 +13,6 @@ from apps.schedules.ical_utils import ( users_in_ical, ) from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar -from common.constants.role import Role @pytest.mark.django_db @@ -26,13 +26,10 @@ def test_users_in_ical_email_case_insensitive(make_organization_and_user, make_u @pytest.mark.django_db -@pytest.mark.parametrize( - "include_viewers", - [True, False], -) +@pytest.mark.parametrize("include_viewers", [True, False]) def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_for_organization, include_viewers): organization, user = make_organization_and_user() - viewer = make_user_for_organization(organization, Role.VIEWER) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) usernames = [user.username, viewer.username] result = users_in_ical(usernames, organization, include_viewers=include_viewers) @@ -43,15 +40,12 @@ def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_f @pytest.mark.django_db -@pytest.mark.parametrize( - "include_viewers", - [True, False], -) +@pytest.mark.parametrize("include_viewers", [True, False]) def test_list_users_to_notify_from_ical_viewers_inclusion( make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift, include_viewers ): organization, user = make_organization_and_user() - viewer = make_user_for_organization(organization, Role.VIEWER) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) date = timezone.now().replace(tzinfo=None, microsecond=0) diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 32a1d826..a7d0ceb5 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -4,9 +4,9 @@ import pytest import pytz from django.utils import timezone +from apps.api.permissions import LegacyAccessControlRole from apps.schedules.ical_utils import memoized_users_in_ical from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb -from common.constants.role import Role @pytest.mark.django_db @@ -18,7 +18,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched name="test_web_schedule", ) user = make_user_for_organization(organization) - viewer = make_user_for_organization(organization, role=Role.VIEWER) + viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start_date = now - timezone.timedelta(days=7) @@ -190,7 +190,7 @@ def test_filter_events_include_empty(make_organization, make_user_for_organizati schedule_class=OnCallScheduleWeb, name="test_web_schedule", ) - user = make_user_for_organization(organization, role=Role.VIEWER) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start_date = now - timezone.timedelta(days=7) diff --git a/engine/apps/slack/models/slack_team_identity.py b/engine/apps/slack/models/slack_team_identity.py index 3abefc6e..2c8bc4a8 100644 --- a/engine/apps/slack/models/slack_team_identity.py +++ b/engine/apps/slack/models/slack_team_identity.py @@ -4,10 +4,11 @@ from django.apps import apps from django.db import models from django.db.models import JSONField +from apps.api.permissions import RBACPermission from apps.slack.constants import SLACK_INVALID_AUTH_RESPONSE, SLACK_WRONG_TEAM_NAMES from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException -from common.constants.role import Role +from apps.user_management.models.user import User from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsType, write_chatops_insight_log logger = logging.getLogger(__name__) @@ -127,8 +128,10 @@ class SlackTeamIdentity(models.Model): sc = SlackClientWithErrorHandling(self.bot_access_token) members = self.get_conversation_members(sc, channel_id) - users = organization.users.filter(slack_user_identity__slack_id__in=members, role__in=[Role.ADMIN, Role.EDITOR]) - return users + return organization.users.filter( + slack_user_identity__slack_id__in=members, + **User.build_permissions_query(RBACPermission.Permissions.CHATOPS_WRITE, organization), + ) def get_conversation_members(self, slack_client, channel_id): try: diff --git a/engine/apps/slack/models/slack_usergroup.py b/engine/apps/slack/models/slack_usergroup.py index 2b5f8fb6..a319f133 100644 --- a/engine/apps/slack/models/slack_usergroup.py +++ b/engine/apps/slack/models/slack_usergroup.py @@ -7,9 +7,10 @@ from django.db import models from django.db.models import JSONField from django.utils import timezone +from apps.api.permissions import RBACPermission from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException -from common.constants.role import Role +from apps.user_management.models.user import User from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) @@ -105,7 +106,8 @@ class SlackUserGroup(models.Model): def get_users_from_members_for_organization(self, organization): return organization.users.filter( - slack_user_identity__slack_id__in=self.members, role__in=[Role.ADMIN, Role.EDITOR] + slack_user_identity__slack_id__in=self.members, + **User.build_permissions_query(RBACPermission.Permissions.CHATOPS_WRITE, organization), ) @classmethod diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index 7526843f..8208919d 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -5,8 +5,8 @@ from django.db import transaction from jinja2 import TemplateSyntaxError from rest_framework.response import Response +from apps.api.permissions import RBACPermission from apps.slack.scenarios import scenario_step -from common.constants.role import Role from common.insight_log import EntityEvent, write_resource_insight_log from common.jinja_templater import jinja_template_env @@ -21,7 +21,7 @@ class OpenAlertAppearanceDialogStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "open Alert Appearance" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 62b0be00..ea0bcb6b 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -14,6 +14,7 @@ from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackR from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation from apps.alerts.tasks import custom_button_result from apps.alerts.utils import render_curl_command +from apps.api.permissions import RBACPermission from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME, SLACK_RATE_LIMIT_DELAY from apps.slack.scenarios import scenario_step from apps.slack.scenarios.slack_renderer import AlertGroupLogSlackRenderer @@ -31,7 +32,6 @@ from apps.slack.tasks import ( update_incident_slack_message, ) from apps.slack.utils import get_cache_key_update_incident_slack_message -from common.constants.role import Role from common.utils import clean_markup, is_string_with_visible_characters from .step_mixins import CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin @@ -222,7 +222,7 @@ class InviteOtherPersonToIncident( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "invite to incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -263,7 +263,7 @@ class SilenceGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "silence incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -293,7 +293,7 @@ class UnSilenceGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "unsilence incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -317,7 +317,7 @@ class SelectAttachGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "Select Incident for Attaching to" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -473,7 +473,7 @@ class AttachGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "Attach incident" def process_signal(self, log_record): @@ -536,7 +536,7 @@ class UnAttachGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "Unattach incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -555,7 +555,7 @@ class StopInvitationProcess(CheckAlertIsUnarchivedMixin, IncidentActionsAccessCo scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "stop invitation" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -580,7 +580,8 @@ class CustomButtonProcessStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + # TODO: + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "click custom button" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -642,7 +643,7 @@ class ResolveGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "resolve incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -688,7 +689,7 @@ class UnResolveGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "unresolve incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -711,7 +712,7 @@ class AcknowledgeGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "acknowledge incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -737,7 +738,7 @@ class UnAcknowledgeGroupStep( scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, ] - ALLOWED_ROLES = [Role.ADMIN, Role.EDITOR] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] ACTION_VERBOSE = "unacknowledge incident" def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): diff --git a/engine/apps/slack/scenarios/scenario_step.py b/engine/apps/slack/scenarios/scenario_step.py index 007e9535..16be0f21 100644 --- a/engine/apps/slack/scenarios/scenario_step.py +++ b/engine/apps/slack/scenarios/scenario_step.py @@ -13,7 +13,6 @@ from apps.slack.slack_client.exceptions import ( SlackAPIRateLimitException, SlackAPITokenException, ) -from common.constants.role import Role logger = logging.getLogger(__name__) @@ -162,15 +161,6 @@ class ScenarioStep(object): step = step_class(slack_team_identity) step.process_scenario(slack_user_identity, slack_team_identity, payload, action=action, **kwargs) - def get_permission_denied_prompt(self): - current_role = self.user.get_role_display() - admins_queryset = self.organization.users.filter(role=Role.ADMIN).select_related("slack_user_identity") - admins_verbal = "No admins" - if admins_queryset.count() > 0: - admins_verbal = ", ".join(["<@{}>".format(admin.slack_user_identity.slack_id) for admin in admins_queryset]) - - return current_role, admins_verbal - def open_warning_window(self, payload, warning_text, title=None): if title is None: title = ":warning: Warning" diff --git a/engine/apps/slack/scenarios/step_mixins.py b/engine/apps/slack/scenarios/step_mixins.py index 1c7fdf0b..03ebdd91 100644 --- a/engine/apps/slack/scenarios/step_mixins.py +++ b/engine/apps/slack/scenarios/step_mixins.py @@ -1,11 +1,13 @@ import logging from abc import ABC, abstractmethod +from apps.api.permissions import user_is_authorized + logger = logging.getLogger(__name__) class AccessControl(ABC): - ALLOWED_ROLES = [] + REQUIRED_PERMISSIONS = [] ACTION_VERBOSE = "" def dispatch(self, slack_user_identity, slack_team_identity, payload, action=None): @@ -15,7 +17,7 @@ class AccessControl(ABC): self.send_denied_message(payload) def check_membership(self): - return self.user.role in self.ALLOWED_ROLES + return user_is_authorized(self.user, self.REQUIRED_PERMISSIONS) @abstractmethod def send_denied_message(self, payload): @@ -62,9 +64,7 @@ class IncidentActionsAccessControlMixin(AccessControl): class CheckAlertIsUnarchivedMixin(object): - - ALLOWED_ROLES = [] - + REQUIRED_PERMISSIONS = [] ACTION_VERBOSE = "" def check_alert_is_unarchived(self, slack_team_identity, payload, alert_group, warning=True): diff --git a/engine/apps/slack/tests/test_reset_slack.py b/engine/apps/slack/tests/test_reset_slack.py index 229f1534..b64e7b3b 100644 --- a/engine/apps/slack/tests/test_reset_slack.py +++ b/engine/apps/slack/tests/test_reset_slack.py @@ -7,24 +7,24 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.test import APIClient -from common.constants.role import Role +from apps.api.permissions import LegacyAccessControlRole @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", [ - (Role.ADMIN, status.HTTP_200_OK), - (Role.EDITOR, status.HTTP_403_FORBIDDEN), - (Role.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), ], ) def test_reset_slack_integration_permissions( - make_organization_and_user_with_plugin_token, role, expected_status, load_slack_urls, make_user_auth_headers + make_organization_and_user_with_plugin_token, load_slack_urls, make_user_auth_headers, role, expected_status ): settings.FEATURE_SLACK_INTEGRATION_ENABLED = True - _, user, token = make_organization_and_user_with_plugin_token(role) + _, user, token = make_organization_and_user_with_plugin_token(role=role) client = APIClient() url = reverse("reset-slack") diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index b22e0ba2..439a8f64 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -11,7 +11,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.api.permissions import IsAdmin, MethodPermission +from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.base.utils import live_settings from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING @@ -533,10 +533,12 @@ class SlackEventApiEndpointView(APIView): class ResetSlackView(APIView): - permission_classes = (IsAuthenticated, MethodPermission) + permission_classes = (IsAuthenticated, RBACPermission) authentication_classes = [PluginAuthentication] - method_permissions = {IsAdmin: {"POST"}} + rbac_permissions = { + "post": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + } def post(self, request): organization = request.auth.organization diff --git a/engine/apps/telegram/updates/update_handlers/button_press.py b/engine/apps/telegram/updates/update_handlers/button_press.py index 6afa11a5..005460fe 100644 --- a/engine/apps/telegram/updates/update_handlers/button_press.py +++ b/engine/apps/telegram/updates/update_handlers/button_press.py @@ -4,12 +4,12 @@ from typing import Callable, Optional, Tuple from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup +from apps.api.permissions import RBACPermission, user_is_authorized from apps.telegram.models import TelegramToUserConnector from apps.telegram.renderers.keyboard import Action from apps.telegram.updates.update_handlers import UpdateHandler from apps.telegram.utils import CallbackQueryFactory from apps.user_management.models import User -from common.constants.role import Role logger = logging.getLogger(__name__) @@ -58,7 +58,8 @@ class ButtonPressHandler(UpdateHandler): if not user: return False - return user.organization == alert_group.channel.organization and user.role in [Role.ADMIN, Role.EDITOR] + has_permission = user_is_authorized(user, [RBACPermission.Permissions.CHATOPS_WRITE]) + return user.organization == alert_group.channel.organization and has_permission @staticmethod def _get_action_context(data: str) -> ActionContext: diff --git a/engine/apps/user_management/migrations/0005_rbac_permissions.py b/engine/apps/user_management/migrations/0005_rbac_permissions.py new file mode 100644 index 00000000..560aa144 --- /dev/null +++ b/engine/apps/user_management/migrations/0005_rbac_permissions.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.15 on 2022-10-25 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0004_auto_20221025_0316'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='is_rbac_permissions_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='permissions', + field=models.JSONField(default=list), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 0be08493..f428b32d 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -192,6 +192,7 @@ class Organization(MaintainableObject): pricing_version = models.PositiveIntegerField(choices=PRICING_CHOICES, default=FREE_PUBLIC_BETA_PRICING) is_amixr_migration_started = models.BooleanField(default=False) + is_rbac_permissions_enabled = models.BooleanField(default=False) class Meta: unique_together = ("stack_id", "org_id") diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 81af4e70..2bfb7998 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -1,4 +1,6 @@ +import json import logging +import typing from urllib.parse import urljoin from django.apps import apps @@ -9,13 +11,26 @@ from django.db.models.signals import post_save from django.dispatch import receiver from emoji import demojize +from apps.api.permissions import ( + LegacyAccessControlCompatiblePermission, + LegacyAccessControlRole, + RBACPermission, + user_is_authorized, +) from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization -from common.constants.role import Role from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) +class PermissionsRegexQuery(typing.TypedDict): + permissions__regex: str + + +class RoleInQuery(typing.TypedDict): + role__in: typing.List[int] + + def generate_public_primary_key_for_user(): prefix = "U" new_public_primary_key = generate_public_primary_key(prefix) @@ -60,8 +75,9 @@ class UserManager(models.Manager): email=user["email"], name=user["name"], username=user["login"], - role=Role[user["role"].upper()], + role=LegacyAccessControlRole[user["role"].upper()], avatar_url=user["avatarUrl"], + permissions=user["permissions"], ) for user in grafana_users.values() if user["userId"] not in existing_user_ids @@ -76,23 +92,31 @@ class UserManager(models.Manager): users_to_update = [] for user in organization.users.filter(user_id__in=existing_user_ids): grafana_user = grafana_users[user.user_id] - g_user_role = Role[grafana_user["role"].upper()] + g_user_role = LegacyAccessControlRole[grafana_user["role"].upper()] + if ( user.email != grafana_user["email"] or user.name != grafana_user["name"] or user.username != grafana_user["login"] or user.role != g_user_role or user.avatar_url != grafana_user["avatarUrl"] + # instead of looping through the array of permission objects, simply take the hash + # of the string representation of the data structures and compare. + # Need to first convert the lists of objects to strings because lists/dicts are not hashable + # (because lists and dicts are not hashable.. as they are mutable) + # https://stackoverflow.com/a/22003440 + or hash(json.dumps(user.permissions)) != hash(json.dumps(grafana_user["permissions"])) ): user.email = grafana_user["email"] user.name = grafana_user["name"] user.username = grafana_user["login"] user.role = g_user_role user.avatar_url = grafana_user["avatarUrl"] + user.permissions = grafana_user["permissions"] users_to_update.append(user) organization.users.bulk_update( - users_to_update, ["email", "name", "username", "role", "avatar_url"], batch_size=5000 + users_to_update, ["email", "name", "username", "role", "avatar_url", "permissions"], batch_size=5000 ) @@ -135,7 +159,7 @@ class User(models.Model): email = models.EmailField() name = models.CharField(max_length=300) username = models.CharField(max_length=300) - role = models.PositiveSmallIntegerField(choices=Role.choices()) + role = models.PositiveSmallIntegerField(choices=LegacyAccessControlRole.choices()) avatar_url = models.URLField() # don't use "_timezone" directly, use the "timezone" property since it can be populated via slack user identity @@ -154,6 +178,7 @@ class User(models.Model): # is_active = None is used to be able to have multiple deleted users with the same user_id is_active = models.BooleanField(null=True, default=True) + permissions = models.JSONField(null=False, default=list) def __str__(self): return f"{self.pk}: {self.username}" @@ -187,13 +212,14 @@ class User(models.Model): return hasattr(self, "telegram_connection") def self_or_admin(self, user_to_check, organization) -> bool: + has_admin_permission = user_is_authorized(user_to_check, [RBACPermission.Permissions.USER_SETTINGS_ADMIN]) return user_to_check.pk == self.pk or ( - user_to_check.role == Role.ADMIN and organization.pk == user_to_check.organization_id + has_admin_permission and organization.pk == user_to_check.organization_id ) @property def is_notification_allowed(self): - return self.role in (Role.ADMIN, Role.EDITOR) + return user_is_authorized(self, [RBACPermission.Permissions.NOTIFICATIONS_READ]) # using in-memory cache instead of redis to avoid pickling python objects # @timed_lru_cache(timeout=100) @@ -249,6 +275,7 @@ class User(models.Model): result = { "username": self.username, + # LEGACY.. role should get removed eventually.. it's probably safe to remove it now? "role": self.get_role_display(), "notification_policies": notification_policies_verbal, } @@ -262,6 +289,24 @@ class User(models.Model): def insight_logs_metadata(self): return {} + @staticmethod + def build_permissions_query( + permission: LegacyAccessControlCompatiblePermission, organization + ) -> typing.Union[PermissionsRegexQuery, RoleInQuery]: + """ + This method returns a django query filter that is compatible with RBAC + as well as legacy "basic" role based authorization. If a permission is provided we simply do + a regex search where the permission column contains the permission value (need to use regex because + the JSON contains method is not supported by sqlite) + + If RBAC is not supported for the org, we make the assumption that we are looking for any users with AT LEAST + the fallback role. Ex: if the fallback role were editor than we would get editors and admins. + """ + if organization.is_rbac_permissions_enabled: + # https://stackoverflow.com/a/50251879 + return PermissionsRegexQuery(permissions__regex=r".*{0}.*".format(permission.value)) + return RoleInQuery(role__lte=permission.fallback_role.value) + # TODO: check whether this signal can be moved to save method of the model @receiver(post_save, sender=User) diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 80826c5a..740b3332 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -14,9 +14,23 @@ logger.setLevel(logging.DEBUG) def sync_organization(organization): client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) - api_users, call_status = client.get_users() + rbac_is_enabled = client.is_rbac_enabled_for_organization() + organization.is_rbac_permissions_enabled = rbac_is_enabled - sync_instance_info(organization) + if organization.gcom_token: + gcom_client = GcomAPIClient(organization.gcom_token) + instance_info = gcom_client.get_instance_info(organization.stack_id) + if not instance_info or str(instance_info["orgId"]) != organization.org_id: + return + + organization.stack_slug = instance_info["slug"] + organization.org_slug = instance_info["orgSlug"] + organization.org_title = instance_info["orgName"] + organization.region_slug = instance_info["regionSlug"] + organization.grafana_url = instance_info["url"] + organization.gcom_token_org_last_time_synced = timezone.now() + + api_users = client.get_users(rbac_is_enabled) if api_users: organization.api_token_status = Organization.API_TOKEN_STATUS_OK @@ -34,25 +48,11 @@ def sync_organization(organization): "last_time_synced", "api_token_status", "gcom_token_org_last_time_synced", + "is_rbac_permissions_enabled", ] ) -def sync_instance_info(organization): - if organization.gcom_token: - gcom_client = GcomAPIClient(organization.gcom_token) - instance_info, _ = gcom_client.get_instance_info(organization.stack_id) - if not instance_info or str(instance_info["orgId"]) != organization.org_id: - return - - organization.stack_slug = instance_info["slug"] - organization.org_slug = instance_info["orgSlug"] - organization.org_title = instance_info["orgName"] - organization.region_slug = instance_info["regionSlug"] - organization.grafana_url = instance_info["url"] - organization.gcom_token_org_last_time_synced = timezone.now() - - def sync_users_and_teams(client, api_users, organization): # check if api_users are shaped correctly. e.g. for paused instance, the response is not a list. if not api_users or not isinstance(api_users, (tuple, list)): diff --git a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py index c26b4216..b3b26e4f 100644 --- a/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py +++ b/engine/apps/user_management/tests/test_free_public_beta_subcription_strategy.py @@ -1,7 +1,7 @@ import pytest +from apps.api.permissions import LegacyAccessControlRole from apps.twilioapp.constants import TwilioCallStatuses, TwilioMessageStatuses -from common.constants.role import Role @pytest.mark.django_db @@ -13,8 +13,8 @@ def test_phone_calls_left( make_alert_group, ): organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - user = make_user_for_organization(organization, role=Role.EDITOR) + admin = make_user_for_organization(organization) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) @@ -28,8 +28,8 @@ def test_sms_left( make_organization, make_user_for_organization, make_sms, make_alert_receive_channel, make_alert_group ): organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - user = make_user_for_organization(organization, role=Role.EDITOR) + admin = make_user_for_organization(organization) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_sms(receiver=admin, status=TwilioMessageStatuses.SENT, represents_alert_group=alert_group) @@ -48,8 +48,8 @@ def test_phone_calls_and_sms_counts_together( make_alert_group, ): organization = make_organization() - admin = make_user_for_organization(organization, role=Role.ADMIN) - user = make_user_for_organization(organization, role=Role.EDITOR) + admin = make_user_for_organization(organization) + user = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_phone_call(receiver=admin, status=TwilioCallStatuses.COMPLETED, represents_alert_group=alert_group) diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index c7e03bb9..c896e725 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist 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 @@ -21,6 +22,7 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati "login": "test", "role": "admin", "avatarUrl": "/test/1234", + "permissions": [], } for user_id in (2, 3) ) @@ -97,11 +99,7 @@ def test_sync_users_for_team(make_organization, make_user_for_organization, make @pytest.mark.django_db -def test_sync_organization( - make_organization, - make_team, - make_user_for_organization, -): +def test_sync_organization(make_organization, make_team, make_user_for_organization): organization = make_organization() api_users_response = ( @@ -112,6 +110,7 @@ def test_sync_organization( "login": "test", "role": "admin", "avatarUrl": "test.test/test", + "permissions": [], }, ) @@ -135,10 +134,11 @@ def test_sync_organization( }, ) - with patch.object(GrafanaAPIClient, "get_users", return_value=(api_users_response, {"status_code": 200})): - 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)): - sync_organization(organization) + with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=IS_RBAC_ENABLED): + 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)): + sync_organization(organization) # check that users are populated assert organization.users.count() == 1 @@ -154,6 +154,9 @@ def test_sync_organization( 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.django_db def test_duplicate_user_ids(make_organization, make_user_for_organization): @@ -178,6 +181,7 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization): "login": "test", "role": "admin", "avatarUrl": "test.test/test", + "permissions": [], } ] @@ -192,7 +196,7 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization): def test_cleanup_organization_deleted(make_organization): organization = make_organization(gcom_token="TEST_GCOM_TOKEN") - with patch.object(GcomAPIClient, "get_instance_info", return_value=({"status": "deleted"}, None)): + with patch.object(GcomAPIClient, "get_instance_info", return_value={"status": "deleted"}): cleanup_organization(organization.id) with pytest.raises(ObjectDoesNotExist): diff --git a/engine/apps/user_management/tests/test_user.py b/engine/apps/user_management/tests/test_user.py index 74440cd1..6928d489 100644 --- a/engine/apps/user_management/tests/test_user.py +++ b/engine/apps/user_management/tests/test_user.py @@ -1,20 +1,15 @@ -# from unittest.mock import Mock, patch - import pytest +from apps.api.permissions import LegacyAccessControlRole from apps.user_management.models import User -from common.constants.role import Role @pytest.mark.django_db -def test_self_or_admin( - make_organization, - make_user_for_organization, -): +def test_self_or_admin(make_organization, make_user_for_organization): organization = make_organization() admin = make_user_for_organization(organization) second_admin = make_user_for_organization(organization) - editor = make_user_for_organization(organization, role=Role.EDITOR) + editor = make_user_for_organization(organization, role=LegacyAccessControlRole.EDITOR) another_organization = make_organization() admin_from_another_organization = make_user_for_organization(another_organization) @@ -26,10 +21,7 @@ def test_self_or_admin( @pytest.mark.django_db -def test_lower_email_filter( - make_organization, - make_user_for_organization, -): +def test_lower_email_filter(make_organization, make_user_for_organization): organization = make_organization() user = make_user_for_organization(organization, email="TestingUser@test.com") make_user_for_organization(organization, email="testing_user@test.com") diff --git a/engine/common/constants/role.py b/engine/common/constants/role.py deleted file mode 100644 index 69a05d04..00000000 --- a/engine/common/constants/role.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import IntEnum - - -class Role(IntEnum): - ADMIN = 0 - EDITOR = 1 - VIEWER = 2 - - @classmethod - def choices(cls): - return tuple((option.value, option.name) for option in cls) diff --git a/engine/conftest.py b/engine/conftest.py index 7ac895b7..11db43ec 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -1,4 +1,5 @@ import json +import os import sys import typing import uuid @@ -35,6 +36,13 @@ from apps.alerts.tests.factories import ( ResolutionNoteFactory, ResolutionNoteSlackMessageFactory, ) +from apps.api.permissions import ( + ACTION_PREFIX, + GrafanaAPIPermission, + LegacyAccessControlCompatiblePermission, + LegacyAccessControlRole, + RBACPermission, +) from apps.auth_token.models import ApiAuthToken, PluginAuthToken from apps.base.models.user_notification_policy_log_record import ( UserNotificationPolicyLogRecord, @@ -72,7 +80,6 @@ from apps.telegram.tests.factories import ( from apps.twilioapp.tests.factories import PhoneCallFactory, SMSFactory from apps.user_management.models.user import User, listen_for_user_model_save from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory -from common.constants.role import Role register(OrganizationFactory) register(UserFactory) @@ -112,6 +119,8 @@ register(EmailMessageFactory) register(IntegrationHeartBeatFactory) register(LiveSettingFactory) +IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True" + @pytest.fixture(autouse=True) def mock_slack_api_call(monkeypatch): @@ -142,18 +151,16 @@ def mock_telegram_bot_username(monkeypatch): @pytest.fixture def make_organization(): def _make_organization(**kwargs): - organization = OrganizationFactory(**kwargs) - - return organization + return OrganizationFactory(**kwargs, is_rbac_permissions_enabled=IS_RBAC_ENABLED) return _make_organization @pytest.fixture -def make_user_for_organization(): - def _make_user_for_organization(organization, role=Role.ADMIN, **kwargs): +def make_user_for_organization(make_user): + def _make_user_for_organization(organization, role: typing.Optional[LegacyAccessControlRole] = None, **kwargs): post_save.disconnect(listen_for_user_model_save, sender=User) - user = UserFactory(organization=organization, role=role, **kwargs) + user = make_user(organization=organization, role=role, **kwargs) post_save.disconnect(listen_for_user_model_save, sender=User) return user @@ -200,19 +207,84 @@ def make_user_auth_headers(): return _make_user_auth_headers +RoleMapping = typing.Dict[LegacyAccessControlRole, typing.List[LegacyAccessControlCompatiblePermission]] + + +def get_user_permission_role_mapping_from_frontend_plugin_json() -> RoleMapping: + """ + This is used to take the RBAC permission -> basic role grants on the frontend + and test that the RBAC grants work the same way against the backend in terms of authorization + """ + + class PluginJSONRoleDefinition(typing.TypedDict): + permissions: typing.List[GrafanaAPIPermission] + + class PluginJSONRole(typing.TypedDict): + role: PluginJSONRoleDefinition + grants: typing.List[str] + + class PluginJSON(typing.TypedDict): + roles: typing.List[PluginJSONRole] + + with open("../grafana-plugin/src/plugin.json") as fp: + plugin_json: PluginJSON = json.load(fp) + + role_mapping: RoleMapping = { + LegacyAccessControlRole.VIEWER: [], + LegacyAccessControlRole.EDITOR: [], + LegacyAccessControlRole.ADMIN: [], + } + + all_permission_classes: typing.Dict[str, LegacyAccessControlCompatiblePermission] = { + getattr(RBACPermission.Permissions, attr).value: getattr(RBACPermission.Permissions, attr) + for attr in dir(RBACPermission.Permissions) + if not attr.startswith("_") + } + + # we just care about getting the basic role grants, everything else can be ignored + for role in plugin_json["roles"]: + if grants := role["grants"]: + for permission in role["role"]["permissions"]: + # only concerned with grafana-oncall-app specific grants + # ignore things like plugins.app:access actions + action = permission["action"] + permission_class = None + + if action.startswith(ACTION_PREFIX): + permission_class = all_permission_classes[action] + + if permission_class: + for grant in grants: + try: + role = LegacyAccessControlRole[grant.upper()] + if role not in role_mapping[role]: + role_mapping[role].append(permission_class) + except KeyError: + # may come across grants like "Grafana Admin" + # which we can ignore + continue + + return role_mapping + + +ROLE_PERMISSION_MAPPING = get_user_permission_role_mapping_from_frontend_plugin_json() + + @pytest.fixture def make_user(): - def _make_user(role=Role.ADMIN, **kwargs): - user = UserFactory(role=role, **kwargs) - - return user + def _make_user(role: typing.Optional[LegacyAccessControlRole] = None, **kwargs): + role = LegacyAccessControlRole.ADMIN if role is None else role + permissions = ROLE_PERMISSION_MAPPING[role] if IS_RBAC_ENABLED else [] + return UserFactory( + role=role, permissions=[GrafanaAPIPermission(action=perm.value) for perm in permissions], **kwargs + ) return _make_user @pytest.fixture def make_organization_and_user(make_organization, make_user_for_organization): - def _make_organization_and_user(role=Role.ADMIN): + def _make_organization_and_user(role: typing.Optional[LegacyAccessControlRole] = None): organization = make_organization() user = make_user_for_organization(organization=organization, role=role) return organization, user @@ -224,33 +296,31 @@ def make_organization_and_user(make_organization, make_user_for_organization): def make_organization_and_user_with_slack_identities( make_organization_with_slack_team_identity, make_user_with_slack_user_identity ): - def _make_organization_and_user_with_slack_identities(role=Role.ADMIN): + def _make_organization_and_user_with_slack_identities(role: typing.Optional[LegacyAccessControlRole] = None): organization, slack_team_identity = make_organization_with_slack_team_identity() user, slack_user_identity = make_user_with_slack_user_identity(slack_team_identity, organization, role=role) - return organization, user, slack_team_identity, slack_user_identity return _make_organization_and_user_with_slack_identities @pytest.fixture -def make_user_with_slack_user_identity(): - def _make_slack_user_identity_with_user(slack_team_identity, organization, role=Role.ADMIN, **kwargs): - slack_user_identity = SlackUserIdentityFactory( - slack_team_identity=slack_team_identity, - **kwargs, - ) - user = UserFactory(slack_user_identity=slack_user_identity, organization=organization, role=role) +def make_user_with_slack_user_identity(make_user): + def _make_slack_user_identity_with_user( + slack_team_identity, organization, role: typing.Optional[LegacyAccessControlRole] = None, **kwargs + ): + slack_user_identity = SlackUserIdentityFactory(slack_team_identity=slack_team_identity, **kwargs) + user = make_user(slack_user_identity=slack_user_identity, organization=organization, role=role) return user, slack_user_identity return _make_slack_user_identity_with_user @pytest.fixture -def make_organization_with_slack_team_identity(make_slack_team_identity): +def make_organization_with_slack_team_identity(make_slack_team_identity, make_organization): def _make_slack_team_identity_with_organization(**kwargs): slack_team_identity = make_slack_team_identity(**kwargs) - organization = OrganizationFactory(slack_team_identity=slack_team_identity) + organization = make_organization(slack_team_identity=slack_team_identity) return organization, slack_team_identity return _make_slack_team_identity_with_organization @@ -565,10 +635,9 @@ def mock_start_disable_maintenance_task(monkeypatch): @pytest.fixture() def make_organization_and_user_with_plugin_token(make_organization_and_user, make_token_for_organization): - def _make_organization_and_user_with_plugin_token(role=Role.ADMIN): - organization, user = make_organization_and_user(role=role) + def _make_organization_and_user_with_plugin_token(role: typing.Optional[LegacyAccessControlRole] = None): + organization, user = make_organization_and_user(role) _, token = make_token_for_organization(organization) - return organization, user, token return _make_organization_and_user_with_plugin_token diff --git a/engine/tox.ini b/engine/tox.ini index 0a721f1e..7cabc843 100644 --- a/engine/tox.ini +++ b/engine/tox.ini @@ -9,6 +9,6 @@ banned-modules = [pytest] # https://pytest-django.readthedocs.io/en/latest/configuring_django.html#order-of-choosing-settings # https://pytest-django.readthedocs.io/en/latest/database.html -addopts = --reuse-db --nomigrations --color=yes --showlocals +addopts = --color=yes --showlocals # https://pytest-django.readthedocs.io/en/latest/faq.html#my-tests-are-not-being-found-why python_files = tests.py test_*.py *_tests.py diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 78b76592..0c74a2a9 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -56,7 +56,7 @@ "@babel/preset-typescript": "^7.18.6", "@grafana/data": "^9.2.4", "@grafana/eslint-config": "^5.0.0", - "@grafana/runtime": "^9.2.4", + "@grafana/runtime": "9.3.0-beta1", "@grafana/toolkit": "^9.2.4", "@grafana/ui": "^9.2.4", "@jest/globals": "^27.5.1", diff --git a/grafana-plugin/src/__mocks__/grafana/app/core/core.ts b/grafana-plugin/src/__mocks__/grafana/app/core/core.ts deleted file mode 100644 index abe4971e..00000000 --- a/grafana-plugin/src/__mocks__/grafana/app/core/core.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const contextSrv = { - hasRole: jest.fn(), -}; diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 4166833d..873bdd57 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -17,8 +17,8 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Alert } from 'models/alertgroup/alertgroup.types'; import { makeRequest } from 'network'; -import { UserAction } from 'state/userAction'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import styles from './AlertTemplatesForm.module.css'; @@ -153,7 +153,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { There are no alerts from this monitoring yet. {demoAlertEnabled ? ( - + @@ -240,7 +240,7 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => {
))} - + diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx index 782eb0d3..b9beecb3 100644 --- a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx @@ -8,7 +8,7 @@ import Text from 'components/Text/Text'; import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import styles from './NewScheduleSelector.module.css'; @@ -49,7 +49,7 @@ const NewScheduleSelector: FC = (props) => { Configure rotations and shifts directly in Grafana On-Call - + diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 52c47cd1..05eca27f 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -20,7 +20,7 @@ import { } from 'models/escalation_policy/escalation_policy.types'; import { WaitDelay } from 'models/wait_delay'; import { SelectOption } from 'state/types'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import DragHandle from './DragHandle'; import PolicyNote from './PolicyNote'; @@ -53,14 +53,14 @@ export class EscalationPolicy extends React.Component - + {escalationOption && reactStringReplace(escalationOption.display_name, /\{\{([^}]+)\}\}/g, this.replacePlaceholder)} {this._renderNote()} {is_final ? null : ( - + + + + { - + Edit teams diff --git a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx b/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx index 8695d4e6..41047296 100644 --- a/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx +++ b/grafana-plugin/src/containers/HeartbeatModal/HeartbeatForm.tsx @@ -12,8 +12,8 @@ import { HeartGreenIcon, HeartRedIcon } from 'icons'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { SelectOption } from 'state/types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import styles from './HeartbeatForm.module.css'; @@ -90,7 +90,7 @@ const HeartbeatForm = observer(({ alertReceveChannelId, onUpdate }: HeartBeatMod

OnCall will issue an incident if no alert is received every - + {

- + diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index 47317d42..ff96a67f 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -9,7 +9,7 @@ import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import { form } from './OutgoingWebhookForm.config'; @@ -56,7 +56,7 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { >
- + diff --git a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx index 16c5ad07..41aa5b16 100644 --- a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx +++ b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx @@ -14,7 +14,7 @@ import { NotificationPolicyType } from 'models/notification_policy'; import { User as UserType } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import { getColor } from './PersonalNotificationSettings.helpers'; import img from './img/default-step.png'; @@ -105,7 +105,7 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin const user = userStore.items[userPk]; - const userAction = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateNotificationPolicies; + const userAction = isCurrent ? UserActions.UserSettingsWrite : UserActions.NotificationSettingsWrite; const getPhoneStatus = () => { if (store.hasFeature(AppFeature.CloudNotifications)) { return user.cloud_connection_status; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index fe8eb348..ec1a65a3 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -16,8 +16,8 @@ import { getColor, getFromString } from 'models/schedule/schedule.helpers'; import { Layer, Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; import { findColor } from './Rotations.helpers'; @@ -112,7 +112,7 @@ class Rotations extends Component {
) : ( - + diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 811797dc..ff3bc9e4 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -15,8 +15,8 @@ import { getOverrideColor, getOverridesFromStore } from 'models/schedule/schedul import { Schedule, ScheduleType, Shift, ShiftEvents } from 'models/schedule/schedule.types'; import { Timezone } from 'models/timezone/timezone.types'; import { WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config'; import { findColor } from './Rotations.helpers'; @@ -94,7 +94,7 @@ class ScheduleOverrides extends Component ) : ( - + diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 2fb0f480..a926fb4c 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -9,7 +9,7 @@ import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; import { apiForm, calendarForm, iCalForm } from './ScheduleForm.config'; import { prepareForEdit } from './ScheduleForm.helpers'; @@ -77,7 +77,7 @@ const ScheduleForm = observer((props: ScheduleFormProps) => {
- + diff --git a/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx b/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx index 2b1fdbc5..2eab10f6 100644 --- a/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx +++ b/grafana-plugin/src/containers/SlackIntegrationButton/SlackIntegrationButton.tsx @@ -6,7 +6,7 @@ import { observer } from 'mobx-react'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserActions } from 'utils/authorization'; const SlackIntegrationButton = observer((props: { className: string; disabled?: boolean }) => { const { className, disabled } = props; @@ -35,7 +35,7 @@ const SlackIntegrationButton = observer((props: { className: string; disabled?: if (store.teamStore.currentTeam?.slack_team_identity) { return ( - + diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts index 78d2d144..028672af 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts @@ -1,4 +1,4 @@ -import { User, UserRole } from 'models/user/user.types'; +import { User } from 'models/user/user.types'; export enum UserSettingsTab { UserInfo, @@ -12,5 +12,4 @@ export enum UserSettingsTab { export interface UserFormData extends Partial { slack_user_identity_name?: string; telegram_configuration_telegram_nick_name?: string; - role?: UserRole; } diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx index d5637b0e..1b58108d 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/ICalConnector.tsx @@ -8,8 +8,8 @@ import Text from 'components/Text/Text'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { openNotification } from 'utils'; +import { UserActions } from 'utils/authorization'; import styles from './index.module.css'; @@ -88,7 +88,7 @@ const ICalConnector = (props: ICalConnectorProps) => { In case you lost your iCal link you can revoke it and generate a new one. - + diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 7758b450..9eecb73e 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -9,8 +9,8 @@ import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; interface CloudPhoneSettingsProps extends WithStoreProps { userPk?: User['pk']; @@ -119,7 +119,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { return ( <> - {store.isUserActionAllowed(UserAction.UpdateOtherUsersSettings) ? ( + {store.isUserActionAllowed(UserActions.OtherSettingsWrite) ? ( OnCall use Grafana Cloud for SMS and phone call notifications diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx index a06208dc..b8936524 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification.tsx @@ -10,8 +10,8 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { openErrorNotification } from 'utils'; +import { UserAction, UserActions } from 'utils/authorization'; import styles from './PhoneVerification.module.css'; @@ -137,7 +137,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => { const isPhoneValid = phoneHasMinimumLength && PHONE_REGEX.test(phone); const showPhoneInputError = phoneHasMinimumLength && !isPhoneValid && !isPhoneNumberHidden && !isLoading; - const action = isCurrentUser ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const action = isCurrentUser ? UserActions.UserSettingsWrite : UserActions.UserSettingsAdmin; const isButtonDisabled = phone === user.verified_phone_number || (!isCodeSent && !isPhoneValid) || !isTwilioConfigured; @@ -264,7 +264,7 @@ function ForgetPhoneScreen({ phone, onCancel, onForget }: ForgetPhoneScreenProps } interface PhoneVerificationButtonsGroupProps { - action: UserAction.UpdateOwnSettings | UserAction.UpdateOtherUsersSettings; + action: UserAction; isCodeSent: boolean; isButtonDisabled: boolean; diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx index d5d81090..a506af70 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab.tsx @@ -6,7 +6,6 @@ import cn from 'classnames/bind'; import Text from 'components/Text/Text'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { Connectors } from 'containers/UserSettings/parts/connectors'; -import { getRole } from 'models/user/user.helpers'; import { User } from 'models/user/user.types'; import { useStore } from 'state/useStore'; @@ -31,7 +30,7 @@ export const UserInfoTab = (props: UserInfoTabProps) => { <>
- To edit user details such as Username, email, and role, please visit{' '} + To edit user details such as Username, email, and roles, please visit{' '} Grafana User settings.
@@ -43,10 +42,6 @@ export const UserInfoTab = (props: UserInfoTabProps) => { {storeUser.email || '—'}
-
- - {getRole(storeUser.role)} -
); diff --git a/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx b/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx index 2215b7a8..2f28b504 100644 --- a/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx +++ b/grafana-plugin/src/containers/WithPermissionControl/WithPermissionControl.tsx @@ -5,7 +5,7 @@ import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserAction } from 'utils/authorization'; import styles from './WithPermissionControl.module.css'; diff --git a/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx b/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx index b5074956..9ea83545 100644 --- a/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx +++ b/grafana-plugin/src/containers/WithPermissionControl2/WithPermissionControl.tsx @@ -4,7 +4,7 @@ import { Tooltip } from '@grafana/ui'; import { observer } from 'mobx-react'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; +import { UserAction } from 'utils/authorization'; interface WithPermissionControlProps { userAction: UserAction; diff --git a/grafana-plugin/src/index.d.ts b/grafana-plugin/src/index.d.ts index 98c6c4b4..05637e54 100644 --- a/grafana-plugin/src/index.d.ts +++ b/grafana-plugin/src/index.d.ts @@ -22,6 +22,12 @@ declare module 'grafana/app/core/core' { // https://github.com/grafana/grafana/blob/main/public/app/core/services/context_srv.ts#L59 export const contextSrv: { - hasRole(role: OrgRole): boolean; + user: { + orgRole: OrgRole | ''; + permissions?: Record; + }; + + hasAccess(action: string, fallBack: boolean): boolean; + accessControlEnabled(): boolean; }; } diff --git a/grafana-plugin/src/models/team/team.types.ts b/grafana-plugin/src/models/team/team.types.ts index 505052db..cf511c96 100644 --- a/grafana-plugin/src/models/team/team.types.ts +++ b/grafana-plugin/src/models/team/team.types.ts @@ -1,5 +1,4 @@ import { SlackChannel } from 'models/slack_channel/slack_channel.types'; -import { UserRole } from 'models/user/user.types'; export enum SubscriptionStatus { OK, @@ -63,7 +62,6 @@ export interface Team { // ex team settings archive_alerts_from: string; is_resolution_note_required: boolean; - user_role_by_default: UserRole; env_status: { twilio_configured: boolean; diff --git a/grafana-plugin/src/models/user.ts b/grafana-plugin/src/models/user.ts index 2c96b801..3b267954 100644 --- a/grafana-plugin/src/models/user.ts +++ b/grafana-plugin/src/models/user.ts @@ -1,7 +1,3 @@ -import { UserAction } from 'state/userAction'; - -import { UserRole } from './user/user.types'; - export interface UserDTO { pk: number; slack_login: string; @@ -16,7 +12,6 @@ export interface UserDTO { verified_phone_number?: string; unverified_phone_number?: string; phone_verified: boolean; - role: UserRole; telegram_configuration: { telegram_nick_name: string; telegram_chat_id: number; @@ -29,6 +24,5 @@ export interface UserDTO { inviter_name: string | null; video_conference_link: string | null; }; - permissions: UserAction[]; trigger_video_call?: boolean; } diff --git a/grafana-plugin/src/models/user/user.config.ts b/grafana-plugin/src/models/user/user.config.ts deleted file mode 100644 index 6c2dcdf3..00000000 --- a/grafana-plugin/src/models/user/user.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserRole } from 'models/user/user.types'; - -export const DEFAULT_USER_ROLES = [ - { display_name: 'Admin', value: UserRole.ADMIN }, - { display_name: 'Editor', value: UserRole.EDITOR }, - { - display_name: 'Viewer', - value: UserRole.VIEWER, - }, -]; diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index 7d21b87d..0911b4e9 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -2,33 +2,7 @@ import React from 'react'; import { pick } from 'lodash-es'; -import { User, UserRole } from './user.types'; - -export const getIconType = (role: UserRole) => { - switch (role) { - case UserRole.ADMIN: - return 'crown'; - case UserRole.EDITOR: - return 'user'; - case UserRole.VIEWER: - return 'eye'; - default: - return 'user'; - } -}; - -export const getRole = (role: UserRole) => { - switch (role) { - case UserRole.ADMIN: - return 'Admin'; - case UserRole.EDITOR: - return 'Editor'; - case UserRole.VIEWER: - return 'Viewer'; - default: - return ''; - } -}; +import { User } from './user.types'; export const getTimezone = (user: User) => { return user.timezone || 'UTC'; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index edcea94d..0c5a822e 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -8,6 +8,7 @@ import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { move } from 'state/helpers'; +import { UserActions } from 'utils/authorization'; import { getTimezone, prepareForUpdate } from './user.helpers'; import { User } from './user.types'; @@ -55,7 +56,7 @@ export class UserStore extends BaseStore { const response = await makeRequest('/user/', {}); let timezone; - if (!response.timezone) { + if (!response.timezone && this.rootStore.isUserActionAllowed(UserActions.UserSettingsWrite)) { timezone = dayjs.tz.guess(); this.update(response.pk, { timezone }); } @@ -101,11 +102,11 @@ export class UserStore extends BaseStore { } @action - async updateItems(f: any = { searchTerm: '', roles: undefined }, page = 1) { + async updateItems(f: any = { searchTerm: '' }, page = 1) { const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility - const { searchTerm: search, roles } = filters; + const { searchTerm: search } = filters; const { count, results } = await makeRequest(this.path, { - params: { search, roles, page }, + params: { search, page }, }); this.items = { diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 4c7b6fb8..58de40b2 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -1,12 +1,5 @@ import { Team } from 'models/team/team.types'; import { Timezone } from 'models/timezone/timezone.types'; -import { UserAction } from 'state/userAction'; - -export enum UserRole { - ADMIN, - EDITOR, - VIEWER, -} export interface MessagingBackends { [key: string]: any; @@ -25,7 +18,6 @@ export interface User { username: string; slack_id: string; phone_verified: boolean; - role: UserRole; telegram_configuration: { telegram_nick_name: string; telegram_chat_id: number; // TODO check if string @@ -51,7 +43,6 @@ export interface User { inviter_name: string | null; video_conference_link: string | null; }; - permissions: UserAction[]; trigger_video_call?: boolean; export_url?: string; status?: number; diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 16a65c55..d01bd347 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -27,9 +27,9 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm import { EscalationChain } from 'models/escalation_chain/escalation_chain.types'; import { pages } from 'pages'; import { PageProps, WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import styles from './EscalationChains.module.css'; @@ -151,7 +151,7 @@ class EscalationChainsPage extends React.Component
- + @@ -149,7 +149,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); const unacknowledgeButton = ( - + @@ -157,7 +157,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); const unresolveButton = ( - + @@ -165,7 +165,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); const acknowledgeButton = ( - + @@ -189,7 +189,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key if (incident.status === IncidentStatus.Silenced) { buttons.push( - + diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index c4f48065..4be78be1 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -49,10 +49,10 @@ import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/r import { pages } from 'pages'; import { PageProps, WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import { openNotification } from 'utils'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import sanitize from 'utils/sanitize'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers'; @@ -229,7 +229,7 @@ class IncidentPage extends React.Component #{incident.root_alert_group.inside_organization_number}{' '} {incident.root_alert_group.render_for_web.title} {' '} - + @@ -646,7 +646,7 @@ function AttachedIncidentsList({ #{incident.inside_organization_number} {incident.render_for_web.title} - + diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 198c2da4..10503e7b 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -24,9 +24,9 @@ import { pages } from 'pages'; import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from 'pages/incident/Incident.helpers'; import { move } from 'state/helpers'; import { PageProps, WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; import SilenceDropdown from './parts/SilenceDropdown'; @@ -197,7 +197,7 @@ class Incidents extends React.Component
{'resolve' in store.alertGroupStore.bulkActions && ( - +
- + @@ -165,12 +165,12 @@ class OutgoingWebhooks extends React.Component { return ( - + - + @@ -355,10 +355,10 @@ class SchedulesPage extends React.Component { return ( - + - + diff --git a/grafana-plugin/src/pages/settings/SettingsPage.tsx b/grafana-plugin/src/pages/settings/SettingsPage.tsx index be6af07b..c56bb504 100644 --- a/grafana-plugin/src/pages/settings/SettingsPage.tsx +++ b/grafana-plugin/src/pages/settings/SettingsPage.tsx @@ -5,13 +5,13 @@ import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; -import { pages } from 'pages'; import ChatOpsPage from 'pages/settings/tabs/ChatOps/ChatOps'; import MainSettings from 'pages/settings/tabs/MainSettings/MainSettings'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; import { AppFeature } from 'state/features'; import { RootBaseStore } from 'state/rootBaseStore'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import { SettingsPageTab } from './SettingsPage.types'; import CloudPage from './tabs/Cloud/CloudPage'; @@ -50,13 +50,10 @@ class SettingsPage extends React.Component this.setState({ activeTab: tab }); }; - const grafanaUser = window.grafanaBootData.user; const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings); const hasCloudPage = store.hasFeature(AppFeature.CloudConnection); - const showCloudPage = - hasCloudPage && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true); - const showLiveSettings = - hasLiveSettings && (pages['cloud'].role === 'Admin' ? pages['cloud'].role === grafanaUser.orgRole : true); + const showCloudPage = hasCloudPage && store.isUserActionAllowed(UserActions.OtherSettingsWrite); + const showLiveSettings = hasLiveSettings && store.isUserActionAllowed(UserActions.OtherSettingsRead); if (isTopNavbar()) { return ( diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx index 3bd3e13e..392acfb3 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings.tsx @@ -16,8 +16,8 @@ import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config' import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; +import { UserActions } from 'utils/authorization'; import styles from './SlackSettings.module.css'; @@ -108,7 +108,7 @@ class SlackSettings extends Component {
- + { - + }
diff --git a/grafana-plugin/src/pages/users/Users.helpers.ts b/grafana-plugin/src/pages/users/Users.helpers.ts index bc4fd318..991434fd 100644 --- a/grafana-plugin/src/pages/users/Users.helpers.ts +++ b/grafana-plugin/src/pages/users/Users.helpers.ts @@ -1,4 +1,4 @@ -import { User as UserType, UserRole } from 'models/user/user.types'; +import { User as UserType } from 'models/user/user.types'; export const getUserRowClassNameFn = (userPkToEdit?: UserType['pk'], currentUserPk?: UserType['pk']) => { return (user: UserType) => { @@ -13,15 +13,3 @@ export const getUserRowClassNameFn = (userPkToEdit?: UserType['pk'], currentUser return ''; }; }; - -export const getRealFilters = (filters: any) => { - let realFilters = { ...filters }; - if (!realFilters.roles || !realFilters.roles.length) { - realFilters = { - ...filters, - roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER], - }; - } - - return realFilters; -}; diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index 6faa3b55..6f191fc5 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -19,15 +19,14 @@ import Text from 'components/Text/Text'; import UsersFilters from 'components/UsersFilters/UsersFilters'; import UserSettings from 'containers/UserSettings/UserSettings'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; -import { getRole } from 'models/user/user.helpers'; -import { User as UserType, UserRole } from 'models/user/user.types'; +import { User as UserType } from 'models/user/user.types'; import { pages } from 'pages'; import { PageProps, WithStoreProps } from 'state/types'; -import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; +import { UserActions } from 'utils/authorization'; -import { getRealFilters, getUserRowClassNameFn } from './Users.helpers'; +import { getUserRowClassNameFn } from './Users.helpers'; import styles from './Users.module.css'; @@ -43,7 +42,6 @@ interface UsersState extends PageBaseState { userPkToEdit?: UserType['pk'] | 'new'; usersFilters?: { searchTerm: string; - roles?: UserRole[]; }; } @@ -55,7 +53,6 @@ class Users extends React.Component { userPkToEdit: undefined, usersFilters: { searchTerm: '', - roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER], }, errorData: initErrorDataState(), @@ -77,18 +74,18 @@ class Users extends React.Component { const { usersFilters, page } = this.state; const { userStore } = store; - if (!store.isUserActionAllowed(UserAction.ViewOtherUsers)) { + if (!store.isUserActionAllowed(UserActions.UserSettingsWrite)) { return; } LocationHelper.update({ p: page }, 'partial'); - return await userStore.updateItems(getRealFilters(usersFilters), page); + return await userStore.updateItems(usersFilters, page); }; componentDidUpdate(prevProps: UsersProps) { const { store } = this.props; - if (!this.initialUsersLoaded && store.isUserActionAllowed(UserAction.ViewOtherUsers)) { + if (!this.initialUsersLoaded && store.isUserActionAllowed(UserActions.UserSettingsWrite)) { this.updateUsers(); this.initialUsersLoaded = true; } @@ -131,12 +128,6 @@ class Users extends React.Component { title: 'User', render: this.renderTitle, }, - { - width: '5%', - title: 'Role', - key: 'role', - render: this.renderRole, - }, { width: '20%', title: 'Status', @@ -163,12 +154,9 @@ class Users extends React.Component { ]; const handleClear = () => - this.setState( - { usersFilters: { searchTerm: '', roles: [UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER] } }, - () => { - this.debouncedUpdateUsers(); - } - ); + this.setState({ usersFilters: { searchTerm: '' } }, () => { + this.debouncedUpdateUsers(); + }); const { count, results } = userStore.getSearchResult(); @@ -202,7 +190,7 @@ class Users extends React.Component { - {store.isUserActionAllowed(UserAction.ViewOtherUsers) ? ( + {store.isUserActionAllowed(UserActions.UserSettingsRead) ? ( <>
{ ); }; - renderRole = (user: UserType) => { - return getRole(user.role); - }; - renderNotificationsChain = (user: UserType) => { return user.notification_chain_verbal.default; }; @@ -299,7 +283,7 @@ class Users extends React.Component { const { userStore } = store; const isCurrent = userStore.currentUserPk === user.pk; - const action = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateOtherUsersSettings; + const action = isCurrent ? UserActions.UserSettingsWrite : UserActions.UserSettingsAdmin; return ( diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 93e971a1..9e26e3a5 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -34,6 +34,7 @@ "name": "Alert Groups", "path": "/a/grafana-oncall-app/?page=incidents", "role": "Viewer", + "action": "grafana-oncall-app.alert-groups:read", "defaultNav": true, "addToNav": true }, @@ -42,6 +43,7 @@ "name": "Users", "path": "/a/grafana-oncall-app/?page=users", "role": "Viewer", + "action": "grafana-oncall-app.user-settings:read", "addToNav": true }, { @@ -49,6 +51,7 @@ "name": "Integrations", "path": "/a/grafana-oncall-app/?page=integrations", "role": "Viewer", + "action": "grafana-oncall-app.integrations:read", "addToNav": true }, { @@ -56,6 +59,7 @@ "name": "Escalation Chains", "path": "/a/grafana-oncall-app/?page=escalations", "role": "Viewer", + "action": "grafana-oncall-app.escalation-chains:read", "addToNav": true }, { @@ -63,6 +67,7 @@ "name": "Schedules", "path": "/a/grafana-oncall-app/?page=schedules", "role": "Viewer", + "action": "grafana-oncall-app.schedules:read", "addToNav": true }, { @@ -70,6 +75,7 @@ "name": "Outgoing Webhooks", "path": "/a/grafana-oncall-app/?page=outgoing_webhooks", "role": "Viewer", + "action": "grafana-oncall-app.outgoing-webhooks:read", "addToNav": true }, { @@ -77,6 +83,7 @@ "name": "Maintenance", "path": "/a/grafana-oncall-app/?page=maintenance", "role": "Viewer", + "action": "grafana-oncall-app.maintenance:read", "addToNav": true }, { @@ -84,6 +91,7 @@ "name": "Settings", "path": "/a/grafana-oncall-app/?page=settings", "role": "Viewer", + "action": "grafana-oncall-app.other-settings:read", "addToNav": true } ], @@ -175,6 +183,418 @@ ] } ], + "roles": [ + { + "role": { + "name": "Admin", + "description": "Read/write access to everything in OnCall", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + + { "action": "grafana-oncall-app.alert-groups:read" }, + { "action": "grafana-oncall-app.alert-groups:write" }, + + { "action": "grafana-oncall-app.integrations:read" }, + { "action": "grafana-oncall-app.integrations:write" }, + { "action": "grafana-oncall-app.integrations:test" }, + + { "action": "grafana-oncall-app.escalation-chains:read" }, + { "action": "grafana-oncall-app.escalation-chains:write" }, + + { "action": "grafana-oncall-app.schedules:read" }, + { "action": "grafana-oncall-app.schedules:write" }, + { "action": "grafana-oncall-app.schedules:export" }, + + { "action": "grafana-oncall-app.chatops:read" }, + { "action": "grafana-oncall-app.chatops:write" }, + { "action": "grafana-oncall-app.chatops:update-settings" }, + + { "action": "grafana-oncall-app.outgoing-webhooks:read" }, + { "action": "grafana-oncall-app.outgoing-webhooks:write" }, + + { "action": "grafana-oncall-app.maintenance:read" }, + { "action": "grafana-oncall-app.maintenance:write" }, + + { "action": "grafana-oncall-app.api-keys:read" }, + { "action": "grafana-oncall-app.api-keys:write" }, + + { "action": "grafana-oncall-app.notifications:read" }, + + { "action": "grafana-oncall-app.notification-settings:read" }, + { "action": "grafana-oncall-app.notification-settings:write" }, + + { "action": "grafana-oncall-app.user-settings:read" }, + { "action": "grafana-oncall-app.user-settings:write" }, + { "action": "grafana-oncall-app.user-settings:admin" }, + + { "action": "grafana-oncall-app.other-settings:read" }, + { "action": "grafana-oncall-app.other-settings:write" } + ], + "hidden": true + }, + "grants": ["Grafana Admin", "Admin"] + }, + { + "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.", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + + { "action": "grafana-oncall-app.alert-groups:read" }, + { "action": "grafana-oncall-app.alert-groups:write" }, + + { "action": "grafana-oncall-app.integrations:read" }, + { "action": "grafana-oncall-app.integrations:test" }, + + { "action": "grafana-oncall-app.escalation-chains:read" }, + + { "action": "grafana-oncall-app.schedules:read" }, + { "action": "grafana-oncall-app.schedules:export" }, + + { "action": "grafana-oncall-app.chatops:read" }, + { "action": "grafana-oncall-app.chatops:write" }, + + { "action": "grafana-oncall-app.outgoing-webhooks:read" }, + + { "action": "grafana-oncall-app.maintenance:read" }, + { "action": "grafana-oncall-app.maintenance:write" }, + + { "action": "grafana-oncall-app.api-keys:read" }, + { "action": "grafana-oncall-app.api-keys:write" }, + + { "action": "grafana-oncall-app.notifications:read" }, + + { "action": "grafana-oncall-app.notification-settings:read" }, + { "action": "grafana-oncall-app.notification-settings:write" }, + + { "action": "grafana-oncall-app.user-settings:read" }, + { "action": "grafana-oncall-app.user-settings:write" }, + + { "action": "grafana-oncall-app.other-settings:read" } + ] + }, + "grants": ["Editor"] + }, + { + "role": { + "name": "Reader", + "description": "Read-only access to everything in OnCall", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + + { "action": "grafana-oncall-app.alert-groups:read" }, + { "action": "grafana-oncall-app.integrations:read" }, + { "action": "grafana-oncall-app.escalation-chains:read" }, + { "action": "grafana-oncall-app.schedules:read" }, + { "action": "grafana-oncall-app.chatops:read" }, + { "action": "grafana-oncall-app.outgoing-webhooks:read" }, + { "action": "grafana-oncall-app.maintenance:read" }, + { "action": "grafana-oncall-app.api-keys:read" }, + { "action": "grafana-oncall-app.notification-settings:read" }, + { "action": "grafana-oncall-app.user-settings:read" }, + { "action": "grafana-oncall-app.other-settings:read" } + ] + }, + "grants": ["Viewer"] + }, + { + "role": { + "name": "OnCaller", + "description": "Grants read access to everything in OnCall. In addition, grants edit access to Alert Groups and Schedules", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + + { "action": "grafana-oncall-app.alert-groups:read" }, + { "action": "grafana-oncall-app.alert-groups:write" }, + + { "action": "grafana-oncall-app.integrations:read" }, + { "action": "grafana-oncall-app.escalation-chains:read" }, + + { "action": "grafana-oncall-app.schedules:read" }, + { "action": "grafana-oncall-app.schedules:write" }, + + { "action": "grafana-oncall-app.chatops:read" }, + { "action": "grafana-oncall-app.outgoing-webhooks:read" }, + { "action": "grafana-oncall-app.maintenance:read" }, + { "action": "grafana-oncall-app.api-keys:read" }, + { "action": "grafana-oncall-app.notification-settings:read" }, + { "action": "grafana-oncall-app.user-settings:read" }, + { "action": "grafana-oncall-app.other-settings:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Alert Groups Reader", + "description": "Read-only access to OnCall Alert Groups", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.alert-groups:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Alert Groups Editor", + "description": "Read/write access to OnCall Alert Groups", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.alert-groups:read" }, + { "action": "grafana-oncall-app.alert-groups:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Integrations Reader", + "description": "Read-only access to OnCall Integrations", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.integrations:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Integrations Editor", + "description": "Read/write access to OnCall Integrations", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.integrations:read" }, + { "action": "grafana-oncall-app.integrations:write" }, + { "action": "grafana-oncall-app.integrations:test" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Escalation Chains Reader", + "description": "Read-only access to OnCall Escalation Chains", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.escalation-chains:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Escalation Chains Editor", + "description": "Read/write access to OnCall Escalation Chains", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.escalation-chains:read" }, + { "action": "grafana-oncall-app.escalation-chains:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Schedules Reader", + "description": "Read-only access to OnCall Schedules", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.schedules:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Schedules Editor", + "description": "Read/write access to OnCall Schedules", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.schedules:read" }, + { "action": "grafana-oncall-app.schedules:write" }, + { "action": "grafana-oncall-app.schedules:export" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "ChatOps Reader", + "description": "Read-only access to OnCall ChatOps", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.chatops:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "ChatOps Editor", + "description": "Read/write access to OnCall ChatOps", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.chatops:read" }, + { "action": "grafana-oncall-app.chatops:write" }, + { "action": "grafana-oncall-app.chatops:update-settings" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Outgoing Webhooks Reader", + "description": "Read-only access to OnCall Outgoing Webhooks", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.outgoing-webhooks:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Outgoing Webhooks Editor", + "description": "Read/write access to OnCall Outgoing Webhooks", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.outgoing-webhooks:read" }, + { "action": "grafana-oncall-app.outgoing-webhooks:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Maintenance Reader", + "description": "Read-only access to OnCall Maintenance", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.maintenance:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Maintenance Editor", + "description": "Read/write access to OnCall Maintenance", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.maintenance:read" }, + { "action": "grafana-oncall-app.maintenance:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "API Keys Reader", + "description": "Read-only access to OnCall API Keys", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.api-keys:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "API Keys Editor", + "description": "Read/write access to OnCall API Keys. Also grants access to be able to consume the API.", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.api-keys:read" }, + { "action": "grafana-oncall-app.api-keys:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Notification Settings Reader", + "description": "Read-only access to OnCall Notification Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.notification-settings:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Notification Settings Editor", + "description": "Read/write access to OnCall Notification Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.notification-settings:read" }, + { "action": "grafana-oncall-app.notification-settings:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "User Settings Reader", + "description": "Read-only access to OnCall User Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.user-settings:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "User Settings Editor", + "description": "Read/write access to own OnCall User Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.user-settings:read" }, + { "action": "grafana-oncall-app.user-settings:write" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "User Settings Admin", + "description": "Read/write access to your own, plus other's OnCall User Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.user-settings:read" }, + { "action": "grafana-oncall-app.user-settings:write" }, + { "action": "grafana-oncall-app.user-settings:admin" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Settings Reader", + "description": "Read-only access to OnCall Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.other-settings:read" } + ] + }, + "grants": [] + }, + { + "role": { + "name": "Settings Editor", + "description": "Read/write access to OnCall Settings", + "permissions": [ + { "action": "plugins.app:access", "scope": "plugins.app:id:grafana-oncall-app" }, + { "action": "grafana-oncall-app.other-settings:read" }, + { "action": "grafana-oncall-app.other-settings:write" } + ] + }, + "grants": [] + } + ], "dependencies": { "grafanaDependency": ">=8.3.2", "grafanaVersion": "8.3", diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 43288bb3..fadc91eb 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -15,6 +15,7 @@ import Header from 'navbar/Header/Header'; import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar'; import { AppRootProps } from 'types'; +import Unauthorized from 'components/Unauthorized'; import DefaultPageLayout from 'containers/DefaultPageLayout/DefaultPageLayout'; import 'interceptors'; import { pages } from 'pages'; @@ -83,6 +84,9 @@ export const Root = observer((props: AppRootProps) => { return null; } + const { action: pagePermissionAction } = pages[page]; + const userHasAccess = pagePermissionAction ? store.isUserActionAllowed(pagePermissionAction) : true; + return ( {!isTopNavbar() && ( @@ -101,7 +105,11 @@ export const Root = observer((props: AppRootProps) => { 'u-position-relative' )} > - + {userHasAccess ? ( + + ) : ( + + )}
); diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 17f2f48e..54c87b4c 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -1,5 +1,3 @@ -import { OrgRole } from '@grafana/data'; -import { contextSrv } from 'grafana/app/core/core'; import { action, observable } from 'mobx'; import moment from 'moment-timezone'; import qs from 'query-string'; @@ -32,7 +30,7 @@ import { makeRequest } from 'network'; import { NavMenuItem } from 'pages/routes'; import { AppFeature } from 'state/features'; import PluginState from 'state/plugin'; -import { UserAction } from 'state/userAction'; +import { UserActions, isUserActionAllowed } from 'utils/authorization'; // ------ Dashboard ------ // @@ -164,8 +162,10 @@ export class RootBaseStore { return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.'); } - if (!contextSrv.hasRole(OrgRole.Admin)) { - return this.setupPluginError('🚫 Admin must sign on to setup OnCall before a Viewer can use it'); + if (!this.isUserActionAllowed(UserActions.PluginsInstall)) { + return this.setupPluginError( + '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' + ); } try { @@ -199,9 +199,7 @@ export class RootBaseStore { this.appLoading = false; } - isUserActionAllowed(action: UserAction) { - return this.userStore.currentUser && this.userStore.currentUser.permissions.includes(action); - } + isUserActionAllowed = isUserActionAllowed; hasFeature(feature: string | AppFeature) { // todo use AppFeature only diff --git a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts index 3cbc51e2..62c4f013 100644 --- a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts +++ b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts @@ -1,15 +1,14 @@ -import { OrgRole } from '@grafana/data'; -import { contextSrv as contextSrvOriginal } from 'grafana/app/core/core'; import { OnCallAppPluginMeta } from 'types'; import PluginState from 'state/plugin'; +import { UserActions } from 'utils/authorization'; import { RootBaseStore } from './'; -const contextSrv = contextSrvOriginal as { hasRole: jest.Mock> }; - jest.mock('state/plugin'); +const PluginInstallAction = UserActions.PluginsInstall; + const generatePluginData = ( onCallApiUrl: OnCallAppPluginMeta['jsonData']['onCallApiUrl'] = null ): OnCallAppPluginMeta => @@ -123,7 +122,7 @@ describe('rootBaseStore', () => { version: 'asdfasdf', license: 'asdfasdf', }); - contextSrv.hasRole.mockReturnValueOnce(false); + rootBaseStore.isUserActionAllowed = jest.fn().mockReturnValueOnce(false); PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); // test @@ -133,14 +132,14 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); - expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + expect(rootBaseStore.isUserActionAllowed).toHaveBeenCalledTimes(1); + expect(rootBaseStore.isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual( - '🚫 Admin must sign on to setup OnCall before a Viewer can use it' + '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' ); }); @@ -160,7 +159,7 @@ describe('rootBaseStore', () => { version: 'asdfasdf', license: 'asdfasdf', }); - contextSrv.hasRole.mockReturnValueOnce(true); + rootBaseStore.isUserActionAllowed = jest.fn().mockReturnValueOnce(true); PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; @@ -171,8 +170,8 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); - expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + expect(rootBaseStore.isUserActionAllowed).toHaveBeenCalledTimes(1); + expect(rootBaseStore.isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); @@ -199,7 +198,7 @@ describe('rootBaseStore', () => { version: 'asdfasdf', license: 'asdfasdf', }); - contextSrv.hasRole.mockReturnValueOnce(true); + rootBaseStore.isUserActionAllowed = jest.fn().mockReturnValueOnce(true); PluginState.installPlugin = jest.fn().mockRejectedValueOnce(installPluginError); PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(humanReadableErrorMsg); @@ -210,8 +209,8 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); - expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + expect(rootBaseStore.isUserActionAllowed).toHaveBeenCalledTimes(1); + expect(rootBaseStore.isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); diff --git a/grafana-plugin/src/state/userAction.ts b/grafana-plugin/src/state/userAction.ts deleted file mode 100644 index d106d740..00000000 --- a/grafana-plugin/src/state/userAction.ts +++ /dev/null @@ -1,23 +0,0 @@ -export enum UserAction { - UpdateIncidents = 'update_incidents', - UpdateAlertReceiveChannels = 'update_alert_receive_channels', - UpdateEscalationPolicies = 'update_escalation_policies', - UpdateNotificationPolicies = 'update_notification_policies', - UpdateGeneralLogChannelId = 'update_general_log_channel_id', - UpdateGlobalSettings = 'update_global_settings', - UpdateOwnSettings = 'update_own_settings', - UpdateOtherUsersSettings = 'update_other_users_settings', - ViewOtherUsers = 'view_other_users', - UpdateIntegrations = 'update_integrations', - UpdateSchedules = 'update_schedules', - UpdateCustomActions = 'update_custom_actions', - UpdateApiTokens = 'update_api_tokens', - UpdateMaintenances = 'update_maintenances', - CreateTeam = 'create_team', - UpdateTeams = 'update_teams', - SendDemoAlert = 'send_demo_alert', - UpdateCurler = 'update_curler', - - // for testing purposes - Impossible = 'impossible', -} diff --git a/grafana-plugin/src/types.ts b/grafana-plugin/src/types.ts index 33e14637..b418f69d 100644 --- a/grafana-plugin/src/types.ts +++ b/grafana-plugin/src/types.ts @@ -1,4 +1,4 @@ -import { AppRootProps as BaseAppRootProps, AppPluginMeta, PluginConfigPageProps } from '@grafana/data'; +import { AppRootProps as BaseAppRootProps, AppPluginMeta, CurrentUserDTO, PluginConfigPageProps } from '@grafana/data'; export type OnCallPluginMetaJSONData = { stackId: number; @@ -21,7 +21,10 @@ export type OnCallPluginConfigPageProps = PluginConfigPageProps ({ + contextSrv: { + user: { + orgRole: null, + }, + hasAccess: (_action, _fallback): boolean => null, + }, +})); + +jest.mock('@grafana/runtime', () => ({ + config: { + featureToggles: { + accessControlOnCall: true, + }, + }, +})); + +describe('userHasMinimumRequiredRole', () => { + test.each([ + [OrgRole.Admin, OrgRole.Viewer, false], + [OrgRole.Admin, OrgRole.Editor, false], + [OrgRole.Admin, OrgRole.Admin, true], + [OrgRole.Editor, OrgRole.Viewer, false], + [OrgRole.Editor, OrgRole.Editor, true], + [OrgRole.Editor, OrgRole.Admin, true], + [OrgRole.Viewer, OrgRole.Viewer, true], + [OrgRole.Viewer, OrgRole.Editor, true], + [OrgRole.Viewer, OrgRole.Admin, true], + ])('Required role: %s Current role: %s', (requiredRole, mockCurrentRole, expected) => { + contextSrv.user.orgRole = mockCurrentRole; + expect(auth.userHasMinimumRequiredRole(requiredRole)).toBe(expected); + }); +}); + +describe('isUserActionAllowed', () => { + test('if RBAC is supported by the frontend, it uses the RBAC permission', () => { + // mocks + const permission = 'potato'; + contextSrv.user.permissions = { + [permission]: true, + }; + config.featureToggles.accessControlOnCall = true; + + // test + assertions + expect(auth.isUserActionAllowed({ permission, fallbackMinimumRoleRequired: OrgRole.Viewer })).toEqual(true); + }); + + test('if RBAC is not supported by the frontend, it uses the fallback role', () => { + // mocks + const permission = 'potato'; + contextSrv.user.orgRole = OrgRole.Editor; + config.featureToggles.accessControlOnCall = false; + + // test + assertions + expect(auth.isUserActionAllowed({ permission, fallbackMinimumRoleRequired: OrgRole.Viewer })).toEqual(true); + expect(auth.isUserActionAllowed({ permission, fallbackMinimumRoleRequired: OrgRole.Admin })).toEqual(false); + }); +}); + +describe('generatePermissionString', () => { + test('it properly builds permission strings with prefixes', () => { + expect(auth.generatePermissionString(auth.Resource.API_KEYS, auth.Action.READ, true)).toEqual( + 'grafana-oncall-app.api-keys:read' + ); + }); + + test('it properly builds permission strings without prefixes', () => { + expect(auth.generatePermissionString(auth.Resource.TEAMS, auth.Action.READ, false)).toEqual('teams:read'); + }); +}); diff --git a/grafana-plugin/src/utils/authorization/index.ts b/grafana-plugin/src/utils/authorization/index.ts new file mode 100644 index 00000000..4aa51b9b --- /dev/null +++ b/grafana-plugin/src/utils/authorization/index.ts @@ -0,0 +1,156 @@ +import { OrgRole } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { contextSrv } from 'grafana/app/core/core'; + +const ONCALL_PERMISSION_PREFIX = 'grafana-oncall-app'; + +export type UserAction = { + permission: string; + fallbackMinimumRoleRequired: OrgRole; +}; + +export enum Resource { + ALERT_GROUPS = 'alert-groups', + INTEGRATIONS = 'integrations', + ESCALATION_CHAINS = 'escalation-chains', + SCHEDULES = 'schedules', + CHATOPS = 'chatops', + OUTGOING_WEBHOOKS = 'outgoing-webhooks', + MAINTENANCE = 'maintenance', + API_KEYS = 'api-keys', + NOTIFICATIONS = 'notifications', + + NOTIFICATION_SETTINGS = 'notification-settings', + USER_SETTINGS = 'user-settings', + OTHER_SETTINGS = 'other-settings', + + TEAMS = 'teams', + PLUGINS = 'plugins', +} + +export enum Action { + READ = 'read', + WRITE = 'write', + ADMIN = 'admin', + TEST = 'test', + EXPORT = 'export', + UPDATE_SETTINGS = 'update-settings', + INSTALL = 'install', +} + +type Actions = + | 'AlertGroupsRead' + | 'AlertGroupsWrite' + | 'IntegrationsRead' + | 'IntegrationsWrite' + | 'IntegrationsTest' + | 'EscalationChainsRead' + | 'EscalationChainsWrite' + | 'SchedulesRead' + | 'SchedulesWrite' + | 'SchedulesExport' + | 'ChatOpsRead' + | 'ChatOpsWrite' + | 'ChatOpsUpdateSettings' + | 'OutgoingWebhooksRead' + | 'OutgoingWebhooksWrite' + | 'MaintenanceRead' + | 'MaintenanceWrite' + | 'APIKeysRead' + | 'APIKeysWrite' + | 'NotificationsRead' + | 'NotificationSettingsRead' + | 'NotificationSettingsWrite' + | 'UserSettingsRead' + | 'UserSettingsWrite' + | 'UserSettingsAdmin' + | 'OtherSettingsRead' + | 'OtherSettingsWrite' + | 'TeamsWrite' + | 'PluginsInstall'; + +const roleMapping: Record = { + [OrgRole.Admin]: 0, + [OrgRole.Editor]: 1, + [OrgRole.Viewer]: 2, +}; + +/** + * The logic here is: + * - an Admin should be able to do everything (including whatever an Editor and Viewer can do) + * - an Editor should be able to do things Editors and Viewers can do + * - a Viewer is only allowed to do things Viewers can do + */ +export const userHasMinimumRequiredRole = (minimumRoleRequired: OrgRole): boolean => + roleMapping[contextSrv.user.orgRole] <= roleMapping[minimumRoleRequired]; + +/** + * See here for more info on the hasAccess method + * https://github.com/grafana/grafana/blob/main/public/app/core/services/context_srv.ts#L165-L170 + * + * As a fallback (second argument), for cases where RBAC is not enabled for a grafana instance, rely on basic roles + */ +export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean => { + if (config.featureToggles.accessControlOnCall) { + return !!contextSrv.user.permissions?.[permission]; + } + return userHasMinimumRequiredRole(fallbackMinimumRoleRequired); +}; + +export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string => + `${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`; + +const constructAction = ( + resource: Resource, + action: Action, + fallbackMinimumRoleRequired: OrgRole, + includePrefix = true +): UserAction => ({ + permission: generatePermissionString(resource, action, includePrefix), + fallbackMinimumRoleRequired, +}); + +export const UserActions: { [action in Actions]: UserAction } = { + AlertGroupsRead: constructAction(Resource.ALERT_GROUPS, Action.READ, OrgRole.Viewer), + AlertGroupsWrite: constructAction(Resource.ALERT_GROUPS, Action.WRITE, OrgRole.Editor), + + IntegrationsRead: constructAction(Resource.INTEGRATIONS, Action.READ, OrgRole.Viewer), + IntegrationsWrite: constructAction(Resource.INTEGRATIONS, Action.WRITE, OrgRole.Admin), + IntegrationsTest: constructAction(Resource.INTEGRATIONS, Action.TEST, OrgRole.Editor), + + EscalationChainsRead: constructAction(Resource.ESCALATION_CHAINS, Action.READ, OrgRole.Viewer), + EscalationChainsWrite: constructAction(Resource.ESCALATION_CHAINS, Action.WRITE, OrgRole.Admin), + + SchedulesRead: constructAction(Resource.SCHEDULES, Action.READ, OrgRole.Viewer), + SchedulesWrite: constructAction(Resource.SCHEDULES, Action.WRITE, OrgRole.Admin), + SchedulesExport: constructAction(Resource.SCHEDULES, Action.WRITE, OrgRole.Editor), + + ChatOpsRead: constructAction(Resource.CHATOPS, Action.READ, OrgRole.Viewer), + ChatOpsWrite: constructAction(Resource.CHATOPS, Action.WRITE, OrgRole.Editor), + ChatOpsUpdateSettings: constructAction(Resource.CHATOPS, Action.UPDATE_SETTINGS, OrgRole.Admin), + + OutgoingWebhooksRead: constructAction(Resource.OUTGOING_WEBHOOKS, Action.READ, OrgRole.Viewer), + OutgoingWebhooksWrite: constructAction(Resource.OUTGOING_WEBHOOKS, Action.WRITE, OrgRole.Admin), + + MaintenanceRead: constructAction(Resource.MAINTENANCE, Action.READ, OrgRole.Viewer), + MaintenanceWrite: constructAction(Resource.MAINTENANCE, Action.WRITE, OrgRole.Editor), + + APIKeysRead: constructAction(Resource.API_KEYS, Action.READ, OrgRole.Viewer), + APIKeysWrite: constructAction(Resource.API_KEYS, Action.WRITE, OrgRole.Editor), + + NotificationsRead: constructAction(Resource.NOTIFICATIONS, Action.READ, OrgRole.Editor), + + NotificationSettingsRead: constructAction(Resource.NOTIFICATION_SETTINGS, Action.READ, OrgRole.Viewer), + NotificationSettingsWrite: constructAction(Resource.NOTIFICATION_SETTINGS, Action.WRITE, OrgRole.Editor), + + UserSettingsRead: constructAction(Resource.USER_SETTINGS, Action.READ, OrgRole.Viewer), + UserSettingsWrite: constructAction(Resource.USER_SETTINGS, Action.WRITE, OrgRole.Editor), + UserSettingsAdmin: constructAction(Resource.USER_SETTINGS, Action.ADMIN, OrgRole.Admin), + + OtherSettingsRead: constructAction(Resource.OTHER_SETTINGS, Action.READ, OrgRole.Viewer), + OtherSettingsWrite: constructAction(Resource.OTHER_SETTINGS, Action.WRITE, OrgRole.Admin), + + // These are not oncall specific + TeamsWrite: constructAction(Resource.TEAMS, Action.WRITE, OrgRole.Admin, false), + PluginsInstall: constructAction(Resource.PLUGINS, Action.INSTALL, OrgRole.Admin, false), +}; diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 1d3a045f..b09206b0 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1141,7 +1141,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.10" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.20.1" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== @@ -1192,6 +1192,11 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.0.tgz#fe364f025ba74f6de6c837a84ef44bdb1d61e68f" integrity sha512-mgmE7XBYY/21erpzhexk4Cj1cyTQ9LzvnTxtzM17BJ7ERMNE6W72mQRo0I1Ud8eFJ+RVVIcBNhLFZ3GX4XFz5w== +"@braintree/sanitize-url@6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.1.tgz#45ff061b9ded1c6e4474b33b336ebb1b986b825a" + integrity sha512-zr9Qs9KFQiEvMWdZesjcmRJlUck5NR+eKGS1uyKk+oYTWwlYrsoPEi6VmG6/TzBD1hKCGEimrhTgGS6hvn/xIQ== + "@csstools/postcss-color-function@^1.0.3": version "1.1.1" resolved "https://registry.yarnpkg.com/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz#2bd36ab34f82d0497cfacdc9b18d34b5e6f64b6b" @@ -1286,6 +1291,17 @@ "@emotion/weak-memoize" "^0.3.0" stylis "4.1.3" +"@emotion/css@11.10.5": + version "11.10.5" + resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.10.5.tgz#ca01bb83ce60517bc3a5c01d27ccf552fed84d9d" + integrity sha512-maJy0wG82hWsiwfJpc3WrYsyVwUbdu+sdIseKUB+/OLjB8zgc3tqkT6eO0Yt0AhIkJwGGnmMY/xmQwEAgQ4JHA== + dependencies: + "@emotion/babel-plugin" "^11.10.5" + "@emotion/cache" "^11.10.5" + "@emotion/serialize" "^1.1.1" + "@emotion/sheet" "^1.2.1" + "@emotion/utils" "^1.2.0" + "@emotion/css@11.9.0": version "11.9.0" resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.9.0.tgz#d5aeaca5ed19fc61cbdc9e032ad0b32fa6e366be" @@ -1307,20 +1323,7 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.0.tgz#f580f9beb67176fa57aae70b08ed510e1b18980f" integrity sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA== -"@emotion/react@11.9.3": - version "11.9.3" - resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.9.3.tgz#f4f4f34444f6654a2e550f5dab4f2d360c101df9" - integrity sha512-g9Q1GcTOlzOEjqwuLF/Zd9LC+4FljjPjDfxSM7KmEakm+hsHXk+bYZ2q+/hTJzr0OUNkujo72pXLQvXj6H+GJQ== - dependencies: - "@babel/runtime" "^7.13.10" - "@emotion/babel-plugin" "^11.7.1" - "@emotion/cache" "^11.9.3" - "@emotion/serialize" "^1.0.4" - "@emotion/utils" "^1.1.0" - "@emotion/weak-memoize" "^0.2.5" - hoist-non-react-statics "^3.3.1" - -"@emotion/react@^11.8.1": +"@emotion/react@11.10.5", "@emotion/react@^11.8.1": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.10.5.tgz#95fff612a5de1efa9c0d535384d3cfa115fe175d" integrity sha512-TZs6235tCJ/7iF6/rvTaOH4oxQg2gMAcdHemjwLKIjKz4rRuYe1HJ2TQJKnAcRAfOUDdU8XoDadCe1rl72iv8A== @@ -1334,6 +1337,19 @@ "@emotion/weak-memoize" "^0.3.0" hoist-non-react-statics "^3.3.1" +"@emotion/react@11.9.3": + version "11.9.3" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.9.3.tgz#f4f4f34444f6654a2e550f5dab4f2d360c101df9" + integrity sha512-g9Q1GcTOlzOEjqwuLF/Zd9LC+4FljjPjDfxSM7KmEakm+hsHXk+bYZ2q+/hTJzr0OUNkujo72pXLQvXj6H+GJQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@emotion/babel-plugin" "^11.7.1" + "@emotion/cache" "^11.9.3" + "@emotion/serialize" "^1.0.4" + "@emotion/utils" "^1.1.0" + "@emotion/weak-memoize" "^0.2.5" + hoist-non-react-statics "^3.3.1" + "@emotion/serialize@^1.0.3", "@emotion/serialize@^1.0.4", "@emotion/serialize@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.1.tgz#0595701b1902feded8a96d293b26be3f5c1a5cf0" @@ -1408,6 +1424,18 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.2.tgz#d06a66d3ad8214186eda2432ac8b8d81868a571f" + integrity sha512-Skfy0YS3NJ5nV9us0uuPN0HDk1Q4edljaOhRBJGDWs9EBa7ZVMYBHRFlhLvvmwEoaIM9BlH6QJFn9/uZg0bACg== + +"@floating-ui/dom@^1.0.1": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.6.tgz#e42393ec381a4fe96673fbcee137a95e86c93ebc" + integrity sha512-kt/tg1oip9OAH1xjCTcx1OpcUpu9rjDw3GKJ/rEhUqhO7QyJWfrHU0DpLTNsH67+JyFL5Kv9X1utsXwKFVtyEQ== + dependencies: + "@floating-ui/core" "^1.0.2" + "@formatjs/ecma402-abstract@1.13.0": version "1.13.0" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.13.0.tgz#df6db3cbee0182bbd2fd6217103781c802aee819" @@ -1447,25 +1475,6 @@ dependencies: tslib "2.4.0" -"@grafana/agent-core@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@grafana/agent-core/-/agent-core-0.4.0.tgz#0252a888ab16dea82d97c571ca765383a1d6b319" - integrity sha512-yFbTRWVZKwUTdZ3A1AAzinWhkY0UkmduOEmlr0EYT5DJUOS/vEnzev5oB3Mh00bUUvN+AUvlMx4Nvnju1ahmJg== - dependencies: - "@opentelemetry/api" "^1.1.0" - "@opentelemetry/api-metrics" "^0.29.1" - "@opentelemetry/otlp-transformer" "^0.29.1" - uuid "^8.3.2" - -"@grafana/agent-web@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@grafana/agent-web/-/agent-web-0.4.0.tgz#03c4da34e29b4ca9f40c3574b2e85a7127a070fd" - integrity sha512-rVjLmQ/+Q8j3klDVlgt2pb3fIeWMvn3UAQLSBTC0L53Z/snNGvKQBe8b14ndjO6+cxWXFMc2kMJpw6NxpSYL5Q== - dependencies: - "@grafana/agent-core" "^0.4.0" - ua-parser-js "^1.0.2" - web-vitals "^2.1.4" - "@grafana/data@9.2.4", "@grafana/data@^9.2.4": version "9.2.4" resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.2.4.tgz#38067f207006c07754c3ed5a8835dc1909df7e2d" @@ -1518,6 +1527,32 @@ uplot "1.6.22" xss "1.0.13" +"@grafana/data@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/data/-/data-9.3.0-beta1.tgz#0c1d8da18b8f9a5c7e77312a1b36daf394bfc596" + integrity sha512-36ozpmsPjSW+yA/QFIX5j63aRV2sxQwros73YStV7x3aGM2P/vaxKm/Zux84uJxihwA9Ao8bf6RXTP3GBKcvSg== + dependencies: + "@braintree/sanitize-url" "6.0.1" + "@grafana/schema" "9.3.0-beta1" + "@types/d3-interpolate" "^1.4.0" + d3-interpolate "1.4.0" + date-fns "2.29.3" + eventemitter3 "4.0.7" + fast_array_intersect "1.1.0" + history "4.10.1" + lodash "4.17.21" + marked "4.2.0" + moment "2.29.4" + moment-timezone "0.5.38" + ol "7.1.0" + papaparse "5.3.2" + regenerator-runtime "0.13.10" + rxjs "7.5.7" + tinycolor2 "1.4.2" + tslib "2.4.1" + uplot "1.6.22" + xss "1.0.14" + "@grafana/e2e-selectors@9.2.4": version "9.2.4" resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.2.4.tgz#748539cc0313ee1c23055a100313235ef2fca64b" @@ -1536,6 +1571,15 @@ tslib "2.4.0" typescript "4.8.2" +"@grafana/e2e-selectors@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/e2e-selectors/-/e2e-selectors-9.3.0-beta1.tgz#49ca6a4957763a8fee8560a5cd7f546a3f4853d3" + integrity sha512-0uG9eltmh/FPLk32+pfpw4Vz8WQNuVOy/E4pnIh2Wv9BlqHWxrABX7o6YlXzlCQMv8mxhCcey/OxJHC4AZxPzA== + dependencies: + "@grafana/tsconfig" "^1.2.0-rc1" + tslib "2.4.1" + typescript "4.8.4" + "@grafana/eslint-config@5.0.0", "@grafana/eslint-config@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@grafana/eslint-config/-/eslint-config-5.0.0.tgz#e08a89d378772340bc6cd1872ec4d15666269aba" @@ -1550,21 +1594,40 @@ eslint-plugin-react-hooks "4.3.0" typescript "4.6.4" -"@grafana/runtime@^9.2.4": - version "9.2.4" - resolved "https://registry.yarnpkg.com/@grafana/runtime/-/runtime-9.2.4.tgz#f3d1a4e2ee51fed76ac31a37422e5978e3ff57a9" - integrity sha512-k6YLPBB8waRe5SqzwmhxwzEYduY3GvBsZTbERIwf+8/ep7kfRnqAhbbFFUujQU4+pgBd3N6HtXBg9KKB6eLqaA== +"@grafana/faro-core@^1.0.0-beta2": + version "1.0.0-beta2" + resolved "https://registry.yarnpkg.com/@grafana/faro-core/-/faro-core-1.0.0-beta2.tgz#97636677c1d687b0b238642a3978334652f263a5" + integrity sha512-htw6qrl4EsjxUrIugd+85H8voIxm+Vs8uOl4gGhsscb1/nUJoqTZmegUTXR+sYGyWZdHztoGV+rm5yerWrKCbQ== dependencies: - "@grafana/agent-web" "^0.4.0" - "@grafana/data" "9.2.4" - "@grafana/e2e-selectors" "9.2.4" - "@grafana/ui" "9.2.4" + "@opentelemetry/api" "^1.1.0" + "@opentelemetry/api-metrics" "^0.33.0" + "@opentelemetry/otlp-transformer" "^0.33.0" + fast-deep-equal "^3.1.3" + +"@grafana/faro-web-sdk@1.0.0-beta2": + version "1.0.0-beta2" + resolved "https://registry.yarnpkg.com/@grafana/faro-web-sdk/-/faro-web-sdk-1.0.0-beta2.tgz#d096a350d6366a108428a205753c797802eb480d" + integrity sha512-Z/ZbMpBG4/+ZHuPntVTANvStBP1pkDT3+oqKDYW3O4iP4wBhIUyXk7Pmr9LJZIjcStBizEFMH/N/F/gyD5DHjQ== + dependencies: + "@grafana/faro-core" "^1.0.0-beta2" + ua-parser-js "^1.0.32" + web-vitals "^3.0.4" + +"@grafana/runtime@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/runtime/-/runtime-9.3.0-beta1.tgz#4bcd5d8c24c1e810b254f113598cbb1cb759ee16" + integrity sha512-Fd87OXQbf9IqGeOitwF8KBuyvw9Yv9VDmC30UKCvpQVtKTYoHngEYXMD1ZLUgmb4G18PYDsBqYfth4InfPAlSQ== + dependencies: + "@grafana/data" "9.3.0-beta1" + "@grafana/e2e-selectors" "9.3.0-beta1" + "@grafana/faro-web-sdk" "1.0.0-beta2" + "@grafana/ui" "9.3.0-beta1" "@sentry/browser" "6.19.7" history "4.10.1" lodash "4.17.21" - rxjs "7.5.6" + rxjs "7.5.7" systemjs "0.20.19" - tslib "2.4.0" + tslib "2.4.1" "@grafana/schema@9.2.4": version "9.2.4" @@ -1580,6 +1643,13 @@ dependencies: tslib "2.4.0" +"@grafana/schema@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/schema/-/schema-9.3.0-beta1.tgz#0554d8a6c9de51e3f55f00da614d8c8f091980ab" + integrity sha512-/12NkJXGfbo3bWPUMsSGJXZiLOil3TX2xoiL86ssnziSdzN9b7uJ6xhdEUfZ3sdm4pXuiBq4tlJ9FUP6n6he8Q== + dependencies: + tslib "2.4.1" + "@grafana/toolkit@^9.2.4": version "9.2.6" resolved "https://registry.yarnpkg.com/@grafana/toolkit/-/toolkit-9.2.6.tgz#55d424321a65a027f3365c6e0df649bcc1d2c9d6" @@ -1675,16 +1745,16 @@ resolved "https://registry.yarnpkg.com/@grafana/tsconfig/-/tsconfig-1.2.0-rc1.tgz#10973c978ec95b0ea637511254b5f478bce04de7" integrity sha512-+SgQeBQ1pT6D/E3/dEdADqTrlgdIGuexUZ8EU+8KxQFKUeFeU7/3z/ayI2q/wpJ/Kr6WxBBNlrST6aOKia19Ag== -"@grafana/ui@9.2.4", "@grafana/ui@^9.2.4": - version "9.2.4" - resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.4.tgz#885b0f10bd700aa0dc094f2fcb554477fc47e410" - integrity sha512-V9sNQwcAkMAmWjM/DLMw9X+J0nqBmrwNV1uJ1kyS+3cRRwCNyJsZUz3NuOnzCbvCEl3bopLyY/WBSHONbLEoig== +"@grafana/ui@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.6.tgz#1974edb48b5873c257278886b65ccb1d7e45a33a" + integrity sha512-NM+tNbpks218QHQo9hywr/00fMOjQt0shf3Sq2ywr8e172PXuPxh/AuTACKuVFgW2FY1z5StFUc2B40Dgvn0JQ== dependencies: "@emotion/css" "11.9.0" "@emotion/react" "11.9.3" - "@grafana/data" "9.2.4" - "@grafana/e2e-selectors" "9.2.4" - "@grafana/schema" "9.2.4" + "@grafana/data" "9.2.6" + "@grafana/e2e-selectors" "9.2.6" + "@grafana/schema" "9.2.6" "@monaco-editor/react" "4.4.5" "@popperjs/core" "2.11.5" "@react-aria/button" "3.6.1" @@ -1741,16 +1811,86 @@ uplot "1.6.22" uuid "8.3.2" -"@grafana/ui@9.2.6": - version "9.2.6" - resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.6.tgz#1974edb48b5873c257278886b65ccb1d7e45a33a" - integrity sha512-NM+tNbpks218QHQo9hywr/00fMOjQt0shf3Sq2ywr8e172PXuPxh/AuTACKuVFgW2FY1z5StFUc2B40Dgvn0JQ== +"@grafana/ui@9.3.0-beta1": + version "9.3.0-beta1" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.3.0-beta1.tgz#941db8fab3f570e1639257311c514cd6708fb297" + integrity sha512-40bQV7gHqONb18G7MmhueuvJcX+DGJYeKTiexZ+wLEW46/74iBIhRI5RyDQsqFntnZpOeVZuQQODWPlZZ7lYpw== + dependencies: + "@emotion/css" "11.10.5" + "@emotion/react" "11.10.5" + "@grafana/data" "9.3.0-beta1" + "@grafana/e2e-selectors" "9.3.0-beta1" + "@grafana/schema" "9.3.0-beta1" + "@leeoniya/ufuzzy" "0.8.0" + "@monaco-editor/react" "4.4.6" + "@popperjs/core" "2.11.6" + "@react-aria/button" "3.6.1" + "@react-aria/dialog" "3.3.1" + "@react-aria/focus" "3.8.0" + "@react-aria/menu" "3.6.1" + "@react-aria/overlays" "3.10.1" + "@react-aria/utils" "3.13.1" + "@react-stately/menu" "3.4.1" + "@sentry/browser" "6.19.7" + ansicolor "1.1.100" + calculate-size "1.1.1" + classnames "2.3.2" + core-js "3.26.0" + d3 "5.15.0" + date-fns "2.29.3" + hoist-non-react-statics "3.3.2" + i18next "^22.0.0" + immutable "4.1.0" + is-hotkey "0.2.0" + jquery "3.6.1" + lodash "4.17.21" + memoize-one "6.0.0" + moment "2.29.4" + monaco-editor "0.34.0" + ol "7.1.0" + prismjs "1.29.0" + rc-cascader "3.7.0" + rc-drawer "4.4.3" + rc-slider "10.0.1" + rc-time-picker "^3.7.3" + rc-tooltip "5.2.2" + react-beautiful-dnd "13.1.1" + react-calendar "3.9.0" + react-colorful "5.6.1" + react-custom-scrollbars-2 "4.5.0" + react-dropzone "14.2.3" + react-highlight-words "0.18.0" + react-hook-form "7.5.3" + react-i18next "^12.0.0" + react-inlinesvg "3.0.1" + react-popper "2.3.0" + react-popper-tooltip "^4.3.1" + react-router-dom "^5.2.0" + react-select "5.6.0" + react-select-event "^5.1.0" + react-table "7.8.0" + react-transition-group "4.4.5" + react-use "17.4.0" + react-window "1.8.8" + rxjs "7.5.7" + slate "0.47.9" + slate-plain-serializer "0.7.13" + slate-react "0.22.10" + tinycolor2 "1.4.2" + tslib "2.4.1" + uplot "1.6.22" + uuid "9.0.0" + +"@grafana/ui@^9.2.4": + version "9.2.4" + resolved "https://registry.yarnpkg.com/@grafana/ui/-/ui-9.2.4.tgz#885b0f10bd700aa0dc094f2fcb554477fc47e410" + integrity sha512-V9sNQwcAkMAmWjM/DLMw9X+J0nqBmrwNV1uJ1kyS+3cRRwCNyJsZUz3NuOnzCbvCEl3bopLyY/WBSHONbLEoig== dependencies: "@emotion/css" "11.9.0" "@emotion/react" "11.9.3" - "@grafana/data" "9.2.6" - "@grafana/e2e-selectors" "9.2.6" - "@grafana/schema" "9.2.6" + "@grafana/data" "9.2.4" + "@grafana/e2e-selectors" "9.2.4" + "@grafana/schema" "9.2.4" "@monaco-editor/react" "4.4.5" "@popperjs/core" "2.11.5" "@react-aria/button" "3.6.1" @@ -2135,6 +2275,11 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@leeoniya/ufuzzy@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-0.8.0.tgz#2ccfc29453e168ce5866bf6dee89771db404a7f7" + integrity sha512-EOc0fEsIqe6CDZxC14efhybnPcXyJi7VaZby40mWASZD0CI78ONoF+4+LGlcT58jsAIwEims5ARbRqo+BVHEAQ== + "@mapbox/jsonlint-lines-primitives@~2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" @@ -2179,6 +2324,14 @@ "@monaco-editor/loader" "^1.3.2" prop-types "^15.7.2" +"@monaco-editor/react@4.4.6": + version "4.4.6" + resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.4.6.tgz#8ae500b0edf85276d860ed702e7056c316548218" + integrity sha512-Gr3uz3LYf33wlFE3eRnta4RxP5FSNxiIV9ENn2D2/rN8KgGAD8ecvcITRtsbbyuOuNkwbuHYxfeaz2Vr+CtyFA== + dependencies: + "@monaco-editor/loader" "^1.3.2" + prop-types "^15.7.2" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2200,10 +2353,10 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@opentelemetry/api-metrics@0.29.2", "@opentelemetry/api-metrics@^0.29.1": - version "0.29.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.29.2.tgz#daa823e0965754222b49a6ae6133df8b39ff8fd2" - integrity sha512-yRdF5beqKuEdsPNoO7ijWCQ9HcyN0Tlgicf8RS6gzGOI54d6Hj7yKquJ6+X9XV+CSRbRWJYb+lOsXyso7uyX2g== +"@opentelemetry/api-metrics@0.33.0", "@opentelemetry/api-metrics@^0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-metrics/-/api-metrics-0.33.0.tgz#753d355289b7811ad254d6e5b0193bd1b9f23ab0" + integrity sha512-78evfPRRRnJA6uZ3xuBuS3VZlXTO/LRs+Ff1iv3O/7DgibCtq9k27T6Zlj8yRdJDFmcjcbQrvC0/CpDpWHaZYA== dependencies: "@opentelemetry/api" "^1.0.0" @@ -2212,55 +2365,55 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.3.0.tgz#27c6f776ac3c1c616651e506a89f438a0ed6a055" integrity sha512-YveTnGNsFFixTKJz09Oi4zYkiLT5af3WpZDu4aIUM7xX+2bHAkOJayFTVQd6zB8kkWPpbua4Ha6Ql00grdLlJQ== -"@opentelemetry/core@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.3.1.tgz#6eef5c5efca9a4cd7daa0cd4c7ff28ca2317c8d7" - integrity sha512-k7lOC86N7WIyUZsUuSKZfFIrUtINtlauMGQsC1r7jNmcr0vVJGqK1ROBvt7WWMxLbpMnt1q2pXJO8tKu0b9auA== +"@opentelemetry/core@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.7.0.tgz#83bdd1b7a4ceafcdffd6590420657caec5f7b34c" + integrity sha512-AVqAi5uc8DrKJBimCTFUT4iFI+5eXpo4sYmGbQ0CypG0piOTHE2g9c5aSoTGYXu3CzOmJZf7pT6Xh+nwm5d6yQ== dependencies: - "@opentelemetry/semantic-conventions" "1.3.1" + "@opentelemetry/semantic-conventions" "1.7.0" -"@opentelemetry/otlp-transformer@^0.29.1": - version "0.29.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.29.2.tgz#61897d3d747182ab7e315a88a9a710a759c13390" - integrity sha512-Y6dJj+rhRGynxhLlgEJkdkXuLHdFG8igcSBv6oy3m3GHSSvZkyNV34dVjtZJ586mUXsbFuAf6uqjzteobewO1g== +"@opentelemetry/otlp-transformer@^0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.33.0.tgz#6fd3ddc944f017da08d445f142cad1779770e0e0" + integrity sha512-L4OpsUaki9/Fib17t44YkDvAz3RpMZTtl6hYBhcTqAnqY0wVBpQf0ra25GyHQTKj+oiA//ZxvOlmmM/dXCYxoQ== dependencies: - "@opentelemetry/api-metrics" "0.29.2" - "@opentelemetry/core" "1.3.1" - "@opentelemetry/resources" "1.3.1" - "@opentelemetry/sdk-metrics-base" "0.29.2" - "@opentelemetry/sdk-trace-base" "1.3.1" + "@opentelemetry/api-metrics" "0.33.0" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/resources" "1.7.0" + "@opentelemetry/sdk-metrics" "0.33.0" + "@opentelemetry/sdk-trace-base" "1.7.0" -"@opentelemetry/resources@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.3.1.tgz#9fd85ac4ffeefc35441404b384d5c1db8b243121" - integrity sha512-X8bl3X0YjlsHWy0Iv0KUETtZuRUznX4yr1iScKCtfy8AoRfZFc2xxWKMDJ0TrqYwSapgeg4YwpmRzUKmmnrbeA== +"@opentelemetry/resources@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.7.0.tgz#90ccd3a6a86b4dfba4e833e73944bd64958d78c5" + integrity sha512-u1M0yZotkjyKx8dj+46Sg5thwtOTBmtRieNXqdCRiWUp6SfFiIP0bI+1XK3LhuXqXkBXA1awJZaTqKduNMStRg== dependencies: - "@opentelemetry/core" "1.3.1" - "@opentelemetry/semantic-conventions" "1.3.1" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/semantic-conventions" "1.7.0" -"@opentelemetry/sdk-metrics-base@0.29.2": - version "0.29.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics-base/-/sdk-metrics-base-0.29.2.tgz#bd515455f1d90e211458dcf957f0ae937772b155" - integrity sha512-7hhhZ/6YRRgAXOUTeCsbe6SIk3wZAdAHnEwGGp7aiVH5AOyioHyHInw4EHtowlD6dbLxUWURjh6k+Geht2zbxg== +"@opentelemetry/sdk-metrics@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-0.33.0.tgz#c4e51decc6e3bb0e1e97c7b081955d357e46c2fe" + integrity sha512-ZXPixOlTd/FHLwpkmm5nTpJE7bZOPfmbSz8hBVFCEHkXE1aKEKaM38UFnZ+2xzOY1tDsDwyxEiiBiDX8y3039A== dependencies: - "@opentelemetry/api-metrics" "0.29.2" - "@opentelemetry/core" "1.3.1" - "@opentelemetry/resources" "1.3.1" + "@opentelemetry/api-metrics" "0.33.0" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/resources" "1.7.0" lodash.merge "4.6.2" -"@opentelemetry/sdk-trace-base@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.3.1.tgz#958083dbab928eefd17848959ac8810c787bec7f" - integrity sha512-Or95QZ+9QyvAiwqj+K68z8bDDuyWF50c37w17D10GV1dWzg4Ezcectsu/GB61QcBxm3Y4br0EN5F5TpIFfFliQ== +"@opentelemetry/sdk-trace-base@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.7.0.tgz#b498424e0c6340a9d80de63fd408c5c2130a60a5" + integrity sha512-Iz84C+FVOskmauh9FNnj4+VrA+hG5o+tkMzXuoesvSfunVSioXib0syVFeNXwOm4+M5GdWCuW632LVjqEXStIg== dependencies: - "@opentelemetry/core" "1.3.1" - "@opentelemetry/resources" "1.3.1" - "@opentelemetry/semantic-conventions" "1.3.1" + "@opentelemetry/core" "1.7.0" + "@opentelemetry/resources" "1.7.0" + "@opentelemetry/semantic-conventions" "1.7.0" -"@opentelemetry/semantic-conventions@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.3.1.tgz#ba07b864a3c955f061aa30ea3ef7f4ae4449794a" - integrity sha512-wU5J8rUoo32oSef/rFpOT1HIjLjAv3qIDHkw1QIhODV3OpAVHi5oVzlouozg9obUmZKtbZ0qUe/m7FP0y0yBzA== +"@opentelemetry/semantic-conventions@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.7.0.tgz#af80a1ef7cf110ea3a68242acd95648991bcd763" + integrity sha512-FGBx/Qd09lMaqQcogCHyYrFEpTx4cAjeS+48lMIR12z7LdH+zofGDVQSubN59nL6IpubfKqTeIDu9rNO28iHVA== "@petamoriken/float16@^3.4.7": version "3.6.6" @@ -2277,7 +2430,7 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== -"@popperjs/core@^2.11.5": +"@popperjs/core@2.11.6", "@popperjs/core@^2.11.5": version "2.11.6" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== @@ -4616,7 +4769,7 @@ classnames@2.3.1: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== -classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: +classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== @@ -4901,6 +5054,11 @@ core-js@3.25.1: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.1.tgz#5818e09de0db8956e16bf10e2a7141e931b7c69c" integrity sha512-sr0FY4lnO1hkQ4gLDr24K0DGnweGO1QwSj5BpfQjpSJPdqWalja4cTps29Y/PJVG/P7FYlPDkH3hO+Tr0CvDgQ== +core-js@3.26.0: + version "3.26.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.26.0.tgz#a516db0ed0811be10eac5d94f3b8463d03faccfe" + integrity sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw== + core-js@^2.4.0: version "2.6.12" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" @@ -5425,6 +5583,11 @@ date-fns@2.29.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.1.tgz#9667c2615525e552b5135a3116b95b1961456e60" integrity sha512-dlLD5rKaKxpFdnjrs+5azHDFOPEu4ANy/LTh04A1DTzMM7qoajmKCBc8pkKRFT41CNzw+4gQh79X5C+Jq27HAw== +date-fns@2.29.3: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== + dayjs@^1.11.5: version "1.11.6" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.6.tgz#2e79a226314ec3ec904e3ee1dd5a4f5e5b1c7afb" @@ -5754,6 +5917,11 @@ duplexer@^0.1.2: resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== +earcut@^2.2.3: + version "2.2.4" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a" + integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ== + electron-to-chromium@^1.4.251: version "1.4.284" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz#61046d1e4cab3a25238f6bf7413795270f125592" @@ -7173,6 +7341,13 @@ html-minifier-terser@^6.0.2: relateurl "^0.2.7" terser "^5.10.0" +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html-tags@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.2.0.tgz#dbb3518d20b726524e4dd43de397eb0a95726961" @@ -7263,6 +7438,13 @@ hyphenate-style-name@^1.0.0, hyphenate-style-name@^1.0.2: resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d" integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== +i18next@^22.0.0: + version "22.0.6" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-22.0.6.tgz#d7029912f8aa74ff295c0d9afd1b7dea45859b49" + integrity sha512-RlreNGoPIdDP4QG+qSA9PxZKGwlzmcozbI9ObI6+OyUa/Rp0EjZZA9ubyBjw887zVNZsC+7FI3sXX8oiTzAfig== + dependencies: + "@babel/runtime" "^7.17.2" + iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -8421,6 +8603,11 @@ jquery@3.6.0: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== +jquery@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.1.tgz#fab0408f8b45fc19f956205773b62b292c147a16" + integrity sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw== + js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -8931,6 +9118,11 @@ marked@4.1.0: resolved "https://registry.yarnpkg.com/marked/-/marked-4.1.0.tgz#3fc6e7485f21c1ca5d6ec4a39de820e146954796" integrity sha512-+Z6KDjSPa6/723PQYyc1axYZpYYpDnECDaU6hkaf5gqBieBkMKYReL5hteF2QizhlMbgbo8umXl/clZ67+GlsA== +marked@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.0.tgz#f1683b077626a6c53e28926b798a18184aa13a91" + integrity sha512-1qWHjHlBKwjnDfrkxd0L3Yx4LTad/WO7+d13YsXAC/ZfKj7p0xkLV3sDXJzfWgL7GfW4IBZwMAYWaz+ifyQouQ== + matchmediaquery@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.3.1.tgz#8247edc47e499ebb7c58f62a9ff9ccf5b815c6d7" @@ -8988,7 +9180,7 @@ memfs@^3.1.2, memfs@^3.4.1: dependencies: fs-monkey "^1.0.3" -memoize-one@6.0.0: +memoize-one@6.0.0, memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== @@ -9175,7 +9367,7 @@ moment-timezone@0.5.35: dependencies: moment ">= 2.9.0" -moment-timezone@^0.5.35: +moment-timezone@0.5.38, moment-timezone@^0.5.35: version "0.5.38" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.38.tgz#9674a5397b8be7c13de820fd387d8afa0f725aad" integrity sha512-nMIrzGah4+oYZPflDvLZUgoVUO4fvAqHstvG3xAUnMolWncuAiLDWNnJZj6EwJGMGfb1ZcuTFE6GI3hNOVWI/Q== @@ -9504,6 +9696,14 @@ object.values@^1.1.5: define-properties "^1.1.4" es-abstract "^1.20.4" +ol-mapbox-style@9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/ol-mapbox-style/-/ol-mapbox-style-9.1.0.tgz#1504b1a2c3cc23482c3c95cd55a1cf1d2ac8a451" + integrity sha512-R/XE6FdviaXNdnSw6ItHSEreMtQU68cwQCGv4Kl8yG0V1dZhnI5JWr8IOphJwffPVxfWTCnJb5aALGSB89MvhA== + dependencies: + "@mapbox/mapbox-gl-style-spec" "^13.23.1" + mapbox-to-css-font "^2.4.1" + ol-mapbox-style@^8.0.5: version "8.2.1" resolved "https://registry.yarnpkg.com/ol-mapbox-style/-/ol-mapbox-style-8.2.1.tgz#0f0c252b6495853a137d7e4dd3f915fab664b356" @@ -9522,6 +9722,17 @@ ol@6.15.1: pbf "3.2.1" rbush "^3.0.1" +ol@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/ol/-/ol-7.1.0.tgz#aab69a0539e59d6a4361cbc0f69f8b00c7298c9c" + integrity sha512-mAeV5Ca4mFhYaJoGWNZnIMN5VNnFTf63FgZjBiYu/DjQDGKNsD5QyvvqVziioVdOOgl6b8rPB/ypj2XNBinPwA== + dependencies: + earcut "^2.2.3" + geotiff "2.0.4" + ol-mapbox-style "9.1.0" + pbf "3.2.1" + rbush "^3.0.1" + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -10705,6 +10916,18 @@ rc-cascader@3.6.1: rc-tree "~5.6.3" rc-util "^5.6.1" +rc-cascader@3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/rc-cascader/-/rc-cascader-3.7.0.tgz#98134df578ce1cca22be8fb4319b04df4f3dca36" + integrity sha512-SFtGpwmYN7RaWEAGTS4Rkc62ZV/qmQGg/tajr/7mfIkleuu8ro9Hlk6J+aA0x1YS4zlaZBtTcSaXM01QMiEV/A== + dependencies: + "@babel/runtime" "^7.12.5" + array-tree-filter "^2.1.0" + classnames "^2.3.1" + rc-select "~14.1.0" + rc-tree "~5.7.0" + rc-util "^5.6.1" + rc-drawer@4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/rc-drawer/-/rc-drawer-4.4.3.tgz#2094937a844e55dc9644236a2d9fba79c344e321" @@ -10756,6 +10979,16 @@ rc-select@~14.1.0: rc-util "^5.16.1" rc-virtual-list "^3.2.0" +rc-slider@10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-10.0.1.tgz#7058c68ff1e1aa4e7c3536e5e10128bdbccb87f9" + integrity sha512-igTKF3zBet7oS/3yNiIlmU8KnZ45npmrmHlUUio8PNbIhzMcsh+oE/r2UD42Y6YD2D/s+kzCQkzQrPD6RY435Q== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.5" + rc-util "^5.18.1" + shallowequal "^1.1.0" + rc-slider@9.7.5: version "9.7.5" resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-9.7.5.tgz#193141c68e99b1dc3b746daeb6bf852946f5b7f4" @@ -10790,7 +11023,7 @@ rc-time-picker@^3.7.3: rc-trigger "^2.2.0" react-lifecycles-compat "^3.0.4" -rc-tooltip@^5.0.1: +rc-tooltip@5.2.2, rc-tooltip@^5.0.1: version "5.2.2" resolved "https://registry.yarnpkg.com/rc-tooltip/-/rc-tooltip-5.2.2.tgz#e5cafa8ecebf78108936a0bcb93c150fa81ac93b" integrity sha512-jtQzU/18S6EI3lhSGoDYhPqNpWajMtS5VV/ld1LwyfrDByQpYmw/LW6U7oFXXLukjfDHQ7Ju705A82PRNFWYhg== @@ -10810,6 +11043,17 @@ rc-tree@~5.6.3: rc-util "^5.16.1" rc-virtual-list "^3.4.8" +rc-tree@~5.7.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/rc-tree/-/rc-tree-5.7.0.tgz#d0e316eeeac2ba4a1c36b2b2201d84884f1c76a1" + integrity sha512-F+Ewkv/UcutshnVBMISP+lPdHDlcsL+YH/MQDVWbk+QdkfID7vXiwrHMEZn31+2Rbbm21z/HPceGS8PXGMmnQg== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "2.x" + rc-motion "^2.0.1" + rc-util "^5.16.1" + rc-virtual-list "^3.4.8" + rc-trigger@^2.2.0: version "2.6.5" resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-2.6.5.tgz#140a857cf28bd0fa01b9aecb1e26a50a700e9885" @@ -10845,7 +11089,7 @@ rc-util@^4.0.4, rc-util@^4.15.3, rc-util@^4.4.0: react-lifecycles-compat "^3.0.4" shallowequal "^1.1.0" -rc-util@^5.15.0, rc-util@^5.16.1, rc-util@^5.19.2, rc-util@^5.21.0, rc-util@^5.22.5, rc-util@^5.3.0, rc-util@^5.6.1, rc-util@^5.7.0: +rc-util@^5.15.0, rc-util@^5.16.1, rc-util@^5.18.1, rc-util@^5.19.2, rc-util@^5.21.0, rc-util@^5.22.5, rc-util@^5.3.0, rc-util@^5.6.1, rc-util@^5.7.0: version "5.24.4" resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.24.4.tgz#a4126f01358c86f17c1bf380a1d83d6c9155ae65" integrity sha512-2a4RQnycV9eV7lVZPEJ7QwJRPlZNc06J7CwcwZo4vIHr3PfUqtYgl1EkUV9ETAc6VRRi8XZOMFhYG63whlIC9Q== @@ -10877,6 +11121,19 @@ react-beautiful-dnd@13.1.0: redux "^4.0.4" use-memo-one "^1.1.1" +react-beautiful-dnd@13.1.1: + version "13.1.1" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" + integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-calendar@3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-3.7.0.tgz#951d56e91afb33b1c1e019cb790349fbffcc6894" @@ -10887,11 +11144,26 @@ react-calendar@3.7.0: merge-class-names "^1.1.1" prop-types "^15.6.0" +react-calendar@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-3.9.0.tgz#4dfe342ef61574c0e819e49847981076c7af58ea" + integrity sha512-g6RJCEaPovHTiV2bMhBUfm0a1YoMj4bOUpL8hQSLmR1Glhc7lgRLtZBd4mcC4jkoGsb+hv9uA/QH4pZcm5l9lQ== + dependencies: + "@wojtekmaj/date-utils" "^1.0.2" + get-user-locale "^1.2.0" + merge-class-names "^1.1.1" + prop-types "^15.6.0" + react-colorful@5.5.1: version "5.5.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== +react-colorful@5.6.1: + version "5.6.1" + resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" + integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== + react-copy-to-clipboard@^5.0.2: version "5.1.0" resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c" @@ -10965,6 +11237,15 @@ react-dropzone@14.2.2: file-selector "^0.6.0" prop-types "^15.8.1" +react-dropzone@14.2.3: + version "14.2.3" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b" + integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug== + dependencies: + attr-accept "^2.2.2" + file-selector "^0.6.0" + prop-types "^15.8.1" + react-emoji-render@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/react-emoji-render/-/react-emoji-render-1.2.4.tgz#fa3542a692e1eed3236f0f12b8e3a61b2818e2c2" @@ -11005,6 +11286,14 @@ react-hook-form@7.5.3: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.5.3.tgz#9a624fa14ec153b154891c5ebddae02ec5c2e40f" integrity sha512-5T0mfJ4kCPKljd7t3Rgp7lML4Y2+kaZIeMdN6Zo/J7gBQ+WkrDBHOftdOtz4X+7/eqHGak5yL5evNpYdA9abVA== +react-i18next@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-12.0.0.tgz#634015a2c035779c5736ae4c2e5c34c1659753b1" + integrity sha512-/O7N6aIEAl1FaWZBNvhdIo9itvF/MO/nRKr9pYqRc9LhuC1u21SlfwpiYQqvaeNSEW3g3qUXLREOWMt+gxrWbg== + dependencies: + "@babel/runtime" "^7.14.5" + html-parse-stringify "^3.0.1" + react-immutable-proptypes@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz#cce96d68cc3c18e89617cbf3092d08e35126af4a" @@ -11020,6 +11309,14 @@ react-inlinesvg@3.0.0: exenv "^1.2.2" react-from-dom "^0.6.2" +react-inlinesvg@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/react-inlinesvg/-/react-inlinesvg-3.0.1.tgz#2133f5d2c770ac405060db2ce1c13eed30e7e83b" + integrity sha512-cBfoyfseNI2PkDA7ZKIlDoHq0eMfpoC3DhKBQNC+/X1M4ZQB+aXW+YiNPUDDDKXUsGDUIZWWiZWNFeauDIVdoA== + dependencies: + exenv "^1.2.2" + react-from-dom "^0.6.2" + react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -11145,6 +11442,21 @@ react-select@5.4.0: prop-types "^15.6.0" react-transition-group "^4.3.0" +react-select@5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.6.0.tgz#d987f4c86b3dcd32307a0104e503e4e8a9777a34" + integrity sha512-uUvP/72rA8NGhOL16RVBaeC12Wa4NUE0iXIa6hz0YRno9ZgxTmpuMeKzjR7vHcwmigpVCoe0prP+3NVb6Obq8Q== + dependencies: + "@babel/runtime" "^7.12.0" + "@emotion/cache" "^11.4.0" + "@emotion/react" "^11.8.1" + "@floating-ui/dom" "^1.0.1" + "@types/react-transition-group" "^4.4.0" + memoize-one "^6.0.0" + prop-types "^15.6.0" + react-transition-group "^4.3.0" + use-isomorphic-layout-effect "^1.1.2" + react-shallow-renderer@^16.13.1: version "16.15.0" resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" @@ -11194,7 +11506,7 @@ react-transition-group@4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" -react-transition-group@^4.3.0, react-transition-group@^4.4.5: +react-transition-group@4.4.5, react-transition-group@^4.3.0, react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== @@ -11237,6 +11549,14 @@ react-window@1.8.7: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" +react-window@1.8.8: + version "1.8.8" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243" + integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -11321,6 +11641,11 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== +regenerator-runtime@0.13.10, regenerator-runtime@^0.13.10: + version "0.13.10" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" + integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== + regenerator-runtime@0.13.9: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" @@ -11331,11 +11656,6 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.10: - version "0.13.10" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" - integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== - regenerator-transform@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" @@ -11602,6 +11922,13 @@ rxjs@7.5.6: dependencies: tslib "^2.1.0" +rxjs@7.5.7, rxjs@^7.5.1, rxjs@^7.5.5: + version "7.5.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" + integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== + dependencies: + tslib "^2.1.0" + rxjs@^6.4.0, rxjs@^6.6.0: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" @@ -11609,13 +11936,6 @@ rxjs@^6.4.0, rxjs@^6.6.0: dependencies: tslib "^1.9.0" -rxjs@^7.5.1, rxjs@^7.5.5: - version "7.5.7" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" - integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== - dependencies: - tslib "^2.1.0" - safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -11901,7 +12221,7 @@ slate-plain-serializer@0.7.11: resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.11.tgz#74ff6eb949e9fbd92ad98ed833d74d5082f2688b" integrity sha512-vzXQ68GiHHcTUcAB6ggf2qN/sX9BoLs77SMHacp5Gkg+oHAA/NxRzRH4efDAhpiJqfJZDrA3rQySK6+Y7KAuwg== -slate-plain-serializer@^0.7.11: +slate-plain-serializer@0.7.13, slate-plain-serializer@^0.7.11: version "0.7.13" resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.13.tgz#6de8f5c645dd749f1b2e4426c20de74bfd213adf" integrity sha512-TtrlaslxQBEMV0LYdf3s7VAbTxRPe1xaW10WNNGAzGA855/0RhkaHjKkQiRjHv5rvbRleVf7Nxr9fH+4uErfxQ== @@ -12791,16 +13111,16 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslib@2.4.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" - integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -12874,7 +13194,12 @@ typescript@4.8.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== -ua-parser-js@^1.0.2: +typescript@4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" + integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== + +ua-parser-js@^1.0.32: version "1.0.32" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.32.tgz#786bf17df97de159d5b1c9d5e8e9e89806f8a030" integrity sha512-dXVsz3M4j+5tTiovFVyVqssXBu5HM47//YSOeZ9fQkdDKkfzv2v3PP1jmH6FUyPW+yCSn7aBVK1fGGKNhowdDA== @@ -13040,6 +13365,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-isomorphic-layout-effect@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + use-memo-one@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" @@ -13070,6 +13400,11 @@ uuid@8.3.2, uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" @@ -13122,6 +13457,11 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -13165,10 +13505,10 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -web-vitals@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c" - integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg== +web-vitals@^3.0.4: + version "3.1.0" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.1.0.tgz#a6f5156cb6c7fee562da46078540265ac2cd2d16" + integrity sha512-zCeQ+bOjWjJbXv5ZL0r8Py3XP2doCQMZXNKlBGfUjPAVZWokApdeF/kFlK1peuKlCt8sL9TFkKzyXE9/cmNJQA== web-worker@^1.2.0: version "1.2.0" @@ -13379,6 +13719,14 @@ xss@1.0.13: commander "^2.20.3" cssfilter "0.0.10" +xss@1.0.14: + version "1.0.14" + resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694" + integrity sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw== + dependencies: + commander "^2.20.3" + cssfilter "0.0.10" + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" From 09850941128752a8cb35151a0bfcb5629fd50460 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Tue, 29 Nov 2022 11:46:19 +0000 Subject: [PATCH 10/48] Add a temporary URL for the mobile app API --- engine/engine/urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index a29c11c5..cd7bdc63 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -54,6 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED: urlpatterns += [ path("mobile_app/v1/", include("apps.mobile_app.urls", namespace="mobile_app")), + path("api/internal/v1/mobile_app/", include("apps.mobile_app.urls", namespace="mobile_app")), ] From df2f96e43a7696a8b24938e97dac957215922350 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Tue, 29 Nov 2022 12:53:15 +0100 Subject: [PATCH 11/48] don't show user tabs they aren't authorized to see (#914) --- .../src/containers/AlertRules/AlertRules.tsx | 8 +++--- .../parts/connectors/SlackConnector.tsx | 4 +-- .../ApiTokenSettings/ApiTokenSettings.tsx | 4 +-- .../DefaultPageLayout/DefaultPageLayout.tsx | 4 +-- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 4 +-- .../PhoneVerification/PhoneVerification.tsx | 4 +-- .../WithPermissionControl.tsx | 7 ++--- .../WithPermissionControl.tsx | 7 ++--- grafana-plugin/src/models/user/user.ts | 4 +-- grafana-plugin/src/pages/index.tsx | 28 ++++++++++--------- .../OutgoingWebhooks.test.tsx | 7 +++-- .../outgoing_webhooks/OutgoingWebhooks.tsx | 4 +-- grafana-plugin/src/pages/routes.tsx | 18 ------------ .../src/pages/schedule/Schedule.tsx | 4 +-- .../src/pages/settings/SettingsPage.tsx | 6 ++-- .../tabs/LiveSettings/LiveSettingsPage.tsx | 5 ++-- grafana-plugin/src/pages/users/Users.tsx | 12 ++++---- .../src/plugin/GrafanaPluginRootPage.tsx | 3 +- .../src/state/rootBaseStore/index.ts | 10 ++----- .../state/rootBaseStore/rootBaseStore.test.ts | 23 ++++++++------- 20 files changed, 71 insertions(+), 95 deletions(-) diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index baf9e3e0..c696a0e9 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -37,7 +37,7 @@ import { MaintenanceType } from 'models/maintenance/maintenance.types'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { openNotification } from 'utils'; -import { UserActions } from 'utils/authorization'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; import sanitize from 'utils/sanitize'; import styles from './AlertRules.module.css'; @@ -223,7 +223,7 @@ class AlertRules extends React.Component { - {store.isUserActionAllowed(UserActions.UserSettingsRead) ? ( + {isUserActionAllowed(UserActions.UserSettingsRead) ? ( <>
{ return ( - +
-`; - -exports[`MobileAppVerification it shows a QR code if the app isn't already connected 1`] = ` -
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - Note: the QR code is only valid for one minute. If you have issues connecting your mobile app, try refreshing this page to generate a new code. - -
-
-
-
-
-
-
-
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
-
-
-
-
- Apple - - iOS - -
-
-
-
- Play Store - - Android - -
-
-
-
-
-
-
-
-
-`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = ` -
-
-
-
-
- Loading... - -
- -
-
-
-
-
-
-
-
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
-
-
-
-
- Apple - - iOS - -
-
-
-
- Play Store - - Android - -
-
-
-
-
-
-
-
-
-`; - -exports[`MobileAppVerification it shows a loading message if it is currently fetching the QR code 1`] = ` -
-
-
-
-
- Loading... - -
- -
-
-
-
-
-
-
-
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
-
-
-
-
- Apple - - iOS - -
-
-
-
- Play Store - - Android - -
-
-
-
-
-
-
-
-
-`; - -exports[`MobileAppVerification it shows a message when the mobile app is already connected 1`] = ` -
-
-
-
-
-
- - Your mobile app is currently connected. Click below to disconnect. - -
-
+
+
+`; + +exports[`MobileAppVerification it shows a QR code if the app isn't already connected 1`] = ` +
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
-
-
+ - Apple - - iOS - -
-
-
-
- Play Store - - Android - -
+ iOS +
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a loading message if it is currently fetching the QR code 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+ Loading... + +
+ +
+
+
+
+
+`; + +exports[`MobileAppVerification it shows a message when the mobile app is already connected 1`] = ` +
+
+
+
+
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
+
+
+
+ Apple + + iOS + +
+
+
+
+ Play Store + + Android + +
+
+
+
+
+
+
+
+
+ + App connected +
+ + + +
+
+
+
+ + You can sync one application to your account. To setup new device please disconnect app first. + +
+
+
+ +
@@ -4995,98 +592,89 @@ exports[`MobileAppVerification it shows a message when the mobile app is already exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 1`] = `
- - There was an error disconnecting your mobile app. Please try again. - -
-
-
-
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
-
-
+ - Apple - - iOS - -
+ iOS +
+
+
-
+ - Play Store - - Android - -
+ Android +
+
+ + There was an error disconnecting your mobile app. Please try again. + +
`; @@ -5094,98 +682,89 @@ exports[`MobileAppVerification it shows an error message if there was an error d exports[`MobileAppVerification it shows an error message if there was an error fetching the QR code 1`] = `
- - There was an error fetching your QR code. Please try again. - -
-
-
-
+ + Download + +
+
+ + The Grafana IRM app is available on both the App Store and Google Play Store. + +
+
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
-
-
+ - Apple - - iOS - -
+ iOS +
+
+
-
+ - Play Store - - Android - -
+ Android +
+
+ + There was an error fetching your QR code. Please try again. + +
`; diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.module.scss b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.module.scss new file mode 100644 index 00000000..8e4b047d --- /dev/null +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.module.scss @@ -0,0 +1,6 @@ +.disconnect-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx index b660d5af..f3551b49 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import DisconnectButton from '.'; +import DisconnectButton from './DisconnectButton'; describe('DisconnectButton', () => { test('it renders properly', () => { diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.tsx similarity index 59% rename from grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx rename to grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.tsx index ec108f47..435dabb4 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/index.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/DisconnectButton.tsx @@ -1,17 +1,27 @@ import React, { FC } from 'react'; import { Button } from '@grafana/ui'; +import cn from 'classnames/bind'; import WithConfirm from 'components/WithConfirm/WithConfirm'; +import styles from './DisconnectButton.module.scss'; + +const cx = cn.bind(styles); + type Props = { onClick: () => void; }; -// TODO: right now this shows a confirmation pop-up modal on top of the user settings modal, do we want to maybe change this? const DisconnectButton: FC = ({ onClick }) => ( - diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap index 88ffb190..22996a57 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/DisconnectButton/__snapshots__/DisconnectButton.test.tsx.snap @@ -3,7 +3,8 @@ exports[`DisconnectButton it renders properly 1`] = `
- + */} Manual connection From c640653faae011fb325ac26e300823cc9139a60a Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Tue, 6 Dec 2022 16:53:26 +0200 Subject: [PATCH 37/48] fixed double requests, specify timeout of 6000 in awaitFor calls --- .../MobileAppVerification.test.tsx | 18 +- .../MobileAppVerification.tsx | 22 +- .../MobileAppVerification.test.tsx.snap | 2321 ++++++++++++++++- 3 files changed, 2245 insertions(+), 116 deletions(-) diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx index a073d68f..945fdf0d 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.test.tsx @@ -207,9 +207,12 @@ describe('MobileAppVerification', () => { render(); - await waitFor(() => { - expect(loadUserMock).toHaveBeenCalled(); - }); + await waitFor( + () => { + expect(loadUserMock).toHaveBeenCalled(); + }, + { timeout: 6000 } + ); }); test('it polls loadUser after disconnect', async () => { @@ -231,8 +234,11 @@ describe('MobileAppVerification', () => { await user.click(button); // click the disconnect button, which opens the modal await user.click(screen.getByText('Remove')); // click the confirm button within the modal, which actually triggers the callback - await waitFor(() => { - expect(loadUserMock).toHaveBeenCalled(); - }); + await waitFor( + () => { + expect(loadUserMock).toHaveBeenCalled(); + }, + { timeout: 6000 } + ); }); }); diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx index b6449296..b19f36d9 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -78,22 +78,18 @@ const MobileAppVerification = observer(({ userPk }: Props) => { } setDisconnectingMobileApp(false); - queueRefreshQR(); - pollUserProfile(); + clearTimeouts(); + triggerTimeouts(); }, [userPk, resetState]); useEffect(() => { if (!isUserConnected()) { - queueRefreshQR(); - pollUserProfile(); + triggerTimeouts(); } // clear on unmount return () => { - if (userTimeoutId) { - clearTimeout(userTimeoutId); - clearTimeout(refreshTimeoutId); - } + clearTimeouts(); }; }, []); @@ -154,6 +150,16 @@ const MobileAppVerification = observer(({ userPk }: Props) => {
); + function clearTimeouts(): void { + clearTimeout(userTimeoutId); + clearTimeout(refreshTimeoutId); + } + + function triggerTimeouts(): void { + setTimeout(() => queueRefreshQR(), INTERVAL_QUEUE_QR); + setTimeout(() => pollUserProfile(), INTERVAL_POLLING); + } + function isUserConnected(user?: User): boolean { return !!(user || userStore.currentUser).messaging_backends[BACKEND]?.connected; } diff --git a/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap b/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap index 546f29bb..e471869c 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppVerification/__snapshots__/MobileAppVerification.test.tsx.snap @@ -1,18 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 1`] = `
`; - -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 2`] = `
`; - -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 3`] = `
`; - -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 4`] = `
`; - -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 5`] = `
`; - -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 6`] = `
`; - -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 7`] = ` +exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 1`] = `
- App connected -
- - - -
+ Sign In
- You can sync one application to your account. To setup new device please disconnect app first. + Open Grafana IRM mobile application and scan this code to sync it with your account.
- - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -158,30 +2331,6 @@ exports[`MobileAppVerification if we disconnect the app, it disconnects and fetc
`; -exports[`MobileAppVerification if we disconnect the app, it disconnects and fetches a new QR code 8`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 1`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 2`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 3`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 4`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 5`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 6`] = `
`; - -exports[`MobileAppVerification it polls loadUser after disconnect 7`] = `
`; - -exports[`MobileAppVerification it polls loadUser on first render if not connected 1`] = `
`; - -exports[`MobileAppVerification it polls loadUser on first render if not connected 2`] = `
`; - -exports[`MobileAppVerification it polls loadUser on first render if not connected 3`] = `
`; - -exports[`MobileAppVerification it polls loadUser on first render if not connected 4`] = `
`; - exports[`MobileAppVerification it shows a QR code if the app isn't already connected 1`] = `
`; -exports[`MobileAppVerification it shows a QR code if the app isn't already connected 2`] = `
`; - -exports[`MobileAppVerification it shows a QR code if the app isn't already connected 3`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 2`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 3`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 4`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 5`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 6`] = ` +exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 1`] = `
`; -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 7`] = `
`; - -exports[`MobileAppVerification it shows a loading message if it is currently disconnecting 8`] = `
`; - exports[`MobileAppVerification it shows a loading message if it is currently fetching the QR code 1`] = `
`; -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 1`] = `
`; - -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 2`] = `
`; - -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 3`] = `
`; - -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 4`] = `
`; - -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 5`] = `
`; - -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 6`] = ` +exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 1`] = `
`; -exports[`MobileAppVerification it shows an error message if there was an error disconnecting the mobile app 7`] = `
`; - exports[`MobileAppVerification it shows an error message if there was an error fetching the QR code 1`] = `
`; - -exports[`MobileAppVerification it shows an error message if there was an error fetching the QR code 2`] = `
`; From c04590f74676340f7bbb9f48670c26d55c6d1ec4 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Tue, 6 Dec 2022 17:15:08 +0200 Subject: [PATCH 38/48] mark classname as optional --- .../parts/QRCode/QRCode.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx index 0e8e65c9..b41de79d 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/parts/QRCode/QRCode.tsx @@ -6,13 +6,17 @@ import Block from 'components/GBlock/Block'; type Props = { value: string; - className: string; + className?: string; }; -const QRCode: FC = ({ value, className }) => ( - - - -); +const QRCode: FC = (props: Props) => { + const { value, className = '' } = props; + + return ( + + + + ); +}; export default QRCode; From 4f18be927af8288aa4ebf34cc74dda3130730dfe Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 7 Dec 2022 00:20:54 +0800 Subject: [PATCH 39/48] Fix migration --- .../migrations/0006_organization_uuid.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/engine/apps/user_management/migrations/0006_organization_uuid.py b/engine/apps/user_management/migrations/0006_organization_uuid.py index 63d16ded..ab2e1d2b 100644 --- a/engine/apps/user_management/migrations/0006_organization_uuid.py +++ b/engine/apps/user_management/migrations/0006_organization_uuid.py @@ -4,6 +4,15 @@ from django.db import migrations, models import uuid +def fill_org_uuid(apps, schema_editor): + Organization = apps.get_model('user_management', 'Organization') + orgs_to_update = [] + for org in Organization.objects.all(): + org.uuid = uuid.uuid4() + orgs_to_update.append(org) + Organization.objects.bulk_update(orgs_to_update, ["uuid"], batch_size=5000) + + class Migration(migrations.Migration): dependencies = [ @@ -12,6 +21,12 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( + model_name='organization', + name='uuid', + field=models.UUIDField(null=True), + ), + migrations.RunPython(fill_org_uuid, migrations.RunPython.noop), + migrations.AlterField( model_name='organization', name='uuid', field=models.UUIDField(default=uuid.uuid4, editable=False), From c31ac3b9777b576e7f21f118896e8d7ef1ebb4fd Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 7 Dec 2022 12:03:50 +0800 Subject: [PATCH 40/48] Fix create_oncall_connector --- engine/apps/user_management/models/organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 1a571b1f..db3d7f0d 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -46,7 +46,7 @@ class OrganizationQuerySet(models.QuerySet): def create(self, **kwargs): instance = super().create(**kwargs) if settings.FEATURE_MULTIREGION_ENABLED: - create_oncall_connector(instance.public_primary_key, settings.ONCALL_BACKEND_REGION) + create_oncall_connector(instance.uuid, settings.ONCALL_BACKEND_REGION) return instance def delete(self): From c1307a44d7a559ea2547042c2ec9066ac9878e33 Mon Sep 17 00:00:00 2001 From: Erikas Truskauskas <87935328+3rikas@users.noreply.github.com> Date: Wed, 7 Dec 2022 10:10:10 +0200 Subject: [PATCH 41/48] Make job-migrate ttlSecondsAfterFinished customizable (#957) Co-authored-by: Joey Orlando --- CHANGELOG.md | 1 + helm/oncall/Chart.yaml | 2 +- helm/oncall/templates/engine/job-migrate.yaml | 4 +++- helm/oncall/values.yaml | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e26bd7..d7c8ead8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 to update values that may be invalid - Add a `permalinks.web` field, which is a permalink to the alert group web app page, to the alert group internal/public API responses +- Added the ability to customize job-migrate `ttlSecondsAfterFinished` field in the helm chart ### Fixed diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index 4db90153..70f7df3c 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.0.13 +version: 1.1.0 # 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/templates/engine/job-migrate.yaml b/helm/oncall/templates/engine/job-migrate.yaml index 47667afc..2bb478fa 100644 --- a/helm/oncall/templates/engine/job-migrate.yaml +++ b/helm/oncall/templates/engine/job-migrate.yaml @@ -7,7 +7,9 @@ metadata: {{- include "oncall.engine.labels" . | nindent 4 }} spec: backoffLimit: 15 - ttlSecondsAfterFinished: 20 + {{- if .Values.migrate.ttlSecondsAfterFinished }} + ttlSecondsAfterFinished: {{ .Values.migrate.ttlSecondsAfterFinished }} + {{- end }} template: metadata: name: {{ printf "%s-migrate-%s" (include "oncall.engine.fullname" .) (now | date "2006-01-02-15-04-05") }} diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index b4e05a50..a21ce82c 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -140,6 +140,8 @@ oncall: # Whether to run django database migrations automatically migrate: enabled: true + # TTL can be unset by setting ttlSecondsAfterFinished: "" + ttlSecondsAfterFinished: 20 # Additional env variables to add to deployments env: {} From a080d8fd7df28ae55f8d85d64091a40ffb2e2ac2 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Wed, 7 Dec 2022 11:16:47 +0200 Subject: [PATCH 42/48] review --- .../MobileAppVerification/MobileAppVerification.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx index b19f36d9..bedcdf99 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -156,8 +156,8 @@ const MobileAppVerification = observer(({ userPk }: Props) => { } function triggerTimeouts(): void { - setTimeout(() => queueRefreshQR(), INTERVAL_QUEUE_QR); - setTimeout(() => pollUserProfile(), INTERVAL_POLLING); + setTimeout(queueRefreshQR, INTERVAL_QUEUE_QR); + setTimeout(pollUserProfile, INTERVAL_POLLING); } function isUserConnected(user?: User): boolean { @@ -173,7 +173,7 @@ const MobileAppVerification = observer(({ userPk }: Props) => { setIsQRBlurry(true); await fetchQRCode(false); setIsQRBlurry(false); - setTimeout(() => queueRefreshQR(), INTERVAL_QUEUE_QR); + setTimeout(queueRefreshQR, INTERVAL_QUEUE_QR); } } @@ -183,7 +183,7 @@ const MobileAppVerification = observer(({ userPk }: Props) => { const user = await userStore.loadUser(userPk); if (!isUserConnected(user)) { - setUserTimeoutId(setTimeout(() => pollUserProfile(), INTERVAL_POLLING)); + setUserTimeoutId(setTimeout(pollUserProfile, INTERVAL_POLLING)); } else { setMobileAppIsCurrentlyConnected(true); } From 357cd072c9ad4453a5903b841dd07b8cfb820552 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 7 Dec 2022 11:24:23 +0100 Subject: [PATCH 43/48] Fixed error while creating schedule and fixed Schedules types mistakes --- .../NewScheduleSelector/NewScheduleSelector.tsx | 2 +- .../components/SchedulesFilters_NEW/SchedulesFilters.tsx | 4 ++-- grafana-plugin/src/models/schedule/schedule.types.ts | 4 ++-- grafana-plugin/src/pages/schedule/Schedule.tsx | 9 ++++++--- grafana-plugin/src/pages/schedules/Schedules.tsx | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx index b9beecb3..fdbce3d4 100644 --- a/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx +++ b/grafana-plugin/src/components/NewScheduleSelector/NewScheduleSelector.tsx @@ -50,7 +50,7 @@ const NewScheduleSelector: FC = (props) => { - diff --git a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx index cc6ee209..2b7f6a0b 100644 --- a/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx +++ b/grafana-plugin/src/components/SchedulesFilters_NEW/SchedulesFilters.tsx @@ -72,7 +72,7 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => { { label: 'All', value: undefined }, { label: 'Web', - value: ScheduleType.API, + value: ScheduleType.Calendar, }, { label: 'ICal', @@ -80,7 +80,7 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => { }, { label: 'API', - value: ScheduleType.Calendar, + value: ScheduleType.API, }, ]} value={value?.type} diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index 8b703377..c38a69eb 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,9 +6,9 @@ import { User } from 'models/user/user.types'; import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { - 'Calendar', - 'Ical', 'API', + 'Ical', + 'Calendar', } export interface RotationFormLiveParams { diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index b950e4c0..4039fca2 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -147,9 +147,12 @@ class SchedulePage extends React.Component )} - + {schedule?.type === ScheduleType.Ical && ( + + )} + {(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && (
diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index 924ad5a0..d61fee49 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -21,6 +21,7 @@ import UserSettings from 'containers/UserSettings/UserSettings'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { User as UserType } from 'models/user/user.types'; import { pages } from 'pages'; +import { AppFeature } from 'state/features'; import { PageProps, WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import LocationHelper from 'utils/LocationHelper'; @@ -116,7 +117,11 @@ class Users extends React.Component { render() { const { usersFilters, userPkToEdit, page, errorData } = this.state; - const { store, query } = this.props; + const { + store, + query, + query: { id }, + } = this.props; const { userStore } = store; const columns = [ @@ -157,6 +162,8 @@ class Users extends React.Component { }); const { count, results } = userStore.getSearchResult(); + const showMobileAppScreen: boolean = + id !== undefined && id !== 'me' && id === userStore.currentUserPk && store.hasFeature(AppFeature.MobileApp); return ( @@ -232,7 +239,13 @@ class Users extends React.Component { /> )}
- {userPkToEdit && } + {userPkToEdit && ( + + )}
)}