cardo-python-utils 0.5.dev30__tar.gz → 0.5.dev32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. cardo_python_utils-0.5.dev32/MANIFEST.in +5 -0
  2. {cardo_python_utils-0.5.dev30/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev32}/PKG-INFO +3 -1
  3. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32/cardo_python_utils.egg-info}/PKG-INFO +3 -1
  4. cardo_python_utils-0.5.dev32/cardo_python_utils.egg-info/SOURCES.txt +58 -0
  5. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/cardo_python_utils.egg-info/requires.txt +2 -0
  6. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/pyproject.toml +3 -1
  7. cardo_python_utils-0.5.dev32/python_utils/django/README.md +122 -0
  8. cardo_python_utils-0.5.dev32/python_utils/django/__init__.py +1 -0
  9. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/admin/auth.py +33 -2
  10. cardo_python_utils-0.5.dev32/python_utils/django/admin/templates/__init__.py +3 -0
  11. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/admin/user_group.py +2 -2
  12. cardo_python_utils-0.5.dev32/python_utils/django/admin/views.py +63 -0
  13. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/api/utils.py +18 -1
  14. cardo_python_utils-0.5.dev32/python_utils/django/apps.py +5 -0
  15. {cardo_python_utils-0.5.dev30/python_utils/keycloak/django → cardo_python_utils-0.5.dev32/python_utils/django/auth}/service.py +8 -6
  16. cardo_python_utils-0.5.dev32/python_utils/django/celery/__init__.py +4 -0
  17. cardo_python_utils-0.5.dev32/python_utils/django/celery/tenant_aware_task.py +31 -0
  18. cardo_python_utils-0.5.dev32/python_utils/django/db/routers.py +11 -0
  19. cardo_python_utils-0.5.dev32/python_utils/django/db/transaction.py +26 -0
  20. cardo_python_utils-0.5.dev32/python_utils/django/db/utils.py +66 -0
  21. cardo_python_utils-0.5.dev32/python_utils/django/management/commands/__init__.py +0 -0
  22. cardo_python_utils-0.5.dev32/python_utils/django/management/commands/migrateall.py +25 -0
  23. cardo_python_utils-0.5.dev32/python_utils/django/management/commands/tenant_aware_command.py +74 -0
  24. cardo_python_utils-0.5.dev32/python_utils/django/middleware/__init__.py +6 -0
  25. cardo_python_utils-0.5.dev32/python_utils/django/middleware/tenant_aware_http_middleware.py +113 -0
  26. cardo_python_utils-0.5.dev32/python_utils/django/models/__init__.py +0 -0
  27. cardo_python_utils-0.5.dev32/python_utils/django/oidc_settings.py +91 -0
  28. cardo_python_utils-0.5.dev32/python_utils/django/redis/__init__.py +4 -0
  29. cardo_python_utils-0.5.dev32/python_utils/django/redis/key_function.py +5 -0
  30. cardo_python_utils-0.5.dev32/python_utils/django/settings.py +17 -0
  31. cardo_python_utils-0.5.dev32/python_utils/django/storage/__init__.py +6 -0
  32. cardo_python_utils-0.5.dev32/python_utils/django/storage/tenant_aware_storage.py +86 -0
  33. cardo_python_utils-0.5.dev32/python_utils/django/tenant_context.py +89 -0
  34. cardo_python_utils-0.5.dev30/MANIFEST.in +0 -4
  35. cardo_python_utils-0.5.dev30/cardo_python_utils.egg-info/SOURCES.txt +0 -36
  36. cardo_python_utils-0.5.dev30/python_utils/keycloak/utils.py +0 -51
  37. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/LICENSE +0 -0
  38. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/README.rst +0 -0
  39. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
  40. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/cardo_python_utils.egg-info/top_level.txt +0 -0
  41. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/__init__.py +0 -0
  42. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/choices.py +0 -0
  43. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/data_structures.py +0 -0
  44. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/db.py +0 -0
  45. {cardo_python_utils-0.5.dev30/python_utils/keycloak/django → cardo_python_utils-0.5.dev32/python_utils/django/admin}/__init__.py +0 -0
  46. {cardo_python_utils-0.5.dev30/python_utils/keycloak/django/admin → cardo_python_utils-0.5.dev32/python_utils/django/admin/templates}/user_groups_changelist.html +0 -0
  47. {cardo_python_utils-0.5.dev30/python_utils/keycloak/django/admin → cardo_python_utils-0.5.dev32/python_utils/django/api}/__init__.py +0 -0
  48. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/api/drf.py +0 -0
  49. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/api/ninja.py +0 -0
  50. {cardo_python_utils-0.5.dev30/python_utils/keycloak/django/api → cardo_python_utils-0.5.dev32/python_utils/django/db}/__init__.py +0 -0
  51. {cardo_python_utils-0.5.dev30/python_utils/keycloak/django/models → cardo_python_utils-0.5.dev32/python_utils/django/management}/__init__.py +0 -0
  52. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/models/user_group.py +0 -0
  53. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/tests/__init__.py +0 -0
  54. {cardo_python_utils-0.5.dev30/python_utils/keycloak → cardo_python_utils-0.5.dev32/python_utils}/django/tests/conftest.py +0 -0
  55. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/django_utils.py +0 -0
  56. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/esma_choices.py +0 -0
  57. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/exceptions.py +0 -0
  58. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/imports.py +0 -0
  59. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/math.py +0 -0
  60. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/text.py +0 -0
  61. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/time.py +0 -0
  62. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/python_utils/types_hinting.py +0 -0
  63. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev32}/setup.cfg +0 -0
