Skip to content

Commit 468fd01

Browse files
authored
test(seer): Add service account auth matrix (#128814)
Adds service account ViewerContext rows to the agent-token auth matrix, so we can iterate on service account authorization endpoint by endpoint. Oracle: an owner token holding the account's scopes. The account is linked to the project in an open-membership org, so it should reach everything the owner does. User-global routes must deny it. Until service account auth lands (#128779), every row is a non-strict `xfail`. After that, rows that still don't match are listed in `SERVICE_ACCOUNT_KNOWN_GAPS` as strict `xfail`s, with the cause as the reason. A fixed row XPASSes and fails until its entry is removed. Against #128779, 36 of 261 rows are gaps: | Cause | Rows | |---|---| | Endpoint requires or stores state for `request.user` | 19 | | Control silo (SA auth is cell-only) | 12 | | 500 using an anonymous `request.user` as an actor | 3 | | Checks the caller's org role | 2 |
1 parent bd12d1b commit 468fd01

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

‎tests/sentry/seer/endpoints/test_organization_agent_token.py‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import re
4+
from collections.abc import Callable
45
from contextlib import ExitStack, nullcontext
56
from dataclasses import dataclass
67
from datetime import timedelta
@@ -38,9 +39,11 @@
3839
from sentry.organizations.services.organization import organization_service
3940
from sentry.replays.lib.storage import FilestoreBlob, RecordingSegmentStorageMeta
4041
from sentry.replays.testutils import mock_replay, mock_replay_viewed
42+
from sentry.roles import organization_roles
4143
from sentry.seer import agent_token
4244
from sentry.seer.models.agent_write_grant import SeerAgentWriteGrant
4345
from sentry.seer.signed_seer_api import _resolve_viewer_context
46+
from sentry.service_accounts.models import GRANTABLE_SCOPES
4447
from sentry.silo.base import SiloMode
4548
from sentry.snuba.dataset import Dataset
4649
from 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, ...]:
586590
AGENT_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+
589647
def _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+
25652708
def _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

Comments
 (0)