Browse Source

chore(staff): Let staff access proj details + statsv2 endpoint (#65405)

This endpoint was deceptively tricky to allow staff to access.

### Issue
Although we pass the normal permission class check with
`OrganizationAndStaffPermission`, we don't have the permissions to fetch
the stats of the project we want on _admin. When filtering the list of
projects in `OrganizationEndpoint`'s `_filter_projects_by_permissions`,
we use the `has_project_access` in the request's access

https://github.com/getsentry/sentry/blob/b21cc4d0bb8cc74e60bdb2300a8f8ac1a35627ff/src/sentry/api/bases/organization.py#L388-L395

The problem with staff is access is set to `OrganizationlessAccess`
which will __usually__ be `False` (in some cases it will differ if you
are part of the org the project is in).

https://github.com/getsentry/sentry/blob/e7bf11893f067cfbf2c0d8b26f2aa4e49b4e576c/src/sentry/auth/access.py#L817-L818

### Workaround
The workaround I came up with is hacky and not clean, so I'm very open
to any other ways of doing this.
I broke the check in `_filter_projects_by_permissions` up, so that for
the very specific case of this endpoint being hit by staff, we bypass
the check with a lambda that just checks that the project is active.

---

Also, I refactored `get_projects` and `_filter_projects_by_permissions`
for readability.
1. `get_projects` - pull out validation logic when requesting specific
projects into `_validate_fetched_projects`
2. `_filter_projects_by_permissions` - use early return after hitting
`if force_global_perms:`
Seiji Chew 1 year ago
parent
commit
c2e81a42bd

+ 53 - 45
src/sentry/api/bases/organization.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from collections.abc import Sequence
 from datetime import datetime
 from typing import Any, TypedDict
 
@@ -15,6 +16,7 @@ from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.helpers.environments import get_environments
 from sentry.api.permissions import SentryPermission, StaffPermissionMixin
 from sentry.api.utils import get_date_range_from_params, is_member_disabled_from_limit
+from sentry.auth.staff import is_active_staff
 from sentry.auth.superuser import is_active_superuser
 from sentry.constants import ALL_ACCESS_PROJECT_ID, ALL_ACCESS_PROJECTS_SLUG, ObjectStatus
 from sentry.exceptions import InvalidParams
@@ -280,6 +282,21 @@ class FilterParams(TypedDict, total=False):
     environment_objects: list[Environment] | None
 
 
+def _validate_fetched_projects(
+    filtered_projects: Sequence[Project],
+    slugs: set[str] | None,
+    ids: set[int] | None,
+) -> None:
+    """
+    Validates that user has access to the specific projects they are requesting.
+    """
+    missing_project_ids = ids and ids != {p.id for p in filtered_projects}
+    missing_project_slugs = slugs and slugs != {p.slug for p in filtered_projects}
+
+    if missing_project_ids or missing_project_slugs:
+        raise PermissionDenied
+
+
 class OrganizationEndpoint(Endpoint):
     permission_classes: tuple[type[BasePermission], ...] = (OrganizationPermission,)
 
@@ -301,19 +318,14 @@ class OrganizationEndpoint(Endpoint):
 
         :param request:
         :param organization: Organization to fetch projects for
-        :param force_global_perms: Permission override. Allows subclasses to
-        perform their own validation and allow the user to access any project
-        in the organization. This is a hack to support the old
-        `request.auth.has_scope` way of checking permissions, don't use it
-        for anything else, we plan to remove this once we remove uses of
-        `auth.has_scope`.
-        :param include_all_accessible: Whether to factor the organization
-        allow_joinleave flag into permission checks. We should ideally
-        standardize how this is used and remove this parameter.
-        :param project_ids: Projects if they were passed via request
-        data instead of get params
-        :param project_slugs: Project slugs if they were passed via request
-        data instead of get params
+        :param force_global_perms: Permission override. Allows subclasses to perform their own validation
+        and allow the user to access any project in the organization. This is a hack to support the old
+        `request.auth.has_scope` way of checking permissions, don't use it for anything else, we plan to
+        remove this once we remove uses of `auth.has_scope`.
+        :param include_all_accessible: Whether to factor the organization allow_joinleave flag into
+        permission checks. We should ideally standardize how this is used and remove this parameter.
+        :param project_ids: Projects if they were passed via request data instead of get params
+        :param project_slugs: Project slugs if they were passed via request  data instead of get params
         :return: A list of Project objects, or raises PermissionDenied.
 
         NOTE: If both project_ids and project_slugs are passed, we will default
@@ -329,7 +341,7 @@ class OrganizationEndpoint(Endpoint):
         if project_ids is None and slugs:
             # If we're querying for project slugs specifically
             if ALL_ACCESS_PROJECTS_SLUG in slugs:
-                # All projects i have access to
+                # All projects I have access to
                 include_all_accessible = True
             else:
                 qs = qs.filter(slug__in=slugs)
@@ -341,7 +353,7 @@ class OrganizationEndpoint(Endpoint):
                 include_all_accessible = True
             elif ids:
                 qs = qs.filter(id__in=ids)
-            # No project ids === `all projects i am a member of`
+            # No project ids === `all projects I am a member of`
 
         with sentry_sdk.start_span(op="fetch_organization_projects") as span:
             projects = list(qs)
@@ -355,20 +367,10 @@ class OrganizationEndpoint(Endpoint):
             force_global_perms=force_global_perms,
             include_all_accessible=include_all_accessible,
         )
