# What this PR does **NOTE**: should be merged/released after https://github.com/grafana/irm/pull/183 has been rolled out to most stacks (as that frontend update is what will grant that new RBAC "action" to users whom already have the "OnCall Admin" RBAC role assigned) tldr; from the comment in the `RBACPermission.Permission.ADMIN` comment in `engine/apps/api/permissions.py`: > NOTE: this is a bit of a hack for now. See https://github.com/grafana/support-escalations/issues/12625 > Basically when it comes to filtering teams that are configured to share their resources with > "Team members and admins", we have no way of knowing, when a user is ACTUALLY an Admin when RBAC is involed. > > Example: Take a user with the basic role of None/Editor/Viewer but with the "OnCall Admin" role assigned. > Without this RBAC permission, we have no way of knowing that the user is ACTUALLY an "Admin". ## Which issue(s) this PR closes Closes https://github.com/grafana/support-escalations/issues/12625 ## 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.
433 lines
17 KiB
Python
433 lines
17 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 import utils as google_utils
|
|
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 is_admin(self) -> bool:
|
|
return user_is_authorized(self, [RBACPermission.Permissions.ADMIN])
|
|
|
|
@property
|
|
def available_teams(self) -> "RelatedManager['Team']":
|
|
if self.is_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_notification_allowed(self) -> bool:
|
|
return user_is_authorized(self, [RBACPermission.Permissions.NOTIFICATIONS_READ])
|
|
|
|
@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 google_oauth2_token_is_missing_scopes(self) -> bool:
|
|
if not self.has_google_oauth2_connected:
|
|
return False
|
|
return not google_utils.user_granted_all_required_scopes(self.google_oauth2_user.oauth_scope)
|
|
|
|
def avatar_full_url(self, organization: "Organization"):
|
|
"""
|
|
Use arg `organization` instead of `self.organization` to avoid multiple requests to db when getting avatar for
|
|
users list
|
|
"""
|
|
return urljoin(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_has_user_settings_admin_permission(self, user_to_check: "User", organization: "Organization") -> bool:
|
|
has_permission = user_is_authorized(user_to_check, [RBACPermission.Permissions.USER_SETTINGS_ADMIN])
|
|
return user_to_check.pk == self.pk or (has_permission and organization.pk == user_to_check.organization_id)
|
|
|
|
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, organization):
|
|
return {
|
|
"username": self.username,
|
|
"pk": self.public_primary_key,
|
|
"avatar": self.avatar_url,
|
|
"avatar_full": self.avatar_full_url(organization),
|
|
}
|
|
|
|
# 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 save_google_oauth2_settings(self, google_oauth2_response: "GoogleOauth2Response") -> None:
|
|
logger.info(
|
|
f"Saving Google OAuth2 settings for user {self.pk} "
|
|
f"sub={google_oauth2_response.get('sub')} "
|
|
f"oauth_scope={google_oauth2_response.get('scope')}"
|
|
)
|
|
|
|
_, 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 reset_google_oauth2_settings(self) -> None:
|
|
logger.info(f"Resetting Google OAuth2 settings for user {self.pk}")
|
|
|
|
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,),
|
|
)
|