@@ -0,0 +1,5 @@
1
+ include LICENSE
2
+ include README.rst
3
+ include python_utils/django/admin/templates/user_groups_changelist.html
4
+ include python_utils/django/README.md
5
+ recursive-exclude tests *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev30
3
+ Version: 0.5.dev32
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -27,12 +27,14 @@ License-File: LICENSE
27
27
  Provides-Extra: django-keycloak
28
28
  Requires-Dist: PyJWT>=2.10.1; extra == "django-keycloak"
29
29
  Requires-Dist: mozilla-django-oidc>=4.0.1; extra == "django-keycloak"
30
+ Requires-Dist: requests; extra == "django-keycloak"
30
31
  Provides-Extra: django-keycloak-groups
31
32
  Requires-Dist: python-keycloak>=5.8.1; extra == "django-keycloak-groups"
32
33
  Provides-Extra: all
33
34
  Requires-Dist: PyJWT>=2.10.1; extra == "all"
34
35
  Requires-Dist: mozilla-django-oidc>=4.0.1; extra == "all"
35
36
  Requires-Dist: python-keycloak>=5.8.1; extra == "all"
37
+ Requires-Dist: requests; extra == "all"
36
38
  Provides-Extra: dev
37
39
  Requires-Dist: pytest>=7.0; extra == "dev"
38
40
  Requires-Dist: pytest-django>=4.5; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev30
3
+ Version: 0.5.dev32
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -27,12 +27,14 @@ License-File: LICENSE
27
27
  Provides-Extra: django-keycloak
28
28
  Requires-Dist: PyJWT>=2.10.1; extra == "django-keycloak"
29
29
  Requires-Dist: mozilla-django-oidc>=4.0.1; extra == "django-keycloak"
30
+ Requires-Dist: requests; extra == "django-keycloak"
30
31
  Provides-Extra: django-keycloak-groups
31
32
  Requires-Dist: python-keycloak>=5.8.1; extra == "django-keycloak-groups"
32
33
  Provides-Extra: all
33
34
  Requires-Dist: PyJWT>=2.10.1; extra == "all"
34
35
  Requires-Dist: mozilla-django-oidc>=4.0.1; extra == "all"
35
36
  Requires-Dist: python-keycloak>=5.8.1; extra == "all"
37
+ Requires-Dist: requests; extra == "all"
36
38
  Provides-Extra: dev
37
39
  Requires-Dist: pytest>=7.0; extra == "dev"
38
40
  Requires-Dist: pytest-django>=4.5; extra == "dev"
