From c67e77bdfd1c5d35a7a2aa827d9fdca5102265a7 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:23:51 -0700 Subject: [PATCH 1/7] fix(workflows): Advertise missing scope for all-project updates --- src/sentry/api/permissions.py | 14 ++++++++ .../endpoints/organization_workflow_index.py | 6 ++-- .../endpoints/validators/utils.py | 13 +++++--- .../test_organization_workflow_details.py | 32 +++++++++++++++++++ 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index e6078edbc1af..5c93437e7904 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -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, @@ -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, @@ -48,6 +51,17 @@ 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 request.auth and required_scope not in add_scope_hierarchy(request.auth.get_scopes()): + 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 diff --git a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py index d560220e9847..a03cd0d602ee 100644 --- a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py @@ -76,7 +76,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 @@ -136,7 +136,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 @@ -246,7 +246,7 @@ def filter_workflows(self, request: Request, organization: Organization) -> Quer all_projects_detector = get_all_projects_detector(organization.id) if all_projects_detector: all_projects_workflows_q = Q(detectorworkflow__detector_id=all_projects_detector.id) - if should_include_all_projects_detector_workflows(request, organization): + if should_include_all_projects_detector_workflows_or_raise(request, organization): accessible_workflows |= all_projects_workflows_q else: queryset = queryset.exclude(all_projects_workflows_q) diff --git a/src/sentry/workflow_engine/endpoints/validators/utils.py b/src/sentry/workflow_engine/endpoints/validators/utils.py index 8d2ff7b74fd5..7f52ae70bc9a 100644 --- a/src/sentry/workflow_engine/endpoints/validators/utils.py +++ b/src/sentry/workflow_engine/endpoints/validators/utils.py @@ -12,6 +12,7 @@ from rest_framework.request import Request from sentry import audit_log, features +from sentry.api.permissions import enforce_scope from sentry.issues import grouptype from sentry.models.organization import Organization from sentry.models.project import Project @@ -411,14 +412,16 @@ def should_include_all_projects_detector(request: Request, organization: Organiz ) -def should_include_all_projects_detector_workflows( +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. """ - return features.has("organizations:workflow-engine-all-projects-detector", organization) and ( - request.method == "GET" - or can_edit_all_project_detector_workflow_connections(request=request) - ) + 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 diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py index 4339670a6c2c..75807b34ccea 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py @@ -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 @@ -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 @@ -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" @@ -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) @@ -255,6 +261,32 @@ 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"' + ) + def test_update_action_filter_with_string_encoded_id(self) -> None: dcg = DataConditionGroup.objects.create( organization=self.organization, From c2be113ea5af500f22edadb793e82816e5d6f6df Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:40:13 -0700 Subject: [PATCH 2/7] fix(api): Only advertise grantable agent scopes Simulate the member/token scope intersection before returning an insufficient-scope challenge, so reminting with the advertised scope is guaranteed to satisfy the same check. --- src/sentry/api/permissions.py | 6 +++- src/sentry/auth/access.py | 25 +++++++++++++++++ .../test_organization_workflow_details.py | 28 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index 5c93437e7904..9c97359e8446 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -57,7 +57,11 @@ def enforce_scope(request: Request, required_scope: str) -> None: return if required_scope in add_scope_hierarchy(list(request.access.scopes)): return - if request.auth and required_scope not in add_scope_hierarchy(request.auth.get_scopes()): + 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 diff --git a/src/sentry/auth/access.py b/src/sentry/auth/access.py index 7ff06f2f7799..c47ecbf333f6 100644 --- a/src/sentry/auth/access.py +++ b/src/sentry/auth/access.py @@ -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 @@ -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) @@ -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: @@ -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: diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py index 75807b34ccea..09d675ad5ceb 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_details.py @@ -287,6 +287,34 @@ def test_all_projects_workflow_agent_token_advertises_org_write(self) -> None: 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, From c194a8d76516a5da78a23643d5d24a694bd4f519 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:54:56 -0700 Subject: [PATCH 3/7] fix(workflows): Scope all-project bulk checks to matches --- .../endpoints/organization_workflow_index.py | 7 +- .../test_organization_workflow_index.py | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py index a03cd0d602ee..abff73616ffe 100644 --- a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py @@ -246,7 +246,12 @@ def filter_workflows(self, request: Request, organization: Organization) -> Quer all_projects_detector = get_all_projects_detector(organization.id) if all_projects_detector: all_projects_workflows_q = Q(detectorworkflow__detector_id=all_projects_detector.id) - if should_include_all_projects_detector_workflows_or_raise(request, organization): + should_check_all_projects_access = ( + request.method == "GET" or queryset.filter(all_projects_workflows_q).exists() + ) + if should_check_all_projects_access and ( + should_include_all_projects_detector_workflows_or_raise(request, organization) + ): accessible_workflows |= all_projects_workflows_q else: queryset = queryset.exclude(all_projects_workflows_q) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py index d6a6fdc4200e..bce5cfc07506 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py @@ -3,6 +3,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 @@ -11,6 +13,7 @@ from sentry.deletions.tasks.scheduled import run_scheduled_deletions from sentry.grouping.grouptype import ErrorGroupType from sentry.incidents.grouptype import MetricIssue +from sentry.seer import agent_token from sentry.testutils.asserts import assert_org_audit_log_exists from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature @@ -35,6 +38,8 @@ ProjectAccessTestMixin, ) +AGENT_TOKEN_SECRET = "test-seer-api-shared-secret-thirty-two-bytes!" + class OrganizationWorkflowAPITestCase(APITestCase): endpoint = "sentry-api-0-organization-workflow-index" @@ -1658,6 +1663,69 @@ def test_bulk_enable_workflows_by_ids_success(self) -> None: self.workflow_three.refresh_from_db() assert self.workflow_three.enabled is False + @with_feature("organizations:workflow-engine-all-projects-detector") + @override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET) + def test_agent_token_can_update_ordinary_workflow_when_all_projects_workflow_exists( + self, + ) -> None: + all_projects_workflow = self.create_workflow(organization_id=self.organization.id) + self.create_detector_workflow( + workflow=all_projects_workflow, + detector=ensure_default_all_projects_detector(self.organization.id), + ) + token, _ = agent_token.encode_agent_token( + user_id=self.user.id, + organization_id=self.organization.id, + scopes=["alerts:write"], + session_id="workflow-update", + ) + client = APIClient() + + with self.feature(agent_token.FEATURE_FLAG): + response = client.put( + f"/api/0/organizations/{self.organization.slug}/workflows/", + data={"enabled": True}, + format="json", + query_params={"id": str(self.workflow.id)}, + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == 200, response.content + self.workflow.refresh_from_db() + assert self.workflow.enabled is True + + @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: + all_projects_workflow = self.create_workflow( + organization_id=self.organization.id, enabled=False + ) + self.create_detector_workflow( + workflow=all_projects_workflow, + detector=ensure_default_all_projects_detector(self.organization.id), + ) + token, _ = agent_token.encode_agent_token( + user_id=self.user.id, + organization_id=self.organization.id, + scopes=["alerts:write"], + session_id="workflow-update", + ) + client = APIClient() + + with self.feature(agent_token.FEATURE_FLAG): + response = client.put( + f"/api/0/organizations/{self.organization.slug}/workflows/", + data={"enabled": True}, + format="json", + query_params={"id": str(all_projects_workflow.id)}, + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == 403, response.content + assert ( + response["WWW-Authenticate"] == 'Bearer error="insufficient_scope", scope="org:write"' + ) + def test_bulk_enable_all_projects_slug_sentinel_includes_detached_workflows(self) -> None: self.create_detector_workflow( workflow=self.workflow, detector=self.create_detector(project=self.project) From 63a007062a3ffadafc07077e605abe32a5cfc76a Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:08:58 -0700 Subject: [PATCH 4/7] fix(workflows): Preserve project-filtered bulk updates --- .../endpoints/organization_workflow_index.py | 6 +- .../test_organization_workflow_index.py | 85 +++++++++++++++---- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py index abff73616ffe..460bd204d826 100644 --- a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py @@ -246,8 +246,10 @@ def filter_workflows(self, request: Request, organization: Organization) -> Quer all_projects_detector = get_all_projects_detector(organization.id) if all_projects_detector: all_projects_workflows_q = Q(detectorworkflow__detector_id=all_projects_detector.id) - should_check_all_projects_access = ( - request.method == "GET" or queryset.filter(all_projects_workflows_q).exists() + has_explicit_workflow_selector = bool(raw_idlist or raw_detectorlist or raw_query) + should_check_all_projects_access = request.method == "GET" or ( + has_explicit_workflow_selector + and queryset.filter(all_projects_workflows_q).exists() ) if should_check_all_projects_access and ( should_include_all_projects_detector_workflows_or_raise(request, organization) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py index bce5cfc07506..3f6a4e72e2a5 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py @@ -1541,6 +1541,17 @@ def setUp(self) -> None: organization_id=self.organization.id, name="Third Workflow", enabled=False ) + def _create_alerts_write_agent_client(self) -> APIClient: + token, _ = agent_token.encode_agent_token( + user_id=self.user.id, + organization_id=self.organization.id, + scopes=["alerts:write"], + session_id="workflow-update", + ) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + return client + def test_team_admin_can_update_project_scoped_workflow(self) -> None: detector = self.create_detector(project=self.project) self.create_detector_workflow(workflow=self.workflow, detector=detector) @@ -1673,13 +1684,7 @@ def test_agent_token_can_update_ordinary_workflow_when_all_projects_workflow_exi workflow=all_projects_workflow, detector=ensure_default_all_projects_detector(self.organization.id), ) - token, _ = agent_token.encode_agent_token( - user_id=self.user.id, - organization_id=self.organization.id, - scopes=["alerts:write"], - session_id="workflow-update", - ) - client = APIClient() + client = self._create_alerts_write_agent_client() with self.feature(agent_token.FEATURE_FLAG): response = client.put( @@ -1687,7 +1692,6 @@ def test_agent_token_can_update_ordinary_workflow_when_all_projects_workflow_exi data={"enabled": True}, format="json", query_params={"id": str(self.workflow.id)}, - HTTP_AUTHORIZATION=f"Bearer {token}", ) assert response.status_code == 200, response.content @@ -1696,7 +1700,11 @@ def test_agent_token_can_update_ordinary_workflow_when_all_projects_workflow_exi @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: + def test_agent_token_can_update_by_project_when_all_projects_workflow_exists(self) -> None: + self.create_detector_workflow( + workflow=self.workflow, + detector=self.create_detector(project=self.project), + ) all_projects_workflow = self.create_workflow( organization_id=self.organization.id, enabled=False ) @@ -1704,13 +1712,59 @@ def test_all_projects_workflow_agent_token_advertises_org_write(self) -> None: workflow=all_projects_workflow, detector=ensure_default_all_projects_detector(self.organization.id), ) - token, _ = agent_token.encode_agent_token( - user_id=self.user.id, - organization_id=self.organization.id, - scopes=["alerts:write"], - session_id="workflow-update", + client = self._create_alerts_write_agent_client() + + with self.feature(agent_token.FEATURE_FLAG): + response = client.put( + f"/api/0/organizations/{self.organization.slug}/workflows/", + data={"enabled": True}, + format="json", + query_params={"project": str(self.project.id)}, + ) + + assert response.status_code == 200, response.content + self.workflow.refresh_from_db() + all_projects_workflow.refresh_from_db() + assert self.workflow.enabled is True + assert all_projects_workflow.enabled is False + + @with_feature("organizations:workflow-engine-all-projects-detector") + @override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET) + def test_agent_token_can_update_all_accessible_when_all_projects_workflow_exists(self) -> None: + all_projects_workflow = self.create_workflow( + organization_id=self.organization.id, enabled=False ) - client = APIClient() + self.create_detector_workflow( + workflow=all_projects_workflow, + detector=ensure_default_all_projects_detector(self.organization.id), + ) + client = self._create_alerts_write_agent_client() + + with self.feature(agent_token.FEATURE_FLAG): + response = client.put( + f"/api/0/organizations/{self.organization.slug}/workflows/", + data={"enabled": True}, + format="json", + query_params={"projectSlug": "$all"}, + ) + + assert response.status_code == 200, response.content + self.workflow.refresh_from_db() + all_projects_workflow.refresh_from_db() + assert self.workflow.enabled is True + assert all_projects_workflow.enabled is False + + @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: + all_projects_workflow = self.create_workflow( + organization_id=self.organization.id, enabled=False + ) + self.create_detector_workflow( + workflow=all_projects_workflow, + detector=ensure_default_all_projects_detector(self.organization.id), + ) + client = self._create_alerts_write_agent_client() with self.feature(agent_token.FEATURE_FLAG): response = client.put( @@ -1718,7 +1772,6 @@ def test_all_projects_workflow_agent_token_advertises_org_write(self) -> None: data={"enabled": True}, format="json", query_params={"id": str(all_projects_workflow.id)}, - HTTP_AUTHORIZATION=f"Bearer {token}", ) assert response.status_code == 403, response.content From 02be8dbee8eccacda2c1d7cbeedac849186a90c2 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:30:02 -0700 Subject: [PATCH 5/7] fix(workflows): Include authorized all-project bulk matches --- .../endpoints/organization_workflow_index.py | 15 +++++++++++---- .../workflow_engine/endpoints/validators/utils.py | 9 +++++++++ .../endpoints/test_organization_workflow_index.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py index 460bd204d826..8e2f0e5b25c0 100644 --- a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py @@ -76,6 +76,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 @@ -247,13 +248,19 @@ def filter_workflows(self, request: Request, organization: Organization) -> Quer if all_projects_detector: all_projects_workflows_q = Q(detectorworkflow__detector_id=all_projects_detector.id) has_explicit_workflow_selector = bool(raw_idlist or raw_detectorlist or raw_query) - should_check_all_projects_access = request.method == "GET" or ( + should_enforce_all_projects_access = request.method != "GET" and ( has_explicit_workflow_selector and queryset.filter(all_projects_workflows_q).exists() ) - if should_check_all_projects_access and ( - should_include_all_projects_detector_workflows_or_raise(request, organization) - ): + if should_enforce_all_projects_access: + include_all_projects_workflows = ( + should_include_all_projects_detector_workflows_or_raise(request, organization) + ) + else: + include_all_projects_workflows = should_include_all_projects_detector_workflows( + request, organization + ) + if include_all_projects_workflows: accessible_workflows |= all_projects_workflows_q else: queryset = queryset.exclude(all_projects_workflows_q) diff --git a/src/sentry/workflow_engine/endpoints/validators/utils.py b/src/sentry/workflow_engine/endpoints/validators/utils.py index 7f52ae70bc9a..c1cdefe9515f 100644 --- a/src/sentry/workflow_engine/endpoints/validators/utils.py +++ b/src/sentry/workflow_engine/endpoints/validators/utils.py @@ -412,6 +412,15 @@ def should_include_all_projects_detector(request: Request, organization: Organiz ) +def should_include_all_projects_detector_workflows( + request: Request, organization: Organization +) -> bool: + 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: diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py index 3f6a4e72e2a5..daa33e4664bd 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py @@ -1779,10 +1779,18 @@ def test_all_projects_workflow_agent_token_advertises_org_write(self) -> None: response["WWW-Authenticate"] == 'Bearer error="insufficient_scope", scope="org:write"' ) + @with_feature("organizations:workflow-engine-all-projects-detector") def test_bulk_enable_all_projects_slug_sentinel_includes_detached_workflows(self) -> None: self.create_detector_workflow( workflow=self.workflow, detector=self.create_detector(project=self.project) ) + all_projects_workflow = self.create_workflow( + organization_id=self.organization.id, enabled=False + ) + self.create_detector_workflow( + workflow=all_projects_workflow, + detector=ensure_default_all_projects_detector(self.organization.id), + ) response = self.get_success_response( self.organization.slug, @@ -1793,13 +1801,16 @@ def test_bulk_enable_all_projects_slug_sentinel_includes_detached_workflows(self self.workflow.refresh_from_db() self.workflow_two.refresh_from_db() self.workflow_three.refresh_from_db() + all_projects_workflow.refresh_from_db() assert self.workflow.enabled is True assert self.workflow_two.enabled is True assert self.workflow_three.enabled is True + assert all_projects_workflow.enabled is True assert {workflow["id"] for workflow in response.data} == { str(self.workflow.id), str(self.workflow_two.id), str(self.workflow_three.id), + str(all_projects_workflow.id), } def test_bulk_disable_workflows_by_ids_success(self) -> None: From e744074f4ecbd95041152ed8b398a41019ab10aa Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:39:41 -0700 Subject: [PATCH 6/7] ref(workflows): Check all-project scope in mutation path --- .../endpoints/organization_workflow_index.py | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py index 8e2f0e5b25c0..c7fa4decdcbf 100644 --- a/src/sentry/workflow_engine/endpoints/organization_workflow_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_workflow_index.py @@ -247,20 +247,7 @@ def filter_workflows(self, request: Request, organization: Organization) -> Quer all_projects_detector = get_all_projects_detector(organization.id) if all_projects_detector: all_projects_workflows_q = Q(detectorworkflow__detector_id=all_projects_detector.id) - has_explicit_workflow_selector = bool(raw_idlist or raw_detectorlist or raw_query) - should_enforce_all_projects_access = request.method != "GET" and ( - has_explicit_workflow_selector - and queryset.filter(all_projects_workflows_q).exists() - ) - if should_enforce_all_projects_access: - include_all_projects_workflows = ( - should_include_all_projects_detector_workflows_or_raise(request, organization) - ) - else: - include_all_projects_workflows = should_include_all_projects_detector_workflows( - request, organization - ) - if include_all_projects_workflows: + if should_include_all_projects_detector_workflows(request, organization): accessible_workflows |= all_projects_workflows_q else: queryset = queryset.exclude(all_projects_workflows_q) @@ -275,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 + if not workflows: + return queryset, workflows + if not can_edit_workflows(workflows, request): raise PermissionDenied From 717b74f2d19b5fb8c030b9c7e70daad48d4e5b09 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:02:58 -0700 Subject: [PATCH 7/7] test(workflows): Cover mixed all-project bulk updates --- .../test_organization_workflow_index.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py index daa33e4664bd..4a1ede826dca 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_workflow_index.py @@ -1674,6 +1674,34 @@ def test_bulk_enable_workflows_by_ids_success(self) -> None: self.workflow_three.refresh_from_db() assert self.workflow_three.enabled is False + @with_feature("organizations:workflow-engine-all-projects-detector") + def test_bulk_enable_workflows_by_ids_including_all_projects(self) -> None: + all_projects_workflow = self.create_workflow( + organization_id=self.organization.id, enabled=False + ) + self.create_detector_workflow( + workflow=all_projects_workflow, + detector=ensure_default_all_projects_detector(self.organization.id), + ) + + response = self.get_success_response( + self.organization.slug, + qs_params=[ + ("id", str(self.workflow.id)), + ("id", str(all_projects_workflow.id)), + ], + raw_data={"enabled": True}, + ) + + self.workflow.refresh_from_db() + all_projects_workflow.refresh_from_db() + assert self.workflow.enabled is True + assert all_projects_workflow.enabled is True + assert {workflow["id"] for workflow in response.data} == { + str(self.workflow.id), + str(all_projects_workflow.id), + } + @with_feature("organizations:workflow-engine-all-projects-detector") @override_settings(SEER_API_SHARED_SECRET=AGENT_TOKEN_SECRET) def test_agent_token_can_update_ordinary_workflow_when_all_projects_workflow_exists(