Skip to content

Commit 9561866

Browse files
shalehn2ygk
andauthored
Honor database assignment from router (#1450)
* Improve multiple database support. The token models might not be stored in the default database. There might not _be_ a default database. Intead, the code now relies on Django's routers to determine the actual database to use when creating transactions. This required moving from decorators to context managers for those transactions. To test the multiple database scenario a new settings file as added which derives from settings.py and then defines different databases and the routers needed to access them. The commit is larger than might be expected because when there are multiple databases the Django tests have to be told which databases to work on. Rather than copying the various test cases or making multiple database specific ones the decision was made to add wrappers around the standard Django TestCase classes and programmatically define the databases for them. This enables all of the same test code to work for both the one database and the multi database scenarios with minimal maintenance costs. A tox environment that uses the multi db settings file has been added to ensure both scenarios are always tested. * changelog entry and authors update * PR review response. Document multiple database requires in advanced_topics.rst. Add an ImproperlyConfigured validator to the ready method of the DOTConfig app. Fix IDToken doc string. Document the use of _save_bearer_token. Define LocalIDToken and use it for validating the configuration test. Questionably, define py39-multi-db-invalid-token-configuration-dj42. This will consistently cause tox runs to fail until it is worked out how to mark this as an expected failure. * move migration * update migration * use django checks system * drop misconfigured db check. Let's find a better way. * run checks * maybe a better test definition * listing tests was breaking things * No more magic. * Oops. Debugger. * Use retrieven_current_databases in django_db marked tests. * Updates. Prove the checks work. Document test requirements. * fix typo --------- Co-authored-by: Alan Crosswell <[email protected]> Co-authored-by: Alan Crosswell <[email protected]>
1 parent 1d19e3d commit 9561866

40 files changed

+392
-79
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Rodney Richardson
102102
Rustem Saiargaliev
103103
Rustem Saiargaliev
104104
Sandro Rodrigues
105+
Sean 'Shaleh' Perry
105106
Shaheed Haque
106107
Shaun Stanworth
107108
Sayyid Hamid Mahdavi

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
* Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims
2424
* Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation.
2525
* #1446 use generic models pk instead of id.
26+
* Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct
27+
database to use instead of assuming that 'default' is the correct one.
2628
* Bump oauthlib version to 3.2.2 and above
2729
* Update the OAuth2Validator's invalidate_authorization_code method to return an InvalidGrantError if the associated grant does not exist.
2830

docs/advanced_topics.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ That's all, now Django OAuth Toolkit will use your model wherever an Application
6565
is because of the way Django currently implements swappable models.
6666
See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details.
6767

68+
Configuring multiple databases
69+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
70+
71+
There is no requirement that the tokens are stored in the default database or that there is a
72+
default database provided the database routers can determine the correct Token locations. Because the
73+
Tokens have foreign keys to the ``User`` model, you likely want to keep the tokens in the same database
74+
as your User model. It is also important that all of the tokens are stored in the same database.
75+
This could happen for instance if one of the Tokens is locally overridden and stored in a separate database.
76+
The reason for this is transactions will only be made for the database where AccessToken is stored
77+
even when writing to RefreshToken or other tokens.
78+
6879
Multiple Grants
6980
~~~~~~~~~~~~~~~
7081

docs/contributing.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,26 @@ Open :file:`mycoverage/index.html` in your browser and you can see a coverage su
252252

253253
There's no need to wait for Codecov to complain after you submit your PR.
254254

255+
The tests are generic and written to work with both single database and multiple database configurations. tox will run
256+
tests both ways. You can see the configurations used in tests/settings.py and tests/multi_db_settings.py.
257+
258+
When there are multiple databases defined, Django tests will not work unless they are told which database(s) to work with.
259+
For test writers this means any test must either:
260+
- instead of Django's TestCase or TransactionTestCase use the versions of those
261+
classes defined in tests/common_testing.py
262+
- when using pytest's `django_db` mark, define it like this:
263+
`@pytest.mark.django_db(databases=retrieve_current_databases())`
264+
265+
In test code, anywhere the database is referenced the Django router needs to be used exactly like the package's code.
266+
267+
.. code-block:: python
268+
269+
token_database = router.db_for_write(AccessToken)
270+
with self.assertNumQueries(1, using=token_database):
271+
# call something using the database
272+
273+
Without the 'using' option, this test fails in the multiple database scenario because 'default' will be used instead.
274+
255275
Code conventions matter
256276
-----------------------
257277

oauth2_provider/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@
44
class DOTConfig(AppConfig):
55
name = "oauth2_provider"
66
verbose_name = "Django OAuth Toolkit"
7+
8+
def ready(self):
9+
# Import checks to ensure they run.
10+
from . import checks # noqa: F401

oauth2_provider/checks.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from django.apps import apps
2+
from django.core import checks
3+
from django.db import router
4+
5+
from .settings import oauth2_settings
6+
7+
8+
@checks.register(checks.Tags.database)
9+
def validate_token_configuration(app_configs, **kwargs):
10+
databases = set(
11+
router.db_for_write(apps.get_model(model))
12+
for model in (
13+
oauth2_settings.ACCESS_TOKEN_MODEL,
14+
oauth2_settings.ID_TOKEN_MODEL,
15+
oauth2_settings.REFRESH_TOKEN_MODEL,
16+
)
17+
)
18+
19+
# This is highly unlikely, but let's warn people just in case it does.
20+
# If the tokens were allowed to be in different databases this would require all
21+
# writes to have a transaction around each database. Instead, let's enforce that
22+
# they all live together in one database.
23+
# The tokens are not required to live in the default database provided the Django
24+
# routers know the correct database for them.
25+
if len(databases) > 1:
26+
return [checks.Error("The token models are expected to be stored in the same database.")]
27+
28+
return []

oauth2_provider/models.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
import logging
33
import time
44
import uuid
5+
from contextlib import suppress
56
from datetime import timedelta
67
from urllib.parse import parse_qsl, urlparse
78

89
from django.apps import apps
910
from django.conf import settings
1011
from django.contrib.auth.hashers import identify_hasher, make_password
1112
from django.core.exceptions import ImproperlyConfigured
12-
from django.db import models, transaction
13+
from django.db import models, router, transaction
1314
from django.urls import reverse
1415
from django.utils import timezone
1516
from django.utils.translation import gettext_lazy as _
@@ -512,17 +513,19 @@ def revoke(self):
512513
Mark this refresh token revoked and revoke related access token
513514
"""
514515
access_token_model = get_access_token_model()
516+
access_token_database = router.db_for_write(access_token_model)
515517
refresh_token_model = get_refresh_token_model()
516-
with transaction.atomic():
518+
519+
# Use the access_token_database instead of making the assumption it is in 'default'.
520+
with transaction.atomic(using=access_token_database):
517521
token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True)
518522
if not token:
519523
return
520524
self = list(token)[0]
521525

522-
try:
523-
access_token_model.objects.get(pk=self.access_token_id).revoke()
524-
except access_token_model.DoesNotExist:
525-
pass
526+
with suppress(access_token_model.DoesNotExist):
527+
access_token_model.objects.get(id=self.access_token_id).revoke()
528+
526529
self.access_token = None
527530
self.revoked = timezone.now()
528531
self.save()
@@ -655,7 +658,7 @@ def get_access_token_model():
655658

656659

657660
def get_id_token_model():
658-
"""Return the AccessToken model that is active in this project."""
661+
"""Return the IDToken model that is active in this project."""
659662
return apps.get_model(oauth2_settings.ID_TOKEN_MODEL)
660663

661664

oauth2_provider/oauth2_validators.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from django.contrib.auth import authenticate, get_user_model
1616
from django.contrib.auth.hashers import check_password, identify_hasher
1717
from django.core.exceptions import ObjectDoesNotExist
18-
from django.db import transaction
18+
from django.db import router, transaction
1919
from django.http import HttpRequest
2020
from django.utils import dateformat, timezone
2121
from django.utils.crypto import constant_time_compare
@@ -567,11 +567,23 @@ def rotate_refresh_token(self, request):
567567
"""
568568
return oauth2_settings.ROTATE_REFRESH_TOKEN
569569

570-
@transaction.atomic
571570
def save_bearer_token(self, token, request, *args, **kwargs):
572571
"""
573-
Save access and refresh token, If refresh token is issued, remove or
574-
reuse old refresh token as in rfc:`6`
572+
Save access and refresh token.
573+
574+
Override _save_bearer_token and not this function when adding custom logic
575+
for the storing of these token. This allows the transaction logic to be
576+
separate from the token handling.
577+
"""
578+
# Use the AccessToken's database instead of making the assumption it is in 'default'.
579+
with transaction.atomic(using=router.db_for_write(AccessToken)):
580+
return self._save_bearer_token(token, request, *args, **kwargs)
581+
582+
def _save_bearer_token(self, token, request, *args, **kwargs):
583+
"""
584+
Save access and refresh token.
585+
586+
If refresh token is issued, remove or reuse old refresh token as in rfc:`6`.
575587
576588
@see: https://rfc-editor.org/rfc/rfc6749.html#section-6
577589
"""
@@ -793,7 +805,6 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs
793805

794806
return rt.application == client
795807

796-
@transaction.atomic
797808
def _save_id_token(self, jti, request, expires, *args, **kwargs):
798809
scopes = request.scope or " ".join(request.scopes)
799810

@@ -894,7 +905,9 @@ def finalize_id_token(self, id_token, token, token_handler, request):
894905
claims=json.dumps(id_token, default=str),
895906
)
896907
jwt_token.make_signed_token(request.client.jwk_key)
897-
id_token = self._save_id_token(id_token["jti"], request, expiration_time)
908+
# Use the IDToken's database instead of making the assumption it is in 'default'.
909+
with transaction.atomic(using=router.db_for_write(IDToken)):
910+
id_token = self._save_id_token(id_token["jti"], request, expiration_time)
898911
# this is needed by django rest framework
899912
request.access_token = id_token
900913
request.id_token = id_token