@@ -0,0 +1,58 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.rst
4
+ pyproject.toml
5
+ cardo_python_utils.egg-info/PKG-INFO
6
+ cardo_python_utils.egg-info/SOURCES.txt
7
+ cardo_python_utils.egg-info/dependency_links.txt
8
+ cardo_python_utils.egg-info/requires.txt
9
+ cardo_python_utils.egg-info/top_level.txt
10
+ python_utils/__init__.py
11
+ python_utils/choices.py
12
+ python_utils/data_structures.py
13
+ python_utils/db.py
14
+ python_utils/django_utils.py
15
+ python_utils/esma_choices.py
16
+ python_utils/exceptions.py
17
+ python_utils/imports.py
18
+ python_utils/math.py
19
+ python_utils/text.py
20
+ python_utils/time.py
21
+ python_utils/types_hinting.py
22
+ python_utils/django/README.md
23
+ python_utils/django/__init__.py
24
+ python_utils/django/apps.py
25
+ python_utils/django/oidc_settings.py
26
+ python_utils/django/settings.py
27
+ python_utils/django/tenant_context.py
28
+ python_utils/django/admin/__init__.py
29
+ python_utils/django/admin/auth.py
30
+ python_utils/django/admin/user_group.py
31
+ python_utils/django/admin/views.py
32
+ python_utils/django/admin/templates/__init__.py
33
+ python_utils/django/admin/templates/user_groups_changelist.html
34
+ python_utils/django/api/__init__.py
35
+ python_utils/django/api/drf.py
36
+ python_utils/django/api/ninja.py
37
+ python_utils/django/api/utils.py
38
+ python_utils/django/auth/service.py
39
+ python_utils/django/celery/__init__.py
40
+ python_utils/django/celery/tenant_aware_task.py
41
+ python_utils/django/db/__init__.py
42
+ python_utils/django/db/routers.py
43
+ python_utils/django/db/transaction.py
44
+ python_utils/django/db/utils.py
45
+ python_utils/django/management/__init__.py
46
+ python_utils/django/management/commands/__init__.py
47
+ python_utils/django/management/commands/migrateall.py
48
+ python_utils/django/management/commands/tenant_aware_command.py
49
+ python_utils/django/middleware/__init__.py
50
+ python_utils/django/middleware/tenant_aware_http_middleware.py
51
+ python_utils/django/models/__init__.py
52
+ python_utils/django/models/user_group.py
53
+ python_utils/django/redis/__init__.py
54
+ python_utils/django/redis/key_function.py
55
+ python_utils/django/storage/__init__.py
56
+ python_utils/django/storage/tenant_aware_storage.py
57
+ python_utils/django/tests/__init__.py
58
+ python_utils/django/tests/conftest.py
@@ -3,6 +3,7 @@
3
3
  PyJWT>=2.10.1
4
4
  mozilla-django-oidc>=4.0.1
5
5
  python-keycloak>=5.8.1
6
+ requests
6
7
 
7
8
  [dev]
8
9
  pytest>=7.0
@@ -13,6 +14,7 @@ tox>=3.25
13
14
  [django-keycloak]
14
15
  PyJWT>=2.10.1
15
16
  mozilla-django-oidc>=4.0.1
17
+ requests
16
18
 
17
19
  [django-keycloak-groups]
18
20
  python-keycloak>=5.8.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cardo-python-utils"
7
- version = "0.5.dev30"
7
+ version = "0.5.dev32"
8
8
  description = "Python library enhanced with a wide range of functions for different scenarios."
9
9
  readme = "README.rst"
10
10
  requires-python = ">=3.8"
@@ -34,6 +34,7 @@ dependencies = []
34
34
  django-keycloak = [
35
35
  "PyJWT>=2.10.1",
36
36
  "mozilla-django-oidc>=4.0.1",
37
+ "requests",
37
38
  ]
