Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(demo-mode): read-only user #79665

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/accept_project_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from django.http import Http404
from django.utils.encoding import force_str
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

Expand All @@ -11,6 +10,7 @@
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.decorators import sudo_required
from sentry.api.endpoints.project_transfer import SALT
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.api.serializers.models.organization import (
DetailedOrganizationSerializerWithProjectsAndTeams,
Expand All @@ -33,7 +33,7 @@ class AcceptProjectTransferEndpoint(Endpoint):
"POST": ApiPublishStatus.PRIVATE,
}
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't usually use SentryPermission directly, it's usually a base class we inherit from and apply scopes to.

It seems like if you use this directly, then no users will have permissions on this api:

allowed_scopes: set[str] = set(self.scope_map.get(request.method, []))
current_scopes = request.auth.get_scopes()
return any(s in allowed_scopes for s in current_scopes)

Since this just defined empty scopes, this any can't return True. Not sure if there's some other piece I'm missing that circumvents this.


def get_validated_data(self, data, user):
try:
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/api_application_details.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from django.db import router, transaction
from rest_framework import serializers
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListField

from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.deletions.models.scheduleddeletion import ScheduledDeletion
from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus
Expand Down Expand Up @@ -59,7 +59,7 @@ class ApiApplicationDetailsEndpoint(ApiApplicationEndpoint):
"PUT": ApiPublishStatus.PRIVATE,
}
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def get(self, request: Request, application: ApiApplication) -> Response:
return Response(serialize(application, request.user))
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/api_application_rotate_secret.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
from sentry.api.endpoints.api_application_details import ApiApplicationEndpoint
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.models.apiapplication import ApiApplication, generate_token

Expand All @@ -18,7 +18,7 @@ class ApiApplicationRotateSecretEndpoint(ApiApplicationEndpoint):
}
owner = ApiOwner.ENTERPRISE
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def post(self, request: Request, application: ApiApplication) -> Response:
new_token = generate_token()
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/api_applications.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.paginator import OffsetPaginator
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus

Expand All @@ -17,7 +17,7 @@ class ApiApplicationsEndpoint(Endpoint):
"POST": ApiPublishStatus.PRIVATE,
}
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def get(self, request: Request) -> Response:
queryset = ApiApplication.objects.filter(
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/api_authorizations.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from django.db import router, transaction
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.paginator import OffsetPaginator
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.hybridcloud.models.outbox import outbox_context
from sentry.models.apiapplication import ApiApplicationStatus
Expand All @@ -23,7 +23,7 @@ class ApiAuthorizationsEndpoint(Endpoint):
}
owner = ApiOwner.ENTERPRISE
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def get(self, request: Request) -> Response:
queryset = ApiAuthorization.objects.filter(
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/api_token_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from django.views.decorators.cache import never_cache
from rest_framework import serializers
from rest_framework.fields import CharField
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

Expand All @@ -12,6 +11,7 @@
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.endpoints.api_tokens import get_appropriate_user_id
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.models.apitoken import ApiToken

Expand All @@ -30,7 +30,7 @@ class ApiTokenDetailsEndpoint(Endpoint):
"DELETE": ApiPublishStatus.PRIVATE,
}
owner = ApiOwner.SECURITY
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

@method_decorator(never_cache)
def get(self, request: Request, token_id: int) -> Response:
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/api_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from django.views.decorators.cache import never_cache
from rest_framework import serializers
from rest_framework.fields import CharField
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

Expand All @@ -15,6 +14,7 @@
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.fields import MultipleChoiceField
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.auth.elevated_mode import has_elevated_mode
from sentry.hybridcloud.models.outbox import outbox_context
Expand Down Expand Up @@ -62,7 +62,7 @@ class ApiTokensEndpoint(Endpoint):
"POST": ApiPublishStatus.PRIVATE,
}
authentication_classes = (SessionNoAuthTokenAuthentication,)
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

@method_decorator(never_cache)
def get(self, request: Request) -> Response:
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from django.http import HttpResponse
from django.utils import timezone
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.permissions import SentryPermission
from sentry.assistant import manager
from sentry.models.assistant import AssistantActivity

