11from __future__ import annotations
22
33import re
4+ from collections .abc import Callable
45from contextlib import ExitStack , nullcontext
56from dataclasses import dataclass
67from datetime import timedelta
3839from sentry .organizations .services .organization import organization_service
3940from sentry .replays .lib .storage import FilestoreBlob , RecordingSegmentStorageMeta
4041from sentry .replays .testutils import mock_replay , mock_replay_viewed
42+ from sentry .roles import organization_roles
4143from sentry .seer import agent_token
4244from sentry .seer .models .agent_write_grant import SeerAgentWriteGrant
4345from sentry .seer .signed_seer_api import _resolve_viewer_context
46+ from sentry .service_accounts .models import GRANTABLE_SCOPES
4447from sentry .silo .base import SiloMode
4548from sentry .snuba .dataset import Dataset
4649from sentry .snuba .models import SnubaQuery , SnubaQueryEventType
@@ -329,6 +332,7 @@ class MatrixAuthentication(StrEnum):
329332 AGENT_TOKEN = "agent_token"
330333 SCOPED_DOWN_AGENT_TOKEN = "scoped_down_agent_token"
331334 APPROVED_AGENT_TOKEN = "approved_agent_token"
335+ SERVICE_ACCOUNT_VIEWER_CONTEXT = "service_account_viewer_context"
332336
333337
334338@dataclass (frozen = True )
@@ -586,6 +590,60 @@ def _public_mutation_endpoints() -> tuple[PublicMutationEndpoint, ...]:
586590AGENT_SUDO_BOUNDARY_MUTATIONS = frozenset ({("TeamDetailsEndpoint" , "DELETE" )})
587591
588592
593+ # Oracle: an owner token with the account's scopes. The account is linked to the project
594+ # in an open-membership org, so it should reach everything the owner does. User-global
595+ # routes must deny it, since it has no user.
596+ SERVICE_ACCOUNT_SCOPES = frozenset (organization_roles .get ("owner" ).scopes & GRANTABLE_SCOPES )
597+
598+
599+ class ServiceAccountGap (StrEnum ):
600+ CONTROL_SILO = "service account auth is cell-only"
601+ REQUIRES_USER = "rejects, or stores state for, an anonymous request.user"
602+ USER_ACTOR_ERROR = "500s using an anonymous request.user as an actor"
603+ NO_ORG_ROLE = "checks the caller's org role"
604+
605+
606+ # Rows that don't match the oracle yet. Strict, so a fixed row fails until removed here.
607+ SERVICE_ACCOUNT_KNOWN_GAPS : dict [str , ServiceAccountGap ] = {
608+ "OrganizationIntegrationChannelValidate" : ServiceAccountGap .CONTROL_SILO ,
609+ "OrganizationIntegrationChannels" : ServiceAccountGap .CONTROL_SILO ,
610+ "OrganizationIntegrationDetails" : ServiceAccountGap .CONTROL_SILO ,
611+ "OrganizationIntegrationDetails_delete" : ServiceAccountGap .CONTROL_SILO ,
612+ "OrganizationIntegrations" : ServiceAccountGap .CONTROL_SILO ,
613+ "OrganizationSentryAppComponents" : ServiceAccountGap .CONTROL_SILO ,
614+ "OrganizationSentryApps" : ServiceAccountGap .CONTROL_SILO ,
615+ "SentryAppDetails" : ServiceAccountGap .CONTROL_SILO ,
616+ "SentryAppDetails_delete" : ServiceAccountGap .CONTROL_SILO ,
617+ "SentryAppDetails_put" : ServiceAccountGap .CONTROL_SILO ,
618+ "SentryAppInstallationExternalIssueActions_post" : ServiceAccountGap .CONTROL_SILO ,
619+ "SentryAppInstallationExternalRequests" : ServiceAccountGap .CONTROL_SILO ,
620+ "GroupIntegrationDetails" : ServiceAccountGap .REQUIRES_USER ,
621+ "GroupIntegrationDetails_post" : ServiceAccountGap .REQUIRES_USER ,
622+ "GroupIntegrationDetails_put" : ServiceAccountGap .REQUIRES_USER ,
623+ "OrganizationDashboards" : ServiceAccountGap .REQUIRES_USER ,
624+ "OrganizationDashboards_post" : ServiceAccountGap .REQUIRES_USER ,
625+ "OrganizationDetectorIndex" : ServiceAccountGap .REQUIRES_USER ,
626+ "OrganizationDetectorIndex_delete" : ServiceAccountGap .REQUIRES_USER ,
627+ "OrganizationDetectorIndex_put" : ServiceAccountGap .REQUIRES_USER ,
628+ "OrganizationGroupSearchViews" : ServiceAccountGap .REQUIRES_USER ,
629+ "OrganizationGroupSearchViews_post" : ServiceAccountGap .REQUIRES_USER ,
630+ "OrganizationMemberDetails" : ServiceAccountGap .REQUIRES_USER ,
631+ "OrganizationMemberDetails_delete" : ServiceAccountGap .REQUIRES_USER ,
632+ "OrganizationMemberDetails_put" : ServiceAccountGap .REQUIRES_USER ,
633+ "OrganizationMemberTeamDetails_delete" : ServiceAccountGap .REQUIRES_USER ,
634+ "OrganizationMemberTeamDetails_post" : ServiceAccountGap .REQUIRES_USER ,
635+ "OrganizationMemberTeamDetails_put" : ServiceAccountGap .REQUIRES_USER ,
636+ "OrganizationProjects_post" : ServiceAccountGap .REQUIRES_USER ,
637+ "OrganizationStarredServiceSpans_delete" : ServiceAccountGap .REQUIRES_USER ,
638+ "ProjectDetails_put" : ServiceAccountGap .REQUIRES_USER ,
639+ "OrganizationStarredServiceSpans_post" : ServiceAccountGap .USER_ACTOR_ERROR ,
640+ "ProjectDetails_delete" : ServiceAccountGap .USER_ACTOR_ERROR ,
641+ "TeamDetails_delete" : ServiceAccountGap .USER_ACTOR_ERROR ,
642+ "EventAttachmentDetails" : ServiceAccountGap .NO_ORG_ROLE ,
643+ "OrganizationMemberIndex_post" : ServiceAccountGap .NO_ORG_ROLE ,
644+ }
645+
646+
589647def _matrix_cases () -> tuple [tuple [PublicGetEndpoint , MatrixAuthentication ], ...]:
590648 cases : list [tuple [PublicGetEndpoint , MatrixAuthentication ]] = []
591649 for endpoint in PUBLIC_GET_ENDPOINTS :
@@ -595,6 +653,7 @@ def _matrix_cases() -> tuple[tuple[PublicGetEndpoint, MatrixAuthentication], ...
595653 MatrixAuthentication .VIEWER_CONTEXT ,
596654 MatrixAuthentication .AGENT_TOKEN ,
597655 MatrixAuthentication .SCOPED_DOWN_AGENT_TOKEN ,
656+ MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT ,
598657 ):
599658 cases .append ((endpoint , authentication ))
600659 return tuple (cases )
@@ -620,6 +679,7 @@ def _private_helper_get_matrix_cases() -> tuple[
620679 MatrixAuthentication .VIEWER_CONTEXT ,
621680 MatrixAuthentication .AGENT_TOKEN ,
622681 MatrixAuthentication .SCOPED_DOWN_AGENT_TOKEN ,
682+ MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT ,
623683 )
624684 )
625685
@@ -2147,6 +2207,51 @@ def _mutation_payload(self, endpoint: PublicMutationEndpoint) -> dict[str, Any]:
21472207 }
21482208 return payloads .get (key , {})
21492209
2210+ def _create_matrix_service_account (self ) -> None :
2211+ # Before any control-silo override, so the cell replica is written.
2212+ service_account = self .create_service_account (
2213+ organization = self .org ,
2214+ name = "permission-matrix" ,
2215+ scope_list = sorted (SERVICE_ACCOUNT_SCOPES ),
2216+ project_ids = [self .project .id ],
2217+ )
2218+ self ._service_account_context = encode_viewer_context (
2219+ ViewerContext (
2220+ actor = Actor (type = ActorType .SERVICE_ACCOUNT , actor_id = service_account .id ),
2221+ organization_id = self .org .id ,
2222+ project_id = self .project .id ,
2223+ ),
2224+ key = SECRET ,
2225+ )
2226+
2227+ def _assert_service_account_outcome (
2228+ self ,
2229+ endpoint : PublicGetEndpoint | PublicMutationEndpoint ,
2230+ authorization_oracle : Response ,
2231+ response : Response ,
2232+ ) -> None :
2233+ matches_oracle = response .status_code == authorization_oracle .status_code and (
2234+ authorization_oracle .status_code < 400
2235+ or response .content == authorization_oracle .content
2236+ )
2237+ assert matches_oracle , (
2238+ endpoint .path_template ,
2239+ authorization_oracle .content ,
2240+ response .content ,
2241+ )
2242+
2243+ def _assert_service_account_user_global_get_denied (self , endpoint : PublicGetEndpoint ) -> None :
2244+ self ._create_matrix_service_account ()
2245+ assert endpoint .silo_mode is not None
2246+ with assume_test_silo_mode (endpoint .silo_mode ):
2247+ session_response = self .client .get (endpoint .path_template )
2248+ response = self ._request (
2249+ endpoint .path_template , MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT
2250+ )
2251+
2252+ assert session_response .status_code == 200 , session_response .content
2253+ assert response .status_code in {401 , 403 }, response .content
2254+
21502255 def _user_token (self , scopes : frozenset [str ] | None = None ) -> str :
21512256 with assume_test_silo_mode (SiloMode .CONTROL ):
21522257 token = self .create_user_auth_token (
@@ -2182,6 +2287,8 @@ def _request(
21822287 key = SECRET ,
21832288 )
21842289 return client .get (path , HTTP_X_VIEWER_CONTEXT = viewer_context )
2290+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2291+ return client .get (path , HTTP_X_VIEWER_CONTEXT = self ._service_account_context )
21852292 if authentication in {
21862293 MatrixAuthentication .AGENT_TOKEN ,
21872294 MatrixAuthentication .SCOPED_DOWN_AGENT_TOKEN ,
@@ -2213,6 +2320,8 @@ def _mutation_request(
22132320 ),
22142321 key = SECRET ,
22152322 )
2323+ elif authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2324+ headers ["HTTP_X_VIEWER_CONTEXT" ] = self ._service_account_context
22162325 elif authentication in {
22172326 MatrixAuthentication .AGENT_TOKEN ,
22182327 MatrixAuthentication .SCOPED_DOWN_AGENT_TOKEN ,
@@ -2300,6 +2409,8 @@ def _assert_public_get_authentication(
23002409 with self .feature (FLAG ):
23012410 agent_bearer = self ._mint_agent_token (requested_scopes = requested_scopes )
23022411 agent_scopes = frozenset (agent_token .decode_agent_token (agent_bearer )["scopes" ])
2412+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2413+ self ._create_matrix_service_account ()
23032414 if endpoint .endpoint_name == "SeerModelsEndpoint" :
23042415 cache .set (SEER_MODELS_CACHE_KEY , {"models" : ["matrix-model" ]})
23052416 self .addCleanup (cache .delete , SEER_MODELS_CACHE_KEY )
@@ -2369,6 +2480,12 @@ def _assert_public_get_authentication(
23692480 MatrixAuthentication .USER_TOKEN ,
23702481 user_token_scopes = agent_scopes ,
23712482 )
2483+ elif authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2484+ authorization_oracle = self ._request (
2485+ path ,
2486+ MatrixAuthentication .USER_TOKEN ,
2487+ user_token_scopes = SERVICE_ACCOUNT_SCOPES ,
2488+ )
23722489 response = (
23732490 baseline
23742491 if authentication is MatrixAuthentication .SESSION
@@ -2378,6 +2495,9 @@ def _assert_public_get_authentication(
23782495 assert baseline .status_code < 500 , baseline .content
23792496 expected_baseline_status = OUTER_BOUNDARY_GET_STATUSES .get (endpoint .endpoint_name , 200 )
23802497 assert baseline .status_code == expected_baseline_status , baseline .content
2498+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2499+ self ._assert_service_account_outcome (endpoint , authorization_oracle , response )
2500+ return
23812501
23822502 # Successful representations can legitimately differ by credential (for
23832503 # example, an ``access`` field must reflect narrower token scopes), and
@@ -2439,6 +2559,8 @@ def _assert_public_mutation_authentication(
24392559 requested_scopes = requested_scopes ,
24402560 )
24412561 agent_scopes = frozenset (agent_token .decode_agent_token (agent_bearer )["scopes" ])
2562+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2563+ self ._create_matrix_service_account ()
24422564
24432565 silo_scope = (
24442566 assume_test_silo_mode (endpoint .silo_mode )
@@ -2503,6 +2625,14 @@ def _assert_public_mutation_authentication(
25032625 MatrixAuthentication .USER_TOKEN ,
25042626 user_token_scopes = agent_scopes ,
25052627 )
2628+ elif authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2629+ authorization_oracle = self ._rolled_back_mutation_request (
2630+ endpoint ,
2631+ path ,
2632+ payload ,
2633+ MatrixAuthentication .USER_TOKEN ,
2634+ user_token_scopes = SERVICE_ACCOUNT_SCOPES ,
2635+ )
25062636
25072637 response = (
25082638 baseline
@@ -2528,6 +2658,9 @@ def _assert_public_mutation_authentication(
25282658 )
25292659 else :
25302660 assert baseline .status_code == outer_status , baseline .content
2661+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2662+ self ._assert_service_account_outcome (endpoint , authorization_oracle , response )
2663+ return
25312664
25322665 if (
25332666 authentication is MatrixAuthentication .APPROVED_AGENT_TOKEN
@@ -2562,6 +2695,16 @@ def _assert_public_mutation_authentication(
25622695 )
25632696
25642697
2698+ def _mark_service_account_gap [F : Callable [..., None ]](test : F , test_id : str ) -> F :
2699+ # Until service account auth lands (#128779), service account requests are anonymous.
2700+ if not hasattr (access , "ServiceAccountAccess" ):
2701+ return pytest .mark .xfail (reason = "service account auth isn't supported yet" )(test )
2702+ gap = SERVICE_ACCOUNT_KNOWN_GAPS .get (test_id )
2703+ if gap is None :
2704+ return test
2705+ return pytest .mark .xfail (strict = True , reason = gap .value )(test )
2706+
2707+
25652708def _install_public_get_matrix_tests () -> None :
25662709 """Generate real unittest methods because pytest cannot parametrize APITestCase."""
25672710
@@ -2577,6 +2720,8 @@ def test_matrix_cell(
25772720 test_matrix_cell .__name__ = (
25782721 f"test_public_get_{ index :03d} _{ endpoint .test_id } _{ authentication .value } "
25792722 )
2723+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2724+ test_matrix_cell = _mark_service_account_gap (test_matrix_cell , endpoint .test_id )
25802725 setattr (AgentTokenPublicGetMatrixTest , test_matrix_cell .__name__ , test_matrix_cell )
25812726
25822727
@@ -2596,6 +2741,8 @@ def test_matrix_cell(
25962741 test_matrix_cell .__name__ = (
25972742 f"test_public_mutation_{ index :03d} _{ endpoint .test_id } _{ authentication .value } "
25982743 )
2744+ if authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2745+ test_matrix_cell = _mark_service_account_gap (test_matrix_cell , endpoint .test_id )
25992746 setattr (AgentTokenPublicGetMatrixTest , test_matrix_cell .__name__ , test_matrix_cell )
26002747
26012748
@@ -2619,6 +2766,10 @@ def test_get_matrix_cell(
26192766 test_get_matrix_cell .__name__ = (
26202767 f"test_private_helper_get_{ index :03d} _{ get_endpoint .test_id } _{ get_authentication .value } "
26212768 )
2769+ if get_authentication is MatrixAuthentication .SERVICE_ACCOUNT_VIEWER_CONTEXT :
2770+ test_get_matrix_cell = _mark_service_account_gap (
2771+ test_get_matrix_cell , get_endpoint .test_id
2772+ )
26222773 setattr (AgentTokenPublicGetMatrixTest , test_get_matrix_cell .__name__ , test_get_matrix_cell )
26232774
26242775
@@ -2663,3 +2814,21 @@ def test_matrix_cell(
26632814
26642815
26652816_install_user_global_boundary_matrix_tests ()
2817+
2818+
2819+ def _install_service_account_user_global_matrix_tests () -> None :
2820+ for index , endpoint in enumerate (USER_GLOBAL_GET_ENDPOINTS ):
2821+
2822+ def test_matrix_cell (
2823+ self : AgentTokenPublicGetMatrixTest ,
2824+ endpoint : PublicGetEndpoint = endpoint ,
2825+ ) -> None :
2826+ self ._assert_service_account_user_global_get_denied (endpoint )
2827+
2828+ test_matrix_cell .__name__ = (
2829+ f"test_user_global_get_{ index :03d} _{ endpoint .test_id } _service_account_viewer_context"
2830+ )
2831+ setattr (AgentTokenPublicGetMatrixTest , test_matrix_cell .__name__ , test_matrix_cell )
2832+
2833+
2834+ _install_service_account_user_global_matrix_tests ()
0 commit comments