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+
withself.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
classDOTConfig(AppConfig):
55
name="oauth2_provider"
66
verbose_name="Django OAuth Toolkit"
7+
8+
defready(self):
9+
# Import checks to ensure they run.
10+
from . importchecks# 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+
fromdjango.appsimportapps
2+
fromdjango.coreimportchecks
3+
fromdjango.dbimportrouter
4+
5+
from .settingsimportoauth2_settings
6+
7+
8+
@checks.register(checks.Tags.database)
9+
defvalidate_token_configuration(app_configs, **kwargs):
10+
databases=set(
11+
router.db_for_write(apps.get_model(model))
12+
formodelin (
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+
iflen(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
importlogging
33
importtime
44
importuuid
5+
fromcontextlibimportsuppress
56
fromdatetimeimporttimedelta
67
fromurllib.parseimportparse_qsl, urlparse
78

89
fromdjango.appsimportapps
910
fromdjango.confimportsettings
1011
fromdjango.contrib.auth.hashersimportidentify_hasher, make_password
1112
fromdjango.core.exceptionsimportImproperlyConfigured
12-
fromdjango.dbimportmodels, transaction
13+
fromdjango.dbimportmodels, router, transaction
1314
fromdjango.urlsimportreverse
1415
fromdjango.utilsimporttimezone
1516
fromdjango.utils.translationimportgettext_lazyas_
@@ -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-
withtransaction.atomic():
518+
519+
# Use the access_token_database instead of making the assumption it is in 'default'.
520+
withtransaction.atomic(using=access_token_database):
517521
token=refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True)
518522
ifnottoken:
519523
return
520524
self=list(token)[0]
521525

522-
try:
523-
access_token_model.objects.get(pk=self.access_token_id).revoke()
524-
exceptaccess_token_model.DoesNotExist:
525-
pass
526+
withsuppress(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
defget_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
returnapps.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
fromdjango.contrib.authimportauthenticate, get_user_model
1616
fromdjango.contrib.auth.hashersimportcheck_password, identify_hasher
1717
fromdjango.core.exceptionsimportObjectDoesNotExist
18-
fromdjango.dbimporttransaction
18+
fromdjango.dbimportrouter, transaction
1919
fromdjango.httpimportHttpRequest
2020
fromdjango.utilsimportdateformat, timezone
2121
fromdjango.utils.cryptoimportconstant_time_compare
@@ -567,11 +567,23 @@ def rotate_refresh_token(self, request):
567567
"""
568568
returnoauth2_settings.ROTATE_REFRESH_TOKEN
569569

570-
@transaction.atomic
571570
defsave_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+
withtransaction.atomic(using=router.db_for_write(AccessToken)):
580+
returnself._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
returnrt.application==client
795807

796-
@transaction.atomic
797808
def_save_id_token(self, jti, request, expires, *args, **kwargs):
798809
scopes=request.scopeor" ".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+
withtransaction.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+
fromdjango.confimportsettings
2+
fromdjango.testimportTestCaseasDjangoTestCase
3+
fromdjango.testimportTransactionTestCaseasDjangoTransactionTestCase
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+
defretrieve_current_databases():
15+
iflen(settings.DATABASES) >1:
16+
return [namefornameinsettings.DATABASESifname!="default"]
17+
else:
18+
return ["default"]
19+
20+
21+
classOAuth2ProviderBase:
22+
@classmethod
23+
defsetUpClass(cls):
24+
cls.databases=retrieve_current_databases()
25+
super().setUpClass()
26+
27+
28+
classOAuth2ProviderTestCase(OAuth2ProviderBase, DjangoTestCase):
29+
"""Place holder to allow overriding behaviors."""
30+
31+
32+
classOAuth2ProviderTransactionTestCase(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+
classAlphaRouter:
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+
defdb_for_read(self, model, **hints):
13+
ifmodel._meta.app_labelnotinapps_in_beta:
14+
return"alpha"
15+
returnNone
16+
17+
defdb_for_write(self, model, **hints):
18+
ifmodel._meta.app_labelnotinapps_in_beta:
19+
return"alpha"
20+
returnNone
21+
22+
defallow_relation(self, obj1, obj2, **hints):
23+
ifobj1._state.db=="alpha"andobj2._state.db=="alpha":
24+
returnTrue
25+
returnNone
26+
27+
defallow_migrate(self, db, app_label, model_name=None, **hints):
28+
ifapp_labelnotinapps_in_beta:
29+
returndb=="alpha"
30+
returnNone
31+
32+
33+
classBetaRouter:
34+
defdb_for_read(self, model, **hints):
35+
ifmodel._meta.app_labelinapps_in_beta:
36+
return"beta"
37+
returnNone
38+
39+
defdb_for_write(self, model, **hints):
40+
ifmodel._meta.app_labelinapps_in_beta:
41+
return"beta"
42+
returnNone
43+
44+
defallow_relation(self, obj1, obj2, **hints):
45+
ifobj1._state.db=="beta"andobj2._state.db=="beta":
46+
returnTrue
47+
returnNone
48+
49+
defallow_migrate(self, db, app_label, model_name=None, **hints):
50+
ifapp_labelinapps_in_beta:
51+
returndb=="beta"
52+
53+
54+
classCrossDatabaseRouter:
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+
defdb_for_read(self, model, **hints):
59+
ifmodel._meta.model_name=="accesstoken":
60+
return"beta"
61+
returnNone
62+
63+
defdb_for_write(self, model, **hints):
64+
ifmodel._meta.model_name=="accesstoken":
65+
return"beta"
66+
returnNone
67+
68+
defallow_relation(self, obj1, obj2, **hints):
69+
ifobj1._state.db=="beta"andobj2._state.db=="beta":
70+
returnTrue
71+
returnNone
72+
73+
defallow_migrate(self, db, app_label, model_name=None, **hints):
74+
ifmodel_name=="accesstoken":
75+
returndb=="beta"
76+
returnNone

0 commit comments

Comments
(0)