tests/common_testing.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.conf import settings
2+
from django.test import TestCase as DjangoTestCase
3+
from django.test import TransactionTestCase as DjangoTransactionTestCase
4+
5+
6+
# The multiple database scenario setup for these tests purposefully defines 'default' as
7+
# an empty database in order to catch any assumptions in this package about database names
8+
# and in particular to ensure there is no assumption that 'default' is a valid database.
9+
#
10+
# When there are multiple databases defined, Django tests will not work unless they are
11+
# told which database(s) to work with.
12+
13+
14+
def retrieve_current_databases():
15+
if len(settings.DATABASES) > 1:
16+
return [name for name in settings.DATABASES if name != "default"]
17+
else:
18+
return ["default"]
19+
20+
21+
class OAuth2ProviderBase:
22+
@classmethod
23+
def setUpClass(cls):
24+
cls.databases = retrieve_current_databases()
25+
super().setUpClass()
26+
27+
28+
class OAuth2ProviderTestCase(OAuth2ProviderBase, DjangoTestCase):
29+
"""Place holder to allow overriding behaviors."""
30+
31+
32+
class OAuth2ProviderTransactionTestCase(OAuth2ProviderBase, DjangoTransactionTestCase):
33+
"""Place holder to allow overriding behaviors."""

tests/db_router.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
apps_in_beta = {"some_other_app", "this_one_too"}
2+
3+
# These are bare minimum routers to fake the scenario where there is actually a
4+
# decision around where an application's models might live.
5+
6+
7+
class AlphaRouter:
8+
# alpha is where the core Django models are stored including user. To keep things
9+
# simple this is where the oauth2 provider models are stored as well because they
10+
# have a foreign key to User.
11+
12+
def db_for_read(self, model, **hints):
13+
if model._meta.app_label not in apps_in_beta:
14+
return "alpha"
15+
return None
16+
17+
def db_for_write(self, model, **hints):
18+
if model._meta.app_label not in apps_in_beta:
19+
return "alpha"
20+
return None
21+
22+
def allow_relation(self, obj1, obj2, **hints):
23+
if obj1._state.db == "alpha" and obj2._state.db == "alpha":
24+
return True
25+
return None
26+
27+
def allow_migrate(self, db, app_label, model_name=None, **hints):
28+
if app_label not in apps_in_beta:
29+
return db == "alpha"
30+
return None
31+
32+
33+
class BetaRouter:
34+
def db_for_read(self, model, **hints):
35+
if model._meta.app_label in apps_in_beta:
36+
return "beta"
37+
return None
38+
39+
def db_for_write(self, model, **hints):
40+
if model._meta.app_label in apps_in_beta:
41+
return "beta"
42+
return None
43+
44+
def allow_relation(self, obj1, obj2, **hints):
45+
if obj1._state.db == "beta" and obj2._state.db == "beta":
46+
return True
47+
return None
48+
49+
def allow_migrate(self, db, app_label, model_name=None, **hints):
50+
if app_label in apps_in_beta:
51+
return db == "beta"
52+
53+
54+
class CrossDatabaseRouter:
55+
# alpha is where the core Django models are stored including user. To keep things
56+
# simple this is where the oauth2 provider models are stored as well because they
57+
# have a foreign key to User.
58+
def db_for_read(self, model, **hints):
59+
if model._meta.model_name == "accesstoken":
60+
return "beta"
61+
return None
62+
63+
def db_for_write(self, model, **hints):
64+
if model._meta.model_name == "accesstoken":
65+
return "beta"
66+
return None
67+
68+
def allow_relation(self, obj1, obj2, **hints):
69+
if obj1._state.db == "beta" and obj2._state.db == "beta":
70+
return True
71+
return None
72+
73+
def allow_migrate(self, db, app_label, model_name=None, **hints):
74+
if model_name == "accesstoken":
75+
return db == "beta"
76+
return None

0 commit comments

Comments
 (0)