From 3d5712e2908a311c74f201599fdcdcb50f7e8c8e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:38:13 +0000 Subject: [PATCH 1/3] feat(service-accounts): Authenticate requests with service account tokens Add ServiceAccountTokenAuthentication for sntrym_ bearer tokens and register it next to OrgAuthTokenAuthentication in the API and the auth middleware. It looks up the active token by hash (the cell replica in the cell, the token in control), rejects revoked and expired tokens, and resolves the account with resolve_service_account, so it applies the same active, org and feature checks as viewer-context auth. The credential is the same service_account AuthenticatedToken, with entity_id set to the account, so access, rate limits and attribution work unchanged. Its scopes are the token's scopes intersected with the account's current scopes. A token used on another organization's URL fails with a 401. --- src/sentry/api/authentication.py | 81 +++++++++++++- src/sentry/api/base.py | 2 + src/sentry/middleware/auth.py | 2 + tests/sentry/api/test_authentication.py | 118 +++++++++++++++++++++ tests/sentry/service_accounts/test_auth.py | 62 ++++++++++- 5 files changed, 262 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 6cda2808bc10..e1b1a159b258 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -12,7 +12,8 @@ from django.contrib.auth.models import AnonymousUser from django.core.cache import cache from django.http import HttpHeaders, HttpRequest -from django.urls import resolve +from django.urls import Resolver404, resolve +from django.utils import timezone from django.utils.crypto import constant_time_compare from django.utils.encoding import force_str from jwt import MissingRequiredClaimError, PyJWTError @@ -48,7 +49,8 @@ from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.token_exchange.util import GrantTypes -from sentry.service_accounts.auth import authenticate_service_account +from sentry.service_accounts.auth import authenticate_service_account, resolve_service_account +from sentry.service_accounts.models import ServiceAccountToken, ServiceAccountTokenReplica from sentry.service_accounts.tokens import SERVICE_ACCOUNT_TOKEN_PREFIX from sentry.silo.base import SiloLimit, SiloMode from sentry.types.superuser import from_viewer_context_superuser @@ -785,6 +787,81 @@ def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any ) +@AuthenticationSiloLimit(SiloMode.CONTROL, SiloMode.CELL) +class ServiceAccountTokenAuthentication(StandardAuthentication): + """ + Authenticates a service account token. The request acts as the token's service account, + never as the person who created the token, with the token's scopes capped by the + account's current scopes. + """ + + token_name = b"bearer" + + def accepts_auth(self, auth: list[bytes]) -> bool: + if not super().accepts_auth(auth) or len(auth) != 2: + return False + return force_str(auth[1]).startswith(SERVICE_ACCOUNT_TOKEN_PREFIX) + + def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any]: + token_hashed = hash_token(token_str) + + token: ServiceAccountToken | ServiceAccountTokenReplica | None + if SiloMode.get_current_mode() == SiloMode.CONTROL: + token = ServiceAccountToken.objects.filter( + token_hashed=token_hashed, date_deactivated__isnull=True + ).first() + if token is None: + raise AuthenticationFailed("Invalid service account token") + token_id = token.id + service_account_id = token.service_account_id + else: + token = ( + ServiceAccountTokenReplica.objects.select_related("service_account") + .filter(token_hashed=token_hashed, date_deactivated__isnull=True) + .first() + ) + if token is None: + raise AuthenticationFailed("Invalid service account token") + token_id = token.token_id + service_account_id = token.service_account.service_account_id + + if token.date_expires is not None and token.date_expires <= timezone.now(): + raise AuthenticationFailed("Token expired") + + resolved = resolve_service_account(service_account_id, token.organization_id) + if resolved is None: + raise AuthenticationFailed("Invalid service account token") + principal, account_auth = resolved + + # Access already denies other orgs, but failing here gives a clear 401 and covers + # endpoints that never build access. + self._check_organization_url(request, token.organization_id) + + scope = sentry_sdk.get_isolation_scope() + scope.set_tag("api_token_type", self.token_name) + scope.set_attribute("api_token_type", self.token_name.decode()) + scope.set_tag("service_account_token_id", token_id) + scope.set_attribute("service_account_token_id", token_id) + + # Token scopes are fixed at creation, so narrowing the account narrows the token, + # but widening the account doesn't widen it. + scopes = sorted(set(token.scope_list) & set(account_auth.scopes)) + return principal, account_auth.copy(update={"scopes": scopes}) + + def _check_organization_url(self, request: Request, organization_id: int) -> None: + try: + target = resolve(request.path_info).kwargs.get("organization_id_or_slug") + except Resolver404: + return + if target is None or str(target) == str(organization_id): + return + org_context = organization_service.get_organization_by_id( + id=organization_id, include_projects=False, include_teams=False + ) + if org_context is None or org_context.organization.slug != target: + raise AuthenticationFailed("Unauthorized organization access.") + + @AuthenticationSiloLimit(SiloMode.CELL) class DSNAuthentication(StandardAuthentication): token_name = b"dsn" diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index 36e421d99ed2..e9d32f757de7 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -83,6 +83,7 @@ AgentTokenAuthentication, ApiKeyAuthentication, OrgAuthTokenAuthentication, + ServiceAccountTokenAuthentication, UserAuthTokenAuthentication, ViewerContextAuthentication, update_token_access_record, @@ -126,6 +127,7 @@ *map(import_string, settings.SENTRY_EXTRA_API_AUTHENTICATION), UserAuthTokenAuthentication, OrgAuthTokenAuthentication, + ServiceAccountTokenAuthentication, AgentTokenAuthentication, ApiKeyAuthentication, ViewerContextAuthentication, diff --git a/src/sentry/middleware/auth.py b/src/sentry/middleware/auth.py index f4bbb0ac8924..a0df4abf9526 100644 --- a/src/sentry/middleware/auth.py +++ b/src/sentry/middleware/auth.py @@ -16,6 +16,7 @@ AgentTokenAuthentication, ApiKeyAuthentication, OrgAuthTokenAuthentication, + ServiceAccountTokenAuthentication, UserAuthTokenAuthentication, ViewerContextAuthentication, ) @@ -29,6 +30,7 @@ *map(import_string, settings.SENTRY_EXTRA_API_AUTHENTICATION), UserAuthTokenAuthentication, OrgAuthTokenAuthentication, + ServiceAccountTokenAuthentication, AgentTokenAuthentication, ApiKeyAuthentication, ) diff --git a/tests/sentry/api/test_authentication.py b/tests/sentry/api/test_authentication.py index f349743e23e1..8cb3af605f05 100644 --- a/tests/sentry/api/test_authentication.py +++ b/tests/sentry/api/test_authentication.py @@ -1,5 +1,6 @@ import uuid from datetime import UTC, datetime, timedelta +from typing import Any from unittest import mock import orjson @@ -18,6 +19,7 @@ OrgAuthTokenAuthentication, RelayAuthentication, RpcSignatureAuthentication, + ServiceAccountTokenAuthentication, UserAuthTokenAuthentication, ViewerContextAuthentication, compare_service_signature, @@ -37,13 +39,16 @@ from sentry.models.relay import Relay from sentry.sentry_apps.token_exchange.util import GrantTypes from sentry.service_accounts.auth import is_service_account_auth +from sentry.service_accounts.models import ServiceAccountToken from sentry.service_accounts.principal import ServiceAccountPrincipal from sentry.service_accounts.tokens import generate_token as generate_service_account_token +from sentry.service_accounts.tokens import stored_token_fields from sentry.silo.base import SiloMode from sentry.testutils.auth import generate_service_request_signature from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.helpers.features import with_feature +from sentry.testutils.outbox import outbox_runner from sentry.testutils.pytest.fixtures import django_db_all from sentry.testutils.requests import drf_request_from_request from sentry.testutils.silo import assume_test_silo_mode, control_silo_test, no_silo_test @@ -432,6 +437,119 @@ def test_inactive_key(self) -> None: self.auth.authenticate(request) +@with_feature("organizations:service-accounts") +class TestServiceAccountTokenAuthentication(TestCase): + def setUp(self) -> None: + super().setUp() + self.auth = ServiceAccountTokenAuthentication() + self.service_account = self.create_service_account( + organization=self.organization, scope_list=["org:read", "project:read", "event:read"] + ) + self.token_str = generate_service_account_token( + self.organization.slug, "https://test-region.sentry.io" + ) + stored = stored_token_fields(self.token_str) + with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): + self.token = ServiceAccountToken.objects.create( + service_account=self.service_account, + organization_id=self.organization.id, + name="ci", + token_hashed=stored.token_hashed, + token_last_characters=stored.token_last_characters, + scope_list=["project:read", "event:read", "project:write"], + created_by=self.user, + ) + + def request(self, path: str = "/example", token_str: str | None = None) -> Request: + request = _drf_request(path=path) + request.META["HTTP_AUTHORIZATION"] = f"Bearer {token_str or self.token_str}" + return request + + def assert_fails_in_both_silos(self, request: Request) -> None: + with pytest.raises(AuthenticationFailed): + self.auth.authenticate(request) + with assume_test_silo_mode(SiloMode.CONTROL, can_be_monolith=False): + with pytest.raises(AuthenticationFailed): + self.auth.authenticate(request) + + def update_in_control(self, obj: Any, **fields: Any) -> None: + with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): + for name, value in fields.items(): + setattr(obj, name, value) + obj.save() + + def assert_authenticates_as_account(self, scopes: list[str]) -> None: + result = self.auth.authenticate(self.request()) + + assert result is not None + user, auth = result + assert user == ServiceAccountPrincipal( + service_account_id=self.service_account.id, + organization_id=self.organization.id, + name=self.service_account.name, + ) + assert acting_user_id(user) is None + assert is_service_account_auth(auth) + assert auth.entity_id == self.service_account.id + assert auth.organization_id == self.organization.id + assert auth.scopes == scopes + + def test_authenticates_with_token_and_account_scopes(self) -> None: + self.assert_authenticates_as_account(["event:read", "project:read"]) + + def test_authenticates_in_control(self) -> None: + with assume_test_silo_mode(SiloMode.CONTROL, can_be_monolith=False): + self.assert_authenticates_as_account(["event:read", "project:read"]) + + def test_narrowing_the_account_narrows_the_token(self) -> None: + self.update_in_control(self.service_account, scope_list=["project:read"]) + + self.assert_authenticates_as_account(["project:read"]) + with assume_test_silo_mode(SiloMode.CONTROL, can_be_monolith=False): + self.assert_authenticates_as_account(["project:read"]) + + def test_accepts_only_service_account_tokens(self) -> None: + assert self.auth.accepts_auth([b"Bearer", self.token_str.encode()]) + assert not self.auth.accepts_auth([b"Bearer", b"sntrys_abc123_xyz"]) + assert not self.auth.accepts_auth([b"Bearer", b"not-a-prefixed-token"]) + assert not self.auth.accepts_auth([b"Basic", self.token_str.encode()]) + assert not UserAuthTokenAuthentication().accepts_auth([b"Bearer", self.token_str.encode()]) + + def test_unknown_token_fails(self) -> None: + other_token = generate_service_account_token( + self.organization.slug, "https://test-region.sentry.io" + ) + self.assert_fails_in_both_silos(self.request(token_str=other_token)) + + def test_revoked_token_fails(self) -> None: + self.update_in_control(self.token, date_deactivated=datetime.now(UTC)) + self.assert_fails_in_both_silos(self.request()) + + def test_expired_token_fails(self) -> None: + self.update_in_control(self.token, date_expires=datetime.now(UTC) - timedelta(minutes=1)) + self.assert_fails_in_both_silos(self.request()) + + def test_deleted_account_fails(self) -> None: + with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): + assert self.service_account.deactivate() + self.assert_fails_in_both_silos(self.request()) + + def test_without_feature_fails(self) -> None: + with with_feature({"organizations:service-accounts": False}): + self.assert_fails_in_both_silos(self.request()) + + def test_own_organization_url_authenticates(self) -> None: + for org in (self.organization.slug, str(self.organization.id)): + result = self.auth.authenticate(self.request(path=f"/api/0/organizations/{org}/")) + assert result is not None + + def test_other_organization_url_fails(self) -> None: + other_org = self.create_organization() + self.assert_fails_in_both_silos( + self.request(path=f"/api/0/organizations/{other_org.slug}/") + ) + + @control_silo_test class TestTokenAuthentication(TestCase): def setUp(self) -> None: diff --git a/tests/sentry/service_accounts/test_auth.py b/tests/sentry/service_accounts/test_auth.py index ddf1bbbfee44..f735ca0c6021 100644 --- a/tests/sentry/service_accounts/test_auth.py +++ b/tests/sentry/service_accounts/test_auth.py @@ -1,11 +1,16 @@ +from django.urls import reverse +from django.utils import timezone + from sentry.service_accounts.auth import ( SERVICE_ACCOUNT_TOKEN_KIND, authenticate_service_account, resolve_service_account, ) +from sentry.service_accounts.models import ServiceAccountToken from sentry.service_accounts.principal import ServiceAccountPrincipal +from sentry.service_accounts.tokens import generate_token, stored_token_fields from sentry.silo.base import SiloMode -from sentry.testutils.cases import TestCase +from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode @@ -84,3 +89,58 @@ def test_viewer_context_for_a_user_returns_none(self) -> None: ) assert authenticate_service_account(vc) is None + + +@with_feature("organizations:service-accounts") +class ServiceAccountTokenRequestTest(APITestCase): + def setUp(self) -> None: + super().setUp() + self.service_account = self.create_service_account( + organization=self.organization, + scope_list=["org:read", "project:read"], + project_ids=[self.project.id], + ) + self.create_project(organization=self.organization) + self.url = reverse( + "sentry-api-0-organization-projects", + kwargs={"organization_id_or_slug": self.organization.slug}, + ) + + def create_token(self, scope_list: list[str]) -> tuple[str, ServiceAccountToken]: + token_str = generate_token(self.organization.slug, "https://test-region.sentry.io") + stored = stored_token_fields(token_str) + with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): + token = ServiceAccountToken.objects.create( + service_account=self.service_account, + organization_id=self.organization.id, + name="ci", + token_hashed=stored.token_hashed, + token_last_characters=stored.token_last_characters, + scope_list=scope_list, + created_by=self.user, + ) + return token_str, token + + def test_token_reaches_only_the_accounts_projects(self) -> None: + token_str, _ = self.create_token(["org:read", "project:read"]) + + response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Bearer {token_str}") + + assert response.status_code == 200, response.content + assert [project["id"] for project in response.data] == [str(self.project.id)] + + def test_token_scopes_limit_the_account(self) -> None: + token_str, _ = self.create_token(["project:read"]) + + response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Bearer {token_str}") + + assert response.status_code == 403 + + def test_revoked_token_is_rejected(self) -> None: + token_str, token = self.create_token(["org:read", "project:read"]) + with assume_test_silo_mode(SiloMode.CONTROL), outbox_runner(): + token.update(date_deactivated=timezone.now()) + + response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Bearer {token_str}") + + assert response.status_code == 401 From 860210be8db1b52ee3ef167b2618454c72feeff7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:34:11 +0000 Subject: [PATCH 2/3] test(service-accounts): Check that a service account token isn't a browser login The auth middleware leaves Django's request.user anonymous for these tokens, so login_required views redirect to the login page. --- tests/sentry/service_accounts/test_auth.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/sentry/service_accounts/test_auth.py b/tests/sentry/service_accounts/test_auth.py index f735ca0c6021..2330928523b7 100644 --- a/tests/sentry/service_accounts/test_auth.py +++ b/tests/sentry/service_accounts/test_auth.py @@ -144,3 +144,15 @@ def test_revoked_token_is_rejected(self) -> None: response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Bearer {token_str}") assert response.status_code == 401 + + def test_token_is_not_a_browser_login(self) -> None: + token_str, _ = self.create_token(["org:read", "project:read"]) + + response = self.client.post( + reverse("sentry-account-confirm-email-send"), + {"primary-email": "1", "email": "admin@example.com"}, + HTTP_AUTHORIZATION=f"Bearer {token_str}", + ) + + assert response.status_code == 302 + assert response["Location"].startswith("/auth/login/") From ceb375bad9dae9c867800cc8dc119ae143b8c81c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:49:19 +0000 Subject: [PATCH 3/3] ref(service-accounts): Look up service account tokens with get() instead of first() first() adds an ORDER BY to a lookup that matches at most one row, which can lead to poor query plans. Match the org auth token lookup and use get(). Co-Authored-By: Shaun Kaasten <900809+skaasten@users.noreply.github.com> --- src/sentry/api/authentication.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index e1b1a159b258..decea1b4c695 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -805,22 +805,22 @@ def accepts_auth(self, auth: list[bytes]) -> bool: def authenticate_token(self, request: Request, token_str: str) -> tuple[Any, Any]: token_hashed = hash_token(token_str) - token: ServiceAccountToken | ServiceAccountTokenReplica | None + token: ServiceAccountToken | ServiceAccountTokenReplica if SiloMode.get_current_mode() == SiloMode.CONTROL: - token = ServiceAccountToken.objects.filter( - token_hashed=token_hashed, date_deactivated__isnull=True - ).first() - if token is None: + try: + token = ServiceAccountToken.objects.get( + token_hashed=token_hashed, date_deactivated__isnull=True + ) + except ServiceAccountToken.DoesNotExist: raise AuthenticationFailed("Invalid service account token") token_id = token.id service_account_id = token.service_account_id else: - token = ( - ServiceAccountTokenReplica.objects.select_related("service_account") - .filter(token_hashed=token_hashed, date_deactivated__isnull=True) - .first() - ) - if token is None: + try: + token = ServiceAccountTokenReplica.objects.select_related("service_account").get( + token_hashed=token_hashed, date_deactivated__isnull=True + ) + except ServiceAccountTokenReplica.DoesNotExist: raise AuthenticationFailed("Invalid service account token") token_id = token.token_id service_account_id = token.service_account.service_account_id