-        filtered_project_ids = {p.id for p in filtered_projects}
-        filtered_project_slugs = {p.slug for p in filtered_projects}
-
-        if (
-            not include_all_accessible
-            and not filter_by_membership
-            and (
-                (ids and ids != filtered_project_ids) or (slugs and slugs != filtered_project_slugs)
-            )
-        ):
-            # If a user requests all projects - they should get back all projects they have permission for
-            # If a user requests specified projects, but they don't have access to them
-            # Then we should raise a permission denied
-            raise PermissionDenied
+
+        requesting_specific_projects = not include_all_accessible and not filter_by_membership
+        if requesting_specific_projects:
+            _validate_fetched_projects(filtered_projects, slugs, ids)
 
         return filtered_projects
 
@@ -380,28 +382,34 @@ class OrganizationEndpoint(Endpoint):
         force_global_perms: bool = False,
         include_all_accessible: bool = False,
     ) -> list[Project]:
-        user = getattr(request, "user", None)
-        filtered = projects
         with sentry_sdk.start_span(op="apply_project_permissions") as span:
             span.set_data("Project Count", len(projects))
             if force_global_perms:
                 span.set_tag("mode", "force_global_perms")
-            else:
-                if (
-                    user
-                    and is_active_superuser(request)  # superuser should fetch all projects
-                    or not filter_by_membership  # explicitly requested projects
-                    or include_all_accessible  # requested $all projects
-                ):
-                    span.set_tag("mode", "has_project_access")
-                    func = request.access.has_project_access
+                return projects
+
+            # Superuser should fetch all projects.
+            # Also fetch all accessible projects if requesting $all
+            if is_active_superuser(request) or include_all_accessible:
+                span.set_tag("mode", "has_project_access")
+                proj_filter = request.access.has_project_access
+            # Check if explicitly requesting specific projects
+            elif not filter_by_membership:
+                if is_active_staff(request):
+                    # There is a special case for staff, where we want to fetch usage stats for a single
+                    # project in _admin using OrganizationStatsEndpointV2 but cannot use has_project_access
+                    # like superuser because it fails. The workaround is to create a lambda that mimics
+                    # checking for active projects like has_project_access without further validation.
+                    span.set_tag("mode", "staff_fetch_all")
+                    proj_filter = lambda proj: proj.status == ObjectStatus.ACTIVE  # noqa: E731
                 else:
-                    span.set_tag("mode", "has_project_membership")
-                    func = request.access.has_project_membership
-
-                filtered = [p for p in projects if func(p)]
+                    span.set_tag("mode", "has_project_access")
+                    proj_filter = request.access.has_project_access
+            else:
+                span.set_tag("mode", "has_project_membership")
+                proj_filter = request.access.has_project_membership
 
