commit
f8cd00b38f
20 changed files with 185 additions and 122 deletions
|
|
@ -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
|
||||
|
||||
1. Navigate to **Settings** tab in 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. 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from apps.user_management.exceptions import OrganizationDeletedException, Organi
|
|||
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 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication):
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ class ServiceAccountToken(BaseAuthToken):
|
|||
|
||||
@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:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from apps.api.permissions import LegacyAccessControlRole
|
|||
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 test_grafana_authentication_token_setup_org_if_missing(make_organization):
|
|||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 @@ class SyncOnCallSettingsSerializer(serializers.Serializer):
|
|||
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from rest_framework.exceptions import ValidationError
|
|||
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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import Graf
|
|||
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 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -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 ResolutionNoteSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ class WebhookCreateSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from apps.public_api.constants import VALID_DATE_FOR_DELETE_INCIDENT
|
|||
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 @@ class AlertGroupView(
|
|||
|
||||
@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 @@ class AlertGroupView(
|
|||
|
||||
@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 @@ class AlertGroupView(
|
|||
|
||||
@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 @@ class AlertGroupView(
|
|||
|
||||
@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 @@ class AlertGroupView(
|
|||
|
||||
@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 @@ class AlertGroupView(
|
|||
|
||||
@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()
|
||||
|
|
|
|||
|
|
@ -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 ServiceAccount(models.Model):
|
|||
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}"
|
||||
|
|
|
|||
|
|
@ -220,6 +220,10 @@ class User(models.Model):
|
|||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_service_account(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_google_oauth2_connected(self) -> bool:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue