oncall-engine/engine/apps/user_management/models/user.py
Matias Bordese 35f23cdcc6
Rework organization sync and grafana plugin engine backend (#4756)
Related to
https://github.com/grafana/oncall-private/issues/2806#issuecomment-2246286918.

Prepare engine for the backend plugin enablement/migration:

 - Refactor sync code
- Improve plugin user authentication to set up user on-the-fly (when
missing)
- Implement v2 endpoints for install, sync and status (to be used via
the backend plugin)

(most of the changes come from
https://github.com/grafana/oncall/pull/4657; backport all engine changes
that keep backwards compatibility)
2024-07-31 16:12:56 +00:00

413 lines
16 KiB
Python

import datetime
import logging
import re
import typing
from urllib.parse import urljoin
import pytz
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from emoji import demojize
from apps.api.permissions import (
LegacyAccessControlCompatiblePermission,
LegacyAccessControlRole,
RBACPermission,
user_is_authorized,
)
from apps.google.models import GoogleOAuth2User
from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization
from apps.user_management.types import AlertGroupTableColumn, GoogleCalendarSettings
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
from django.db.models.manager import RelatedManager
from apps.alerts.models import AlertGroup, EscalationPolicy
from apps.auth_token.models import ApiAuthToken, ScheduleExportAuthToken, UserScheduleExportAuthToken
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
from apps.slack.models import SlackUserIdentity
from apps.social_auth.types import GoogleOauth2Response
from apps.user_management.models import Organization, Team
logger = logging.getLogger(__name__)
class PermissionsQuery(typing.TypedDict):
permissions__contains: typing.Dict
class PermissionsRegexQuery(typing.TypedDict):
permissions__regex: str
class RoleInQuery(typing.TypedDict):
role__in: typing.List[int]
def generate_public_primary_key_for_user():
prefix = "U"
new_public_primary_key = generate_public_primary_key(prefix)
failure_counter = 0
while User.objects.filter(public_primary_key=new_public_primary_key).exists():
new_public_primary_key = increase_public_primary_key_length(
failure_counter=failure_counter, prefix=prefix, model_name="User"
)
failure_counter += 1
return new_public_primary_key
def default_working_hours():
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"]
weekends = ["saturday", "sunday"]
working_hours = {day: [{"start": "09:00:00", "end": "17:00:00"}] for day in weekdays}
working_hours |= {day: [] for day in weekends}
return working_hours
class UserManager(models.Manager["User"]):
pass
class UserQuerySet(models.QuerySet):
def filter(self, *args, **kwargs):
return super().filter(*args, **kwargs, is_active=True)
def filter_with_deleted(self, *args, **kwargs):
return super().filter(*args, **kwargs)
def delete(self):
# is_active = None is used to be able to have multiple deleted users with the same user_id
return super().update(is_active=None)
def hard_delete(self):
return super().delete()
class User(models.Model):
acknowledged_alert_groups: "RelatedManager['AlertGroup']"
auth_tokens: "RelatedManager['ApiAuthToken']"
current_team: typing.Optional["Team"]
escalation_policy_notify_queues: "RelatedManager['EscalationPolicy']"
google_oauth2_user: typing.Optional[GoogleOAuth2User]
last_notified_in_escalation_policies: "RelatedManager['EscalationPolicy']"
notification_policies: "RelatedManager['UserNotificationPolicy']"
organization: "Organization"
personal_log_records: "RelatedManager['UserNotificationPolicyLogRecord']"
resolved_alert_groups: "RelatedManager['AlertGroup']"
schedule_export_token: "RelatedManager['ScheduleExportAuthToken']"
silenced_alert_groups: "RelatedManager['AlertGroup']"
slack_user_identity: typing.Optional["SlackUserIdentity"]
teams: "RelatedManager['Team']"
user_schedule_export_token: "RelatedManager['UserScheduleExportAuthToken']"
wiped_alert_groups: "RelatedManager['AlertGroup']"
objects = UserManager.from_queryset(UserQuerySet)()
class Meta:
# For some reason there are cases when Grafana user gets deleted,
# and then new Grafana user is created with the same user_id
# Including is_active to unique_together and setting is_active to None allows to
# have multiple deleted users with the same user_id, but user_id is unique among active users
unique_together = ("user_id", "organization", "is_active")
indexes = [
models.Index(fields=["is_active", "organization", "username"]),
models.Index(fields=["is_active", "organization", "email"]),
]
public_primary_key = models.CharField(
max_length=20,
validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)],
unique=True,
default=generate_public_primary_key_for_user,
)
user_id = models.PositiveIntegerField()
organization = models.ForeignKey(to="user_management.Organization", on_delete=models.CASCADE, related_name="users")
current_team = models.ForeignKey(
to="user_management.Team", null=True, default=None, on_delete=models.SET_NULL, related_name="current_team_users"
)
email = models.EmailField()
name = models.CharField(max_length=300)
username = models.CharField(max_length=300)
role = models.PositiveSmallIntegerField(choices=LegacyAccessControlRole.choices())
avatar_url = models.URLField()
# don't use "_timezone" directly, use the "timezone" property since it can be populated via slack user identity
_timezone = models.CharField(max_length=50, null=True, default=None)
working_hours = models.JSONField(null=True, default=default_working_hours)
notification = models.ManyToManyField(
"alerts.AlertGroup", through="alerts.UserHasNotification", related_name="users"
)
unverified_phone_number = models.CharField(max_length=20, null=True, default=None)
_verified_phone_number = models.CharField(max_length=20, null=True, default=None)
hide_phone_number = models.BooleanField(default=False)
slack_user_identity = models.ForeignKey(
"slack.SlackUserIdentity", on_delete=models.PROTECT, null=True, default=None, related_name="users"
)
# is_active = None is used to be able to have multiple deleted users with the same user_id
is_active = models.BooleanField(null=True, default=True)
permissions = models.JSONField(null=False, default=list)
alert_group_table_selected_columns: list[AlertGroupTableColumn] | None = models.JSONField(default=None, null=True)
google_calendar_settings: GoogleCalendarSettings | None = models.JSONField(default=None, null=True)
def __str__(self):
return f"{self.pk}: {self.username}"
@property
def available_teams(self):
if self.role == LegacyAccessControlRole.ADMIN:
return self.organization.teams.all()
return self.organization.teams.filter(Q(is_sharing_resources_to_all=True) | Q(users=self)).distinct()
@property
def is_authenticated(self):
return True
@property
def has_google_oauth2_connected(self) -> bool:
try:
# https://stackoverflow.com/a/35005034/3902555
return self.google_oauth2_user is not None
except ObjectDoesNotExist:
return False
@property
def avatar_full_url(self):
return urljoin(self.organization.grafana_url, self.avatar_url)
@property
def verified_phone_number(self) -> str | None:
"""
Use property to highlight that _verified_phone_number should not be modified directly
"""
return self._verified_phone_number
def save_verified_phone_number(self, phone_number: str) -> None:
self._verified_phone_number = phone_number
self.save(update_fields=["_verified_phone_number"])
def clear_phone_numbers(self) -> None:
self.unverified_phone_number = None
self._verified_phone_number = None
self.save(update_fields=["unverified_phone_number", "_verified_phone_number"])
# TODO: move to telegram app
@property
def is_telegram_connected(self):
return hasattr(self, "telegram_connection")
def self_or_admin(self, user_to_check, organization) -> bool:
has_admin_permission = user_is_authorized(user_to_check, [RBACPermission.Permissions.USER_SETTINGS_ADMIN])
return user_to_check.pk == self.pk or (
has_admin_permission and organization.pk == user_to_check.organization_id
)
@property
def is_notification_allowed(self):
return user_is_authorized(self, [RBACPermission.Permissions.NOTIFICATIONS_READ])
def get_username_with_slack_verbal(self, mention=False) -> str:
slack_verbal = None
if self.slack_user_identity:
slack_verbal = (
f"<@{self.slack_user_identity.slack_id}>"
if mention
else f"@{self.slack_user_identity.profile_display_name or self.slack_user_identity.slack_verbal}"
)
if slack_verbal:
return f"{self.username} ({slack_verbal})"
return self.username
@property
def timezone(self) -> typing.Optional[str]:
if self._timezone:
return self._timezone
if self.slack_user_identity:
return self.slack_user_identity.timezone
return None
@timezone.setter
def timezone(self, value):
self._timezone = value
def is_in_working_hours(self, dt: datetime.datetime, tz: typing.Optional[str] = None) -> bool:
assert dt.tzinfo == datetime.timezone.utc, "dt must be in UTC"
# Default to user's timezone
if not tz:
tz = self.timezone
# If user has no timezone set, any time is considered non-working hours
if not tz:
return False
# Convert to user's timezone and get day name (e.g. monday)
dt = dt.astimezone(pytz.timezone(tz))
day_name = dt.date().strftime("%A").lower()
# If no working hours for the day, return False
if day_name not in self.working_hours or not self.working_hours[day_name]:
return False
# Extract start and end time for the day from working hours
day_start_time_str = self.working_hours[day_name][0]["start"]
day_start_time = datetime.time.fromisoformat(day_start_time_str)
day_end_time_str = self.working_hours[day_name][0]["end"]
day_end_time = datetime.time.fromisoformat(day_end_time_str)
# Calculate day start and end datetime
day_start = dt.replace(
hour=day_start_time.hour, minute=day_start_time.minute, second=day_start_time.second, microsecond=0
)
day_end = dt.replace(
hour=day_end_time.hour, minute=day_end_time.minute, second=day_end_time.second, microsecond=0
)
return day_start <= dt <= day_end
def short(self):
return {
"username": self.username,
"pk": self.public_primary_key,
"avatar": self.avatar_url,
"avatar_full": self.avatar_full_url,
}
# Insight logs
@property
def insight_logs_type_verbal(self):
return "user"
@property
def insight_logs_verbal(self):
return self.username
@property
def insight_logs_serialized(self):
from apps.base.models import UserNotificationPolicy
default, important = UserNotificationPolicy.get_short_verbals_for_user(user=self)
notification_policies_verbal = f"default: {' - '.join(default)}, important: {' - '.join(important)}"
notification_policies_verbal = demojize(notification_policies_verbal)
result = {
"username": self.username,
# LEGACY.. role should get removed eventually.. it's probably safe to remove it now?
"role": self.get_role_display(),
"notification_policies": notification_policies_verbal,
}
if self.verified_phone_number:
result["verified_phone_number"] = self.unverified_phone_number
if self.unverified_phone_number:
result["unverified_phone_number"] = self.unverified_phone_number
return result
@property
def insight_logs_metadata(self):
return {}
@staticmethod
def build_permissions_query(
permission: LegacyAccessControlCompatiblePermission, organization
) -> typing.Union[PermissionsQuery, PermissionsRegexQuery, RoleInQuery]:
"""
This method returns a django query filter that is compatible with RBAC
as well as legacy "basic" role based authorization. If a permission is provided we simply do
a regex search where the permission column contains the permission value (need to use regex because
the JSON contains method is not supported by sqlite)
If RBAC is not supported for the org, we make the assumption that we are looking for any users with AT LEAST
the fallback role. Ex: if the fallback role were editor than we would get editors and admins.
"""
if organization.is_rbac_permissions_enabled:
# https://stackoverflow.com/a/50251879
if settings.DATABASE_TYPE == settings.DATABASE_TYPES.SQLITE3:
# https://docs.djangoproject.com/en/4.2/topics/db/queries/#contains
return PermissionsRegexQuery(permissions__regex=re.escape(permission.value))
required_permission = {"action": permission.value}
return PermissionsQuery(permissions__contains=[required_permission])
return RoleInQuery(role__lte=permission.fallback_role.value)
def get_default_fallback_notification_policy(self) -> "UserNotificationPolicy":
from apps.base.models import UserNotificationPolicy
return UserNotificationPolicy.get_default_fallback_policy(self)
def get_notification_policies_or_use_default_fallback(
self, important=False
) -> typing.Tuple[bool, typing.List["UserNotificationPolicy"]]:
"""
If the user has no notification policies defined, fallback to using e-mail as the notification channel.
The 1st tuple element is a boolean indicating if we are falling back to using a "fallback"/default
notification policy step (which occurs when the user has no notification policies defined).
"""
notification_polices = self.notification_policies.filter(important=important)
if not notification_polices.exists():
return (
True,
[self.get_default_fallback_notification_policy()],
)
return (
False,
list(notification_polices.all()),
)
def update_alert_group_table_selected_columns(self, columns: typing.List[AlertGroupTableColumn]) -> None:
if self.alert_group_table_selected_columns != columns:
self.alert_group_table_selected_columns = columns
self.save(update_fields=["alert_group_table_selected_columns"])
def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None:
_obj, created = GoogleOAuth2User.objects.update_or_create(
user=self,
defaults={
"google_user_id": google_oauth2_response.get("sub"),
"access_token": google_oauth2_response.get("access_token"),
"refresh_token": google_oauth2_response.get("refresh_token"),
"oauth_scope": google_oauth2_response.get("scope"),
},
)
if created:
self.google_calendar_settings = {
"oncall_schedules_to_consider_for_shift_swaps": [],
}
self.save(update_fields=["google_calendar_settings"])
def finish_google_oauth2_disconnection_flow(self) -> None:
GoogleOAuth2User.objects.filter(user=self).delete()
self.google_calendar_settings = None
self.save(update_fields=["google_calendar_settings"])
# TODO: check whether this signal can be moved to save method of the model
@receiver(post_save, sender=User)
def listen_for_user_model_save(sender: User, instance: User, created: bool, *args, **kwargs) -> None:
drop_cached_ical_for_custom_events_for_organization.apply_async(
(instance.organization_id,),
)