-        return filtered
+            return [p for p in projects if proj_filter(p)]
 
     def get_requested_project_ids_unchecked(self, request: Request | HttpRequest) -> set[int]:
         """

+ 2 - 1
src/sentry/api/endpoints/organization_stats_v2.py

@@ -12,7 +12,7 @@ from sentry.api.api_owners import ApiOwner
 from sentry.api.api_publish_status import ApiPublishStatus
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases import NoProjects
-from sentry.api.bases.organization import OrganizationEndpoint
+from sentry.api.bases.organization import OrganizationAndStaffPermission, OrganizationEndpoint
 from sentry.api.utils import handle_query_errors
 from sentry.apidocs.constants import RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED
 from sentry.apidocs.examples.organization_examples import OrganizationExamples
@@ -145,6 +145,7 @@ class OrganizationStatsEndpointV2(OrganizationEndpoint):
             RateLimitCategory.ORGANIZATION: RateLimit(20, 1),
         }
     }
+    permission_classes = (OrganizationAndStaffPermission,)
 
     @extend_schema(
         operation_id="Retrieve Event Counts for an Organization (v2)",

+ 6 - 1
src/sentry/api/endpoints/project_details.py

@@ -19,6 +19,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectPermission
 from sentry.api.decorators import sudo_required
 from sentry.api.fields.empty_integer import EmptyIntegerField
 from sentry.api.fields.sentry_slug import SentrySerializerSlugField
+from sentry.api.permissions import StaffPermissionMixin
 from sentry.api.serializers import serialize
 from sentry.api.serializers.models.project import DetailedProjectSerializer
 from sentry.api.serializers.rest_framework.list import EmptyListField
@@ -450,6 +451,10 @@ class RelaxedProjectPermission(ProjectPermission):
     }
 
 
+class RelaxedProjectAndStaffPermission(StaffPermissionMixin, RelaxedProjectPermission):
+    pass
+
+
 @extend_schema(tags=["Projects"])
 @region_silo_endpoint
 class ProjectDetailsEndpoint(ProjectEndpoint):
@@ -458,7 +463,7 @@ class ProjectDetailsEndpoint(ProjectEndpoint):
         "GET": ApiPublishStatus.PUBLIC,
         "PUT": ApiPublishStatus.PUBLIC,
     }
-    permission_classes = (RelaxedProjectPermission,)
+    permission_classes = (RelaxedProjectAndStaffPermission,)
 
     def _get_unresolved_count(self, project):
         queryset = Group.objects.filter(status=GroupStatus.UNRESOLVED, project=project)

+ 4 - 3
src/sentry/auth/staff.py

@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
 
 from django.conf import settings
 from django.core.signing import BadSignature
+from django.http import HttpRequest
 from django.utils import timezone as django_timezone
 from django.utils.crypto import constant_time_compare, get_random_string
 from rest_framework.request import Request
@@ -43,7 +44,7 @@ STAFF_ORG_ID = getattr(settings, "STAFF_ORG_ID", None)
 UNSET = object()
 
 
-def is_active_staff(request: Request) -> bool:
+def is_active_staff(request: HttpRequest | Request) -> bool:
     if is_system_auth(getattr(request, "auth", None)):
         return True
     staff = getattr(request, "staff", None) or Staff(request)
@@ -64,8 +65,8 @@ class Staff(ElevatedMode):
 
     @property
     def is_active(self) -> bool:
-        # We have a wsgi request with no user.
-        if not hasattr(self.request, "user"):
+        # We have a wsgi request with no user or user is None
+        if not hasattr(self.request, "user") or self.request.user is None:
             return False
         # if we've been logged out
         if not self.request.user.is_authenticated:

+ 2 - 2
src/sentry/auth/superuser.py

@@ -186,8 +186,8 @@ class Superuser(ElevatedMode):
         org = getattr(self.request, "organization", None)
         if org and org.id != self.org_id:
             return self._check_expired_on_org_change()
-        # We have a wsgi request with no user.
-        if not hasattr(self.request, "user"):
+        # We have a wsgi request with no user or user is None.
+        if not hasattr(self.request, "user") or self.request.user is None:
             return False
         # if we've been logged out
         if not self.request.user.is_authenticated:

+ 92 - 61
tests/sentry/api/endpoints/test_project_details.py

@@ -110,33 +110,43 @@ def first_symbol_source_id(sources_json):
 class ProjectDetailsTest(APITestCase):
     endpoint = "sentry-api-0-project-details"
 
-    def test_simple(self):
-        project = self.project  # force creation
+    def setUp(self):
+        super().setUp()
         self.login_as(user=self.user)
 
-        response = self.get_success_response(project.organization.slug, project.slug)
-        assert response.data["id"] == str(project.id)
+    def test_simple(self):
+        response = self.get_success_response(self.project.organization.slug, self.project.slug)
+        assert response.data["id"] == str(self.project.id)
+
+    def test_superuser_simple(self):
+        superuser = self.create_user(is_superuser=True)
+        self.login_as(user=superuser, superuser=True)
+
+        response = self.get_success_response(self.project.organization.slug, self.project.slug)
+        assert response.data["id"] == str(self.project.id)
+
+    def test_staff_simple(self):
+        staff_user = self.create_user(is_staff=True)
+        self.login_as(user=staff_user, staff=True)
+
+        response = self.get_success_response(self.project.organization.slug, self.project.slug)
+        assert response.data["id"] == str(self.project.id)
 
     def test_numeric_org_slug(self):
         # Regression test for https://github.com/getsentry/sentry/issues/2236
-        self.login_as(user=self.user)
-        org = self.create_organization(name="baz", slug="1", owner=self.user)
-        team = self.create_team(organization=org, name="foo", slug="foo")
-        project = self.create_project(name="Bar", slug="bar", teams=[team])
+        project = self.create_project(name="Bar", slug="bar", teams=[self.team])
 
         # We want to make sure we don't hit the LegacyProjectRedirect view at all.
-        url = f"/api/0/projects/{org.slug}/{project.slug}/"
+        url = f"/api/0/projects/{self.organization.slug}/{project.slug}/"
         response = self.client.get(url)
         assert response.status_code == 200
         assert response.data["id"] == str(project.id)
 
     def test_with_stats(self):
-        project = self.create_project()
-        self.create_group(project=project)
-        self.login_as(user=self.user)
+        self.create_group(project=self.project)
 
         response = self.get_success_response(
-            project.organization.slug, project.slug, qs_params={"include": "stats"}
+            self.project.organization.slug, self.project.slug, qs_params={"include": "stats"}
         )
         assert response.data["stats"]["unresolved"] == 1
 
@@ -145,13 +155,11 @@ class ProjectDetailsTest(APITestCase):
             integration = self.create_provider_integration(provider="msteams")
             integration.add_organization(self.organization)
 
-        project = self.create_project()
-        self.create_group(project=project)
-        self.login_as(user=self.user)
+        self.create_group(project=self.project)
 
         response = self.get_success_response(
-            project.organization.slug,
-            project.slug,
+            self.project.organization.slug,
+            self.project.slug,
             qs_params={"expand": "hasAlertIntegration"},
         )
         assert response.data["hasAlertIntegrationInstalled"]
@@ -161,62 +169,57 @@ class ProjectDetailsTest(APITestCase):
             integration = self.create_provider_integration(provider="jira")
             integration.add_organization(self.organization)
 
-        project = self.create_project()
-        self.create_group(project=project)
-        self.login_as(user=self.user)
+        self.create_group(project=self.project)
 
         response = self.get_success_response(
-            project.organization.slug, project.slug, qs_params={"expand": "hasAlertIntegration"}
+            self.project.organization.slug,
+            self.project.slug,
+            qs_params={"expand": "hasAlertIntegration"},
         )
         assert not response.data["hasAlertIntegrationInstalled"]
 
     def test_filters_disabled_plugins(self):
         from sentry.plugins.base import plugins
 
-        project = self.create_project()
-        self.create_group(project=project)
-        self.login_as(user=self.user)
+        self.create_group(project=self.project)
 
         response = self.get_success_response(
-            project.organization.slug,
-            project.slug,
+            self.project.organization.slug,
+            self.project.slug,
         )
         assert response.data["plugins"] == []
 
         asana_plugin = plugins.get("asana")
-        asana_plugin.enable(project)
+        asana_plugin.enable(self.project)
 
         response = self.get_success_response(
-            project.organization.slug,
-            project.slug,
+            self.project.organization.slug,
+            self.project.slug,
         )
         assert len(response.data["plugins"]) == 1
         assert response.data["plugins"][0]["slug"] == asana_plugin.slug
 
     def test_project_renamed_302(self):
-        project = self.create_project()
-        self.login_as(user=self.user)
-
         # Rename the project
         self.get_success_response(
-            project.organization.slug, project.slug, method="put", slug="foobar"
+            self.project.organization.slug, self.project.slug, method="put", slug="foobar"
         )
 
         with outbox_runner():
             response = self.get_success_response(
-                project.organization.slug, project.slug, status_code=302
+                self.project.organization.slug, self.project.slug, status_code=302
             )
         with assume_test_silo_mode(SiloMode.CONTROL):
             assert (
                 AuditLogEntry.objects.get(
-                    organization_id=project.organization_id,
+                    organization_id=self.project.organization_id,
                     event=audit_log.get_event_id("PROJECT_EDIT"),
                 ).data.get("old_slug")
-                == project.slug
+                == self.project.slug
             )
             assert (
                 AuditLogEntry.objects.get(
-                    organization_id=project.organization_id,
+                    organization_id=self.project.organization_id,
                     event=audit_log.get_event_id("PROJECT_EDIT"),
                 ).data.get("new_slug")
                 == "foobar"
@@ -224,9 +227,9 @@ class ProjectDetailsTest(APITestCase):
         assert response.data["slug"] == "foobar"
         assert (
             response.data["detail"]["extra"]["url"]
-            == f"/api/0/projects/{project.organization.slug}/foobar/"
+            == f"/api/0/projects/{self.project.organization.slug}/foobar/"
         )
-        redirect_path = f"/api/0/projects/{project.organization.slug}/foobar/"
+        redirect_path = f"/api/0/projects/{self.project.organization.slug}/foobar/"
         # XXX: AttributeError: 'Response' object has no attribute 'url'
         # (this is with self.assertRedirects(response, ...))
         assert response["Location"] == redirect_path
@@ -435,6 +438,22 @@ class ProjectUpdateTest(APITestCase):
         self.proj_slug = self.project.slug
         self.login_as(user=self.user)
 
+    def test_superuser_simple(self):
+        superuser = self.create_user(is_superuser=True)
+        self.login_as(user=superuser, superuser=True)
+
+        self.get_success_response(self.org_slug, self.proj_slug, platform="native")
+        project = Project.objects.get(id=self.project.id)
+        assert project.platform == "native"
+
+    def test_staff_simple(self):
+        superuser = self.create_user(is_superuser=True)
+        self.login_as(user=superuser, superuser=True)
+
+        self.get_success_response(self.org_slug, self.proj_slug, platform="native")
+        project = Project.objects.get(id=self.project.id)
+        assert project.platform == "native"
+
     def test_blank_subject_prefix(self):
         project = Project.objects.get(id=self.project.id)
         options = {"mail:subject_prefix": "[Sentry]"}
@@ -1381,40 +1400,56 @@ class ProjectDeleteTest(APITestCase):
     endpoint = "sentry-api-0-project-details"
     method = "delete"
 
+    def setUp(self):
+        super().setUp()
+        self.login_as(user=self.user)
+
     @mock.patch("sentry.db.mixin.uuid4")
-    def test_simple(self, mock_uuid4_mixin):
+    def _delete_project_and_assert_deleted(self, mock_uuid4_mixin):
         mock_uuid4_mixin.return_value = self.get_mock_uuid()
-        project = self.create_project()
-
-        self.login_as(user=self.user)
 
         with self.settings(SENTRY_PROJECT=0):
-            self.get_success_response(project.organization.slug, project.slug, status_code=204)
+            self.get_success_response(
+                self.project.organization.slug, self.project.slug, status_code=204
+            )
 
         assert RegionScheduledDeletion.objects.filter(
-            model_name="Project", object_id=project.id
+            model_name="Project", object_id=self.project.id
         ).exists()
 
-        deleted_project = Project.objects.get(id=project.id)
+        deleted_project = Project.objects.get(id=self.project.id)
         assert deleted_project.status == ObjectStatus.PENDING_DELETION
         assert deleted_project.slug == "abc123"
         assert OrganizationOption.objects.filter(
             organization_id=deleted_project.organization_id,
             key=deleted_project.build_pending_deletion_key(),
         ).exists()
-        deleted_project = DeletedProject.objects.get(slug=project.slug)
-        self.assert_valid_deleted_log(deleted_project, project)
+        deleted_project = DeletedProject.objects.get(slug=self.project.slug)
+        self.assert_valid_deleted_log(deleted_project, self.project)
 
-    def test_internal_project(self):
-        project = self.create_project()
+    def test_simple(self):
+        self._delete_project_and_assert_deleted()
 
-        self.login_as(user=self.user)
+    def test_superuser(self):
+        superuser = self.create_user(is_superuser=True)
+        self.login_as(user=superuser, superuser=True)
+
+        self._delete_project_and_assert_deleted()
+
+    def test_staff(self):
+        staff_user = self.create_user(is_staff=True)
+        self.login_as(user=staff_user, staff=True)
 
-        with self.settings(SENTRY_PROJECT=project.id):
-            self.get_error_response(project.organization.slug, project.slug, status_code=403)
+        self._delete_project_and_assert_deleted()
+
+    def test_internal_project(self):
+        with self.settings(SENTRY_PROJECT=self.project.id):
+            self.get_error_response(
+                self.project.organization.slug, self.project.slug, status_code=403
+            )
 
         assert not RegionScheduledDeletion.objects.filter(
-            model_name="Project", object_id=project.id
+            model_name="Project", object_id=self.project.id
         ).exists()
 
 
@@ -1535,9 +1570,7 @@ class TestProjectDetailsDynamicSamplingBiases(TestProjectDetailsDynamicSamplingB
         Tests that when sending a request to enable a dynamic sampling bias,
         the bias will be successfully enabled and the audit log 'SAMPLING_BIAS_ENABLED' will be triggered
         """
