oncall-engine/engine/apps/api/serializers/user.py
Joey Orlando eb777f5415
address Google OAuth2 issues where user didn't grant us the https://www.googleapis.com/auth/calendar.events.readonly scope (#4802)
# What this PR does

Follow up PR to https://github.com/grafana/oncall/pull/4792

Basically if when communicating with Google Calendar's API we encounter
an HTTP 403, or the Google client throws a
`google.auth.exceptions.RefreshError` this means one of three things:
1. the refresh token we have persisted for the user is missing the
`https://www.googleapis.com/auth/calendar.events.readonly` scope (HTTP
403)
2. the Google user has been deleted
(`google.auth.exceptions.RefreshError`)
3. the refresh token has expired (`google.auth.exceptions.RefreshError`)

To prevent scenario 1 above from happening in the future we now will
check that the token has been granted the required scopes. If the user
doesn't grant us all the necessary scopes, we will show them an error
message in the UI:
https://www.loom.com/share/0055ef03192b4154b894c2221cecbd5f

For tokens that were granted prior to this PR and which are missing the
required scope, we will show the user a dismissible warning banner in
the UI letting them know that they will need to reconnect their account
and grant us the missing permissions (see [this second demo
video](https://www.loom.com/share/bf2ee8b840864a64893165370a892bcd)
showing this).

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.

---------

Co-authored-by: Dominik <dominik.broj@grafana.com>
2024-08-14 18:02:34 -04:00

358 lines
12 KiB
Python

import math
import time
import typing
from django.conf import settings
from rest_framework import serializers
from apps.api.serializers.telegram import TelegramToUserConnectorSerializer
from apps.base.messaging import get_messaging_backends
from apps.base.models import UserNotificationPolicy
from apps.base.utils import live_settings
from apps.oss_installation.constants import CloudSyncStatus
from apps.oss_installation.utils import cloud_user_identity_status
from apps.schedules.ical_utils import SchedulesOnCallUsers
from apps.user_management.models import User
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, TimeZoneField
from common.api_helpers.mixins import EagerLoadingMixin
from common.api_helpers.utils import check_phone_number_is_valid
from .custom_serializers import DynamicFieldsModelSerializer
from .organization import FastOrganizationSerializer
from .slack_user_identity import SlackUserIdentitySerializer
from .team import FastTeamSerializer
class UserSerializerContext(typing.TypedDict):
schedules_with_oncall_users: SchedulesOnCallUsers
class UserPermissionSerializer(serializers.Serializer):
action = serializers.CharField(read_only=True)
class GoogleCalendarSettingsSerializer(serializers.Serializer):
# # TODO: figure out how to get OrganizationFilteredPrimaryKeyRelatedField to work with many=True
# oncall_schedules_to_consider_for_shift_swaps =
# oncall_schedules_to_consider_for_shift_swaps = serializers.ListField(
# child=OrganizationFilteredPrimaryKeyRelatedField(
# queryset=OnCallSchedule.objects,
# required=False,
# allow_null=True,
# ),
# required=False,
# allow_null=True,
# )
oncall_schedules_to_consider_for_shift_swaps = serializers.ListField(
child=serializers.CharField(),
required=False,
allow_null=True,
)
class NotificationChainVerbal(typing.TypedDict):
default: str
important: str
class WorkingHoursPeriodSerializer(serializers.Serializer):
start = serializers.CharField()
end = serializers.CharField()
class WorkingHoursSerializer(serializers.Serializer):
monday = serializers.ListField(child=WorkingHoursPeriodSerializer())
tuesday = serializers.ListField(child=WorkingHoursPeriodSerializer())
wednesday = serializers.ListField(child=WorkingHoursPeriodSerializer())
thursday = serializers.ListField(child=WorkingHoursPeriodSerializer())
friday = serializers.ListField(child=WorkingHoursPeriodSerializer())
saturday = serializers.ListField(child=WorkingHoursPeriodSerializer())
sunday = serializers.ListField(child=WorkingHoursPeriodSerializer())
class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
pk = serializers.CharField(read_only=True, source="public_primary_key")
slack_user_identity = SlackUserIdentitySerializer(read_only=True)
telegram_configuration = TelegramToUserConnectorSerializer(source="telegram_connection", read_only=True)
messaging_backends = serializers.SerializerMethodField()
organization = FastOrganizationSerializer(read_only=True)
current_team = TeamPrimaryKeyRelatedField(allow_null=True, required=False)
timezone = TimeZoneField(allow_null=True, required=False)
avatar = serializers.URLField(source="avatar_url", read_only=True)
avatar_full = serializers.URLField(source="avatar_full_url", read_only=True)
notification_chain_verbal = serializers.SerializerMethodField()
cloud_connection_status = serializers.SerializerMethodField()
working_hours = WorkingHoursSerializer(required=False)
SELECT_RELATED = [
"telegram_verification_code",
"telegram_connection",
"organization",
"slack_user_identity",
"mobileappauthtoken",
"google_oauth2_user",
]
class Meta:
model = User
fields = [
"pk",
"organization",
"current_team",
"email",
"username",
"name",
"role",
"avatar",
"avatar_full",
"timezone",
"working_hours",
"unverified_phone_number",
"verified_phone_number",
"slack_user_identity",
"telegram_configuration",
"messaging_backends",
"notification_chain_verbal",
"cloud_connection_status",
"hide_phone_number",
"has_google_oauth2_connected",
]
read_only_fields = [
"email",
"username",
"name",
"role",
"verified_phone_number",
"has_google_oauth2_connected",
]
def validate_working_hours(self, working_hours):
for day in working_hours:
for period in working_hours[day]:
try:
start = time.strptime(period["start"], "%H:%M:%S")
end = time.strptime(period["end"], "%H:%M:%S")
except ValueError:
raise serializers.ValidationError("'start' and 'end' fields must be in '%H:%M:%S' format")
if start >= end:
raise serializers.ValidationError("'start' must be less than 'end'")
return working_hours
def validate_unverified_phone_number(self, value):
if value:
if check_phone_number_is_valid(value):
return value
else:
raise serializers.ValidationError(
"Phone number must be entered in the format: '+999999999'. From 8 to 15 digits allowed."
)
else:
return None
def get_messaging_backends(self, obj: User) -> dict[str, dict]:
serialized_data = {}
supported_backends = get_messaging_backends()
for backend_id, backend in supported_backends:
serialized_data[backend_id] = backend.serialize_user(obj)
return serialized_data
def get_notification_chain_verbal(self, obj: User) -> NotificationChainVerbal:
default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj)
return {"default": " - ".join(default), "important": " - ".join(important)}
def get_cloud_connection_status(self, obj: User) -> CloudSyncStatus | None:
is_open_source_with_cloud_notifications = self.context.get("is_open_source_with_cloud_notifications", None)
is_open_source_with_cloud_notifications = (
is_open_source_with_cloud_notifications
if is_open_source_with_cloud_notifications is not None
else settings.IS_OPEN_SOURCE and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED
)
if is_open_source_with_cloud_notifications:
connector = self.context.get("connector", None)
identities = self.context.get("cloud_identities", {})
identity = identities.get(obj.email, None)
status, _ = cloud_user_identity_status(connector, identity)
return status
return None
def to_representation(self, instance):
result = super().to_representation(instance)
if instance.id != self.context["request"].user.id:
if instance.hide_phone_number:
if result["verified_phone_number"]:
result["verified_phone_number"] = self._hide_phone_number(result["verified_phone_number"])
if result["unverified_phone_number"]:
result["unverified_phone_number"] = self._hide_phone_number(result["unverified_phone_number"])
return result
@staticmethod
def _hide_phone_number(number: str):
HIDE_SYMBOL = "*"
SHOW_LAST_SYMBOLS = 4
if len(number) <= 4:
SHOW_LAST_SYMBOLS = math.ceil(len(number) / 2)
return f"{HIDE_SYMBOL * (len(number) - SHOW_LAST_SYMBOLS)}{number[-SHOW_LAST_SYMBOLS:]}"
class UserSerializer(ListUserSerializer):
context: UserSerializerContext
is_currently_oncall = serializers.SerializerMethodField()
google_calendar_settings = GoogleCalendarSettingsSerializer(required=False)
class Meta(ListUserSerializer.Meta):
fields = ListUserSerializer.Meta.fields + [
"is_currently_oncall",
"google_calendar_settings",
]
read_only_fields = ListUserSerializer.Meta.read_only_fields + [
"is_currently_oncall",
]
def get_is_currently_oncall(self, obj: User) -> bool:
# Serializer context is set here: apps.api.views.user.UserView.get_serializer_context.
return any(obj in users for users in self.context.get("schedules_with_oncall_users", {}).values())
class CurrentUserSerializer(UserSerializer):
rbac_permissions = UserPermissionSerializer(read_only=True, many=True, source="permissions")
class Meta(UserSerializer.Meta):
fields = UserSerializer.Meta.fields + [
"rbac_permissions",
"google_oauth2_token_is_missing_scopes",
]
read_only_fields = UserSerializer.Meta.read_only_fields + [
"google_oauth2_token_is_missing_scopes",
]
class UserHiddenFieldsSerializer(ListUserSerializer):
fields_available_for_all_users = [
"pk",
"organization",
"current_team",
"username",
"avatar",
"timezone",
"working_hours",
"notification_chain_verbal",
]
def to_representation(self, instance):
ret = super(ListUserSerializer, self).to_representation(instance)
if instance.id != self.context["request"].user.id:
for field in ret:
if field not in self.fields_available_for_all_users:
ret[field] = "******"
ret["hidden_fields"] = True
return ret
class ScheduleUserSerializer(ListUserSerializer):
fields_to_keep = [
"pk",
"organization",
"email",
"username",
"name",
"avatar",
"avatar_full",
"timezone",
"working_hours",
"slack_user_identity",
"telegram_configuration",
]
def to_representation(self, instance):
serialized = super(ListUserSerializer, self).to_representation(instance)
ret = {field: value for field, value in serialized.items() if field in self.fields_to_keep}
return ret
class FastUserSerializer(serializers.ModelSerializer):
pk = serializers.CharField(source="public_primary_key")
class Meta:
model = User
fields = [
"pk",
"username",
]
read_only_fields = [
"pk",
"username",
]
class FilterUserSerializer(EagerLoadingMixin, serializers.ModelSerializer):
value = serializers.CharField(source="public_primary_key")
display_name = serializers.CharField(source="username")
class Meta:
model = User
fields = ["value", "display_name"]
read_only_fields = [
"pk",
"username",
]
class UserShortSerializer(serializers.ModelSerializer):
username = serializers.CharField()
pk = serializers.CharField(source="public_primary_key")
avatar = serializers.CharField(source="avatar_url")
avatar_full = serializers.CharField(source="avatar_full_url")
class Meta:
model = User
fields = [
"username",
"pk",
"avatar",
"avatar_full",
]
read_only_fields = [
"username",
"pk",
"avatar",
"avatar_full",
]
class UserIsCurrentlyOnCallSerializer(UserShortSerializer, EagerLoadingMixin):
context: UserSerializerContext
teams = FastTeamSerializer(read_only=True, many=True)
is_currently_oncall = serializers.SerializerMethodField()
SELECT_RELATED = ["organization"]
PREFETCH_RELATED = ["teams"]
class Meta(UserShortSerializer.Meta):
fields = UserShortSerializer.Meta.fields + [
"name",
"timezone",
"teams",
"is_currently_oncall",
]
def get_is_currently_oncall(self, obj: User) -> bool:
# Serializer context is set here: apps.api.views.user.UserView.get_serializer_context.
return any(obj in users for users in self.context.get("schedules_with_oncall_users", {}).values())
class PagedUserSerializer(serializers.Serializer):
class Meta:
fields = [
"username",
"pk",
"avatar",
"avatar_full",
"important",
]