Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: use locks to handle apigrants safely #82052

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
24 changes: 20 additions & 4 deletions src/sentry/api/endpoints/api_authorizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sentry.hybridcloud.models.outbox import outbox_context
from sentry.models.apiapplication import ApiApplicationStatus
from sentry.models.apiauthorization import ApiAuthorization
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken


Expand Down Expand Up @@ -44,18 +45,33 @@ def delete(self, request: Request) -> Response:
return Response({"authorization": ""}, status=400)

try:
auth = ApiAuthorization.objects.get(user_id=request.user.id, id=authorization)
api_authorization = ApiAuthorization.objects.get(
user_id=request.user.id, id=authorization
)
except ApiAuthorization.DoesNotExist:
return Response(status=404)

with outbox_context(transaction.atomic(using=router.db_for_write(ApiToken)), flush=False):
for token in ApiToken.objects.filter(
user_id=request.user.id,
application=auth.application_id,
scoping_organization_id=auth.organization_id,
application=api_authorization.application_id,
scoping_organization_id=api_authorization.organization_id,
):
token.delete()

auth.delete()
# remove any grants that were created from this authorization
# that may not have been exchanged for a token yet
with outbox_context(transaction.atomic(using=router.db_for_write(ApiGrant)), flush=False):
for grant in ApiGrant.objects.filter(
user_id=request.user.id,
application=api_authorization.application_id,
organization_id=api_authorization.organization_id,
):
grant.delete()

with outbox_context(
transaction.atomic(using=router.db_for_write(ApiAuthorization)), flush=False
):
api_authorization.delete()

return Response(status=204)
12 changes: 12 additions & 0 deletions src/sentry/models/apigrant.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
DEFAULT_EXPIRATION = timedelta(minutes=10)


class InvalidGrantError(Exception):
pass


class ExpiredGrantError(Exception):
pass


def default_expiration():
return timezone.now() + DEFAULT_EXPIRATION

Expand Down Expand Up @@ -97,6 +105,10 @@ def is_expired(self):
def redirect_uri_allowed(self, uri):
return uri == self.redirect_uri

@classmethod
def get_lock_key(cls, grant_id):
return f"api_grant:{grant_id}"

@classmethod
def sanitize_relocation_json(
cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None
Expand Down
42 changes: 30 additions & 12 deletions src/sentry/models/apitoken.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
from sentry.hybridcloud.outbox.base import ControlOutboxProducingManager, ReplicatedControlModel
from sentry.hybridcloud.outbox.category import OutboxCategory
from sentry.models.apigrant import ApiGrant
from sentry.locks import locks
from sentry.models.apiapplication import ApiApplicationStatus
from sentry.models.apigrant import ApiGrant, ExpiredGrantError, InvalidGrantError
from sentry.models.apiscopes import HasApiScopes
from sentry.types.region import find_all_region_names
from sentry.types.token import AuthTokenType
Expand Down Expand Up @@ -261,17 +263,33 @@ def handle_async_replication(self, region_name: str, shard_identifier: int) -> N

@classmethod
def from_grant(cls, grant: ApiGrant):
with transaction.atomic(router.db_for_write(cls)):
api_token = cls.objects.create(
application=grant.application,
user=grant.user,
scope_list=grant.get_scopes(),
scoping_organization_id=grant.organization_id,
)

# remove the ApiGrant from the database to prevent reuse of the same
# authorization code
grant.delete()
if grant.application.status != ApiApplicationStatus.active:
raise InvalidGrantError()

if grant.is_expired():
raise ExpiredGrantError()

lock = locks.get(
ApiGrant.get_lock_key(grant.id),
duration=10,
name="api_grant",
)

# we use a lock to prevent race conditions when creating the ApiToken
# an attacker could send two requests to create an access/refresh token pair
# at the same time, using the same grant, and get two different tokens
with lock.acquire():
with transaction.atomic(router.db_for_write(cls)):
api_token = cls.objects.create(
application=grant.application,
user=grant.user,
scope_list=grant.get_scopes(),
scoping_organization_id=grant.organization_id,
)

# remove the ApiGrant from the database to prevent reuse of the same
# authorization code
grant.delete()

return api_token

Expand Down
7 changes: 6 additions & 1 deletion src/sentry/runner/commands/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ def remove_expired_values_for_org_members(


def delete_api_models(is_filtered: Callable[[type[Model]], bool]) -> None:
from sentry.models.apigrant import DEFAULT_EXPIRATION as API_GRANT_EXPIRATION
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import DEFAULT_EXPIRATION as API_TOKEN_EXPIRATION
from sentry.models.apitoken import ApiToken

for model_tp in (ApiGrant, ApiToken):
Expand All @@ -325,8 +327,11 @@ def delete_api_models(is_filtered: Callable[[type[Model]], bool]) -> None:
if is_filtered(model_tp):
debug_output(f">> Skipping {model_tp.__name__}")
else:
expiration_threshold = (
API_GRANT_EXPIRATION if model_tp is ApiGrant else API_TOKEN_EXPIRATION
)
queryset = model_tp.objects.filter(
expires_at__lt=(timezone.now() - timedelta(days=API_TOKEN_TTL_IN_DAYS))
expires_at__lt=(timezone.now() - expiration_threshold)
)

# SentryAppInstallations are associated to ApiTokens. We're okay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from sentry.sentry_apps.token_exchange.refresher import Refresher
from sentry.sentry_apps.token_exchange.util import GrantTypes
from sentry.sentry_apps.utils.errors import SentryAppIntegratorError
from sentry.utils.locking import UnableToAcquireLock

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -86,6 +87,8 @@ def post(self, request: Request, installation) -> Response:
},
)
raise
except UnableToAcquireLock:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this happen frequently? I wonder if we need to do a frontend change to improve user experience if double requests to this endpoint can easily happen.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should never happen in practice.