-
-        project = self.project  # force creation
-        project.update_option(
+        self.project.update_option(
             "sentry:dynamic_sampling_biases",
             [
                 {"id": "boostEnvironments", "active": False},
@@ -1581,9 +1614,7 @@ class TestProjectDetailsDynamicSamplingBiases(TestProjectDetailsDynamicSamplingB
         Tests that when sending a request to disable a dynamic sampling bias,
         the bias will be successfully disabled and the audit log 'SAMPLING_BIAS_DISABLED' will be triggered
         """
-
-        project = self.project  # force creation
-        project.update_option(
+        self.project.update_option(
             "sentry:dynamic_sampling_biases",
             [
                 {"id": "boostEnvironments", "active": True},

+ 198 - 116
tests/snuba/api/endpoints/test_organization_stats_v2.py

@@ -1,8 +1,5 @@
-import functools
 from datetime import datetime, timedelta, timezone
 
-from django.urls import reverse
-
 from sentry.constants import DataCategory
 from sentry.testutils.cases import APITestCase, OutcomesSnubaTest
 from sentry.testutils.helpers.datetime import freeze_time
@@ -12,6 +9,8 @@ from sentry.utils.outcomes import Outcome
 
 @region_silo_test
 class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
+    endpoint = "sentry-api-0-organization-stats-v2"
+
     def setUp(self):
         super().setUp()
         self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc)
@@ -64,7 +63,6 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "quantity": 1,
             }
         )
-
         self.store_outcomes(
             {
                 "org_id": self.org.id,
@@ -88,23 +86,20 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
             }
         )
 
-    def do_request(self, query, user=None, org=None):
+    def do_request(self, query, user=None, org=None, status_code=200):
         self.login_as(user=user or self.user)
-        url = reverse(
-            "sentry-api-0-organization-stats-v2",
-            kwargs={"organization_slug": (org or self.organization).slug},
-        )
-        return self.client.get(url, query, format="json")
+        org_slug = (org or self.organization).slug
+        if status_code >= 400:
+            return self.get_error_response(org_slug, **query, status_code=status_code)
+        return self.get_success_response(org_slug, **query, status_code=status_code)
 
     def test_empty_request(self):
-        response = self.do_request({})
-        assert response.status_code == 400, response.content
+        response = self.do_request({}, status_code=400)
         assert result_sorted(response.data) == {"detail": 'At least one "field" is required.'}
 
     def test_inaccessible_project(self):
-        response = self.do_request({"project": [self.project3.id]})
+        response = self.do_request({"project": [self.project3.id]}, status_code=403)
 
-        assert response.status_code == 403, response.content
         assert result_sorted(response.data) == {
             "detail": "You do not have permission to perform this action."
         }
@@ -120,9 +115,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
             },
             user=self.user2,
             org=self.org3,
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {
             "detail": "No projects available",
         }
@@ -133,20 +128,20 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "field": ["summ(qarntenty)"],
                 "statsPeriod": "1d",
                 "interval": "1d",
-            }
+            },
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {
             "detail": 'Invalid field: "summ(qarntenty)"',
         }
 
     def test_no_end_param(self):
         response = self.do_request(
-            {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"}
+            {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"},
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {"detail": "start and end are both required"}
 
     @freeze_time(datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc))
@@ -158,9 +153,10 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "category": ["error"],
                 "start": "2021-03-14T15:30:00",
                 "end": "2021-03-14T16:30:00",
-            }
+            },
+            status_code=200,
         )
-        assert response.status_code == 200, response.content
+
         assert result_sorted(response.data) == {
             "intervals": [
                 "2021-03-14T12:00:00Z",
@@ -187,10 +183,10 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "category": "scoobydoo",
-            }
+            },
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {
             "detail": 'Invalid category: "scoobydoo"',
         }
@@ -203,10 +199,10 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "interval": "1d",
                 "category": "error",
                 "outcome": "scoobydoo",
-            }
+            },
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {
             "detail": 'Invalid outcome: "scoobydoo"',
         }
@@ -218,27 +214,22 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "groupBy": ["category_"],
                 "statsPeriod": "1d",
                 "interval": "1d",
-            }
+            },
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {"detail": 'Invalid groupBy: "category_"'}
 
     def test_resolution_invalid(self):
-        self.login_as(user=self.user)
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "bad_interval",
-            }
+            },
+            org=self.org,
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
-
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_attachment_filter_only(self):
         response = self.do_request(
@@ -248,10 +239,10 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "interval": "1d",
                 "field": ["sum(quantity)"],
                 "category": ["error", "attachment"],
-            }
+            },
+            status_code=400,
         )
 
-        assert response.status_code == 400, response.content
         assert result_sorted(response.data) == {
             "detail": "if filtering by attachment no other category may be present"
         }
@@ -265,10 +256,10 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "field": ["sum(quantity)"],
-            }
+            },
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
             "groups": [
@@ -285,10 +276,10 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "interval": "6h",
                 "field": ["sum(quantity)"],
                 "category": ["error"],
-            }
+            },
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "intervals": [
                 "2021-03-13T12:00:00Z",
@@ -319,9 +310,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "category": ["error", "transaction"],
             },
             user=self.user2,
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -342,9 +333,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "category": ["error", "transaction"],
             },
             user=self.user2,
+            status_code=403,
         )
 
-        assert response.status_code == 403
         response = self.do_request(
             {
                 "project": [-1],
@@ -355,9 +346,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "groupBy": ["project"],
             },
             user=self.user2,
+            status_code=200,
         )
 
-        assert response.status_code == 200
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -379,9 +370,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
             },
             org=self.organization,
             user=user,
+            status_code=403,
         )
 
-        assert response.status_code == 403, response.content
         assert result_sorted(response.data) == {
             "detail": "You do not have permission to perform this action."
         }
@@ -397,9 +388,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
             },
             org=self.organization,
             user=user,
+            status_code=403,
         )
 
-        assert response.status_code == 403, response.content
         assert result_sorted(response.data) == {
             "detail": "You do not have permission to perform this action."
         }
@@ -418,9 +409,9 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
                 "groupBy": ["project"],
             },
             user=self.user2,
+            status_code=200,
         )
 
-        assert response.status_code == 200
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -438,19 +429,17 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_org_simple(self):
-        make_request = functools.partial(
-            self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "2d",
                 "interval": "1d",
                 "field": ["sum(quantity)"],
                 "groupBy": ["category", "outcome", "reason"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "start": "2021-03-12T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -482,21 +471,73 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
             ],
         }
 
+    @freeze_time("2021-03-14T12:27:28.303Z")
+    def test_staff_org_individual_category(self):
+        staff_user = self.create_user(is_staff=True, is_superuser=True)
+        self.login_as(user=staff_user, superuser=True)
+
+        category_group_mapping = {
+            "attachment": {
+                "by": {
+                    "outcome": "rate_limited",
+                    "reason": "spike_protection",
+                },
+                "totals": {"sum(quantity)": 1024},
+                "series": {"sum(quantity)": [0, 0, 1024]},
+            },
+            "error": {
+                "by": {"outcome": "accepted", "reason": "none"},
+                "totals": {"sum(quantity)": 6},
+                "series": {"sum(quantity)": [0, 0, 6]},
+            },
+            "transaction": {
+                "by": {
+                    "reason": "spike_protection",
+                    "outcome": "rate_limited",
+                },
+                "totals": {"sum(quantity)": 1},
+                "series": {"sum(quantity)": [0, 0, 1]},
+            },
+        }
+
+        # Test each category individually
+        for category in ["attachment", "error", "transaction"]:
+            response = self.do_request(
+                {
+                    "category": category,
+                    "statsPeriod": "2d",
+                    "interval": "1d",
+                    "field": ["sum(quantity)"],
+                    "groupBy": ["outcome", "reason"],
+                },
+                org=self.org,
+                status_code=200,
+            )
+
+            assert result_sorted(response.data) == {
+                "start": "2021-03-12T00:00:00Z",
+                "end": "2021-03-15T00:00:00Z",
+                "intervals": [
+                    "2021-03-12T00:00:00Z",
+                    "2021-03-13T00:00:00Z",
+                    "2021-03-14T00:00:00Z",
+                ],
+                "groups": [category_group_mapping[category]],
+            }
+
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_org_multiple_fields(self):
-        make_request = functools.partial(
-            self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "2d",
                 "interval": "1d",
                 "field": ["sum(quantity)", "sum(times_seen)"],
                 "groupBy": ["category", "outcome", "reason"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "start": "2021-03-12T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -530,21 +571,18 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_org_group_by_project(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "field": ["sum(times_seen)"],
                 "groupBy": ["project"],
                 "category": ["error", "transaction"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -562,27 +600,26 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_org_project_totals_per_project(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response_per_group = make_request(
+        response_per_group = self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "1h",
                 "field": ["sum(times_seen)"],
                 "groupBy": ["project"],
                 "category": ["error", "transaction"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
-
-        response_total = make_request(
+        response_total = self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "1h",
                 "field": ["sum(times_seen)"],
                 "category": ["error", "transaction"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
 
         per_group_total = 0
@@ -595,21 +632,18 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_project_filter(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "project": self.project.id,
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "field": ["sum(quantity)"],
                 "category": ["error", "transaction"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -620,22 +654,80 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
         }
 
     @freeze_time("2021-03-14T12:27:28.303Z")
-    def test_reason_filter(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
+    def test_staff_project_filter(self):
+        staff_user = self.create_user(is_staff=True, is_superuser=True)
+        self.login_as(user=staff_user, superuser=True)
+
+        shared_query_params = {
+            "field": "sum(quantity)",
+            "groupBy": ["outcome", "reason"],
+            "interval": "1d",
+            "statsPeriod": "1d",
+        }
+        shared_data = {
+            "start": "2021-03-13T00:00:00Z",
+            "end": "2021-03-15T00:00:00Z",
+            "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
+        }
+
+        # Test error category
+        response = self.do_request(
+            {
+                **shared_query_params,
+                "category": "error",
+                "project": self.project.id,
+            },
+            org=self.org,
+            status_code=200,
         )
-        response = make_request(
+
+        assert result_sorted(response.data) == {
+            **shared_data,
+            "groups": [
+                {
+                    "by": {"outcome": "accepted", "reason": "none"},
+                    "totals": {"sum(quantity)": 6},
+                    "series": {"sum(quantity)": [0, 6]},
+                },
+            ],
+        }
+
+        # Test transaction category
+        response = self.do_request(
+            {
+                **shared_query_params,
+                "category": "transaction",
+                "project": self.project2.id,
+            },
+            org=self.org,
+            status_code=200,
+        )
+
+        assert result_sorted(response.data) == {
+            **shared_data,
+            "groups": [
+                {
+                    "by": {"outcome": "rate_limited", "reason": "spike_protection"},
+                    "totals": {"sum(quantity)": 1},
+                    "series": {"sum(quantity)": [0, 1]},
+                }
+            ],
+        }
+
+    @freeze_time("2021-03-14T12:27:28.303Z")
+    def test_reason_filter(self):
+        response = self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "field": ["sum(times_seen)"],
                 "reason": ["spike_protection"],
                 "groupBy": ["category"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
 
-        assert response.status_code == 200, response.content
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -656,20 +748,18 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_outcome_filter(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "field": ["sum(quantity)"],
                 "outcome": "accepted",
                 "category": ["error", "transaction"],
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
-        assert response.status_code == 200, response.content
+
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -681,19 +771,17 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_category_filter(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "1d",
                 "interval": "1d",
                 "field": ["sum(quantity)"],
                 "category": "error",
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
-        assert response.status_code == 200, response.content
+
         assert result_sorted(response.data) == {
             "start": "2021-03-13T00:00:00Z",
             "end": "2021-03-15T00:00:00Z",
@@ -705,19 +793,17 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_minute_interval_sum_quantity(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "1h",
                 "interval": "15m",
                 "field": ["sum(quantity)"],
                 "category": "error",
-            }
+            },
+            org=self.org,
+            status_code=200,
         )
-        assert response.status_code == 200, response.content
+
         assert result_sorted(response.data) == {
             "start": "2021-03-14T11:15:00Z",
             "end": "2021-03-14T12:30:00Z",
@@ -739,11 +825,7 @@ class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
 
     @freeze_time("2021-03-14T12:27:28.303Z")
     def test_minute_interval_sum_times_seen(self):
-        make_request = functools.partial(
-            self.client.get,
-            reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
-        )
-        response = make_request(
+        response = self.do_request(
             {
                 "statsPeriod": "1h",
                 "interval": "15m",