From b1a92c257db9064ae3da2f748333901adfe6a4f4 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 13 Nov 2024 14:17:52 -0300 Subject: [PATCH 1/4] Enable service account token auth for public API --- ...065_alertreceivechannel_service_account.py | 20 ++ .../alerts/models/alert_receive_channel.py | 14 +- engine/apps/api/permissions.py | 6 + engine/apps/auth_token/auth.py | 42 +--- .../auth_token/grafana/grafana_auth_token.py | 6 + .../migrations/0007_serviceaccounttoken.py | 29 +++ engine/apps/auth_token/models/__init__.py | 1 + .../models/service_account_token.py | 128 +++++++++++ engine/apps/auth_token/tests/helpers.py | 18 ++ .../auth_token/tests/test_grafana_auth.py | 208 +++++++++++++++++- engine/apps/grafana_plugin/helpers/client.py | 3 + .../public_api/serializers/integrations.py | 5 +- .../public_api/tests/test_alert_groups.py | 35 +++ .../public_api/tests/test_integrations.py | 42 ++++ .../public_api/tests/test_rbac_permissions.py | 123 +++++++++++ .../public_api/tests/test_resolution_notes.py | 6 +- engine/apps/public_api/views/alert_groups.py | 25 ++- engine/apps/public_api/views/alerts.py | 4 +- .../public_api/views/escalation_chains.py | 4 +- .../public_api/views/escalation_policies.py | 4 +- engine/apps/public_api/views/integrations.py | 4 +- .../apps/public_api/views/on_call_shifts.py | 4 +- engine/apps/public_api/views/organizations.py | 4 +- engine/apps/public_api/views/routes.py | 4 +- engine/apps/public_api/views/schedules.py | 8 +- engine/apps/public_api/views/shift_swap.py | 4 +- .../apps/public_api/views/slack_channels.py | 4 +- engine/apps/public_api/views/teams.py | 4 +- engine/apps/public_api/views/user_groups.py | 4 +- engine/apps/public_api/views/users.py | 8 +- engine/apps/public_api/views/webhooks.py | 4 +- .../migrations/0027_serviceaccount.py | 26 +++ .../apps/user_management/models/__init__.py | 1 + .../user_management/models/service_account.py | 55 +++++ .../apps/user_management/tests/factories.py | 10 +- engine/conftest.py | 36 ++- 36 files changed, 830 insertions(+), 73 deletions(-) create mode 100644 engine/apps/alerts/migrations/0065_alertreceivechannel_service_account.py create mode 100644 engine/apps/auth_token/migrations/0007_serviceaccounttoken.py create mode 100644 engine/apps/auth_token/models/service_account_token.py create mode 100644 engine/apps/auth_token/tests/helpers.py create mode 100644 engine/apps/user_management/migrations/0027_serviceaccount.py create mode 100644 engine/apps/user_management/models/service_account.py diff --git a/engine/apps/alerts/migrations/0065_alertreceivechannel_service_account.py b/engine/apps/alerts/migrations/0065_alertreceivechannel_service_account.py new file mode 100644 index 0000000000..306d8a0408 --- /dev/null +++ b/engine/apps/alerts/migrations/0065_alertreceivechannel_service_account.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.15 on 2024-11-12 13:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0027_serviceaccount'), + ('alerts', '0064_migrate_resolutionnoteslackmessage_slack_channel_id'), + ] + + operations = [ + migrations.AddField( + model_name='alertreceivechannel', + name='service_account', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alert_receive_channels', to='user_management.serviceaccount'), + ), + ] diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index 4fd926ac47..a8cb1494d9 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -234,6 +234,13 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): author = models.ForeignKey( "user_management.User", on_delete=models.SET_NULL, related_name="alert_receive_channels", blank=True, null=True ) + service_account = models.ForeignKey( + "user_management.ServiceAccount", + on_delete=models.SET_NULL, + related_name="alert_receive_channels", + blank=True, + null=True, + ) team = models.ForeignKey( "user_management.Team", on_delete=models.SET_NULL, @@ -764,15 +771,16 @@ def listen_for_alertreceivechannel_model_save( from apps.heartbeat.models import IntegrationHeartBeat if created: - write_resource_insight_log(instance=instance, author=instance.author, event=EntityEvent.CREATED) + author = instance.author or instance.service_account + write_resource_insight_log(instance=instance, author=author, event=EntityEvent.CREATED) default_filter = ChannelFilter(alert_receive_channel=instance, filtering_term=None, is_default=True) default_filter.save() - write_resource_insight_log(instance=default_filter, author=instance.author, event=EntityEvent.CREATED) + write_resource_insight_log(instance=default_filter, author=author, event=EntityEvent.CREATED) TEN_MINUTES = 600 # this is timeout for cloud heartbeats if instance.is_available_for_integration_heartbeat: heartbeat = IntegrationHeartBeat.objects.create(alert_receive_channel=instance, timeout_seconds=TEN_MINUTES) - write_resource_insight_log(instance=heartbeat, author=instance.author, event=EntityEvent.CREATED) + write_resource_insight_log(instance=heartbeat, author=author, event=EntityEvent.CREATED) metrics_add_integrations_to_cache([instance], instance.organization) diff --git a/engine/apps/api/permissions.py b/engine/apps/api/permissions.py index 852506a109..b425c9be84 100644 --- a/engine/apps/api/permissions.py +++ b/engine/apps/api/permissions.py @@ -18,6 +18,12 @@ RBAC_PERMISSIONS_ATTR = "rbac_permissions" RBAC_OBJECT_PERMISSIONS_ATTR = "rbac_object_permissions" +# Using default permissions as proxies for roles since +# we cannot explicitly get role from the service account token +PLUGINS_WRITE = "plugins:write" +DASHBOARDS_WRITE = "dashboards:write" +DASHBOARDS_READ = "dashboards:read" + ViewSetOrAPIView = typing.Union[ViewSet, APIView] diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index dc6ccf7ae0..0a5158de45 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -9,7 +9,6 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request -from apps.api.permissions import GrafanaAPIPermissions, LegacyAccessControlRole from apps.grafana_plugin.helpers.gcom import check_token from apps.grafana_plugin.sync_data import SyncPermission, SyncUser from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException @@ -20,13 +19,13 @@ from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME from .exceptions import InvalidToken -from .grafana.grafana_auth_token import get_service_account_token_permissions from .models import ( ApiAuthToken, GoogleOAuth2Token, IntegrationBacksyncAuthToken, PluginAuthToken, ScheduleExportAuthToken, + ServiceAccountToken, SlackAuthToken, UserScheduleExportAuthToken, ) @@ -336,8 +335,8 @@ def authenticate_credentials( return auth_token.user, auth_token +X_GRAFANA_URL = "X-Grafana-URL" X_GRAFANA_INSTANCE_ID = "X-Grafana-Instance-ID" -GRAFANA_SA_PREFIX = "glsa_" class GrafanaServiceAccountAuthentication(BaseAuthentication): @@ -345,7 +344,7 @@ def authenticate(self, request): auth = get_authorization_header(request).decode("utf-8") if not auth: raise exceptions.AuthenticationFailed("Invalid token.") - if not auth.startswith(GRAFANA_SA_PREFIX): + if not auth.startswith(ServiceAccountToken.GRAFANA_SA_PREFIX): return None organization = self.get_organization(request) @@ -359,6 +358,12 @@ def authenticate(self, request): return self.authenticate_credentials(organization, auth) def get_organization(self, request): + grafana_url = request.headers.get(X_GRAFANA_URL) + if grafana_url: + organization = Organization.objects.filter(grafana_url=grafana_url).first() + if organization: + return organization + if settings.LICENSE == settings.CLOUD_LICENSE_NAME: instance_id = request.headers.get(X_GRAFANA_INSTANCE_ID) if not instance_id: @@ -370,36 +375,13 @@ def get_organization(self, request): return Organization.objects.filter(org_slug=org_slug, stack_slug=instance_slug).first() def authenticate_credentials(self, organization, token): - permissions = get_service_account_token_permissions(organization, token) - if not permissions: + try: + user, auth_token = ServiceAccountToken.validate_token(organization, token) + except InvalidToken: raise exceptions.AuthenticationFailed("Invalid token.") - role = LegacyAccessControlRole.NONE - if not organization.is_rbac_permissions_enabled: - role = self.determine_role_from_permissions(permissions) - - user = User( - organization_id=organization.pk, - name="Grafana Service Account", - username="grafana_service_account", - role=role, - permissions=GrafanaAPIPermissions.construct_permissions(permissions.keys()), - ) - - auth_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account") - return user, auth_token - # Using default permissions as proxies for roles since we cannot explicitly get role from the service account token - def determine_role_from_permissions(self, permissions): - if "plugins:write" in permissions: - return LegacyAccessControlRole.ADMIN - if "dashboards:write" in permissions: - return LegacyAccessControlRole.EDITOR - if "dashboards:read" in permissions: - return LegacyAccessControlRole.VIEWER - return LegacyAccessControlRole.NONE - class IntegrationBacksyncAuthentication(BaseAuthentication): model = IntegrationBacksyncAuthToken diff --git a/engine/apps/auth_token/grafana/grafana_auth_token.py b/engine/apps/auth_token/grafana/grafana_auth_token.py index 07bae6446f..6576e41793 100644 --- a/engine/apps/auth_token/grafana/grafana_auth_token.py +++ b/engine/apps/auth_token/grafana/grafana_auth_token.py @@ -46,3 +46,9 @@ def get_service_account_token_permissions(organization: Organization, token: str grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token) permissions, _ = grafana_api_client.get_service_account_token_permissions() return permissions + + +def get_service_account_details(organization: Organization, token: str) -> typing.Dict[str, typing.List[str]]: + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token) + user_data, _ = grafana_api_client.get_current_user() + return user_data diff --git a/engine/apps/auth_token/migrations/0007_serviceaccounttoken.py b/engine/apps/auth_token/migrations/0007_serviceaccounttoken.py new file mode 100644 index 0000000000..920b9ada3e --- /dev/null +++ b/engine/apps/auth_token/migrations/0007_serviceaccounttoken.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.15 on 2024-11-12 13:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0027_serviceaccount'), + ('auth_token', '0006_googleoauth2token'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceAccountToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_key', models.CharField(db_index=True, max_length=8)), + ('digest', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(null=True)), + ('service_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='user_management.serviceaccount')), + ], + options={ + 'unique_together': {('token_key', 'service_account', 'digest')}, + }, + ), + ] diff --git a/engine/apps/auth_token/models/__init__.py b/engine/apps/auth_token/models/__init__.py index 272adbda60..42cc60c516 100644 --- a/engine/apps/auth_token/models/__init__.py +++ b/engine/apps/auth_token/models/__init__.py @@ -4,5 +4,6 @@ from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401 from .plugin_auth_token import PluginAuthToken # noqa: F401 from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401 +from .service_account_token import ServiceAccountToken # noqa: F401 from .slack_auth_token import SlackAuthToken # noqa: F401 from .user_schedule_export_auth_token import UserScheduleExportAuthToken # noqa: F401 diff --git a/engine/apps/auth_token/models/service_account_token.py b/engine/apps/auth_token/models/service_account_token.py new file mode 100644 index 0000000000..dc85314d0d --- /dev/null +++ b/engine/apps/auth_token/models/service_account_token.py @@ -0,0 +1,128 @@ +import binascii +from hmac import compare_digest + +from django.db import models + +from apps.api.permissions import ( + DASHBOARDS_READ, + DASHBOARDS_WRITE, + PLUGINS_WRITE, + GrafanaAPIPermissions, + LegacyAccessControlRole, +) +from apps.auth_token import constants +from apps.auth_token.crypto import hash_token_string +from apps.auth_token.exceptions import InvalidToken +from apps.auth_token.grafana.grafana_auth_token import ( + get_service_account_details, + get_service_account_token_permissions, +) +from apps.auth_token.models import BaseAuthToken +from apps.user_management.models import ServiceAccount, ServiceAccountUser + + +class ServiceAccountTokenManager(models.Manager): + def get_queryset(self): + return super().get_queryset().select_related("service_account__organization") + + +class ServiceAccountToken(BaseAuthToken): + GRAFANA_SA_PREFIX = "glsa_" + + objects = ServiceAccountTokenManager() + + service_account: "ServiceAccount" + service_account = models.ForeignKey(ServiceAccount, on_delete=models.CASCADE, related_name="tokens") + + class Meta: + unique_together = ("token_key", "service_account", "digest") + + @property + def organization(self): + return self.service_account.organization + + @classmethod + def validate_token(cls, organization, token): + # Grafana API request: get permissions and confirm token is valid + permissions = get_service_account_token_permissions(organization, token) + if not permissions: + # NOTE: a token can be disabled/re-enabled (not setting as revoked in oncall DB for now) + raise InvalidToken + + # check if we have already seen this token + validated_token = None + service_account = None + prefix_length = len(cls.GRAFANA_SA_PREFIX) + token_key = token[prefix_length : prefix_length + constants.TOKEN_KEY_LENGTH] + try: + hashable_token = binascii.hexlify(token.encode()).decode() + digest = hash_token_string(hashable_token) + except (TypeError, binascii.Error): + raise InvalidToken + for existing_token in cls.objects.filter(service_account__organization=organization, token_key=token_key): + if compare_digest(digest, existing_token.digest): + validated_token = existing_token + service_account = existing_token.service_account + break + + if not validated_token: + # if it didn't match an existing token, create a new one + # make request to Grafana API api/user using token + service_account_data = get_service_account_details(organization, token) + if not service_account_data: + # Grafana versions < 11.3 return 403 trying to get user details with service account token + # use some default values + service_account_data = { + "login": "grafana_service_account", + "uid": None, # "service-account:7" + } + + grafana_id = 0 # default to zero for old Grafana versions (to keep service account unique) + if service_account_data["uid"] is not None: + # extract service account Grafana ID + try: + grafana_id = int(service_account_data["uid"].split(":")[-1]) + except ValueError: + pass + + # get or create service account + service_account, _ = ServiceAccount.objects.get_or_create( + organization=organization, + grafana_id=grafana_id, + defaults={ + "login": service_account_data["login"], + }, + ) + # create token + validated_token, _ = cls.objects.get_or_create( + service_account=service_account, + token_key=token_key, + digest=digest, + ) + + def _determine_role_from_permissions(permissions): + # Using default permissions as proxies for roles since + # we cannot explicitly get role from the service account token + if PLUGINS_WRITE in permissions: + return LegacyAccessControlRole.ADMIN + if DASHBOARDS_WRITE in permissions: + return LegacyAccessControlRole.EDITOR + if DASHBOARDS_READ in permissions: + return LegacyAccessControlRole.VIEWER + return LegacyAccessControlRole.NONE + + # setup an in-mem ServiceAccountUser + role = LegacyAccessControlRole.NONE + if not organization.is_rbac_permissions_enabled: + role = _determine_role_from_permissions(permissions) + + user = ServiceAccountUser( + organization=organization, + service_account=service_account, + username=service_account.username, + public_primary_key=service_account.public_primary_key, + role=role, + permissions=GrafanaAPIPermissions.construct_permissions(permissions.keys()), + ) + + return user, validated_token diff --git a/engine/apps/auth_token/tests/helpers.py b/engine/apps/auth_token/tests/helpers.py new file mode 100644 index 0000000000..bcecce6f2c --- /dev/null +++ b/engine/apps/auth_token/tests/helpers.py @@ -0,0 +1,18 @@ +import json + +import httpretty + + +def setup_service_account_api_mocks(organization, perms=None, user_data=None, perms_status=200, user_status=200): + # requires enabling httpretty + if perms is None: + perms = {} + mock_response = httpretty.Response(status=perms_status, body=json.dumps(perms)) + perms_url = f"{organization.grafana_url}/api/access-control/user/permissions" + httpretty.register_uri(httpretty.GET, perms_url, responses=[mock_response]) + + if user_data is None: + user_data = {"login": "some-login", "uid": "service-account:42"} + mock_response = httpretty.Response(status=user_status, body=json.dumps(user_data)) + user_url = f"{organization.grafana_url}/api/user" + httpretty.register_uri(httpretty.GET, user_url, responses=[mock_response]) diff --git a/engine/apps/auth_token/tests/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py index 5b78636c4a..f9a716989a 100644 --- a/engine/apps/auth_token/tests/test_grafana_auth.py +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -1,11 +1,16 @@ import typing from unittest.mock import patch +import httpretty import pytest from rest_framework import exceptions from rest_framework.test import APIRequestFactory -from apps.auth_token.auth import GRAFANA_SA_PREFIX, X_GRAFANA_INSTANCE_ID, GrafanaServiceAccountAuthentication +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 ServiceAccountUser from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS @@ -53,7 +58,7 @@ def test_grafana_authentication_cloud_inputs(make_organization, settings): mock.assert_called_once_with(organization, token) -def check_common_inputs() -> (dict[str, typing.Any], str): +def check_common_inputs() -> tuple[dict[str, typing.Any], str]: request = APIRequestFactory().get("/") with pytest.raises(exceptions.AuthenticationFailed): GrafanaServiceAccountAuthentication().authenticate(request) @@ -65,7 +70,7 @@ def check_common_inputs() -> (dict[str, typing.Any], str): result = GrafanaServiceAccountAuthentication().authenticate(request) assert result is None - token = f"{GRAFANA_SA_PREFIX}xyz" + token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" headers = { "HTTP_AUTHORIZATION": token, } @@ -74,3 +79,200 @@ def check_common_inputs() -> (dict[str, typing.Any], str): GrafanaServiceAccountAuthentication().authenticate(request) return headers, token + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_grafana_authentication_missing_org(): + token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" + headers = { + "HTTP_AUTHORIZATION": token, + } + request = APIRequestFactory().get("/", **headers) + + with pytest.raises(exceptions.AuthenticationFailed) as exc: + GrafanaServiceAccountAuthentication().authenticate(request) + assert exc.value.detail == "Invalid organization." + + +@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") + + token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" + headers = { + "HTTP_AUTHORIZATION": token, + "HTTP_X_GRAFANA_URL": organization.grafana_url, + } + request = APIRequestFactory().get("/", **headers) + + # setup Grafana API responses + # permissions endpoint returns a 401 + setup_service_account_api_mocks(organization, perms_status=401) + + with pytest.raises(exceptions.AuthenticationFailed) as exc: + GrafanaServiceAccountAuthentication().authenticate(request) + assert exc.value.detail == "Invalid token." + + last_request = httpretty.last_request() + assert last_request.method == "GET" + expected_url = f"{organization.grafana_url}/api/access-control/user/permissions" + assert last_request.url == expected_url + # the request uses the given token + assert last_request.headers["Authorization"] == f"Bearer {token}" + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.parametrize( + "permissions, expected_role", + [ + ({"plugins:write": "value"}, LegacyAccessControlRole.ADMIN), + ({"dashboards:write": "value"}, LegacyAccessControlRole.EDITOR), + ({"dashboards:read": "value"}, LegacyAccessControlRole.VIEWER), + ({"some-perm": "value"}, LegacyAccessControlRole.NONE), + ], +) +def test_grafana_authentication_existing_token( + make_organization, make_service_account_for_organization, make_token_for_service_account, permissions, expected_role +): + organization = make_organization(grafana_url="http://grafana.test") + 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, + } + request = APIRequestFactory().get("/", **headers) + + # setup Grafana API responses + setup_service_account_api_mocks(organization, permissions) + + user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) + + assert isinstance(user, ServiceAccountUser) + assert user.service_account == service_account + assert user.public_primary_key == service_account.public_primary_key + assert user.username == service_account.username + if not organization.is_rbac_permissions_enabled: + assert user.role == expected_role + else: + assert user.role == LegacyAccessControlRole.NONE + assert auth_token == token + + last_request = httpretty.last_request() + assert last_request.method == "GET" + expected_url = f"{organization.grafana_url}/api/access-control/user/permissions" + assert last_request.url == expected_url + # the request uses the given token + assert last_request.headers["Authorization"] == f"Bearer {token_string}" + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.parametrize( + "permissions, expected_role", + [ + ({"plugins:write": "value"}, LegacyAccessControlRole.ADMIN), + ({"dashboards:write": "value"}, LegacyAccessControlRole.EDITOR), + ({"dashboards:read": "value"}, LegacyAccessControlRole.VIEWER), + ({"some-perm": "value"}, LegacyAccessControlRole.NONE), + ], +) +def test_grafana_authentication_token_created(make_organization, permissions, expected_role): + organization = make_organization(grafana_url="http://grafana.test") + token_string = "glsa_the-token" + + headers = { + "HTTP_AUTHORIZATION": token_string, + "HTTP_X_GRAFANA_URL": organization.grafana_url, + } + request = APIRequestFactory().get("/", **headers) + + # setup Grafana API responses + user_data = {"login": "some-login", "uid": "service-account:42"} + setup_service_account_api_mocks(organization, permissions, user_data) + + user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) + + assert isinstance(user, ServiceAccountUser) + service_account = user.service_account + assert service_account.organization == organization + assert user.public_primary_key == service_account.public_primary_key + assert user.username == service_account.username + assert service_account.grafana_id == 42 + assert service_account.login == "some-login" + if not organization.is_rbac_permissions_enabled: + assert user.role == expected_role + else: + assert user.role == LegacyAccessControlRole.NONE + assert user.permissions == [{"action": p} for p in permissions] + assert auth_token.service_account == user.service_account + + perms_request, user_request = httpretty.latest_requests() + for req in (perms_request, user_request): + assert req.method == "GET" + assert req.headers["Authorization"] == f"Bearer {token_string}" + perms_url = f"{organization.grafana_url}/api/access-control/user/permissions" + assert perms_request.url == perms_url + user_url = f"{organization.grafana_url}/api/user" + assert user_request.url == user_url + + +@pytest.mark.django_db +@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") + token_string = "glsa_the-token" + + headers = { + "HTTP_AUTHORIZATION": token_string, + "HTTP_X_GRAFANA_URL": organization.grafana_url, + } + request = APIRequestFactory().get("/", **headers) + + # setup Grafana API responses + permissions = {"some-perm": "value"} + # User API fails for older Grafana versions + setup_service_account_api_mocks(organization, permissions, user_status=400) + + user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) + + assert isinstance(user, ServiceAccountUser) + service_account = user.service_account + assert service_account.organization == organization + # use fallback data + assert service_account.grafana_id == 0 + assert service_account.login == "grafana_service_account" + assert auth_token.service_account == user.service_account + + +@pytest.mark.django_db +@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") + service_account = make_service_account_for_organization(organization) + token_string = "glsa_the-token" + + headers = { + "HTTP_AUTHORIZATION": token_string, + "HTTP_X_GRAFANA_URL": organization.grafana_url, + } + request = APIRequestFactory().get("/", **headers) + + # setup Grafana API responses + permissions = {"some-perm": "value"} + user_data = { + "login": service_account.login, + "uid": f"service-account:{service_account.grafana_id}", + } + setup_service_account_api_mocks(organization, permissions, user_data) + + user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) + + assert isinstance(user, ServiceAccountUser) + assert user.service_account == service_account + assert auth_token.service_account == service_account diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 2beafa8bdf..17d1cabd20 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -315,6 +315,9 @@ def get_grafana_labels_plugin_settings(self) -> APIClientResponse["GrafanaAPICli def get_grafana_irm_plugin_settings(self) -> APIClientResponse["GrafanaAPIClient.Types.PluginSettings"]: return self.get_grafana_plugin_settings(PluginID.IRM) + def get_current_user(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]: + return self.api_get("api/user") + def get_service_account(self, login: str) -> APIClientResponse["GrafanaAPIClient.Types.ServiceAccountResponse"]: return self.api_get(f"api/serviceaccounts/search?query={login}") diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index b16aeb5472..0cbf460583 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -7,6 +7,7 @@ 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 @@ -123,11 +124,13 @@ def create(self, validated_data): connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization) if connection_error: raise serializers.ValidationError(connection_error) + user = self.context["request"].user with transaction.atomic(): try: instance = AlertReceiveChannel.create( **validated_data, - author=self.context["request"].user, + author=user if not isinstance(user, ServiceAccountUser) else None, + service_account=user.service_account if isinstance(user, ServiceAccountUser) else None, organization=organization, ) except AlertReceiveChannel.DuplicateDirectPagingError: diff --git a/engine/apps/public_api/tests/test_alert_groups.py b/engine/apps/public_api/tests/test_alert_groups.py index 71421cd318..e73bbd37b3 100644 --- a/engine/apps/public_api/tests/test_alert_groups.py +++ b/engine/apps/public_api/tests/test_alert_groups.py @@ -1,5 +1,6 @@ from unittest.mock import patch +import httpretty import pytest from django.urls import reverse from django.utils import timezone @@ -9,6 +10,8 @@ from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup, AlertReceiveChannel from apps.alerts.tasks import delete_alert_group, wipe +from apps.api import permissions +from apps.auth_token.tests.helpers import setup_service_account_api_mocks def construct_expected_response_from_alert_groups(alert_groups): @@ -736,3 +739,35 @@ def test_alert_group_unsilence( assert alert_group.silenced == silenced assert response.status_code == status_code assert response_msg == response.json()["detail"] + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_actions_disabled_for_service_accounts( + make_organization, + make_service_account_for_organization, + make_token_for_service_account, + make_escalation_chain, +): + organization = make_organization(grafana_url="http://grafana.test") + service_account = make_service_account_for_organization(organization) + token_string = "glsa_token" + make_token_for_service_account(service_account, token_string) + make_escalation_chain(organization) + + perms = { + permissions.PLUGINS_WRITE: ["*"], + permissions.RBACPermission.Permissions.ALERT_GROUPS_WRITE.value: ["*"], + } + setup_service_account_api_mocks(organization, perms=perms) + + client = APIClient() + disabled_actions = ["acknowledge", "unacknowledge", "resolve", "unresolve", "silence", "unsilence"] + for action in disabled_actions: + url = reverse(f"api-public:alert_groups-{action}", kwargs={"pk": "ABCDEFG"}) + response = client.post( + url, + HTTP_AUTHORIZATION=f"{token_string}", + HTTP_X_GRAFANA_URL=organization.grafana_url, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index b021df33e1..9eb27cfe48 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -1,9 +1,12 @@ +import httpretty import pytest from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel +from apps.api import permissions +from apps.auth_token.tests.helpers import setup_service_account_api_mocks from apps.base.tests.messaging_backend import TestOnlyBackend TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower() @@ -104,6 +107,45 @@ def test_create_integration( assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_create_integration_via_service_account( + make_organization, + make_service_account_for_organization, + make_token_for_service_account, + make_escalation_chain, +): + organization = make_organization(grafana_url="http://grafana.test") + service_account = make_service_account_for_organization(organization) + token_string = "glsa_token" + make_token_for_service_account(service_account, token_string) + make_escalation_chain(organization) + + perms = { + permissions.PLUGINS_WRITE: ["*"], + permissions.RBACPermission.Permissions.INTEGRATIONS_WRITE.value: ["*"], + } + setup_service_account_api_mocks(organization, perms) + + client = APIClient() + data_for_create = { + "type": "grafana", + "name": "grafana_created", + "team_id": None, + } + url = reverse("api-public:integrations-list") + response = client.post( + url, + data=data_for_create, + format="json", + HTTP_AUTHORIZATION=f"{token_string}", + HTTP_X_GRAFANA_URL=organization.grafana_url, + ) + 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 def test_integration_name_uniqueness( make_organization_and_user_with_token, diff --git a/engine/apps/public_api/tests/test_rbac_permissions.py b/engine/apps/public_api/tests/test_rbac_permissions.py index 9829550d8c..47a9eb3062 100644 --- a/engine/apps/public_api/tests/test_rbac_permissions.py +++ b/engine/apps/public_api/tests/test_rbac_permissions.py @@ -1,5 +1,7 @@ +import json from unittest.mock import patch +import httpretty import pytest from django.urls import reverse from rest_framework import status @@ -9,6 +11,13 @@ from apps.api.permissions import GrafanaAPIPermission, LegacyAccessControlRole, get_most_authorized_role from apps.public_api.urls import router +VIEWS_REQUIRING_USER_AUTH = ( + "EscalationView", + "PersonalNotificationView", + "MakeCallView", + "SendSMSView", +) + @pytest.mark.parametrize( "rbac_enabled,role,give_perm", @@ -96,3 +105,117 @@ def test_rbac_permissions( with patch(method_path, return_value=success): response = client.generic(path=url, method=http_method, HTTP_AUTHORIZATION=token) assert response.status_code == expected + + +@pytest.mark.parametrize( + "rbac_enabled,role,give_perm", + [ + # rbac disabled: we will check the role is enough based on get_most_authorized_role for the perm + (False, LegacyAccessControlRole.ADMIN, None), + (False, LegacyAccessControlRole.EDITOR, None), + (False, LegacyAccessControlRole.VIEWER, None), + (False, LegacyAccessControlRole.NONE, None), + # rbac enabled: having role None, check the perm is required + (True, LegacyAccessControlRole.NONE, False), + (True, LegacyAccessControlRole.NONE, True), + ], +) +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_service_account_auth( + make_organization, + make_service_account_for_organization, + make_token_for_service_account, + rbac_enabled, + role, + give_perm, +): + # APIView default actions + # (name, http method, detail-based) + default_actions = { + "create": ("post", False), + "list": ("get", False), + "retrieve": ("get", True), + "update": ("put", True), + "partial_update": ("patch", True), + "destroy": ("delete", True), + } + + organization = make_organization(grafana_url="http://grafana.test") + service_account = make_service_account_for_organization(organization) + token_string = "glsa_token" + make_token_for_service_account(service_account, token_string) + + if organization.is_rbac_permissions_enabled != rbac_enabled: + # skip if the organization's rbac_enabled is not the expected by the test + return + + client = APIClient() + # check all actions for all public API viewsets + for _, viewset, _basename in router.registry: + if viewset.__name__ == "ActionView": + # old actions (webhooks) are deprecated, no RBAC or service account support + 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: + # setup Grafana API permissions response + # role is given by specific perms + permissions = {} + if role == LegacyAccessControlRole.ADMIN: + permissions = {"plugins:write": "value"} + elif role == LegacyAccessControlRole.EDITOR: + permissions = {"dashboards:write": "value"} + elif role == LegacyAccessControlRole.VIEWER: + permissions = {"dashboards:read": "value"} + + 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]) + + # check what the minimum required role for the perms is + required_role = get_most_authorized_role(required_perms) + # set expected depending on the user's role + expected = status.HTTP_200_OK if role <= required_role else status.HTTP_403_FORBIDDEN + + # iterate over all viewset actions, making an API request for each, + # using the user's token and confirming the response status code + if viewset_method_name in default_actions: + http_method, detail = default_actions[viewset_method_name] + else: + action_method = getattr(viewset, viewset_method_name) + http_method = list(action_method.mapping.keys())[0] + detail = action_method.detail + + method_path = f"{viewset.__module__}.{viewset.__name__}.{viewset_method_name}" + success = Response(status=status.HTTP_200_OK) + kwargs = {"pk": "NONEXISTENT"} if detail else None + if viewset_method_name in default_actions and detail: + url = reverse(f"api-public:{_basename}-detail", kwargs=kwargs) + elif viewset_method_name in default_actions and not detail: + url = reverse(f"api-public:{_basename}-list", kwargs=kwargs) + else: + name = viewset_method_name.replace("_", "-") + url = reverse(f"api-public:{_basename}-{name}", kwargs=kwargs) + + with patch(method_path, return_value=success): + headers = { + "HTTP_AUTHORIZATION": token_string, + "HTTP_X_GRAFANA_URL": organization.grafana_url, + } + response = client.generic(path=url, method=http_method, **headers) + assert ( + response.status_code == expected + if viewset.__name__ not in VIEWS_REQUIRING_USER_AUTH + # user-specific APIs do not support service account auth + else status.HTTP_403_FORBIDDEN + ) diff --git a/engine/apps/public_api/tests/test_resolution_notes.py b/engine/apps/public_api/tests/test_resolution_notes.py index c3a89a1da4..7a730e18ca 100644 --- a/engine/apps/public_api/tests/test_resolution_notes.py +++ b/engine/apps/public_api/tests/test_resolution_notes.py @@ -6,8 +6,8 @@ from rest_framework.test import APIClient from apps.alerts.models import ResolutionNote -from apps.auth_token.auth import GRAFANA_SA_PREFIX, ApiTokenAuthentication, GrafanaServiceAccountAuthentication -from apps.auth_token.models import ApiAuthToken +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication +from apps.auth_token.models import ApiAuthToken, ServiceAccountToken @pytest.mark.django_db @@ -366,7 +366,7 @@ def test_create_resolution_note_grafana_auth(make_organization_and_user, make_al mock_api_key_auth.assert_called_once() assert response.status_code == status.HTTP_403_FORBIDDEN - token = f"{GRAFANA_SA_PREFIX}123" + token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}123" # GrafanaServiceAccountAuthentication handle invalid token with patch( "apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate diff --git a/engine/apps/public_api/views/alert_groups.py b/engine/apps/public_api/views/alert_groups.py index d4f4a302ff..fc5d01d029 100644 --- a/engine/apps/public_api/views/alert_groups.py +++ b/engine/apps/public_api/views/alert_groups.py @@ -12,12 +12,13 @@ from apps.alerts.tasks import delete_alert_group, wipe from apps.api.label_filtering import parse_label_query from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication 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 common.api_helpers.exceptions import BadRequest +from apps.user_management.models import ServiceAccountUser +from common.api_helpers.exceptions import BadRequest, Forbidden from common.api_helpers.filters import ( NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, @@ -57,7 +58,7 @@ class AlertGroupView( mixins.DestroyModelMixin, GenericViewSet, ): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { @@ -170,6 +171,9 @@ def destroy(self, request, *args, **kwargs): @action(methods=["post"], detail=True) def acknowledge(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise Forbidden(detail="Service accounts are not allowed to acknowledge alert groups") + alert_group = self.get_object() if alert_group.acknowledged: @@ -189,6 +193,9 @@ def acknowledge(self, request, pk): @action(methods=["post"], detail=True) def unacknowledge(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise Forbidden(detail="Service accounts are not allowed to unacknowledge alert groups") + alert_group = self.get_object() if not alert_group.acknowledged: @@ -208,6 +215,9 @@ def unacknowledge(self, request, pk): @action(methods=["post"], detail=True) def resolve(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise Forbidden(detail="Service accounts are not allowed to resolve alert groups") + alert_group = self.get_object() if alert_group.resolved: @@ -225,6 +235,9 @@ def resolve(self, request, pk): @action(methods=["post"], detail=True) def unresolve(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise Forbidden(detail="Service accounts are not allowed to unresolve alert groups") + alert_group = self.get_object() if not alert_group.resolved: @@ -241,6 +254,9 @@ def unresolve(self, request, pk): @action(methods=["post"], detail=True) def silence(self, request, pk=None): + if isinstance(request.user, ServiceAccountUser): + raise Forbidden(detail="Service accounts are not allowed to silence alert groups") + alert_group = self.get_object() delay = request.data.get("delay") @@ -267,6 +283,9 @@ def silence(self, request, pk=None): @action(methods=["post"], detail=True) def unsilence(self, request, pk=None): + if isinstance(request.user, ServiceAccountUser): + raise Forbidden(detail="Service accounts are not allowed to unsilence alert groups") + alert_group = self.get_object() if not alert_group.silenced: diff --git a/engine/apps/public_api/views/alerts.py b/engine/apps/public_api/views/alerts.py index b96d51c50c..0f3d1d4669 100644 --- a/engine/apps/public_api/views/alerts.py +++ b/engine/apps/public_api/views/alerts.py @@ -7,7 +7,7 @@ from apps.alerts.models import Alert from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.alerts import AlertSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.mixins import RateLimitHeadersMixin @@ -19,7 +19,7 @@ class AlertFilter(filters.FilterSet): class AlertView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/escalation_chains.py b/engine/apps/public_api/views/escalation_chains.py index 84bb71628d..52a1cc444c 100644 --- a/engine/apps/public_api/views/escalation_chains.py +++ b/engine/apps/public_api/views/escalation_chains.py @@ -5,7 +5,7 @@ from apps.alerts.models import EscalationChain from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers import EscalationChainSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.filters import ByTeamFilter @@ -15,7 +15,7 @@ class EscalationChainView(RateLimitHeadersMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/escalation_policies.py b/engine/apps/public_api/views/escalation_policies.py index ddbaeae803..e91e52f48b 100644 --- a/engine/apps/public_api/views/escalation_policies.py +++ b/engine/apps/public_api/views/escalation_policies.py @@ -5,7 +5,7 @@ from apps.alerts.models import EscalationPolicy from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers import EscalationPolicySerializer, EscalationPolicyUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin @@ -14,7 +14,7 @@ class EscalationPolicyView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/integrations.py b/engine/apps/public_api/views/integrations.py index 26c55224fd..e8ec9a852b 100644 --- a/engine/apps/public_api/views/integrations.py +++ b/engine/apps/public_api/views/integrations.py @@ -5,7 +5,7 @@ from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers import IntegrationSerializer, IntegrationUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.exceptions import BadRequest @@ -24,7 +24,7 @@ class IntegrationView( MaintainableObjectMixin, ModelViewSet, ): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/on_call_shifts.py b/engine/apps/public_api/views/on_call_shifts.py index e825ea3537..2e091e947c 100644 --- a/engine/apps/public_api/views/on_call_shifts.py +++ b/engine/apps/public_api/views/on_call_shifts.py @@ -5,7 +5,7 @@ from rest_framework.viewsets import ModelViewSet from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers import CustomOnCallShiftSerializer, CustomOnCallShiftUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.models import CustomOnCallShift @@ -16,7 +16,7 @@ class CustomOnCallShiftView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/organizations.py b/engine/apps/public_api/views/organizations.py index 1df2f63a5d..473d79de6c 100644 --- a/engine/apps/public_api/views/organizations.py +++ b/engine/apps/public_api/views/organizations.py @@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers import OrganizationSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.models import Organization @@ -15,7 +15,7 @@ class OrganizationView( RateLimitHeadersMixin, ReadOnlyModelViewSet, ): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/routes.py b/engine/apps/public_api/views/routes.py index 7946152718..19ddc1056a 100644 --- a/engine/apps/public_api/views/routes.py +++ b/engine/apps/public_api/views/routes.py @@ -7,7 +7,7 @@ from apps.alerts.models import ChannelFilter from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers import ChannelFilterSerializer, ChannelFilterUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.exceptions import BadRequest @@ -17,7 +17,7 @@ class ChannelFilterView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 6dcca6fd08..5960ad4894 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -9,7 +9,11 @@ from rest_framework.viewsets import ModelViewSet from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication, ScheduleExportAuthentication +from apps.auth_token.auth import ( + ApiTokenAuthentication, + GrafanaServiceAccountAuthentication, + ScheduleExportAuthentication, +) from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer from apps.public_api.serializers.schedules_base import FinalShiftQueryParamsSerializer @@ -28,7 +32,7 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/shift_swap.py b/engine/apps/public_api/views/shift_swap.py index 07f978e5c9..c46c141965 100644 --- a/engine/apps/public_api/views/shift_swap.py +++ b/engine/apps/public_api/views/shift_swap.py @@ -10,7 +10,7 @@ from apps.api.permissions import AuthenticatedRequest, RBACPermission from apps.api.views.shift_swap import BaseShiftSwapViewSet -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.models import ShiftSwapRequest from apps.user_management.models import User @@ -23,7 +23,7 @@ class ShiftSwapViewSet(RateLimitHeadersMixin, BaseShiftSwapViewSet): # set authentication and permission classes - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/slack_channels.py b/engine/apps/public_api/views/slack_channels.py index 77581f3dde..35f384021a 100644 --- a/engine/apps/public_api/views/slack_channels.py +++ b/engine/apps/public_api/views/slack_channels.py @@ -3,7 +3,7 @@ from rest_framework.viewsets import GenericViewSet from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.slack_channel import SlackChannelSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.slack.models import SlackChannel @@ -12,7 +12,7 @@ class SlackChannelView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/teams.py b/engine/apps/public_api/views/teams.py index 490e74efb1..6d399bade5 100644 --- a/engine/apps/public_api/views/teams.py +++ b/engine/apps/public_api/views/teams.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.teams import TeamSerializer from apps.public_api.tf_sync import is_request_from_terraform, sync_teams_on_tf_request from apps.public_api.throttlers.user_throttle import UserThrottle @@ -14,7 +14,7 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewsets.GenericViewSet): serializer_class = TeamSerializer - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/user_groups.py b/engine/apps/public_api/views/user_groups.py index ced7f626bf..bb1dac7f37 100644 --- a/engine/apps/public_api/views/user_groups.py +++ b/engine/apps/public_api/views/user_groups.py @@ -3,7 +3,7 @@ from rest_framework.viewsets import GenericViewSet from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.user_groups import UserGroupSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.slack.models import SlackUserGroup @@ -12,7 +12,7 @@ class UserGroupView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 97315fe202..129096e560 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -6,7 +6,11 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from apps.api.permissions import LegacyAccessControlRole, RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication +from apps.auth_token.auth import ( + ApiTokenAuthentication, + GrafanaServiceAccountAuthentication, + UserScheduleExportAuthentication, +) from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import FastUserSerializer, UserSerializer from apps.public_api.tf_sync import is_request_from_terraform, sync_users_on_tf_request @@ -35,7 +39,7 @@ class Meta: class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/public_api/views/webhooks.py b/engine/apps/public_api/views/webhooks.py index 8f75148b71..b1a6a47bb1 100644 --- a/engine/apps/public_api/views/webhooks.py +++ b/engine/apps/public_api/views/webhooks.py @@ -6,7 +6,7 @@ from rest_framework.viewsets import ModelViewSet from apps.api.permissions import RBACPermission -from apps.auth_token.auth import ApiTokenAuthentication +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.webhooks import ( WebhookCreateSerializer, WebhookResponseSerializer, @@ -21,7 +21,7 @@ class WebhooksView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { diff --git a/engine/apps/user_management/migrations/0027_serviceaccount.py b/engine/apps/user_management/migrations/0027_serviceaccount.py new file mode 100644 index 0000000000..dc9e520b3b --- /dev/null +++ b/engine/apps/user_management/migrations/0027_serviceaccount.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.15 on 2024-11-12 13:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0026_auto_20241017_1919'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grafana_id', models.PositiveIntegerField()), + ('login', models.CharField(max_length=300)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_accounts', to='user_management.organization')), + ], + options={ + 'unique_together': {('grafana_id', 'organization')}, + }, + ), + ] diff --git a/engine/apps/user_management/models/__init__.py b/engine/apps/user_management/models/__init__.py index e2bcd4c7f0..2fd5a9aa1e 100644 --- a/engine/apps/user_management/models/__init__.py +++ b/engine/apps/user_management/models/__init__.py @@ -1,4 +1,5 @@ from .user import User # noqa: F401, isort: skip from .organization import Organization # noqa: F401 from .region import Region # noqa: F401 +from .service_account import ServiceAccount, ServiceAccountUser # noqa: F401 from .team import Team # noqa: F401 diff --git a/engine/apps/user_management/models/service_account.py b/engine/apps/user_management/models/service_account.py new file mode 100644 index 0000000000..5082f7b965 --- /dev/null +++ b/engine/apps/user_management/models/service_account.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from typing import List + +from django.db import models + +from apps.user_management.models import Organization + + +@dataclass +class ServiceAccountUser: + """Authenticated service account in public API requests.""" + + service_account: "ServiceAccount" + organization: "Organization" # required for insight logs interface + 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 + + @property + def id(self): + return self.service_account.id + + @property + def pk(self): + return self.service_account.id + + @property + def organization_id(self): + return self.organization.id + + @property + def is_authenticated(self): + return True + + +class ServiceAccount(models.Model): + organization: "Organization" + + grafana_id = models.PositiveIntegerField() + organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="service_accounts") + login = models.CharField(max_length=300) + + class Meta: + unique_together = ("grafana_id", "organization") + + @property + def username(self): + # required for insight logs interface + return self.login + + @property + def public_primary_key(self): + # required for insight logs interface + return f"service-account:{self.grafana_id}" diff --git a/engine/apps/user_management/tests/factories.py b/engine/apps/user_management/tests/factories.py index ccfbb8586e..a33aefaca1 100644 --- a/engine/apps/user_management/tests/factories.py +++ b/engine/apps/user_management/tests/factories.py @@ -1,6 +1,6 @@ import factory -from apps.user_management.models import Organization, Region, Team, User +from apps.user_management.models import Organization, Region, ServiceAccount, Team, User from common.utils import UniqueFaker @@ -41,3 +41,11 @@ class RegionFactory(factory.DjangoModelFactory): class Meta: model = Region + + +class ServiceAccountFactory(factory.DjangoModelFactory): + grafana_id = UniqueFaker("pyint") + login = UniqueFaker("user_name") + + class Meta: + model = ServiceAccount diff --git a/engine/conftest.py b/engine/conftest.py index a95383dd94..0b66e3adea 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -1,3 +1,4 @@ +import binascii import datetime import json import os @@ -46,11 +47,14 @@ LegacyAccessControlRole, RBACPermission, ) +from apps.auth_token import constants as auth_token_constants +from apps.auth_token.crypto import hash_token_string from apps.auth_token.models import ( ApiAuthToken, GoogleOAuth2Token, IntegrationBacksyncAuthToken, PluginAuthToken, + ServiceAccountToken, SlackAuthToken, ) from apps.base.models.user_notification_policy_log_record import ( @@ -102,7 +106,13 @@ TelegramVerificationCodeFactory, ) 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 apps.user_management.tests.factories import ( + OrganizationFactory, + RegionFactory, + ServiceAccountFactory, + TeamFactory, + UserFactory, +) from apps.webhooks.presets.preset_options import WebhookPresetOptions from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory from apps.webhooks.tests.test_webhook_presets import ( @@ -252,6 +262,30 @@ def _make_user_for_organization(organization, role: typing.Optional[LegacyAccess return _make_user_for_organization +@pytest.fixture +def make_service_account_for_organization(make_user): + def _make_service_account_for_organization(organization, **kwargs): + return ServiceAccountFactory(organization=organization, **kwargs) + + return _make_service_account_for_organization + + +@pytest.fixture +def make_token_for_service_account(): + def _make_token_for_service_account(service_account, token_string): + prefix_length = len(ServiceAccountToken.GRAFANA_SA_PREFIX) + token_key = token_string[prefix_length : prefix_length + auth_token_constants.TOKEN_KEY_LENGTH] + hashable_token = binascii.hexlify(token_string.encode()).decode() + digest = hash_token_string(hashable_token) + return ServiceAccountToken.objects.create( + service_account=service_account, + token_key=token_key, + digest=digest, + ) + + return _make_token_for_service_account + + @pytest.fixture def make_token_for_organization(): def _make_token_for_organization(organization): From 5d63a7857a03d8692dfc81099e58d282f375f7de Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 14 Nov 2024 15:47:37 -0300 Subject: [PATCH 2/4] Add extra check --- engine/apps/auth_token/auth.py | 5 +++-- engine/apps/auth_token/tests/test_grafana_auth.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 0a5158de45..3a7e25d6bd 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -361,8 +361,9 @@ def get_organization(self, request): grafana_url = request.headers.get(X_GRAFANA_URL) if grafana_url: organization = Organization.objects.filter(grafana_url=grafana_url).first() - if organization: - return organization + if not organization: + raise exceptions.AuthenticationFailed("Invalid Grafana URL.") + 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/tests/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py index f9a716989a..37000ca381 100644 --- a/engine/apps/auth_token/tests/test_grafana_auth.py +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -95,6 +95,21 @@ def test_grafana_authentication_missing_org(): assert exc.value.detail == "Invalid organization." +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_grafana_authentication_invalid_grafana_url(): + token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" + headers = { + "HTTP_AUTHORIZATION": token, + "HTTP_X_GRAFANA_URL": "http://grafana.test", # no org for this URL + } + request = APIRequestFactory().get("/", **headers) + + with pytest.raises(exceptions.AuthenticationFailed) as exc: + GrafanaServiceAccountAuthentication().authenticate(request) + assert exc.value.detail == "Invalid Grafana URL." + + @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) def test_grafana_authentication_permissions_call_fails(make_organization): From 58081d9e2e443401cd272e8a64cd55247ded2cab Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 15 Nov 2024 16:44:57 -0300 Subject: [PATCH 3/4] Update time logging middleware to check for service account requests --- engine/engine/middlewares.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/engine/engine/middlewares.py b/engine/engine/middlewares.py index c3da3c4c2b..0173323bc0 100644 --- a/engine/engine/middlewares.py +++ b/engine/engine/middlewares.py @@ -28,9 +28,13 @@ def log_message(request, response, tag, message=""): ) if hasattr(request, "user") and request.user and request.user.id and hasattr(request.user, "organization"): user_id = request.user.id + if hasattr(request.user, "service_account"): + message += f"service_account_id={user_id} " + else: + message += f"user_id={user_id} " org_id = request.user.organization.id org_slug = request.user.organization.org_slug - message += f"user_id={user_id} org_id={org_id} org_slug={org_slug} " + message += f"org_id={org_id} org_slug={org_slug} " if request.path.startswith("/integrations/v1"): split_path = request.path.split("/") integration_type = split_path[3] From cfc29f782da7e0ca6882b89b1e510fa9128dd555 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 18 Nov 2024 16:09:33 -0300 Subject: [PATCH 4/4] Disable service account token auth if RBAC is not enabled --- engine/apps/api/permissions.py | 5 -- .../models/service_account_token.py | 30 ++------- .../auth_token/tests/test_grafana_auth.py | 64 ++++++++++--------- .../public_api/tests/test_alert_groups.py | 1 - .../public_api/tests/test_integrations.py | 10 +-- .../public_api/tests/test_rbac_permissions.py | 25 +------- 6 files changed, 50 insertions(+), 85 deletions(-) diff --git a/engine/apps/api/permissions.py b/engine/apps/api/permissions.py index b425c9be84..d9dad6b37d 100644 --- a/engine/apps/api/permissions.py +++ b/engine/apps/api/permissions.py @@ -18,11 +18,6 @@ RBAC_PERMISSIONS_ATTR = "rbac_permissions" RBAC_OBJECT_PERMISSIONS_ATTR = "rbac_object_permissions" -# Using default permissions as proxies for roles since -# we cannot explicitly get role from the service account token -PLUGINS_WRITE = "plugins:write" -DASHBOARDS_WRITE = "dashboards:write" -DASHBOARDS_READ = "dashboards:read" ViewSetOrAPIView = typing.Union[ViewSet, APIView] diff --git a/engine/apps/auth_token/models/service_account_token.py b/engine/apps/auth_token/models/service_account_token.py index dc85314d0d..716dc55db3 100644 --- a/engine/apps/auth_token/models/service_account_token.py +++ b/engine/apps/auth_token/models/service_account_token.py @@ -3,13 +3,7 @@ from django.db import models -from apps.api.permissions import ( - DASHBOARDS_READ, - DASHBOARDS_WRITE, - PLUGINS_WRITE, - GrafanaAPIPermissions, - LegacyAccessControlRole, -) +from apps.api.permissions import GrafanaAPIPermissions, LegacyAccessControlRole from apps.auth_token import constants from apps.auth_token.crypto import hash_token_string from apps.auth_token.exceptions import InvalidToken @@ -43,6 +37,10 @@ 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: @@ -100,28 +98,12 @@ def validate_token(cls, organization, token): digest=digest, ) - def _determine_role_from_permissions(permissions): - # Using default permissions as proxies for roles since - # we cannot explicitly get role from the service account token - if PLUGINS_WRITE in permissions: - return LegacyAccessControlRole.ADMIN - if DASHBOARDS_WRITE in permissions: - return LegacyAccessControlRole.EDITOR - if DASHBOARDS_READ in permissions: - return LegacyAccessControlRole.VIEWER - return LegacyAccessControlRole.NONE - - # setup an in-mem ServiceAccountUser - role = LegacyAccessControlRole.NONE - if not organization.is_rbac_permissions_enabled: - role = _determine_role_from_permissions(permissions) - user = ServiceAccountUser( organization=organization, service_account=service_account, username=service_account.username, public_primary_key=service_account.public_primary_key, - role=role, + role=LegacyAccessControlRole.NONE, permissions=GrafanaAPIPermissions.construct_permissions(permissions.keys()), ) diff --git a/engine/apps/auth_token/tests/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py index 37000ca381..3a8ec56c0d 100644 --- a/engine/apps/auth_token/tests/test_grafana_auth.py +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -110,10 +110,31 @@ def test_grafana_authentication_invalid_grafana_url(): assert exc.value.detail == "Invalid Grafana URL." +@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 + + token = f"{ServiceAccountToken.GRAFANA_SA_PREFIX}xyz" + headers = { + "HTTP_AUTHORIZATION": token, + "HTTP_X_GRAFANA_URL": organization.grafana_url, + } + request = APIRequestFactory().get("/", **headers) + + with pytest.raises(exceptions.AuthenticationFailed) as exc: + GrafanaServiceAccountAuthentication().authenticate(request) + assert exc.value.detail == "Invalid token." + + @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 = { @@ -140,19 +161,12 @@ def test_grafana_authentication_permissions_call_fails(make_organization): @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) -@pytest.mark.parametrize( - "permissions, expected_role", - [ - ({"plugins:write": "value"}, LegacyAccessControlRole.ADMIN), - ({"dashboards:write": "value"}, LegacyAccessControlRole.EDITOR), - ({"dashboards:read": "value"}, LegacyAccessControlRole.VIEWER), - ({"some-perm": "value"}, LegacyAccessControlRole.NONE), - ], -) def test_grafana_authentication_existing_token( - make_organization, make_service_account_for_organization, make_token_for_service_account, permissions, expected_role + make_organization, make_service_account_for_organization, make_token_for_service_account ): 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) @@ -164,7 +178,7 @@ def test_grafana_authentication_existing_token( request = APIRequestFactory().get("/", **headers) # setup Grafana API responses - setup_service_account_api_mocks(organization, permissions) + setup_service_account_api_mocks(organization, {"some-perm": "value"}) user, auth_token = GrafanaServiceAccountAuthentication().authenticate(request) @@ -172,10 +186,7 @@ def test_grafana_authentication_existing_token( assert user.service_account == service_account assert user.public_primary_key == service_account.public_primary_key assert user.username == service_account.username - if not organization.is_rbac_permissions_enabled: - assert user.role == expected_role - else: - assert user.role == LegacyAccessControlRole.NONE + assert user.role == LegacyAccessControlRole.NONE assert auth_token == token last_request = httpretty.last_request() @@ -188,17 +199,10 @@ def test_grafana_authentication_existing_token( @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) -@pytest.mark.parametrize( - "permissions, expected_role", - [ - ({"plugins:write": "value"}, LegacyAccessControlRole.ADMIN), - ({"dashboards:write": "value"}, LegacyAccessControlRole.EDITOR), - ({"dashboards:read": "value"}, LegacyAccessControlRole.VIEWER), - ({"some-perm": "value"}, LegacyAccessControlRole.NONE), - ], -) -def test_grafana_authentication_token_created(make_organization, permissions, expected_role): +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 = { @@ -208,6 +212,7 @@ def test_grafana_authentication_token_created(make_organization, permissions, ex request = APIRequestFactory().get("/", **headers) # setup Grafana API responses + permissions = {"some-perm": "value"} user_data = {"login": "some-login", "uid": "service-account:42"} setup_service_account_api_mocks(organization, permissions, user_data) @@ -220,10 +225,7 @@ def test_grafana_authentication_token_created(make_organization, permissions, ex assert user.username == service_account.username assert service_account.grafana_id == 42 assert service_account.login == "some-login" - if not organization.is_rbac_permissions_enabled: - assert user.role == expected_role - else: - assert user.role == LegacyAccessControlRole.NONE + assert user.role == LegacyAccessControlRole.NONE assert user.permissions == [{"action": p} for p in permissions] assert auth_token.service_account == user.service_account @@ -241,6 +243,8 @@ def test_grafana_authentication_token_created(make_organization, permissions, ex @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 = { @@ -269,6 +273,8 @@ 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" diff --git a/engine/apps/public_api/tests/test_alert_groups.py b/engine/apps/public_api/tests/test_alert_groups.py index e73bbd37b3..e3cc872e3a 100644 --- a/engine/apps/public_api/tests/test_alert_groups.py +++ b/engine/apps/public_api/tests/test_alert_groups.py @@ -756,7 +756,6 @@ def test_actions_disabled_for_service_accounts( make_escalation_chain(organization) perms = { - permissions.PLUGINS_WRITE: ["*"], permissions.RBACPermission.Permissions.ALERT_GROUPS_WRITE.value: ["*"], } setup_service_account_api_mocks(organization, perms=perms) diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index 9eb27cfe48..796942eb59 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -122,7 +122,6 @@ def test_create_integration_via_service_account( make_escalation_chain(organization) perms = { - permissions.PLUGINS_WRITE: ["*"], permissions.RBACPermission.Permissions.INTEGRATIONS_WRITE.value: ["*"], } setup_service_account_api_mocks(organization, perms) @@ -141,9 +140,12 @@ def test_create_integration_via_service_account( HTTP_AUTHORIZATION=f"{token_string}", HTTP_X_GRAFANA_URL=organization.grafana_url, ) - assert response.status_code == status.HTTP_201_CREATED - integration = AlertReceiveChannel.objects.get(public_primary_key=response.data["id"]) - assert integration.service_account == service_account + 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 @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 47a9eb3062..95154ab4de 100644 --- a/engine/apps/public_api/tests/test_rbac_permissions.py +++ b/engine/apps/public_api/tests/test_rbac_permissions.py @@ -110,11 +110,8 @@ def test_rbac_permissions( @pytest.mark.parametrize( "rbac_enabled,role,give_perm", [ - # rbac disabled: we will check the role is enough based on get_most_authorized_role for the perm + # rbac disabled: auth is disabled (False, LegacyAccessControlRole.ADMIN, None), - (False, LegacyAccessControlRole.EDITOR, None), - (False, LegacyAccessControlRole.VIEWER, None), - (False, LegacyAccessControlRole.NONE, None), # rbac enabled: having role None, check the perm is required (True, LegacyAccessControlRole.NONE, False), (True, LegacyAccessControlRole.NONE, True), @@ -168,24 +165,8 @@ def test_service_account_auth( perms_url = f"{organization.grafana_url}/api/access-control/user/permissions" httpretty.register_uri(httpretty.GET, perms_url, responses=[mock_response]) else: - # setup Grafana API permissions response - # role is given by specific perms - permissions = {} - if role == LegacyAccessControlRole.ADMIN: - permissions = {"plugins:write": "value"} - elif role == LegacyAccessControlRole.EDITOR: - permissions = {"dashboards:write": "value"} - elif role == LegacyAccessControlRole.VIEWER: - permissions = {"dashboards:read": "value"} - - 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]) - - # check what the minimum required role for the perms is - required_role = get_most_authorized_role(required_perms) - # set expected depending on the user's role - expected = status.HTTP_200_OK if role <= required_role else status.HTTP_403_FORBIDDEN + # service account auth is disabled + expected = status.HTTP_403_FORBIDDEN # iterate over all viewset actions, making an API request for each, # using the user's token and confirming the response status code