oncall-engine/engine/apps/public_api/serializers/integrations.py
Matias Bordese 2bcbac8454
Enable service account token auth for public API (#5254)
Related to https://github.com/grafana/oncall-private/issues/2826

Continuing work started in https://github.com/grafana/oncall/pull/5211,
this adds support for Grafana service accounts tokens for API
authentication (except alert group actions which will still require a
user behind). Next steps would be updating the go client and the
terraform provider to allow service account token auth for OnCall
resources.

Following proposal 1.1 from
[doc](https://docs.google.com/document/d/1I3nFbsUEkiNPphBXT-kWefIeramTY71qqZ1OA06Kmls/edit?usp=sharing).
2024-11-19 12:52:23 +00:00

401 lines
18 KiB
Python

from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from jinja2 import TemplateSyntaxError
from rest_framework import fields, serializers
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
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
from common.jinja_templater import jinja_template_env
from common.utils import timed_lru_cache
from .integtration_heartbeat import IntegrationHeartBeatSerializer
from .maintenance import MaintainableObjectSerializerMixin
from .routes import DefaultChannelFilterSerializer
# Behaviour templates are named differently in public api
PUBLIC_BEHAVIOUR_TEMPLATES_FIELDS = ["resolve_signal", "grouping_key", "acknowledge_signal", "source_link"]
# TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD is map from template name in public api to its db field.
# It's applied only for legacy messengers, which are not using messaging backend system
TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD = {
"grouping_key": "grouping_id_template",
"resolve_signal": "resolve_condition_template",
"acknowledge_signal": "acknowledge_condition_template",
"source_link": "source_link_template",
"slack": {
"title": "slack_title_template",
"message": "slack_message_template",
"image_url": "slack_image_url_template",
},
"web": {
"title": "web_title_template",
"message": "web_message_template",
"image_url": "web_image_url_template",
},
"sms": {
"title": "sms_title_template",
},
"phone_call": {
"title": "phone_call_title_template",
},
"telegram": {
"title": "telegram_title_template",
"message": "telegram_message_template",
"image_url": "telegram_image_url_template",
},
}
TEMPLATES_WITH_SEPARATE_DB_FIELD = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM] + PUBLIC_BEHAVIOUR_TEMPLATES_FIELDS
PUBLIC_API_CUSTOMIZABLE_NOTIFICATION_CHANNEL_TEMPLATES = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM]
for _, backend in get_messaging_backends():
if backend.customizable_templates:
PUBLIC_API_CUSTOMIZABLE_NOTIFICATION_CHANNEL_TEMPLATES.append(backend.slug)
class IntegrationTypeField(fields.CharField):
def to_representation(self, value):
return remove_legacy_prefix(value)
def to_internal_value(self, data):
if data not in AlertReceiveChannel.INTEGRATION_TYPES:
raise BadRequest(detail="Invalid integration type")
if has_legacy_prefix(data):
raise BadRequest("This integration type is deprecated")
if data == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING:
raise BadRequest(detail="Direct paging integrations can't be created")
return data
class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, MaintainableObjectSerializerMixin):
id = serializers.CharField(read_only=True, source="public_primary_key")
name = serializers.CharField(required=False, source="verbal_name")
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
link = serializers.ReadOnlyField(source="integration_url")
inbound_email = serializers.ReadOnlyField()
type = IntegrationTypeField(source="integration")
templates = serializers.DictField(required=False)
default_route = serializers.DictField(required=False)
heartbeat = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False, allow_null=True)
PREFETCH_RELATED = ["channel_filters"]
SELECT_RELATED = ["organization", "integration_heartbeat", "team"]
class Meta:
model = AlertReceiveChannel
fields = MaintainableObjectSerializerMixin.Meta.fields + [
"id",
"name",
"description_short",
"team_id",
"link",
"inbound_email",
"type",
"default_route",
"templates",
"heartbeat",
]
def to_representation(self, instance):
result = super().to_representation(instance)
default_route = self._get_default_route_iterative(instance)
serializer = DefaultChannelFilterSerializer(default_route, context=self.context)
result["default_route"] = serializer.data
# add additional templates for messaging backends
result["templates"].update(self._get_messaging_backend_templates(instance))
return result
def create(self, validated_data):
validated_data = self._correct_validated_data(validated_data)
default_route_data = validated_data.pop("default_route", None)
organization = self.context["request"].auth.organization
integration = validated_data.get("integration")
if integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING:
# TODO: probably only needs to check if unified alerting is on
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=user if not isinstance(user, ServiceAccountUser) else None,
service_account=user.service_account if isinstance(user, ServiceAccountUser) else None,
organization=organization,
)
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
if default_route_data:
serializer = DefaultChannelFilterSerializer(
instance.default_channel_filter, default_route_data, context=self.context
)
serializer.is_valid(raise_exception=True)
serializer.save()
return instance
def update(self, *args, **kwargs):
try:
return super().update(*args, **kwargs)
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
def validate(self, attrs):
self.validate_name_uniqueness(attrs)
return attrs
def validate_name_uniqueness(self, attrs):
organization = self.context["request"].auth.organization
verbal_name = attrs.get("verbal_name", None)
if verbal_name is None:
return
team = attrs.get("team", self.instance.team) if self.instance else attrs.get("team")
try:
obj = AlertReceiveChannel.objects.get(
organization=organization,
team=team,
verbal_name=verbal_name,
)
except AlertReceiveChannel.DoesNotExist:
return
except AlertReceiveChannel.MultipleObjectsReturned:
raise BadRequest(detail="An integration with this name already exists for this team")
if self.instance and obj.id == self.instance.id:
return
else:
raise BadRequest(detail="An integration with this name already exists for this team")
def validate_templates(self, templates):
if not isinstance(templates, dict):
raise BadRequest(detail="Invalid template data")
for notification_channel in PUBLIC_API_CUSTOMIZABLE_NOTIFICATION_CHANNEL_TEMPLATES:
template_data = templates.get(notification_channel, {})
if template_data is None:
continue
if not isinstance(template_data, dict):
raise BadRequest(detail=f"Invalid {notification_channel} template data")
for attr, attr_template in template_data.items():
if attr_template is None:
continue
try:
jinja_template_env.from_string(attr_template)
except TemplateSyntaxError:
raise BadRequest(detail=f"invalid {notification_channel} {attr} template")
for template_name in PUBLIC_BEHAVIOUR_TEMPLATES_FIELDS:
template_data = templates.get(template_name, "")
if template_data is None:
continue
if not isinstance(template_data, str):
raise BadRequest(detail=f"Invalid {template_name} template data")
try:
jinja_template_env.from_string(template_data)
except TemplateSyntaxError:
raise BadRequest(detail=f"Invalid {template_name} template data")
return templates
def _correct_validated_data(self, validated_data):
"""
Process input templates data.
1. Reshapes it.
1.1 We are receiving templates in dict format
{
resolve_signal: "resolve me!"
slack: {
title: "title",
message: "message",
image_url: "image_url",
},
...
}
but store them in separate fields: slack_title_template, slack_message_template.
See _correct_validated_data_for_legacy_template method
1.2 We are storing templates from messaging backends in separate messaging_backends_templates field.
So, we need to shape input data related to messaging_backends_templates also.
2. Handle None templates.
Public API set template to default value in two cases: (This behaviour is required by terraform plugin).
2.1 None for the whole template:
{
slack: None,
...
}
In that case all slack templates will be set to default.
2.2 One particular field is None:
{
slack: {
title: "My custom title:
message: None,
},
...
}
In that case slack message template will be set to default.
TODO: System described above is too complicated, should be simplified.
It can be simplified via unification all chatops integrations and messaging_backends
and/or by introducing unified templates
"""
validated_data = self._correct_validated_data_for_messaging_backends_templates(validated_data)
validated_data = self._correct_validated_data_for_legacy_templates(validated_data)
validated_data.pop("templates", {})
return validated_data
def _correct_validated_data_for_legacy_templates(self, validated_data):
"""
_correct_validated_data_for_legacy_template reshapes validated data to store them.
It converts data from "templates" dict to db fields, which were used before messaging backends.
Example:
{
"slack": {
"title": Hello
}
}
Will be converted to
slack_title_template=Hello
"""
templates_data_from_request = validated_data.get("templates", {})
for template_backend_name, template_from_request in templates_data_from_request.items():
# correct_validated_data for templates with its own db fields.
if template_backend_name in TEMPLATES_WITH_SEPARATE_DB_FIELD:
if type(template_from_request) is str: # if it's plain template: {"resolve_signal": "resolve me"}
try:
validated_data[
TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name]
] = template_from_request
except KeyError:
raise BadRequest(detail="Invalid template data")
elif type(template_from_request) is dict: # if it's nested template: {slack: {"title": "some title"}}
for attr, template in template_from_request.items():
try:
validated_data[TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name][attr]] = template
except KeyError:
raise BadRequest(detail="Invalid template data")
elif template_from_request is None:
# if it's we receive None, it's needed to set template to default value
try:
template_to_set_to_default = TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name]
if type(template_to_set_to_default) is str:
# if we receive None for plain template just set it to None
validated_data[TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name]] = None
elif type(template_to_set_to_default) is dict:
# if we receive None for nested template set all it's fields to None
for key in template_to_set_to_default.keys():
validated_data[TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name][key]] = None
except KeyError:
raise BadRequest(detail="Invalid template data")
return validated_data
def _correct_validated_data_for_messaging_backends_templates(self, validated_data):
"""
_correct_validated_data_for_messaging_backends_templates reshapes validated data to store them.
It converts data from "templates" dict to messaging_backends_templates field format.
Example:
{
"msteams": {
"title": Hello
}
}
Will be converted to
messaging_backends={"MSTEAMS": {"title": "Hello"},
"""
templates_data_from_request = validated_data.get("templates", {})
messaging_backends_templates = self.instance.messaging_backends_templates if self.instance else {}
if messaging_backends_templates is None:
messaging_backends_templates = {}
for backend_id, backend in get_messaging_backends():
if not backend.customizable_templates:
continue
backend_template = {}
if backend.slug in templates_data_from_request: # check to modify only templates from request data
template_from_request = templates_data_from_request[backend.slug]
else:
continue
if template_from_request is None:
# If we receive None backend template, like {"msteams": None }, set all template fields to none.
for field in backend.template_fields:
backend_template[field] = None
elif type(template_from_request) is dict:
# go through existing backend_template and update with values from request
backend_template = messaging_backends_templates.get(backend_id, {})
for field in backend.template_fields:
try:
updated_field_template = template_from_request[field]
except KeyError:
continue
backend_template[field] = updated_field_template
# remove backend-specific template from payload
templates_data_from_request.pop(backend.slug, None)
if backend_template:
messaging_backends_templates[backend_id] = backend_template
validated_data["messaging_backends_templates"] = messaging_backends_templates
return validated_data
@staticmethod
def _get_messaging_backend_templates(instance):
result = {}
messaging_backends_templates = instance.messaging_backends_templates or {}
for backend_id, backend in get_messaging_backends():
if not backend.customizable_templates:
continue
if not backend.template_fields:
continue
result[backend.slug] = {
field: messaging_backends_templates.get(backend_id, {}).get(field) for field in backend.template_fields
}
return result
def get_heartbeat(self, obj):
try:
heartbeat = obj.integration_heartbeat
except ObjectDoesNotExist:
return None
return IntegrationHeartBeatSerializer(heartbeat).data
@timed_lru_cache(timeout=5)
def _get_default_route_iterative(self, obj):
"""
Gets default route iterative to not hit db on each integration instance.
"""
for filter in obj.channel_filters.all():
if filter.is_default:
return filter
class IntegrationUpdateSerializer(IntegrationSerializer):
type = IntegrationTypeField(source="integration", read_only=True)
def update(self, instance, validated_data):
validated_data = self._correct_validated_data(validated_data)
default_route_data = validated_data.pop("default_route", None)
default_route = instance.default_channel_filter
if default_route_data:
serializer = DefaultChannelFilterSerializer(default_route, default_route_data, context=self.context)
serializer.is_valid(raise_exception=True)
serializer.save()
return super().update(instance, validated_data)