38
39
  django-keycloak-groups = [
39
40
  "python-keycloak>=5.8.1",
@@ -42,6 +43,7 @@ all = [
42
43
  "PyJWT>=2.10.1",
43
44
  "mozilla-django-oidc>=4.0.1",
44
45
  "python-keycloak>=5.8.1",
46
+ "requests",
45
47
  ]
46
48
  dev = [
47
49
  "pytest>=7.0",
@@ -0,0 +1,122 @@
1
+ This package provides utilities for facilitating IDP communication and multi-tenancy support.
2
+
3
+ # Usage
4
+
5
+
6
+ ## Environment variables to set
7
+
8
+ - AWS_STORAGE_TENANT_BUCKET_NAMES
9
+ - A JSON dictionary where each key is the tenant name and the value is the bucket name.
10
+ - DATABASE_CONFIG
11
+ - A dictionary where each key is the tenant name and the value is a dict with the datase config.
12
+ - If multiple 'DATABASE_CONFIG'-prefixed variables are set, they will be merged into a single dictionary.
13
+ - KEYCLOAK_SERVER_URL
14
+ - The URL of the Keycloak deployment
15
+ - KEYCLOAK_CONFIDENTIAL_CLIENT_ID
16
+ - The id of the confidential client of the backend service
17
+
18
+ ## settings.py file
19
+
20
+ ### For multi-tenancy
21
+
22
+ ```python3
23
+ INSTALLED_APPS = [
24
+ ...
25
+ "python_utils.django",
26
+ ...
27
+ ]
28
+
29
+ MIDDLEWARE = [
30
+ "python_utils.django.middleware.TenantAwareHttpMiddleware",
31
+ ...
32
+ ]
33
+
34
+ # Include the database configuration for each tenant in the DATABASES setting.
35
+ # You can use the get_database_configs() function from python_utils.django.db.utils as a helper.
36
+ DATABASES = {
37
+ 'tenant1': { ... },
38
+ 'tenant2': { ... },
39
+ ...
40
+ }
41
+
42
+ # If you want to override the database alias to use for local development (when DEBUG is True).
43
+ # By default, the first database defined in DATABASES is used.
44
+ DEVELOPMENT_TENANT = "development"
45
+
46
+ # This is required to use the tenant context when routing database queries
47
+ DATABASE_ROUTERS = [
48
+ "python_utils.django.db.routers.TenantAwareRouter"
49
+ ]
50
+
51
+ # If using celery, set the task class to TenantAwareTask:
52
+ CELERY_TASK_CLS = "python_utils.django.celery.TenantAwareTask"
53
+
54
+ # If using Redis caching, configure the cache backend as follows:
55
+ CACHES = {
56
+ "default": {
57
+ "BACKEND": "django_redis.cache.RedisCache",
58
+ "LOCATION": REDIS_LOCATION,
59
+ "KEY_FUNCTION": "python_utils.django.redis.make_tenant_aware_key",
60
+ **OPTIONS,
61
+ }
62
+ }
63
+
64
+ # If using Django Storages with S3, configure the storage backends as follows:
65
+ STORAGES = {
66
+ "default": {
67
+ "BACKEND": "python_utils.django.storage.TenantAwarePrivateS3Storage",
68
+ },
69
+ }
70
+
71
+ # If you want to exclude certain paths from tenant processing, use TENANT_AWARE_EXCLUDED_PATHS:
72
+ # They are considered as prefixes, so all paths starting with the given strings will be excluded.
73
+ TENANT_AWARE_EXCLUDED_PATHS = ("/some/path",)
74
+ ```
75
+
76
+ ### For OIDC auth
77
+
78
+ ```python3
79
+ from python_utils.django.admin.templates import TEMPLATE_PATH
80
+
81
+ INSTALLED_APPS.append("mozilla_django_oidc")
82
+ TEMPLATES[0]["DIRS"].append(TEMPLATE_PATH)
83
+
84
+ JWT_AUDIENCE = "myapp"
85
+ JWT_SCOPE_PREFIX = "myapp"
86
+
87
+ # If using DRF
88
+ REST_FRAMEWORK.update(
89
+ {
90
+ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
91
+ "DEFAULT_AUTHENTICATION_CLASSES": ("python_utils.django.api.drf.AuthenticationBackend",),
92
+ "DEFAULT_PERMISSION_CLASSES": (
93
+ "rest_framework.permissions.IsAuthenticated",
94
+ "python_utils.django.api.drf.HasScope",
95
+ ),
96
+ }
97
+ )
98
+
99
+ # Admin auth backends
100
+ AUTHENTICATION_BACKENDS = [
101
+ "django.contrib.auth.backends.ModelBackend",
102
+ "python_utils.django.admin.auth.AdminAuthenticationBackend",
103
+ ]
104
+
105
+ # If user groups are used for Row Level Security (RLS)
106
+ KEYCLOAK_USER_GROUP_MODEL = "myapp.UserGroup"
107
+
108
+ KEYCLOAK_CONFIDENTIAL_CLIENT_ID = os.getenv("KEYCLOAK_CONFIDENTIAL_CLIENT_ID", f"{JWT_AUDIENCE}_confidential")
109
+
110
+ OIDC_RP_CLIENT_ID = KEYCLOAK_CONFIDENTIAL_CLIENT_ID
111
+ OIDC_RP_CLIENT_SECRET = None
112
+ OIDC_RP_SIGN_ALGO = "RS256"
113
+ OIDC_CREATE_USER = True
114
+
115
+ LOGIN_REDIRECT_URL = "/admin"
116
+ SESSION_COOKIE_AGE = 60 * 30 # 30 minutes
117
+ SESSION_SAVE_EVERY_REQUEST = True # Extend session on each request
118
+ ```
119
+
120
+ ## With django-ninja
121
+
122
+ If using `django-ninja`, apart from the settings configured above, auth utils are provided in the django/api/ninja.py module.
@@ -0,0 +1 @@
1
+ default_app_config = "python_utils.django.apps.DjangoAppConfig"
@@ -1,17 +1,48 @@
1
1
  from django.conf import settings
2
2
  from mozilla_django_oidc.auth import OIDCAuthenticationBackend
3
3
 
4
- from ...utils import get_keycloak_confidential_client_token
4
+ from ..oidc_settings import (
5
+ get_oidc_confidential_client_token,
6
+ get_oidc_op_token_endpoint,
7
+ get_oidc_op_user_endpoint,
8
+ get_oidc_op_jwks_endpoint,
9
+ )
5
10
 
6
11
 
7
12
  class AdminAuthenticationBackend(OIDCAuthenticationBackend):
13
+ """
14
+ Tenant-aware OIDC authentication backend for Django admin.
15
+
16
+ This backend dynamically resolves OIDC endpoints based on the current
17
+ tenant context, allowing each tenant to authenticate against their
18
+ own Keycloak realm.
19
+ """
20
+
21
+ @property
22
+ def OIDC_OP_TOKEN_ENDPOINT(self):
23
+ """Dynamically get the token endpoint for the current tenant."""
24
+
25
+ return get_oidc_op_token_endpoint()
26
+
27
+ @property
28
+ def OIDC_OP_USER_ENDPOINT(self):
29
+ """Dynamically get the userinfo endpoint for the current tenant."""
30
+
31
+ return get_oidc_op_user_endpoint()
32
+
33
+ @property
34
+ def OIDC_OP_JWKS_ENDPOINT(self):
35
+ """Dynamically get the JWKS endpoint for the current tenant."""
36
+
37
+ return get_oidc_op_jwks_endpoint()
38
+
8
39
  def get_token(self, payload):
9
40
  # Instead of passing client_id and client_secret,
10
41
  # client_assertion with service account token will be used
11
42
  payload.pop("client_id", None)
12
43
  payload.pop("client_secret", None)
13
44
 
14
- return get_keycloak_confidential_client_token(**payload)
45
+ return get_oidc_confidential_client_token(**payload)
15
46
 
16
47
  def _get_user_data(self, claims) -> dict:
17
48
  client_roles = (
@@ -0,0 +1,3 @@
1
+ import os
2
+
3
+ TEMPLATE_PATH = os.path.dirname(__file__)
@@ -5,7 +5,7 @@ from django.db.models import Count
5
5
  from django.urls import path
6
6
  from django.shortcuts import redirect
7
7
 
8
- from ..service import KeycloakService
8
+ from ..auth.service import KeycloakService
9
9
 
10
10
 
11
11
  class UserGroupAdminMetaclass(forms.MediaDefiningClass):
@@ -134,7 +134,7 @@ class UserGroupAdminBase(admin.ModelAdmin, metaclass=UserGroupAdminMetaclass):
134
134
  ]
135
135
 
136
136
  @staticmethod
137
- def sync_groups_with_keycloak(request): # noqa
137
+ def sync_groups_with_keycloak(request):
138
138
  """Syncs user groups with Keycloak"""
139
139
 
140
140
  try:
@@ -0,0 +1,63 @@
1
+ """
2
+ Tenant-aware OIDC views for mozilla-django-oidc.
3
+
4
+ These views override the default mozilla-django-oidc views to dynamically
5
+ resolve OIDC endpoints based on the current tenant context.
6
+ """
7
+
8
+ from mozilla_django_oidc.views import (
9
+ OIDCAuthenticationCallbackView,
10
+ OIDCAuthenticationRequestView,
11
+ OIDCLogoutView,
12
+ )
13
+
14
+ from ..oidc_settings import (
15
+ get_oidc_op_authorization_endpoint,
16
+ get_oidc_op_logout_endpoint,
17
+ )
18
+
19
+
20
+ class TenantAwareOIDCAuthenticationRequestView(OIDCAuthenticationRequestView):
21
+ """
22
+ Tenant-aware OIDC authentication request view.
23
+
24
+ Dynamically resolves the authorization endpoint based on the current tenant.
25
+ """
26
+
27
+ @property
28
+ def OIDC_OP_AUTH_ENDPOINT(self):
29
+ """Dynamically get the authorization endpoint for the current tenant."""
30
+ return get_oidc_op_authorization_endpoint()
31
+
32
+ def get_settings(self, attr, *args):
33
+ if attr == "OIDC_OP_AUTHORIZATION_ENDPOINT":
34
+ return self.OIDC_OP_AUTH_ENDPOINT
35
+ return super().get_settings(attr, *args)
36
+
37
+
38
+ class TenantAwareOIDCAuthenticationCallbackView(OIDCAuthenticationCallbackView):
39
+ """
40
+ Tenant-aware OIDC authentication callback view.
41
+
42
+ Uses the tenant-aware authentication backend.
43
+ """
44
+
45
+ pass
46
+
47
+
48
+ class TenantAwareOIDCLogoutView(OIDCLogoutView):
49
+ """
50
+ Tenant-aware OIDC logout view.
51
+
52
+ Dynamically resolves the logout endpoint based on the current tenant.
53
+ """
54
+
55
+ @property
56
+ def OIDC_OP_LOGOUT_ENDPOINT(self):
57
+ """Dynamically get the logout endpoint for the current tenant."""
58
+ return get_oidc_op_logout_endpoint()
59
+
60
+ def get_settings(self, attr, *args):
61
+ if attr == "OIDC_OP_LOGOUT_ENDPOINT":
62
+ return self.OIDC_OP_LOGOUT_ENDPOINT
63
+ return super().get_settings(attr, *args)
@@ -4,10 +4,26 @@ from django.conf import settings
4
4
  from django.contrib.auth import get_user_model
5
5
  from jwt import decode, PyJWKClient
6
6
 
7
- jwks_client = PyJWKClient(getattr(settings, "JWKS_URL", ""))
7
+ from django.oidc_settings import get_oidc_op_jwks_endpoint
8
+
9
+ from ..tenant_context import TenantContext
10
+
11
+ JWKS_CLIENTS = {} # Cache for PyJWKClient instances
12
+
13
+
14
+ def get_jwks_client():
15
+ tenant = TenantContext.get()
16
+
17
+ if tenant not in JWKS_CLIENTS:
18
+ jwks_client = PyJWKClient(get_oidc_op_jwks_endpoint())
19
+ JWKS_CLIENTS[tenant] = jwks_client
20
+
21
+ return JWKS_CLIENTS[tenant]
22
+
8
23
 
9
24
  User = get_user_model()
10
25
 
26
+
11
27
  class TokenPayload(TypedDict, total=False):
12
28
  exp: int
13
29
  iat: int
@@ -35,6 +51,7 @@ def decode_jwt(token: str, audience: Optional[str] = None) -> TokenPayload:
35
51
  jwt.exceptions.PyJWKClientError: If there is an error fetching the signing key.
36
52
  jwt.exceptions.InvalidTokenError: If the token is invalid or cannot be decoded.
37
53
  """
54
+ jwks_client = get_jwks_client()
38
55
  signing_key = jwks_client.get_signing_key_from_jwt(token)
39
56
 
40
57
  if audience is None:
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoAppConfig(AppConfig):
5
+ name = "python_utils.django"
@@ -5,12 +5,12 @@ from keycloak import KeycloakAdmin
5
5
  from keycloak import KeycloakOpenIDConnection
6
6
  from keycloak.exceptions import KeycloakGetError
7
7
 
8
- from ..utils import (
8
+ from ..oidc_settings import (
9
9
  KEYCLOAK_SERVER_URL,
10
- KEYCLOAK_REALM,
11
10
  KEYCLOAK_CONFIDENTIAL_CLIENT_ID,
12
- get_keycloak_confidential_client_token,
11
+ get_oidc_confidential_client_token,
13
12
  )
13
+ from ..tenant_context import TenantContext
14
14
 
15
15
 
16
16
  def get_user_group_model():
@@ -67,12 +67,14 @@ class KeycloakService:
67
67
  deleted_groups.delete()
68
68
 
69
69
  def _get_keycloak_admin(self):
70
+ tenant = TenantContext.get()
71
+
70
72
  keycloak_connection = KeycloakOpenIDConnection(
71
73
  server_url=KEYCLOAK_SERVER_URL,
72
- realm_name=KEYCLOAK_REALM,
73
- user_realm_name=KEYCLOAK_REALM,
74
+ realm_name=tenant,
75
+ user_realm_name=tenant,
74
76
  client_id=KEYCLOAK_CONFIDENTIAL_CLIENT_ID,
75
- token=get_keycloak_confidential_client_token(),
77
+ token=get_oidc_confidential_client_token(),
76
78
  verify=True,
77
79
  )
78
80
  return KeycloakAdmin(connection=keycloak_connection)
@@ -0,0 +1,4 @@
1
+ from .tenant_aware_task import TenantAwareTask
2
+
3
+
4
+ __all__ = ["TenantAwareTask"]
@@ -0,0 +1,31 @@
1
+ from celery import Task
2
+
3
+ from ..settings import TENANT_KEY
4
+ from ..tenant_context import TenantContext
5
+
6
+
7
+ class TenantAwareTask(Task):
8
+ #: Enable argument checking.
9
+ #: You can set this to false if you don't want the signature to be
10
+ #: checked when calling the task.
11
+ #: Set to false because we are attaching a tenant to the task
12
+ #: and we don't want to check the signature.
13
+ #: Defaults to :attr:`app.strict_typing <@Celery.strict_typing>`.
14
+ typing = False
15
+
16
+ def __call__(self, *args, **kwargs):
17
+ """Override the __call__ method to set the tenant name in the thread namespace."""
18
+
19
+ # Celery backend_cleanup doesn't need a tenant and cannot be configured to pass the tenant kwarg
20
+ # because it is dynamically generated at @connect_on_app_finalize by celery itself
21
+ # ref: celery/app/builtins.py def add_backend_cleanup_task
22
+ if self.name != "celery.backend_cleanup":
23
+ tenant = kwargs.pop(TENANT_KEY)
24
+ TenantContext.set(tenant)
25
+
26
+ return self.run(*args, **kwargs)
27
+
28
+ def after_return(self, status, retval, task_id, args, kwargs, einfo):
29
+ """Clear the tenant from the thread namespace after the task has returned."""
30
+ TenantContext.clear()
31
+ super().after_return(status, retval, task_id, args, kwargs, einfo)
@@ -0,0 +1,11 @@
1
+ from ..tenant_context import TenantContext
2
+
3
+
4
+ class TenantAwareRouter:
5
+ @staticmethod
6
+ def db_for_read(model, **hints):
7
+ return TenantContext.get()
8
+
9
+ @staticmethod
10
+ def db_for_write(model, **hints):
11
+ return TenantContext.get()
@@ -0,0 +1,26 @@
1
+ from django.db.transaction import Atomic
2
+
3
+ from ..tenant_context import TenantContext
4
+
5
+
6
+ class TenantAtomic(Atomic):
7
+ """
8
+ A transaction that can be used in a multi-tenant application.
9
+ This transaction is bound to the current tenant.
10
+ The default implementation is to use the default database.
11
+ We want to override this by using the tenant database alias instead.
12
+ """
13
+
14
+ def __enter__(self):
15
+ self.using = TenantContext.get()
16
+ super().__enter__()
17
+
18
+
19
+ def tenant_atomic(using=None, savepoint=True, durable=False):
20
+ # Bare decorator: @atomic -- although the first argument is called
21
+ # `using`, it's actually the function being decorated.
22
+ if callable(using):
23
+ return TenantAtomic(using=None, savepoint=savepoint, durable=durable)(using)
24
+ # Decorator: @atomic(...) or context manager: with atomic(...): ...
25
+ else:
26
+ return TenantAtomic(using, savepoint, durable)
@@ -0,0 +1,66 @@
1
+ from functools import reduce
2
+ import json
3
+ import os
4
+ from typing import NotRequired, TypedDict
5
+ from django.db import connections
6
+
7
+
8
+ class DatabaseConfigData(TypedDict):
9
+ host: str
10
+ name: str
11
+ user: str
12
+ password: str
13
+ port: NotRequired[int]
14
+
15
+
16
+ def get_connection(tenant: str = None):
17
+ """
18
+ Get the connection to the database with the given tenant or the default one.
19
+ Default value will be retrieved from TenantContext.get()
20
+ This uses the connections dict from django.db to get the required connection.
21
+
22
+ The default implementation of this function uses the 'default' alias
23
+ if not argument is given. We want to use the TenantContext class to
24
+ determine the correct alias.
25
+ Args:
26
+ tenant: Name of the tenant database alias
27
+
28
+ Returns: The connection to the database
29
+
30
+ """
31
+ from ..tenant_context import TenantContext
32
+
33
+ database_alias = tenant or TenantContext.get()
34
+ connection = connections[database_alias]
35
+
36
+ return connection
37
+
38
+
39
+ def get_database_configs() -> dict[str, DatabaseConfigData]:
40
+ """
41
+ The env variables prefixed with 'DATABASE_CONFIG' should be provided in the following format:
42
+ {
43
+ "tenant1": {
44
+ "host": "",
45
+ "name": "",
46
+ "user": "",
47
+ "password": "",
48
+ "port": 5432 / null
49
+ },
50
+ "tenant2": {
51
+ "host": "",
52
+ "name": "",
53
+ "user": "",
54
+ "password": ""
55
+ }
56
+ }
57
+ If multiple 'DATABASE_CONFIG'-prefixed variables are set, they will be merged into a single dictionary.
58
+ """
59
+ configs = [json.loads(v or "{}") for k, v in os.environ.items() if k.startswith("DATABASE_CONFIG")]
60
+ database_configs = reduce(lambda a, b: {**a, **b}, configs, {})
61
+ for tenant, db_config in database_configs.items():
62
+ for key in ["host", "name", "user", "password"]:
63
+ if key not in db_config:
64
+ raise ValueError(f"Missing required database config '{key}' for tenant {tenant}")
65
+
66
+ return database_configs
@@ -0,0 +1,25 @@
1
+ from django.core.management.commands.migrate import Command as BaseMigrateCommand
2
+ from django.db.models.signals import post_migrate, pre_migrate
3
+ from django.dispatch import receiver
4
+
5
+ from ...settings import TENANT_DATABASES
6
+ from ...tenant_context import TenantContext
7
+
8
+
9
+ @receiver(pre_migrate)
10
+ def set_tenant_pre_migrate(sender, **kwargs):
11
+ database_alias = kwargs.get("using")
12
+ TenantContext.set(database_alias)
13
+
14
+
15
+ @receiver(post_migrate)
16
+ def clear_tenant_post_migrate(sender, **kwargs):
17
+ TenantContext.clear()
18
+
19
+
20
+ class Command(BaseMigrateCommand):
21
+ def handle(self, *args, **options):
22
+ for tenant in TENANT_DATABASES:
23
+ self.stdout.write(f"Migrating tenant: {tenant}")
24
+ options["database"] = tenant
25
+ super().handle(*args, **options)