Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 79 additions & 2 deletions src/sentry/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
AgentTokenAuthentication,
ApiKeyAuthentication,
OrgAuthTokenAuthentication,
ServiceAccountTokenAuthentication,
UserAuthTokenAuthentication,
ViewerContextAuthentication,
update_token_access_record,
Expand Down Expand Up @@ -126,6 +127,7 @@
*map(import_string, settings.SENTRY_EXTRA_API_AUTHENTICATION),
UserAuthTokenAuthentication,
OrgAuthTokenAuthentication,
ServiceAccountTokenAuthentication,
AgentTokenAuthentication,
ApiKeyAuthentication,
ViewerContextAuthentication,
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AgentTokenAuthentication,
ApiKeyAuthentication,
OrgAuthTokenAuthentication,
ServiceAccountTokenAuthentication,
UserAuthTokenAuthentication,
ViewerContextAuthentication,
)
Expand All @@ -29,6 +30,7 @@
*map(import_string, settings.SENTRY_EXTRA_API_AUTHENTICATION),
UserAuthTokenAuthentication,
OrgAuthTokenAuthentication,
ServiceAccountTokenAuthentication,
AgentTokenAuthentication,
ApiKeyAuthentication,
)
Expand Down
118 changes: 118 additions & 0 deletions tests/sentry/api/test_authentication.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest import mock

import orjson
Expand All @@ -18,6 +19,7 @@
OrgAuthTokenAuthentication,
RelayAuthentication,
RpcSignatureAuthentication,
ServiceAccountTokenAuthentication,
UserAuthTokenAuthentication,
ViewerContextAuthentication,
compare_service_signature,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
74 changes: 73 additions & 1 deletion tests/sentry/service_accounts/test_auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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/")
Loading