Browse Source

feat(leaky-bucket): disabled member redirect and auth (#26157)

This PR checks if a member is disabled from the member limit during auth and loading front-end views. A member would be disabled when an organization downgrades plans (or lets a trial expire) when they've added additional members but their plan only allows 1 user. A disabled member would get redirected to the /organizations/:orgSlug/disabled-member/ view which will explain to the user what needs to be done to become re-enabled (asking their billing admin to upgrade their plan). That view only has a placeholder for now. The code to actually change members to disabled is here: getsentry/getsentry#5604.
Stephen Cefali 3 years ago
parent
commit
5e9db2e673

+ 8 - 1
src/sentry/api/bases/organization.py

@@ -6,7 +6,11 @@ from sentry.api.base import Endpoint
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.helpers.environments import get_environments
 from sentry.api.permissions import SentryPermission
-from sentry.api.utils import InvalidParams, get_date_range_from_params
+from sentry.api.utils import (
+    InvalidParams,
+    get_date_range_from_params,
+    is_member_disabled_from_limit,
+)
 from sentry.auth.superuser import is_active_superuser
 from sentry.constants import ALL_ACCESS_PROJECTS
 from sentry.models import (
@@ -58,6 +62,9 @@ class OrganizationPermission(SentryPermission):
         allowed_scopes = set(self.scope_map.get(request.method, []))
         return any(request.access.has_scope(s) for s in allowed_scopes)
 
+    def is_member_disabled_from_limit(self, request, organization):
+        return is_member_disabled_from_limit(request, organization)
+
 
 class OrganizationAuditPermission(OrganizationPermission):
     scope_map = {"GET": ["org:write"]}

+ 11 - 0
src/sentry/api/exceptions.py

@@ -56,6 +56,17 @@ class SsoRequired(SentryAPIException):
         super().__init__(loginUrl=reverse("sentry-auth-organization", args=[organization.slug]))
 
 
+class MemberDisabledOverLimit(SentryAPIException):
+    status_code = status.HTTP_401_UNAUTHORIZED
+    code = "member-disabled-over-limit"
+    message = "Organization over member limit"
+
+    def __init__(self, organization):
+        super().__init__(
+            next=reverse("sentry-organization-disabled-member", args=[organization.slug])
+        )
+
+
 class SuperuserRequired(SentryAPIException):
     status_code = status.HTTP_403_FORBIDDEN
     code = "superuser-required"

+ 21 - 4
src/sentry/api/permissions.py

@@ -1,6 +1,11 @@
 from rest_framework import permissions
 
-from sentry.api.exceptions import SsoRequired, SuperuserRequired, TwoFactorRequired
+from sentry.api.exceptions import (
+    MemberDisabledOverLimit,
+    SsoRequired,
+    SuperuserRequired,
+    TwoFactorRequired,
+)
 from sentry.auth import access
 from sentry.auth.superuser import is_active_superuser
 from sentry.auth.system import is_system_auth
@@ -64,6 +69,9 @@ class SentryPermission(ScopedPermission):
     def needs_sso(self, request, organization):
         return False
 
+    def is_member_disabled_from_limit(self, request, organization):
+        return False
+
     def determine_access(self, request, organization):
         from sentry.api.base import logger
 
@@ -78,12 +86,14 @@ class SentryPermission(ScopedPermission):
         else:
             request.access = access.from_request(request, organization)
 
+            extra = {"organization_id": organization.id, "user_id": request.user.id}
+
             if auth.is_user_signed_request(request):
                 # if the user comes from a signed request
                 # we let them pass if sso is enabled
                 logger.info(
                     "access.signed-sso-passthrough",
-                    extra={"organization_id": organization.id, "user_id": request.user.id},
+                    extra=extra,
                 )
             elif request.user.is_authenticated:
                 # session auth needs to confirm various permissions
@@ -91,7 +101,7 @@ class SentryPermission(ScopedPermission):
 
                     logger.info(
                         "access.must-sso",
-                        extra={"organization_id": organization.id, "user_id": request.user.id},
+                        extra=extra,
                     )
 
                     raise SsoRequired(organization)
@@ -99,6 +109,13 @@ class SentryPermission(ScopedPermission):
                 if self.is_not_2fa_compliant(request, organization):
                     logger.info(
                         "access.not-2fa-compliant",
-                        extra={"organization_id": organization.id, "user_id": request.user.id},
+                        extra=extra,
                     )
                     raise TwoFactorRequired()
+
+                if self.is_member_disabled_from_limit(request, organization):
+                    logger.info(
+                        "access.member-disabled-from-limit",
+                        extra=extra,
+                    )
+                    raise MemberDisabledOverLimit(organization)

+ 29 - 0
src/sentry/api/utils.py

@@ -1,10 +1,16 @@
+import logging
 from datetime import timedelta
 
 from django.utils import timezone
 
+from sentry.auth.access import get_cached_organization_member
+from sentry.auth.superuser import is_active_superuser
+from sentry.models import OrganizationMember
 from sentry.search.utils import InvalidQuery, parse_datetime_string
 from sentry.utils.dates import parse_stats_period
 
+logger = logging.getLogger(__name__)
+
 MAX_STATS_PERIOD = timedelta(days=90)
 
 
@@ -79,3 +85,26 @@ def get_date_range_from_params(params, optional=False):
         raise InvalidParams("start must be before end")
 
     return start, end
+
+
+def is_member_disabled_from_limit(request, organization):
+    user = request.user
+
+    # never limit sentry apps
+    if getattr(user, "is_sentry_app", False):
+        return False
+
+    # don't limit super users
+    if is_active_superuser(request):
+        return False
+
+    # must be a simple user at this point
+    try:
+        member = get_cached_organization_member(user.id, organization.id)
+    except OrganizationMember.DoesNotExist:
+        # should never happen but if it does, don't block auth
+        # but send error to logs/Sentry
+        logger.error("is_member_disabled_from_limit.member_missing")
+        return False
+    else:
+        return member.flags["member-limit:restricted"]

+ 14 - 2
src/sentry/web/frontend/base.py

@@ -15,6 +15,7 @@ from sudo.views import redirect_to_sudo
 
 from sentry import roles
 from sentry.api.serializers import serialize
+from sentry.api.utils import is_member_disabled_from_limit
 from sentry.auth import access
 from sentry.auth.superuser import is_active_superuser
 from sentry.models import (
@@ -115,6 +116,9 @@ class OrganizationMixin:
             and not is_active_superuser(request)
         )
 
+    def is_member_disabled_from_limit(self, request, organization):
+        return is_member_disabled_from_limit(request, organization)
+
     def get_active_team(self, request, organization, team_slug):
         """
         Returns the currently selected team for the request or None
@@ -211,8 +215,12 @@ class BaseView(View, OrganizationMixin):
         if not self.has_permission(request, *args, **kwargs):
             return self.handle_permission_required(request, *args, **kwargs)
 
-        if "organization" in kwargs and self.is_not_2fa_compliant(request, kwargs["organization"]):
-            return self.handle_not_2fa_compliant(request, *args, **kwargs)
+        if "organization" in kwargs:
+            org = kwargs["organization"]
+            if self.is_member_disabled_from_limit(request, org):
+                return self.handle_disabled_member(org)
+            if self.is_not_2fa_compliant(request, org):
+                return self.handle_not_2fa_compliant(request, *args, **kwargs)
 
         self.request = request
         self.default_context = self.get_context_data(request, *args, **kwargs)
@@ -290,6 +298,10 @@ class BaseView(View, OrganizationMixin):
     def create_audit_entry(self, request, transaction_id=None, **kwargs):
         return create_audit_entry(request, transaction_id, audit_logger, **kwargs)
 
+    def handle_disabled_member(self, organization):
+        redirect_uri = reverse("sentry-organization-disabled-member", args=[organization.slug])
+        return self.redirect(redirect_uri)
+
 
 class OrganizationView(BaseView):
     """

+ 6 - 0
src/sentry/web/frontend/disabled_member_view.py

@@ -0,0 +1,6 @@
+from .react_page import ReactPageView
+
+
+class DisabledMemberView(ReactPageView):
+    def is_member_disabled_from_limit(self, request, organization):
+        return False

+ 6 - 0
src/sentry/web/urls.py

@@ -15,6 +15,7 @@ from sentry.web.frontend.auth_login import AuthLoginView
 from sentry.web.frontend.auth_logout import AuthLogoutView
 from sentry.web.frontend.auth_organization_login import AuthOrganizationLoginView
 from sentry.web.frontend.auth_provider_login import AuthProviderLoginView
+from sentry.web.frontend.disabled_member_view import DisabledMemberView
 from sentry.web.frontend.error_page_embed import ErrorPageEmbedView
 from sentry.web.frontend.group_event_json import GroupEventJsonView
 from sentry.web.frontend.group_plugin_action import GroupPluginActionView
@@ -536,6 +537,11 @@ urlpatterns += [
                     RestoreOrganizationView.as_view(),
                     name="sentry-restore-organization",
                 ),
+                url(
+                    r"^(?P<organization_slug>[^/]+)/disabled-member/$",
+                    DisabledMemberView.as_view(),
+                    name="sentry-organization-disabled-member",
+                ),
                 # need to force these to React and ensure organization_slug is captured
                 # TODO(RyanSkonnord): Generalize to all pages without regressing
                 url(r"^(?P<organization_slug>[\w_-]+)/(settings|discover)/", react_page_view),

+ 4 - 0
static/app/api.tsx

@@ -89,6 +89,10 @@ export const initApiClientErrorHandling = () =>
       return;
     }
 
+    if (code === 'member-disabled-over-limit') {
+      browserHistory.replace(extra.next);
+    }
+
     // Otherwise, the user has become unauthenticated. Send them to auth
     Cookies.set('session_expired', '1');
 

+ 6 - 1
static/app/routes.tsx

@@ -766,7 +766,6 @@ function routes() {
           componentPromise={() => import('app/views/sentryAppExternalInstallation')}
           component={errorHandler(LazyLoad)}
         />
-
         <Redirect from="/account/" to="/settings/account/details/" />
 
         <Redirect from="/share/group/:shareId/" to="/share/issue/:shareId/" />
@@ -788,6 +787,12 @@ function routes() {
           component={errorHandler(LazyLoad)}
         />
 
+        <Route
+          path="/organizations/:orgId/disabled-member/"
+          componentPromise={() => import('app/views/disabledMember')}
+          component={errorHandler(LazyLoad)}
+        />
+
         <Route
           path="/join-request/:orgId/"
           componentPromise={() => import('app/views/organizationJoinRequest')}

+ 1 - 0
static/app/stores/hookStore.tsx

@@ -14,6 +14,7 @@ const validHookNames = new Set<HookName>([
   'analytics:track-adhoc-event',
   'analytics:track-event',
   'analytics:log-experiment',
+  'component:disabled-member',
   'component:header-date-range',
   'component:header-selector-items',
   'component:global-notifications',

Some files were not shown because too many files changed in this diff