From 4574d236496b423728638fd82de176167bac7b01 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 23 Oct 2024 10:58:51 -0300 Subject: [PATCH] WIP --- docker-compose-developer.yml | 1 + ...062_alertreceivechannel_service_account.py | 20 +++ .../alerts/models/alert_receive_channel.py | 14 ++- engine/apps/auth_token/auth.py | 43 ++----- .../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 | 115 ++++++++++++++++++ .../auth_token/tests/test_grafana_auth.py | 5 +- engine/apps/grafana_plugin/helpers/client.py | 3 + .../public_api/serializers/integrations.py | 5 +- .../public_api/tests/test_resolution_notes.py | 6 +- engine/apps/public_api/views/alert_groups.py | 39 +++++- engine/apps/public_api/views/alerts.py | 11 +- engine/apps/public_api/views/escalation.py | 7 +- .../public_api/views/escalation_chains.py | 16 ++- .../public_api/views/escalation_policies.py | 16 ++- engine/apps/public_api/views/integrations.py | 18 ++- .../apps/public_api/views/on_call_shifts.py | 16 ++- engine/apps/public_api/views/organizations.py | 11 +- .../views/personal_notifications.py | 12 +- engine/apps/public_api/views/routes.py | 16 ++- engine/apps/public_api/views/schedules.py | 21 +++- engine/apps/public_api/views/shift_swap.py | 18 ++- .../apps/public_api/views/slack_channels.py | 12 +- engine/apps/public_api/views/teams.py | 12 +- engine/apps/public_api/views/user_groups.py | 12 +- engine/apps/public_api/views/users.py | 17 ++- engine/apps/public_api/views/webhooks.py | 18 ++- .../migrations/0024_serviceaccount.py | 27 ++++ .../apps/user_management/models/__init__.py | 1 + .../user_management/models/service_account.py | 54 ++++++++ 32 files changed, 514 insertions(+), 88 deletions(-) create mode 100644 engine/apps/alerts/migrations/0062_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/user_management/migrations/0024_serviceaccount.py create mode 100644 engine/apps/user_management/models/service_account.py diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index b751ab1e98..0096751dc7 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -317,6 +317,7 @@ services: container_name: grafana labels: *oncall-labels image: "grafana/${GRAFANA_IMAGE:-grafana:latest}" + # image: "grafana/grafana:11.0.6" restart: always environment: GF_SECURITY_ADMIN_USER: oncall diff --git a/engine/apps/alerts/migrations/0062_alertreceivechannel_service_account.py b/engine/apps/alerts/migrations/0062_alertreceivechannel_service_account.py new file mode 100644 index 0000000000..bc15b291ce --- /dev/null +++ b/engine/apps/alerts/migrations/0062_alertreceivechannel_service_account.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.15 on 2024-10-21 19:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0024_serviceaccount'), + ('alerts', '0061_alter_alertgroup_resolved_by_alert'), + ] + + 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 100b5b7b7e..6a2289d058 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/auth_token/auth.py b/engine/apps/auth_token/auth.py index d86ad6eedd..e58af977aa 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -9,7 +9,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request -from apps.api.permissions import GrafanaAPIPermissions, LegacyAccessControlRole, RBACPermission, user_is_authorized +from apps.api.permissions import RBACPermission, user_is_authorized 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 +20,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, ) @@ -338,8 +338,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): @@ -347,7 +347,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) @@ -361,6 +361,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: @@ -372,36 +378,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..e36f92372e --- /dev/null +++ b/engine/apps/auth_token/migrations/0007_serviceaccounttoken.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.15 on 2024-10-21 19:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0024_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..7f5a501f44 --- /dev/null +++ b/engine/apps/auth_token/models/service_account_token.py @@ -0,0 +1,115 @@ +import binascii +from hmac import compare_digest + +from django.db import models + +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 +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 ServiceAccountToken(BaseAuthToken): + GRAFANA_SA_PREFIX = "glsa_" + + 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): + # get permissions and confirm token is valid + permissions = get_service_account_token_permissions(organization, token) + if not permissions: + # TODO: if token in DB, mark as revoked? (NOTE: a token can be disabled/re-enabled) + # consider revoking at the oncall side too? + 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: + # create a token + # make request to api/user using token + service_account_data = get_service_account_details(organization, token) + if not service_account_data: + # older grafana versions 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 = None + if service_account_data["uid"] is not None: + 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/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py index 5b78636c4a..00d9827e23 100644 --- a/engine/apps/auth_token/tests/test_grafana_auth.py +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -5,7 +5,8 @@ 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.auth_token.auth import X_GRAFANA_INSTANCE_ID, GrafanaServiceAccountAuthentication +from apps.auth_token.models import ServiceAccountToken from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS @@ -65,7 +66,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, } 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_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 738219d428..de39915693 100644 --- a/engine/apps/public_api/views/alert_groups.py +++ b/engine/apps/public_api/views/alert_groups.py @@ -11,11 +11,13 @@ from apps.alerts.models import AlertGroup, AlertReceiveChannel from apps.alerts.tasks import delete_alert_group, wipe from apps.api.label_filtering import parse_label_query -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 apps.user_management.models import ServiceAccountUser from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ( NO_TEAM_VALUE, @@ -56,8 +58,20 @@ class AlertGroupView( mixins.DestroyModelMixin, GenericViewSet, ): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "acknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unacknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "resolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unresolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "silence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "unsilence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + } throttle_classes = [UserThrottle] @@ -157,6 +171,10 @@ def destroy(self, request, *args, **kwargs): @action(methods=["post"], detail=True) def acknowledge(self, request, pk): + # TODO: make this a decorator? + if isinstance(request.user, ServiceAccountUser): + raise BadRequest(detail="Service accounts are not allowed to acknowledge alert groups") + alert_group = self.get_object() if alert_group.acknowledged: @@ -176,6 +194,9 @@ def acknowledge(self, request, pk): @action(methods=["post"], detail=True) def unacknowledge(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise BadRequest(detail="Service accounts are not allowed to unacknowledge alert groups") + alert_group = self.get_object() if not alert_group.acknowledged: @@ -195,6 +216,9 @@ def unacknowledge(self, request, pk): @action(methods=["post"], detail=True) def resolve(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise BadRequest(detail="Service accounts are not allowed to resolve alert groups") + alert_group = self.get_object() if alert_group.resolved: @@ -212,6 +236,9 @@ def resolve(self, request, pk): @action(methods=["post"], detail=True) def unresolve(self, request, pk): + if isinstance(request.user, ServiceAccountUser): + raise BadRequest(detail="Service accounts are not allowed to unresolve alert groups") + alert_group = self.get_object() if not alert_group.resolved: @@ -228,6 +255,9 @@ def unresolve(self, request, pk): @action(methods=["post"], detail=True) def silence(self, request, pk=None): + if isinstance(request.user, ServiceAccountUser): + raise BadRequest(detail="Service accounts are not allowed to silence alert groups") + alert_group = self.get_object() delay = request.data.get("delay") @@ -254,6 +284,9 @@ def silence(self, request, pk=None): @action(methods=["post"], detail=True) def unsilence(self, request, pk=None): + if isinstance(request.user, ServiceAccountUser): + raise BadRequest(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 6674ed1bb7..0f3d1d4669 100644 --- a/engine/apps/public_api/views/alerts.py +++ b/engine/apps/public_api/views/alerts.py @@ -6,7 +6,8 @@ from rest_framework.viewsets import GenericViewSet from apps.alerts.models import Alert -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -18,8 +19,12 @@ class AlertFilter(filters.FilterSet): class AlertView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/escalation.py b/engine/apps/public_api/views/escalation.py index ae3b5717df..be54592648 100644 --- a/engine/apps/public_api/views/escalation.py +++ b/engine/apps/public_api/views/escalation.py @@ -4,6 +4,7 @@ from rest_framework.views import APIView from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging +from apps.api.permissions import RBACPermission from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers import AlertGroupSerializer, EscalationSerializer from apps.public_api.throttlers import UserThrottle @@ -16,7 +17,11 @@ class EscalationView(APIView): """ authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "post": [RBACPermission.Permissions.ALERT_GROUPS_DIRECT_PAGING], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/escalation_chains.py b/engine/apps/public_api/views/escalation_chains.py index d8f9351373..52a1cc444c 100644 --- a/engine/apps/public_api/views/escalation_chains.py +++ b/engine/apps/public_api/views/escalation_chains.py @@ -4,7 +4,8 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationChain -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -14,8 +15,17 @@ class EscalationChainView(RateLimitHeadersMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "retrieve": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "create": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "partial_update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "destroy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/escalation_policies.py b/engine/apps/public_api/views/escalation_policies.py index f6dbe4bc02..e91e52f48b 100644 --- a/engine/apps/public_api/views/escalation_policies.py +++ b/engine/apps/public_api/views/escalation_policies.py @@ -4,7 +4,8 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationPolicy -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -13,8 +14,17 @@ class EscalationPolicyView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "retrieve": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], + "create": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "partial_update": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + "destroy": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/integrations.py b/engine/apps/public_api/views/integrations.py index ed17f9aacd..e8ec9a852b 100644 --- a/engine/apps/public_api/views/integrations.py +++ b/engine/apps/public_api/views/integrations.py @@ -4,7 +4,8 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import AlertReceiveChannel -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -23,8 +24,19 @@ class IntegrationView( MaintainableObjectMixin, ModelViewSet, ): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "maintenance_start": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "maintenance_stop": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/on_call_shifts.py b/engine/apps/public_api/views/on_call_shifts.py index af944b0057..dd5d86721b 100644 --- a/engine/apps/public_api/views/on_call_shifts.py +++ b/engine/apps/public_api/views/on_call_shifts.py @@ -4,7 +4,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -15,8 +16,8 @@ class CustomOnCallShiftView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) throttle_classes = [UserThrottle] @@ -29,6 +30,15 @@ class CustomOnCallShiftView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelV filter_backends = [DjangoFilterBackend] filterset_class = ByTeamFilter + rbac_permissions = { + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + } + def get_queryset(self): name = self.request.query_params.get("name", None) schedule_id = self.request.query_params.get("schedule_id", None) diff --git a/engine/apps/public_api/views/organizations.py b/engine/apps/public_api/views/organizations.py index f4fd1352a0..473d79de6c 100644 --- a/engine/apps/public_api/views/organizations.py +++ b/engine/apps/public_api/views/organizations.py @@ -2,7 +2,8 @@ from rest_framework.settings import api_settings from rest_framework.viewsets import ReadOnlyModelViewSet -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -14,8 +15,12 @@ class OrganizationView( RateLimitHeadersMixin, ReadOnlyModelViewSet, ): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "retrieve": [RBACPermission.Permissions.OTHER_SETTINGS_READ], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/personal_notifications.py b/engine/apps/public_api/views/personal_notifications.py index 44b251a38b..acd3ff5c4f 100644 --- a/engine/apps/public_api/views/personal_notifications.py +++ b/engine/apps/public_api/views/personal_notifications.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from apps.api.permissions import RBACPermission from apps.auth_token.auth import ApiTokenAuthentication from apps.base.models import UserNotificationPolicy from apps.public_api.serializers import PersonalNotificationRuleSerializer, PersonalNotificationRuleUpdateSerializer @@ -17,7 +18,16 @@ class PersonalNotificationView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.USER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + "create": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "destroy": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/routes.py b/engine/apps/public_api/views/routes.py index 895e016eaa..19ddc1056a 100644 --- a/engine/apps/public_api/views/routes.py +++ b/engine/apps/public_api/views/routes.py @@ -6,7 +6,8 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ChannelFilter -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -16,8 +17,17 @@ class ChannelFilterView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.INTEGRATIONS_READ], + "retrieve": [RBACPermission.Permissions.INTEGRATIONS_READ], + "create": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "partial_update": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "destroy": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index e1b83cf5a9..5960ad4894 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -8,7 +8,12 @@ from rest_framework.views import Response from rest_framework.viewsets import ModelViewSet -from apps.auth_token.auth import ApiTokenAuthentication, ScheduleExportAuthentication +from apps.api.permissions import RBACPermission +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 @@ -27,8 +32,18 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + "final_shifts": [RBACPermission.Permissions.SCHEDULES_READ], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/shift_swap.py b/engine/apps/public_api/views/shift_swap.py index 29d5fcbe69..6582bc7b0d 100644 --- a/engine/apps/public_api/views/shift_swap.py +++ b/engine/apps/public_api/views/shift_swap.py @@ -8,9 +8,9 @@ from rest_framework.response import Response from rest_framework.serializers import BaseSerializer -from apps.api.permissions import AuthenticatedRequest +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,8 +23,18 @@ class ShiftSwapViewSet(RateLimitHeadersMixin, BaseShiftSwapViewSet): # set authentication and permission classes - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (ApiTokenAuthentication, GrafanaServiceAccountAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.SCHEDULES_READ], + "retrieve": [RBACPermission.Permissions.SCHEDULES_READ], + "create": [RBACPermission.Permissions.SCHEDULES_WRITE], + "update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "partial_update": [RBACPermission.Permissions.SCHEDULES_WRITE], + "destroy": [RBACPermission.Permissions.SCHEDULES_WRITE], + "take": [RBACPermission.Permissions.SCHEDULES_WRITE], + } # public API customizations throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/slack_channels.py b/engine/apps/public_api/views/slack_channels.py index 1f363596f6..1248c90e7f 100644 --- a/engine/apps/public_api/views/slack_channels.py +++ b/engine/apps/public_api/views/slack_channels.py @@ -2,7 +2,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import GenericViewSet -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -11,8 +12,13 @@ class SlackChannelView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (ApiTokenAuthentication, GrafanaServiceAccountAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.CHATOPS_READ], + } + pagination_class = FiftyPageSizePaginator throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/teams.py b/engine/apps/public_api/views/teams.py index 96cea48da4..6d399bade5 100644 --- a/engine/apps/public_api/views/teams.py +++ b/engine/apps/public_api/views/teams.py @@ -2,7 +2,8 @@ from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.permissions import IsAuthenticated -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -13,8 +14,13 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewsets.GenericViewSet): serializer_class = TeamSerializer - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.USER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + } model = Team pagination_class = FiftyPageSizePaginator diff --git a/engine/apps/public_api/views/user_groups.py b/engine/apps/public_api/views/user_groups.py index 3db869545f..d45196c63e 100644 --- a/engine/apps/public_api/views/user_groups.py +++ b/engine/apps/public_api/views/user_groups.py @@ -2,7 +2,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import GenericViewSet -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +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 @@ -11,8 +12,13 @@ class UserGroupView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (ApiTokenAuthentication, GrafanaServiceAccountAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.CHATOPS_READ], + } + pagination_class = FiftyPageSizePaginator throttle_classes = [UserThrottle] diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 524b5ee008..11cdc1d6c1 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -5,8 +5,12 @@ from rest_framework.views import Response from rest_framework.viewsets import ReadOnlyModelViewSet -from apps.api.permissions import LegacyAccessControlRole -from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication +from apps.api.permissions import LegacyAccessControlRole, RBACPermission +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,8 +39,8 @@ class Meta: class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) model = User pagination_class = HundredPageSizePaginator @@ -51,6 +55,11 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet # self.get_object() is not used in export action because UserScheduleExportAuthentication is used extra_actions_ignore_no_get_object = ["schedule_export"] + rbac_permissions = { + "list": [RBACPermission.Permissions.USER_SETTINGS_READ], + "retrieve": [RBACPermission.Permissions.USER_SETTINGS_READ], + } + def get_queryset(self): if is_request_from_terraform(self.request): sync_users_on_tf_request(self.request.auth.organization) diff --git a/engine/apps/public_api/views/webhooks.py b/engine/apps/public_api/views/webhooks.py index 4773e2c38a..6fbaba5a08 100644 --- a/engine/apps/public_api/views/webhooks.py +++ b/engine/apps/public_api/views/webhooks.py @@ -5,7 +5,8 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.webhooks import ( WebhookCreateSerializer, WebhookResponseSerializer, @@ -20,8 +21,19 @@ class WebhooksView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (ApiTokenAuthentication, GrafanaServiceAccountAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "retrieve": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "create": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "partial_update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + } + pagination_class = FiftyPageSizePaginator throttle_classes = [UserThrottle] diff --git a/engine/apps/user_management/migrations/0024_serviceaccount.py b/engine/apps/user_management/migrations/0024_serviceaccount.py new file mode 100644 index 0000000000..4f7998c6ad --- /dev/null +++ b/engine/apps/user_management/migrations/0024_serviceaccount.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.15 on 2024-10-21 14:37 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0023_organization_is_grafana_irm_enabled'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grafana_id', models.PositiveIntegerField(null=True)), + ('login', models.CharField(max_length=300)), + ('last_synced_at', models.DateTimeField(null=True)), + ('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..50a8bf780a --- /dev/null +++ b/engine/apps/user_management/models/service_account.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import List + +from django.db import models + +from apps.user_management.models import Organization + + +@dataclass +class ServiceAccountUser: + 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(null=True) + organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="service_accounts") + login = models.CharField(max_length=300) + last_synced_at = models.DateTimeField(null=True) + + 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}"