diff --git a/docs/sources/manage/notify/ms-teams/index.md b/docs/sources/manage/notify/ms-teams/index.md index e6fd9bd7cd..b62715e626 100644 --- a/docs/sources/manage/notify/ms-teams/index.md +++ b/docs/sources/manage/notify/ms-teams/index.md @@ -23,6 +23,10 @@ aliases: # MS Teams integration for Grafana OnCall +{{< admonition type="note" >}} +This integration is available exclusively on Grafana Cloud. +{{< /admonition >}} + The Microsoft Teams integration for Grafana OnCall embeds your MS Teams channels directly into your incident response workflow to help your team focus on alert resolution. @@ -32,33 +36,38 @@ acknowledge, unacknowledge, resolve, and silence. ## Before you begin -> NOTE: **This integration is available to Grafana Cloud instances of Grafana OnCall only.** - The following is required to connect to Microsoft Teams to Grafana OnCall: - You must have Admin permissions in your Grafana Cloud instance. - You must have Owner permissions in Microsoft Teams. - Install the Grafana IRM app from the [Microsoft Marketplace](https://appsource.microsoft.com/en-us/product/office/WA200004307). -## Install Microsoft Teams integration for Grafana OnCall +## Connect Microsoft Teams with Grafana OnCall + +{{< admonition type="note" >}} +A Microsoft Teams workspace can only be connected to one Grafana Cloud instance and cannot be connected to multiple environments. +{{< /admonition >}} + +To connect Microsoft Teams with Grafana OnCall: -1. Navigate to **Settings** tab in Grafana OnCall. +1. In Grafana OnCall, open **Settings** and click **Chat Ops**. 1. From the **Chat Ops** tab, select **Microsoft Teams** in the side menu. -1. Follow the steps provided to connect to your Teams channels, then click **Done**. -1. To add additional teams and channels click **+Add MS Teams channel** again and repeat step 3 as needed. +1. Follow the in-app instructions to add the Grafana IRM app to your Teams workspace. +1. After your workspace is connected, copy and paste the provided code into a Teams channel to add the IRM bot, then click **Done**. +1. To add additional channels click **+Add MS Teams channel** and repeat step 3 as needed. ## Post-install configuration for Microsoft Teams integration Configure the following settings to ensure Grafana OnCall alerts are routed to the intended Teams channels and users: -- Set a default channel from the list of connected MS Teams channels. This is where alerts will be sent unless otherwise - specified in escalation chains. +- Set a default channel from the list of connected MS Teams channels. +This is where alerts will be sent unless otherwise specified in escalation chains. - Ensure all users verify their MS Teams account in their Grafana OnCall user profile. ### Connect Microsoft Teams user to Grafana OnCall -1. From the **Users** tab in Grafana OnCall, click **View my profile**. -1. In the **User Info** tab, navigate to **Microsoft Teams username**, click **Connect**. +1. From the **Users** tab of Grafana OnCall, click **View my profile**. +1. In the **User Info** tab, locate **Notification channels**, **MS Teams**, and click **Connect account**. 1. Follow the steps provided to connect your Teams user. 1. Navigate back to your Grafana OnCall profile and verify that your Microsoft Teams account is linked to your Grafana OnCall user. diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index 002519260c..106e6d8c79 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -221,7 +221,7 @@ The benefits of connecting to Grafana Cloud OnCall include: To connect to Grafana Cloud OnCall, refer to the **Cloud** page in your OSS Grafana OnCall instance. -Check the Settings page for the Grafana Cloud OnCall API Url. If it's not `https://oncall-prod-us-central-0.grafana.net/oncall`, +Check the Settings page for the Grafana Cloud OnCall API URL. If it's not `https://oncall-prod-us-central-0.grafana.net/oncall`, you will need to set the `GRAFANA_CLOUD_ONCALL_API_URL` variable in your self-hosted OnCall, so that it can connect properly. ## Supported Phone Providers diff --git a/engine/apps/api/permissions.py b/engine/apps/api/permissions.py index d9dad6b37d..f37d72e9fc 100644 --- a/engine/apps/api/permissions.py +++ b/engine/apps/api/permissions.py @@ -175,7 +175,7 @@ def user_is_authorized(user: "User", required_permissions: LegacyAccessControlCo `required_permissions` - A list of permissions that a user must have to be considered authorized """ organization = user.organization - if organization.is_rbac_permissions_enabled: + if organization.is_rbac_permissions_enabled or user.is_service_account: user_permissions = [u["action"] for u in user.permissions] required_permission_values = get_required_permission_values(organization, required_permissions) return all(permission in user_permissions for permission in required_permission_values) diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index cac5e27432..36ea8d82c2 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -16,6 +16,7 @@ from apps.user_management.models import User from apps.user_management.models.organization import Organization from apps.user_management.sync import get_or_create_user +from common.utils import validate_url from settings.base import SELF_HOSTED_SETTINGS from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME @@ -370,14 +371,17 @@ def authenticate(self, request): def get_organization(self, request, auth): grafana_url = request.headers.get(X_GRAFANA_URL) if grafana_url: - organization = Organization.objects.filter(grafana_url=grafana_url).first() - if not organization: - # trigger a request to sync the organization - # (ignore response since we can get a 400 if sync was already triggered; - # if organization exists, we are good) - setup_organization(grafana_url, auth) - organization = Organization.objects.filter(grafana_url=grafana_url).first() - return organization + url = validate_url(grafana_url) + if url is not None: + url = url.rstrip("/") + organization = Organization.objects.filter(grafana_url=url).first() + if not organization: + # trigger a request to sync the organization + # (ignore response since we can get a 400 if sync was already triggered; + # if organization exists, we are good) + setup_organization(url, auth) + organization = Organization.objects.filter(grafana_url=url).first() + return organization if settings.LICENSE == settings.CLOUD_LICENSE_NAME: instance_id = request.headers.get(X_GRAFANA_INSTANCE_ID) diff --git a/engine/apps/auth_token/models/service_account_token.py b/engine/apps/auth_token/models/service_account_token.py index 716dc55db3..de0b6ea6dd 100644 --- a/engine/apps/auth_token/models/service_account_token.py +++ b/engine/apps/auth_token/models/service_account_token.py @@ -37,10 +37,6 @@ def organization(self): @classmethod def validate_token(cls, organization, token): - # require RBAC enabled to allow service account auth - if not organization.is_rbac_permissions_enabled: - raise InvalidToken - # Grafana API request: get permissions and confirm token is valid permissions = get_service_account_token_permissions(organization, token) if not permissions: diff --git a/engine/apps/auth_token/tests/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py index 950e63e1fa..8da611a0fb 100644 --- a/engine/apps/auth_token/tests/test_grafana_auth.py +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -10,7 +10,7 @@ from apps.auth_token.auth import X_GRAFANA_INSTANCE_ID, GrafanaServiceAccountAuthentication from apps.auth_token.models import ServiceAccountToken from apps.auth_token.tests.helpers import setup_service_account_api_mocks -from apps.user_management.models import Organization, ServiceAccountUser +from apps.user_management.models import Organization from common.constants.plugin_ids import PluginID from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS @@ -98,7 +98,7 @@ def test_grafana_authentication_missing_org(): @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) -def test_grafana_authentication_invalid_grafana_url(): +def test_grafana_authentication_no_org_grafana_url(): grafana_url = "http://grafana.test" token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" headers = { @@ -115,31 +115,27 @@ def test_grafana_authentication_invalid_grafana_url(): assert exc.value.detail == "Organization not found." +@pytest.mark.parametrize("grafana_url", ["null;", "foo", ""]) @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) -def test_grafana_authentication_rbac_disabled_fails(make_organization): - organization = make_organization(grafana_url="http://grafana.test") - if organization.is_rbac_permissions_enabled: - return - +def test_grafana_authentication_invalid_grafana_url(grafana_url): token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" headers = { "HTTP_AUTHORIZATION": token, - "HTTP_X_GRAFANA_URL": organization.grafana_url, + "HTTP_X_GRAFANA_URL": grafana_url, # no org for this URL } request = APIRequestFactory().get("/", **headers) + # NOTE: no sync requests are made in this case with pytest.raises(exceptions.AuthenticationFailed) as exc: GrafanaServiceAccountAuthentication().authenticate(request) - assert exc.value.detail == "Invalid token." + assert exc.value.detail == "Organization not found." @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_permissions_call_fails(make_organization): organization = make_organization(grafana_url="http://grafana.test") - if not organization.is_rbac_permissions_enabled: - return token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" headers = { @@ -165,29 +161,29 @@ def test_grafana_authentication_permissions_call_fails(make_organization): @pytest.mark.django_db +@pytest.mark.parametrize("grafana_url", ["http://grafana.test", "http://grafana.test/"]) @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_existing_token( - make_organization, make_service_account_for_organization, make_token_for_service_account + make_organization, make_service_account_for_organization, make_token_for_service_account, grafana_url ): + # org grafana_url is consistently stored without trailing slash organization = make_organization(grafana_url="http://grafana.test") - if not organization.is_rbac_permissions_enabled: - return service_account = make_service_account_for_organization(organization) token_string = "glsa_the-token" token = make_token_for_service_account(service_account, token_string) headers = { "HTTP_AUTHORIZATION": token_string, - "HTTP_X_GRAFANA_URL": organization.grafana_url, + "HTTP_X_GRAFANA_URL": grafana_url, # trailing slash is ignored } request = APIRequestFactory().get("/", **headers) - # setup Grafana API responses + # setup Grafana API responses (use URL without trailing slash) setup_service_account_api_mocks(organization.grafana_url, {"some-perm": "value"}) user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) - assert isinstance(user, ServiceAccountUser) + assert user.is_service_account assert user.service_account == service_account assert user.public_primary_key == service_account.public_primary_key assert user.username == service_account.username @@ -206,8 +202,6 @@ def test_grafana_authentication_existing_token( @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_token_created(make_organization): organization = make_organization(grafana_url="http://grafana.test") - if not organization.is_rbac_permissions_enabled: - return token_string = "glsa_the-token" headers = { @@ -223,7 +217,7 @@ def test_grafana_authentication_token_created(make_organization): user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) - assert isinstance(user, ServiceAccountUser) + assert user.is_service_account service_account = user.service_account assert service_account.organization == organization assert user.public_primary_key == service_account.public_primary_key @@ -248,8 +242,6 @@ def test_grafana_authentication_token_created(make_organization): @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_token_created_older_grafana(make_organization): organization = make_organization(grafana_url="http://grafana.test") - if not organization.is_rbac_permissions_enabled: - return token_string = "glsa_the-token" headers = { @@ -265,7 +257,7 @@ def test_grafana_authentication_token_created_older_grafana(make_organization): user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) - assert isinstance(user, ServiceAccountUser) + assert user.is_service_account service_account = user.service_account assert service_account.organization == organization # use fallback data @@ -278,8 +270,6 @@ def test_grafana_authentication_token_created_older_grafana(make_organization): @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_token_reuse_service_account(make_organization, make_service_account_for_organization): organization = make_organization(grafana_url="http://grafana.test") - if not organization.is_rbac_permissions_enabled: - return service_account = make_service_account_for_organization(organization) token_string = "glsa_the-token" @@ -299,7 +289,7 @@ def test_grafana_authentication_token_reuse_service_account(make_organization, m user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) - assert isinstance(user, ServiceAccountUser) + assert user.is_service_account assert user.service_account == service_account assert auth_token.service_account == service_account @@ -335,7 +325,7 @@ def sync_org(): mock_setup_org.assert_called_once() - assert isinstance(user, ServiceAccountUser) + assert user.is_service_account service_account = user.service_account # organization is created organization = Organization.objects.filter(grafana_url=grafana_url).get() diff --git a/engine/apps/grafana_plugin/serializers/sync_data.py b/engine/apps/grafana_plugin/serializers/sync_data.py index 79902529be..223d73f6b5 100644 --- a/engine/apps/grafana_plugin/serializers/sync_data.py +++ b/engine/apps/grafana_plugin/serializers/sync_data.py @@ -73,6 +73,10 @@ class SyncOnCallSettingsSerializer(serializers.Serializer): labels_enabled = serializers.BooleanField() irm_enabled = serializers.BooleanField(default=False) + def validate_grafana_url(self, value): + # remove trailing slash for URL consistency + return value.rstrip("/") + def create(self, validated_data): return SyncSettings(**validated_data) @@ -81,7 +85,7 @@ def to_representation(self, instance): class SyncDataSerializer(serializers.Serializer): - users = serializers.ListField(child=SyncUserSerializer()) + users = serializers.ListField(child=SyncUserSerializer(), allow_null=True, allow_empty=True) teams = serializers.ListField(child=SyncTeamSerializer(), allow_null=True, allow_empty=True) team_members = TeamMemberMappingField() settings = SyncOnCallSettingsSerializer() diff --git a/engine/apps/grafana_plugin/tests/test_sync_v2.py b/engine/apps/grafana_plugin/tests/test_sync_v2.py index 59291a7ba9..21910bf360 100644 --- a/engine/apps/grafana_plugin/tests/test_sync_v2.py +++ b/engine/apps/grafana_plugin/tests/test_sync_v2.py @@ -10,7 +10,7 @@ from rest_framework.test import APIClient from apps.api.permissions import LegacyAccessControlRole -from apps.grafana_plugin.serializers.sync_data import SyncTeamSerializer +from apps.grafana_plugin.serializers.sync_data import SyncOnCallSettingsSerializer, SyncTeamSerializer from apps.grafana_plugin.sync_data import SyncData, SyncSettings, SyncUser from apps.grafana_plugin.tasks.sync_v2 import start_sync_organizations_v2, sync_organizations_v2 from common.constants.plugin_ids import PluginID @@ -197,6 +197,47 @@ def test_sync_v2_irm_enabled( assert organization.is_grafana_irm_enabled == expected +@patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.check_token", return_value=(None, {"connected": True})) +@pytest.mark.django_db +def test_sync_v2_none_values( + # mock this out so that we're not making a real network call, the sync v2 endpoint ends up calling + # user_management.sync._sync_organization which calls GrafanaApiClient.check_token + _mock_grafana_api_client_check_token, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + settings, +): + settings.LICENSE = settings.CLOUD_LICENSE_NAME + organization, _, token = make_organization_and_user_with_plugin_token() + + client = APIClient() + headers = make_user_auth_headers(None, token, organization=organization) + url = reverse("grafana-plugin:sync-v2") + + data = SyncData( + users=None, + teams=None, + team_members={}, + settings=SyncSettings( + stack_id=organization.stack_id, + org_id=organization.org_id, + license=settings.CLOUD_LICENSE_NAME, + oncall_api_url="http://localhost", + oncall_token="", + grafana_url="http://localhost", + grafana_token="fake_token", + rbac_enabled=False, + incident_enabled=False, + incident_backend_url="", + labels_enabled=False, + irm_enabled=False, + ), + ) + + response = client.post(url, format="json", data=asdict(data), **headers) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.parametrize( "test_team, validation_pass", [ @@ -218,6 +259,28 @@ def test_sync_team_serialization(test_team, validation_pass): assert (validation_error is None) == validation_pass +@pytest.mark.django_db +def test_sync_grafana_url_serialization(): + data = { + "stack_id": 123, + "org_id": 321, + "license": "OSS", + "oncall_api_url": "http://localhost", + "oncall_token": "", + "grafana_url": "http://localhost/", + "grafana_token": "fake_token", + "rbac_enabled": False, + "incident_enabled": False, + "incident_backend_url": "", + "labels_enabled": False, + "irm_enabled": False, + } + serializer = SyncOnCallSettingsSerializer(data=data) + serializer.is_valid(raise_exception=True) + cleaned_data = serializer.save() + assert cleaned_data.grafana_url == "http://localhost" + + @pytest.mark.django_db def test_sync_batch_tasks(make_organization, settings): settings.SYNC_V2_MAX_TASKS = 2 diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index 0cbf460583..704c766069 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -7,7 +7,6 @@ from apps.alerts.models import AlertReceiveChannel from apps.base.messaging import get_messaging_backends from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix -from apps.user_management.models import ServiceAccountUser from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import PHONE_CALL, SLACK, SMS, TELEGRAM, WEB, EagerLoadingMixin @@ -129,8 +128,8 @@ def create(self, validated_data): try: instance = AlertReceiveChannel.create( **validated_data, - author=user if not isinstance(user, ServiceAccountUser) else None, - service_account=user.service_account if isinstance(user, ServiceAccountUser) else None, + author=user if not user.is_service_account else None, + service_account=user.service_account if user.is_service_account else None, organization=organization, ) except AlertReceiveChannel.DuplicateDirectPagingError: diff --git a/engine/apps/public_api/serializers/resolution_notes.py b/engine/apps/public_api/serializers/resolution_notes.py index 6a7749f1fa..9bd64b148f 100644 --- a/engine/apps/public_api/serializers/resolution_notes.py +++ b/engine/apps/public_api/serializers/resolution_notes.py @@ -1,7 +1,6 @@ from rest_framework import serializers from apps.alerts.models import AlertGroup, ResolutionNote -from apps.user_management.models import ServiceAccountUser from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField, UserIdField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import EagerLoadingMixin @@ -36,7 +35,7 @@ class Meta: def create(self, validated_data): user = self.context["request"].user - if not isinstance(user, ServiceAccountUser) and user.pk: + if not user.is_service_account and user.pk: validated_data["author"] = user validated_data["source"] = ResolutionNote.Source.WEB return super().create(validated_data) diff --git a/engine/apps/public_api/serializers/webhooks.py b/engine/apps/public_api/serializers/webhooks.py index 879af9fefc..86cdf68c68 100644 --- a/engine/apps/public_api/serializers/webhooks.py +++ b/engine/apps/public_api/serializers/webhooks.py @@ -163,6 +163,7 @@ def validate_preset(self, preset): raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE) def validate_user(self, user): + # user may also be a string when handling requests from the deprecated custom action API if isinstance(user, ServiceAccountUser): return None return user diff --git a/engine/apps/public_api/tests/test_escalation_chain.py b/engine/apps/public_api/tests/test_escalation_chain.py index b27740b31a..e0888c36d1 100644 --- a/engine/apps/public_api/tests/test_escalation_chain.py +++ b/engine/apps/public_api/tests/test_escalation_chain.py @@ -87,12 +87,9 @@ def test_create_escalation_chain_via_service_account( HTTP_AUTHORIZATION=f"{token_string}", HTTP_X_GRAFANA_URL=organization.grafana_url, ) - if not organization.is_rbac_permissions_enabled: - assert response.status_code == status.HTTP_403_FORBIDDEN - else: - assert response.status_code == status.HTTP_201_CREATED - escalation_chain = organization.escalation_chains.get(name="test") - assert escalation_chain.team == team + assert response.status_code == status.HTTP_201_CREATED + escalation_chain = organization.escalation_chains.get(name="test") + assert escalation_chain.team == team @pytest.mark.django_db diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index 9ac249fd1b..c2ce4cf738 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -140,12 +140,9 @@ def test_create_integration_via_service_account( HTTP_AUTHORIZATION=f"{token_string}", HTTP_X_GRAFANA_URL=organization.grafana_url, ) - if not organization.is_rbac_permissions_enabled: - assert response.status_code == status.HTTP_403_FORBIDDEN - else: - assert response.status_code == status.HTTP_201_CREATED - integration = AlertReceiveChannel.objects.get(public_primary_key=response.data["id"]) - assert integration.service_account == service_account + assert response.status_code == status.HTTP_201_CREATED + integration = AlertReceiveChannel.objects.get(public_primary_key=response.data["id"]) + assert integration.service_account == service_account @pytest.mark.django_db diff --git a/engine/apps/public_api/tests/test_rbac_permissions.py b/engine/apps/public_api/tests/test_rbac_permissions.py index 95154ab4de..1577d21492 100644 --- a/engine/apps/public_api/tests/test_rbac_permissions.py +++ b/engine/apps/public_api/tests/test_rbac_permissions.py @@ -108,13 +108,14 @@ def test_rbac_permissions( @pytest.mark.parametrize( - "rbac_enabled,role,give_perm", + "rbac_enabled,give_perm", [ - # rbac disabled: auth is disabled - (False, LegacyAccessControlRole.ADMIN, None), - # rbac enabled: having role None, check the perm is required - (True, LegacyAccessControlRole.NONE, False), - (True, LegacyAccessControlRole.NONE, True), + # rbac enabled: check the perm is required + (True, False), + (True, True), + # rbac disabled: we still check for perms + (False, False), + (False, True), ], ) @pytest.mark.django_db @@ -124,7 +125,6 @@ def test_service_account_auth( make_service_account_for_organization, make_token_for_service_account, rbac_enabled, - role, give_perm, ): # APIView default actions @@ -155,18 +155,14 @@ def test_service_account_auth( continue for viewset_method_name, required_perms in viewset.rbac_permissions.items(): # setup Grafana API permissions response - if rbac_enabled: - permissions = {"perm": "value"} - expected = status.HTTP_403_FORBIDDEN - if give_perm: - permissions = {perm.value: "value" for perm in required_perms} - expected = status.HTTP_200_OK - mock_response = httpretty.Response(status=200, body=json.dumps(permissions)) - perms_url = f"{organization.grafana_url}/api/access-control/user/permissions" - httpretty.register_uri(httpretty.GET, perms_url, responses=[mock_response]) - else: - # service account auth is disabled - expected = status.HTTP_403_FORBIDDEN + permissions = {"perm": "value"} + expected = status.HTTP_403_FORBIDDEN + if give_perm: + permissions = {perm.value: "value" for perm in required_perms} + expected = status.HTTP_200_OK + mock_response = httpretty.Response(status=200, body=json.dumps(permissions)) + perms_url = f"{organization.grafana_url}/api/access-control/user/permissions" + httpretty.register_uri(httpretty.GET, perms_url, responses=[mock_response]) # iterate over all viewset actions, making an API request for each, # using the user's token and confirming the response status code diff --git a/engine/apps/public_api/tests/test_resolution_notes.py b/engine/apps/public_api/tests/test_resolution_notes.py index 63eaa64501..7854f8e79a 100644 --- a/engine/apps/public_api/tests/test_resolution_notes.py +++ b/engine/apps/public_api/tests/test_resolution_notes.py @@ -185,15 +185,12 @@ def test_create_resolution_note_via_service_account( HTTP_AUTHORIZATION=f"{token_string}", HTTP_X_GRAFANA_URL=organization.grafana_url, ) - if not organization.is_rbac_permissions_enabled: - assert response.status_code == status.HTTP_403_FORBIDDEN - else: - assert response.status_code == status.HTTP_201_CREATED - mock_send_update_resolution_note_signal.assert_called_once() - resolution_note = ResolutionNote.objects.get(public_primary_key=response.data["id"]) - assert resolution_note.author is None - assert resolution_note.text == data["text"] - assert resolution_note.alert_group == alert_group + assert response.status_code == status.HTTP_201_CREATED + mock_send_update_resolution_note_signal.assert_called_once() + resolution_note = ResolutionNote.objects.get(public_primary_key=response.data["id"]) + assert resolution_note.author is None + assert resolution_note.text == data["text"] + assert resolution_note.alert_group == alert_group @pytest.mark.django_db diff --git a/engine/apps/public_api/tests/test_webhooks.py b/engine/apps/public_api/tests/test_webhooks.py index ec6c85d8c1..cca5c00d83 100644 --- a/engine/apps/public_api/tests/test_webhooks.py +++ b/engine/apps/public_api/tests/test_webhooks.py @@ -270,13 +270,10 @@ def test_create_webhook_via_service_account( HTTP_AUTHORIZATION=f"{token_string}", HTTP_X_GRAFANA_URL=organization.grafana_url, ) - if not organization.is_rbac_permissions_enabled: - assert response.status_code == status.HTTP_403_FORBIDDEN - else: - assert response.status_code == status.HTTP_201_CREATED - webhook = Webhook.objects.get(public_primary_key=response.data["id"]) - expected_result = _get_expected_result(webhook) - assert response.data == expected_result + assert response.status_code == status.HTTP_201_CREATED + webhook = Webhook.objects.get(public_primary_key=response.data["id"]) + expected_result = _get_expected_result(webhook) + assert response.data == expected_result @pytest.mark.django_db diff --git a/engine/apps/public_api/views/alert_groups.py b/engine/apps/public_api/views/alert_groups.py index fc5d01d029..c242291afb 100644 --- a/engine/apps/public_api/views/alert_groups.py +++ b/engine/apps/public_api/views/alert_groups.py @@ -17,7 +17,6 @@ from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack_token_for_deleting from apps.public_api.serializers import AlertGroupSerializer from apps.public_api.throttlers.user_throttle import UserThrottle -from apps.user_management.models import ServiceAccountUser from common.api_helpers.exceptions import BadRequest, Forbidden from common.api_helpers.filters import ( NO_TEAM_VALUE, @@ -171,7 +170,7 @@ def destroy(self, request, *args, **kwargs): @action(methods=["post"], detail=True) def acknowledge(self, request, pk): - if isinstance(request.user, ServiceAccountUser): + if request.user.is_service_account: raise Forbidden(detail="Service accounts are not allowed to acknowledge alert groups") alert_group = self.get_object() @@ -193,7 +192,7 @@ def acknowledge(self, request, pk): @action(methods=["post"], detail=True) def unacknowledge(self, request, pk): - if isinstance(request.user, ServiceAccountUser): + if request.user.is_service_account: raise Forbidden(detail="Service accounts are not allowed to unacknowledge alert groups") alert_group = self.get_object() @@ -215,7 +214,7 @@ def unacknowledge(self, request, pk): @action(methods=["post"], detail=True) def resolve(self, request, pk): - if isinstance(request.user, ServiceAccountUser): + if request.user.is_service_account: raise Forbidden(detail="Service accounts are not allowed to resolve alert groups") alert_group = self.get_object() @@ -235,7 +234,7 @@ def resolve(self, request, pk): @action(methods=["post"], detail=True) def unresolve(self, request, pk): - if isinstance(request.user, ServiceAccountUser): + if request.user.is_service_account: raise Forbidden(detail="Service accounts are not allowed to unresolve alert groups") alert_group = self.get_object() @@ -254,7 +253,7 @@ def unresolve(self, request, pk): @action(methods=["post"], detail=True) def silence(self, request, pk=None): - if isinstance(request.user, ServiceAccountUser): + if request.user.is_service_account: raise Forbidden(detail="Service accounts are not allowed to silence alert groups") alert_group = self.get_object() @@ -283,7 +282,7 @@ def silence(self, request, pk=None): @action(methods=["post"], detail=True) def unsilence(self, request, pk=None): - if isinstance(request.user, ServiceAccountUser): + if request.user.is_service_account: raise Forbidden(detail="Service accounts are not allowed to unsilence alert groups") alert_group = self.get_object() diff --git a/engine/apps/user_management/models/service_account.py b/engine/apps/user_management/models/service_account.py index bb9d82711c..87d1205c78 100644 --- a/engine/apps/user_management/models/service_account.py +++ b/engine/apps/user_management/models/service_account.py @@ -1,10 +1,15 @@ +import typing from dataclasses import dataclass -from typing import List from django.db import models from apps.user_management.models import Organization +if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + + from apps.user_management.models import Team + @dataclass class ServiceAccountUser: @@ -15,30 +20,34 @@ class ServiceAccountUser: username: str # required for insight logs interface public_primary_key: str # required for insight logs interface role: str # required for permissions check - permissions: List[str] # required for permissions check + permissions: typing.List[str] # required for permissions check @property - def id(self): + def id(self) -> int: return self.service_account.id @property - def pk(self): + def pk(self) -> int: return self.service_account.id @property - def current_team(self): + def current_team(self) -> None: return None @property - def available_teams(self): + def available_teams(self) -> "RelatedManager['Team']": return self.organization.teams @property - def organization_id(self): + def organization_id(self) -> int: return self.organization.id @property - def is_authenticated(self): + def is_authenticated(self) -> bool: + return True + + @property + def is_service_account(self) -> bool: return True @@ -53,11 +62,11 @@ class Meta: unique_together = ("grafana_id", "organization") @property - def username(self): + def username(self) -> str: # required for insight logs interface return self.login @property - def public_primary_key(self): + def public_primary_key(self) -> str: # required for insight logs interface return f"service-account:{self.grafana_id}" diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 7837841fa9..a71dae3db0 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -220,6 +220,10 @@ def is_notification_allowed(self) -> bool: def is_authenticated(self): return True + @property + def is_service_account(self) -> bool: + return False + @property def has_google_oauth2_connected(self) -> bool: try: diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 8bf60c6d2f..47e165f4d6 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -336,6 +336,8 @@ def _sync_organization_data(organization: Organization, sync_settings: SyncSetti def _sync_users_data(organization: Organization, sync_users: list[SyncUser], delete_extra=False): + if sync_users is None: + return users_to_sync = ( User( organization_id=organization.pk,