diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 6cda2808bc10..decea1b4c695 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 + if SiloMode.get_current_mode() == SiloMode.CONTROL: + 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: + 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 + + 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..2330928523b7 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,70 @@ 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 + + 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/")