Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/sentry/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
from typing import TYPE_CHECKING, Any

from django.conf import settings
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated # noqa: S012
from rest_framework.request import Request

from sentry.api.exceptions import (
INSUFFICIENT_SCOPE_ATTR,
InsufficientScope,
MemberDisabledOverLimit,
SsoRequired,
SuperuserRequired,
Expand All @@ -21,6 +23,7 @@
from sentry.auth.system import is_system_auth
from sentry.demo_mode.utils import get_readonly_scopes, is_demo_mode_enabled, is_demo_user
from sentry.hybridcloud.rpc import extract_id_from
from sentry.models.apiscopes import add_scope_hierarchy
from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used
from sentry.organizations.services.organization import (
RpcOrganization,
Expand Down Expand Up @@ -48,6 +51,21 @@ def _least_privileged_scope(allowed_scopes: set[str]) -> str | None:
return min(grantable_scopes) if grantable_scopes else None


def enforce_scope(request: Request, required_scope: str) -> None:
"""Require a scope and distinguish token failures from other denials."""
if request.access.has_scope(required_scope):
return
if required_scope in add_scope_hierarchy(list(request.access.scopes)):
return
if (
agent_token.is_agent_auth(request.auth)
and required_scope not in settings.SENTRY_TOKEN_ONLY_SCOPES
and request.access.would_have_scope_with_added_auth_scope(required_scope)
):
raise InsufficientScope([required_scope])
raise PermissionDenied


class RelayPermission(BasePermission):
def has_permission(self, request: Request, view: object) -> bool:
return getattr(request, "relay", None) is not None
Expand Down
25 changes: 25 additions & 0 deletions src/sentry/auth/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from sentry.auth.system import is_system_auth
from sentry.constants import ObjectStatus
from sentry.data_secrecy.logic import should_allow_superuser_access
from sentry.models.apiscopes import add_scope_hierarchy
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
from sentry.models.organizationmemberteam import OrganizationMemberTeam
Expand Down Expand Up @@ -132,6 +133,11 @@ def has_scope(self, scope: str) -> bool:
check_scope_declaration(scope)
return scope in self.scopes

def would_have_scope_with_added_auth_scope(self, scope: str) -> bool:
"""Whether adding ``scope`` to the current auth scope cap would grant it."""
check_scope_declaration(scope)
return False

def get_organization_role(self) -> OrganizationRole | None:
if self.role is not None:
return organization_roles.get(self.role)
Expand Down Expand Up @@ -236,6 +242,15 @@ class DbAccess(Access):
def role(self) -> str | None:
return self._member.role if self._member else None

def would_have_scope_with_added_auth_scope(self, scope: str) -> bool:
check_scope_declaration(scope)
if self._member is None or self.scopes_upper_bound is None:
return False
candidate_scopes = _intersect_member_and_token_scopes(
self._member.get_scopes(), self.scopes_upper_bound | {scope}
)
return scope in add_scope_hierarchy(list(candidate_scopes))

@cached_property
def _team_memberships(self) -> Mapping[Team, OrganizationMemberTeam]:
if self._member is None:
Expand Down Expand Up @@ -467,6 +482,16 @@ def scopes(self) -> frozenset[str]:
self.scopes_upper_bound,
)

def would_have_scope_with_added_auth_scope(self, scope: str) -> bool:
check_scope_declaration(scope)
member = self.rpc_user_organization_context.member
if member is None or self.scopes_upper_bound is None:
return False
candidate_scopes = _intersect_member_and_token_scopes(
member.scopes, self.scopes_upper_bound | {scope}
)
return scope in add_scope_hierarchy(list(candidate_scopes))

# TODO(cathy): remove this
@property
def role(self) -> str | None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from sentry.workflow_engine.endpoints.validators.utils import (
is_workflow_connected_to_all_projects_detector,
should_include_all_projects_detector_workflows,
should_include_all_projects_detector_workflows_or_raise,
)
from sentry.workflow_engine.models import DetectorWorkflow, Workflow
from sentry.workflow_engine.models.workflow_fire_history import WorkflowFireHistory
Expand Down Expand Up @@ -136,7 +137,7 @@ def convert_args(
workflow = kwargs["workflow"]
organization = kwargs["organization"]
if is_workflow_connected_to_all_projects_detector(workflow):
if not should_include_all_projects_detector_workflows(request, organization):
if not should_include_all_projects_detector_workflows_or_raise(request, organization):
raise PermissionDenied
return args, kwargs

Expand Down Expand Up @@ -261,14 +262,26 @@ def _get_workflows_for_mutation(
queryset = self.filter_workflows(request, organization)
workflows = list(queryset)

if not workflows:
return queryset, workflows

if raw_idlist := request.GET.getlist("id"):
requested_ids = set(to_valid_int_id_list("id", raw_idlist))
if requested_ids != {workflow.id for workflow in workflows}:
missing_workflow_ids = requested_ids - {workflow.id for workflow in workflows}
if missing_workflow_ids:
all_projects_detector = get_all_projects_detector(organization.id)
if (
all_projects_detector
and DetectorWorkflow.objects.filter(
detector_id=all_projects_detector.id,
workflow_id__in=missing_workflow_ids,
).exists()
):
should_include_all_projects_detector_workflows_or_raise(request, organization)
if not workflows:
return queryset, workflows
raise PermissionDenied
Comment thread
sentry[bot] marked this conversation as resolved.

if not workflows:
return queryset, workflows

if not can_edit_workflows(workflows, request):
raise PermissionDenied

Expand Down
20 changes: 16 additions & 4 deletions src/sentry/workflow_engine/endpoints/validators/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from rest_framework.request import Request

from sentry import audit_log, features
from sentry.api.permissions import enforce_scope
Comment thread
gricha marked this conversation as resolved.
from sentry.issues import grouptype
from sentry.models.organization import Organization
from sentry.models.project import Project
Expand Down Expand Up @@ -414,11 +415,22 @@ def should_include_all_projects_detector(request: Request, organization: Organiz
def should_include_all_projects_detector_workflows(
request: Request, organization: Organization
) -> bool:
"""
The flag is always required to show these workflows, but if it isn't a GET request, also check
that the caller has org:write. alerts:write is not sufficient to connect an all projects detector.
"""
return features.has("organizations:workflow-engine-all-projects-detector", organization) and (
request.method == "GET"
or can_edit_all_project_detector_workflow_connections(request=request)
)


def should_include_all_projects_detector_workflows_or_raise(
request: Request, organization: Organization
) -> bool:
"""
The flag is always required to show these workflows, but if it isn't a GET request, also check
that the caller has org:write. alerts:write is not sufficient to connect an all projects detector.
"""
if not features.has("organizations:workflow-engine-all-projects-detector", organization):
return False
if request.method == "GET":
return True
enforce_scope(request, "org:write")
return True
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from unittest import mock

import responses
from django.test import override_settings
from rest_framework.test import APIClient

from sentry import audit_log
from sentry.api.serializers import serialize
Expand All @@ -12,6 +14,7 @@
from sentry.incidents.grouptype import MetricIssue
from sentry.models.auditlogentry import AuditLogEntry
from sentry.models.rule import Rule
from sentry.seer import agent_token
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers import TaskRunner
Expand All @@ -37,6 +40,8 @@
ProjectAccessTestMixin,
)

AGENT_TOKEN_SECRET = "test-seer-api-shared-secret-thirty-two-bytes!"


class OrganizationWorkflowDetailsBaseTest(APITestCase):
endpoint = "sentry-api-0-organization-workflow-details"
Expand Down Expand Up @@ -235,6 +240,7 @@ def test_update_rejects_non_object_actions(self) -> None:
status_code=400,
)

@with_feature("organizations:workflow-engine-all-projects-detector")
def test_all_projects_workflow_requires_org_write(self) -> None:
detector = ensure_default_all_projects_detector(self.organization.id)
self.create_detector_workflow(workflow=self.workflow, detector=detector)
Expand All @@ -255,6 +261,60 @@ def test_all_projects_workflow_requires_org_write(self) -> None:
self.workflow.refresh_from_db()
assert self.workflow.name != "Unauthorized update"

@with_feature("organizations:workflow-engine-all-projects-detector")
@override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET)
def test_all_projects_workflow_agent_token_advertises_org_write(self) -> None:
detector = ensure_default_all_projects_detector(self.organization.id)
self.create_detector_workflow(workflow=self.workflow, detector=detector)
token, _ = agent_token.encode_agent_token(
user_id=self.user.id,
organization_id=self.organization.id,
scopes=["org:read"],
session_id="workflow-update",
)
client = APIClient()

with self.feature(agent_token.FEATURE_FLAG):
response = client.put(
f"/api/0/organizations/{self.organization.slug}/workflows/{self.workflow.id}/",
data={**self.valid_workflow, "name": "Unauthorized update"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)

assert response.status_code == 403, response.content
assert (
response["WWW-Authenticate"] == 'Bearer error="insufficient_scope", scope="org:write"'
)

@with_feature("organizations:workflow-engine-all-projects-detector")
@override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET)
def test_all_projects_workflow_agent_token_does_not_advertise_ungrantable_scope(self) -> None:
detector = ensure_default_all_projects_detector(self.organization.id)
self.create_detector_workflow(workflow=self.workflow, detector=detector)
user = self.create_user()
self.create_member(
user=user, organization=self.organization, role="member", teams=[self.team]
)
token, _ = agent_token.encode_agent_token(
user_id=user.id,
organization_id=self.organization.id,
scopes=["org:read"],
session_id="workflow-update",
)
client = APIClient()

with self.feature(agent_token.FEATURE_FLAG):
response = client.put(
f"/api/0/organizations/{self.organization.slug}/workflows/{self.workflow.id}/",
data={**self.valid_workflow, "name": "Unauthorized update"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)

assert response.status_code == 403, response.content
assert "insufficient_scope" not in response.get("WWW-Authenticate", "")

def test_update_action_filter_with_string_encoded_id(self) -> None:
dcg = DataConditionGroup.objects.create(
organization=self.organization,
Expand Down
Loading
Loading