Skip to content

Commit

Permalink
Merge pull request #5370 from grafana/dev
Browse files Browse the repository at this point in the history
v1.13.11
  • Loading branch information
matiasb authored Dec 16, 2024
2 parents ae4ada0 + 1399ba9 commit f8cd00b
Show file tree
Hide file tree
Showing 20 changed files with 185 additions and 122 deletions.
29 changes: 19 additions & 10 deletions docs/sources/manage/notify/ms-teams/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/sources/set-up/open-source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 12 additions & 8 deletions engine/apps/auth_token/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions engine/apps/auth_token/models/service_account_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 17 additions & 27 deletions engine/apps/auth_token/tests/test_grafana_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -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"

Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion engine/apps/grafana_plugin/serializers/sync_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
Expand Down
65 changes: 64 additions & 1 deletion engine/apps/grafana_plugin/tests/test_sync_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
[
Expand All @@ -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
Expand Down
Loading

0 comments on commit f8cd00b

Please sign in to comment.