From the user's perspective, they will not see this as it is the request from the backend system of the app to exchange the authorization code for an access token.

We do make some assumptions here though, if they are receiving a 409, they may be sending two requests too quickly. This is a bug in their implementation, but at least one of their request flows will receive the access token. If not, they can retry the request after the lock has expired (we only hold a lock for 10 seconds).

return Response({"error": "invalid_grant"}, status=409)

attrs = {"state": request.data.get("state"), "application": None}

Expand Down
41 changes: 24 additions & 17 deletions src/sentry/sentry_apps/token_exchange/grant_exchanger.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.utils.functional import cached_property

from sentry import analytics
from sentry.locks import locks
from sentry.models.apiapplication import ApiApplication
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
Expand Down Expand Up @@ -33,23 +34,29 @@ class GrantExchanger:
user: User

def run(self):
with transaction.atomic(using=router.db_for_write(ApiToken)):
try:
self._validate()
token = self._create_token()

# Once it's exchanged it's no longer valid and should not be
# exchangeable, so we delete it.
self._delete_grant()
except SentryAppIntegratorError:
logger.info(
"grant-exchanger.context",
extra={
"application_id": self.application.id,
"grant_id": self.grant.id,
},
)
raise
lock = locks.get(
ApiGrant.get_lock_key(self.grant.id),
duration=10,
name="api_grant",
)
with lock.acquire():
with transaction.atomic(using=router.db_for_write(ApiToken)):
try:
self._validate()
token = self._create_token()

# Once it's exchanged it's no longer valid and should not be
# exchangeable, so we delete it.
self._delete_grant()
except SentryAppIntegratorError:
logger.info(
"grant-exchanger.context",
extra={
"application_id": self.application.id,
"grant_id": self.grant.id,
},
)
raise
self.record_analytics()

return token
Expand Down
13 changes: 11 additions & 2 deletions src/sentry/web/frontend/oauth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sentry.models.apitoken import ApiToken
from sentry.sentry_apps.token_exchange.util import GrantTypes
from sentry.utils import json, metrics
from sentry.utils.locking import UnableToAcquireLock
from sentry.web.frontend.base import control_silo_view
from sentry.web.frontend.openidtoken import OpenIDToken

Expand Down Expand Up @@ -128,7 +129,9 @@ def post(self, request: Request) -> HttpResponse:
def get_access_tokens(self, request: Request, application: ApiApplication) -> dict:
code = request.POST.get("code")
try:
grant = ApiGrant.objects.get(application=application, code=code)
grant = ApiGrant.objects.get(
application=application, application__status=ApiApplicationStatus.active, code=code
)
except ApiGrant.DoesNotExist:
return {"error": "invalid_grant", "reason": "invalid grant"}

Expand All @@ -141,7 +144,12 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di
elif grant.redirect_uri != redirect_uri:
return {"error": "invalid_grant", "reason": "invalid redirect URI"}

token_data = {"token": ApiToken.from_grant(grant=grant)}
try:
token_data = {"token": ApiToken.from_grant(grant=grant)}
except UnableToAcquireLock:
# TODO(mdtro): we should return a 409 status code here
return {"error": "invalid_grant", "reason": "invalid grant"}

if grant.has_scope("openid") and options.get("codecov.signing_secret"):
open_id_token = OpenIDToken(
request.POST.get("client_id"),
Expand All @@ -150,6 +158,7 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di
nonce=request.POST.get("nonce"),
)
token_data["id_token"] = open_id_token.get_signed_id_token(grant=grant)

return token_data

def get_refresh_token(self, request: Request, application: ApiApplication) -> dict:
Expand Down
16 changes: 16 additions & 0 deletions tests/sentry/sentry_apps/token_exchange/test_grant_exchanger.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ def test_deletes_grant_on_successful_exchange(self):
self.grant_exchanger.run()
assert not ApiGrant.objects.filter(id=grant_id)

def test_race_condition_on_grant_exchange(self):
from sentry.locks import locks
from sentry.utils.locking import UnableToAcquireLock

# simulate a race condition on the grant exchange
grant_id = self.orm_install.api_grant_id
lock = locks.get(
ApiGrant.get_lock_key(grant_id),
duration=10,
name="api_grant",
)
lock.acquire()

with pytest.raises(UnableToAcquireLock):
self.grant_exchanger.run()

@patch("sentry.analytics.record")
def test_records_analytics(self, record):
GrantExchanger(
Expand Down
23 changes: 23 additions & 0 deletions tests/sentry/web/frontend/test_oauth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.utils import timezone

from sentry.locks import locks
from sentry.models.apiapplication import ApiApplication
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
Expand Down Expand Up @@ -203,6 +204,28 @@ def test_one_time_use_grant(self):
)
assert resp.status_code == 400

def test_grant_lock(self):
self.login_as(self.user)

# Simulate a concurrent request by using an existing grant
# that has its grant lock taken out.
lock = locks.get(ApiGrant.get_lock_key(self.grant.id), duration=10, name="api_grant")
lock.acquire()

# Attempt to create a token with the same grant
# This should fail because the lock is held by the previous request
resp = self.client.post(
self.path,
{
"grant_type": "authorization_code",
"code": self.grant.code,
"client_id": self.application.client_id,
"client_secret": self.client_secret,
},
)
assert resp.status_code == 400
assert resp.json() == {"error": "invalid_grant"}

def test_invalid_redirect_uri(self):
self.login_as(self.user)

Expand Down
Loading