Expand Down Expand Up @@ -62,7 +62,7 @@ class AssistantEndpoint(Endpoint):
"GET": ApiPublishStatus.PRIVATE,
"PUT": ApiPublishStatus.PRIVATE,
}
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def get(self, request: Request) -> Response:
"""Return all the guides with a 'seen' attribute if it has been 'viewed' or 'dismissed'."""
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/broadcast_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from django.db import IntegrityError, router, transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework.permissions import IsAuthenticated

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import AdminBroadcastSerializer, BroadcastSerializer, serialize
from sentry.api.validators import AdminBroadcastValidator, BroadcastValidator
from sentry.models.broadcast import Broadcast, BroadcastSeen
Expand All @@ -27,7 +27,7 @@ class BroadcastDetailsEndpoint(Endpoint):
"GET": ApiPublishStatus.PRIVATE,
"PUT": ApiPublishStatus.PRIVATE,
}
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def _get_broadcast(self, request: Request, broadcast_id):
if request.access.has_permission("broadcasts.admin"):
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/prompts_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from django.http import HttpResponse
from django.utils import timezone
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.permissions import SentryPermission
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.models.promptsactivity import PromptsActivity
Expand Down Expand Up @@ -41,7 +41,7 @@ class PromptsActivityEndpoint(Endpoint):
"GET": ApiPublishStatus.UNKNOWN,
"PUT": ApiPublishStatus.UNKNOWN,
}
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def get(self, request: Request, **kwargs) -> Response:
"""Return feature prompt status if dismissed or in snoozed period"""
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/relocations/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from django.db.models import Q
from django.utils import timezone
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from sentry_sdk import capture_exception
Expand All @@ -20,6 +19,7 @@
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.endpoints.relocations import ERR_FEATURE_DISABLED
from sentry.api.paginator import OffsetPaginator
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.api.serializers.models.relocation import RelocationSerializer
from sentry.auth.elevated_mode import has_elevated_mode
Expand Down Expand Up @@ -162,7 +162,7 @@ class RelocationIndexEndpoint(Endpoint):
"GET": ApiPublishStatus.EXPERIMENTAL,
"POST": ApiPublishStatus.EXPERIMENTAL,
}
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def get(self, request: Request) -> Response:
"""
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/relocations/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from django.db import router
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from sentry_sdk import capture_exception
Expand All @@ -18,6 +17,7 @@
validate_relocation_uniqueness,
)
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.permissions import SentryPermission
from sentry.api.serializers import serialize
from sentry.models.files.file import File
from sentry.models.relocation import Relocation, RelocationFile
Expand All @@ -44,7 +44,7 @@ class RelocationRetryEndpoint(Endpoint):
# TODO(getsentry/team-ospo#214): Stabilize before GA.
"POST": ApiPublishStatus.EXPERIMENTAL,
}
permission_classes = (IsAuthenticated,)
permission_classes = (SentryPermission,)

def post(self, request: Request, relocation_uuid: str) -> Response:
"""
Expand Down
18 changes: 17 additions & 1 deletion src/sentry/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
RpcUserOrganizationContext,
organization_service,
)
from sentry.utils import auth
from sentry.utils import auth, demo_mode

if TYPE_CHECKING:
from sentry.models.organization import Organization
Expand Down Expand Up @@ -213,6 +213,10 @@ def determine_access(

# TODO(iamrajjoshi): Remove this check once we have fully migrated to the new data secrecy logic
organization = org_context.organization

if demo_mode.is_readonly_user(request.user):
org_context.member.scopes = demo_mode.get_readonly_scopes()

if (
request.user
and request.user.is_superuser
Expand Down Expand Up @@ -284,3 +288,15 @@ def determine_access(
extra=extra,
)
raise MemberDisabledOverLimit(organization)

def has_permission(self, request: Request, view: object) -> bool:
if demo_mode.is_readonly_user(request.user) and request.method not in ("GET", "HEAD"):
return False

return super().has_permission(request, view)

def has_object_permission(self, request: Request, view: object | None, obj: Any) -> bool:
if demo_mode.is_readonly_user(request.user) and request.method not in ("GET", "HEAD"):
return False

return super().has_object_permission(request, view, obj)
58 changes: 58 additions & 0 deletions tests/sentry/api/test_permissions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from sentry.api.permissions import (
SentryPermission,
StaffPermission,
SuperuserOrStaffFeatureFlaggedPermission,
SuperuserPermission,
Expand Down Expand Up @@ -34,3 +35,60 @@ def test_superuser_or_staff_feature_flagged_permission_inactive_option(self):

# With active superuser
assert self.superuser_staff_flagged_permission.has_permission(self.superuser_request, None)


class ReadonlyPermissionsTest(DRFPermissionTestCase):
user_permission = SentryPermission()

def setUp(self):
super().setUp()
self.normal_user = self.create_user()
self.readonly_user = self.create_user("[email protected]")

@override_options({"demo-mode.enabled": True, "demo-mode.users": ["[email protected]"]})
def test_readonly_user_has_permission(self):
assert self.user_permission.has_permission(self.make_request(self.readonly_user), None)

def test_readonly_user_has_object_permission(self):
assert not self.user_permission.has_object_permission(
self.make_request(self.readonly_user), None, None
)

@override_options({"demo-mode.enabled": True, "demo-mode.users": ["[email protected]"]})
def test_get_method(self):
assert self.user_permission.has_permission(
self.make_request(self.readonly_user, method="GET"), None
)
assert self.user_permission.has_permission(
self.make_request(self.normal_user, method="GET"), None
)

@override_options({"demo-mode.enabled": True, "demo-mode.users": ["[email protected]"]})
def test_post_method(self):
assert not self.user_permission.has_permission(
self.make_request(self.readonly_user, method="POST"), None
)

assert self.user_permission.has_permission(
self.make_request(self.normal_user, method="GET"), None
)

@override_options({"demo-mode.enabled": True, "demo-mode.users": ["[email protected]"]})
def test_put_method(self):
assert not self.user_permission.has_permission(
self.make_request(self.readonly_user, method="PUT"), None
)

assert self.user_permission.has_permission(
self.make_request(self.normal_user, method="GET"), None
)

@override_options({"demo-mode.enabled": True, "demo-mode.users": ["[email protected]"]})
def test_delete_method(self):
assert not self.user_permission.has_permission(
self.make_request(self.readonly_user, method="DELETE"), None
)

assert self.user_permission.has_permission(
self.make_request(self.normal_user, method="GET"), None
)
Loading