cardo-python-utils 0.5.dev11__tar.gz → 0.5.dev13__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 (35) hide show
  1. {cardo_python_utils-0.5.dev11/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev13}/PKG-INFO +1 -1
  2. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13/cardo_python_utils.egg-info}/PKG-INFO +1 -1
  3. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/pyproject.toml +1 -1
  4. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/api/ninja.py +82 -12
  5. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/api/utils.py +46 -12
  6. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/service.py +89 -1
  7. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/LICENSE +0 -0
  8. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/MANIFEST.in +0 -0
  9. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/README.rst +0 -0
  10. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/cardo_python_utils.egg-info/SOURCES.txt +0 -0
  11. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
  12. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/cardo_python_utils.egg-info/requires.txt +0 -0
  13. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/cardo_python_utils.egg-info/top_level.txt +0 -0
  14. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/__init__.py +0 -0
  15. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/choices.py +0 -0
  16. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/data_structures.py +0 -0
  17. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/db.py +0 -0
  18. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/__init__.py +0 -0
  19. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/admin/__init__.py +0 -0
  20. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/admin/auth.py +0 -0
  21. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/admin/user_group.py +0 -0
  22. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/admin/user_groups_changelist.html +0 -0
  23. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/api/__init__.py +0 -0
  24. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/api/drf.py +0 -0
  25. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/models/__init__.py +0 -0
  26. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/keycloak/models/user_group.py +0 -0
  27. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/django/utils.py +0 -0
  28. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/esma_choices.py +0 -0
  29. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/exceptions.py +0 -0
  30. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/imports.py +0 -0
  31. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/math.py +0 -0
  32. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/text.py +0 -0
  33. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/time.py +0 -0
  34. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/python_utils/types_hinting.py +0 -0
  35. {cardo_python_utils-0.5.dev11 → cardo_python_utils-0.5.dev13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev11
3
+ Version: 0.5.dev13
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev11
3
+ Version: 0.5.dev13
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cardo-python-utils"
7
- version = "0.5.dev11"
7
+ version = "0.5.dev13"
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"
@@ -1,34 +1,77 @@
1
- from typing import Literal
1
+ import logging
2
+ from typing import Literal, Optional
2
3
 
3
4
  from jwt.exceptions import InvalidTokenError
4
5
 
5
6
  from django.conf import settings
7
+ from django.http import HttpRequest
6
8
  from ninja.security import HttpBearer
7
9
  from ninja.errors import AuthenticationError, HttpError
8
10
 
9
- from .utils import create_or_update_user, decode_jwt
11
+ from .utils import (
12
+ acreate_or_update_user,
13
+ create_or_update_user,
14
+ decode_jwt,
15
+ TokenPayload,
16
+ )
17
+
18
+ logger = logging.getLogger()
10
19
 
11
20
 
12
21
  class AuthBearer(HttpBearer):
13
- def authenticate(self, request, token):
22
+ def __call__(self, request: HttpRequest):
23
+ token = self._get_token(request)
24
+ if not token:
25
+ return None
26
+
27
+ return self.authenticate(request, token)
28
+
29
+ def authenticate(self, request: HttpRequest, token: str) -> TokenPayload:
30
+ payload = self._decode_token(token)
31
+
32
+ username = self._get_username(payload)
33
+
34
+ user = create_or_update_user(username, payload)
35
+
36
+ self._verify_scopes(request, payload)
37
+
38
+ request.user = user
39
+
40
+ # The return value is stored in request.auth
41
+ return payload
42
+
43
+ def _get_token(self, request: HttpRequest) -> Optional[str]:
44
+ """
45
+ This part of the token validation is similar to what
46
+ django-ninja is doing in HttpBearer.__call__
47
+ """
48
+ headers = request.headers
49
+ auth_value = headers.get(self.header)
50
+ if not auth_value:
51
+ return None
52
+ parts = auth_value.split(" ")
53
+
54
+ if parts[0].lower() != self.openapi_scheme:
55
+ if settings.DEBUG:
56
+ logger.error(f"Unexpected auth - '{auth_value}'")
57
+ return None
58
+
59
+ return " ".join(parts[1:])
60
+
61
+ def _decode_token(self, token: str) -> TokenPayload:
14
62
  try:
15
- payload = decode_jwt(token)
63
+ return decode_jwt(token)
16
64
  except InvalidTokenError as e:
17
65
  raise AuthenticationError(f"Invalid token: {str(e)}") from e
18
66
 
67
+ def _get_username(self, payload: TokenPayload) -> str:
19
68
  try:
20
- username = payload["preferred_username"]
69
+ return payload["preferred_username"]
21
70
  except KeyError as e:
22
71
  raise AuthenticationError(
23
- "Invalid token: preferred_username not present."
72
+ "Invalid token: 'preferred_username' claim not present."
24
73
  ) from e
25
74
 
26
- user = create_or_update_user(username, payload)
27
-
28
- self._verify_scopes(request, payload)
29
-
30
- return user
31
-
32
75
  def _verify_scopes(self, request, token_payload):
33
76
  allowed_scopes = self._get_view_allowed_scopes(request)
34
77
 
@@ -68,6 +111,33 @@ class AuthBearer(HttpBearer):
68
111
  )
69
112
 
70
113
 
114
+ class AuthBearerAsync(AuthBearer):
115
+ """
116
+ Same as AuthBearer, but with async __call__ and authenticate methods.
117
+ """
118
+
119
+ async def __call__(self, request: HttpRequest):
120
+ token = self._get_token(request)
121
+ if not token:
122
+ return None
123
+
124
+ return await self.authenticate(request, token)
125
+
126
+ async def authenticate(self, request: HttpRequest, token: str) -> TokenPayload:
127
+ payload = self._decode_token(token)
128
+
129
+ username = self._get_username(payload)
130
+
131
+ user = await acreate_or_update_user(username, payload)
132
+
133
+ self._verify_scopes(request, payload)
134
+
135
+ request.user = user
136
+
137
+ # The return value is stored in request.auth
138
+ return payload
139
+
140
+
71
141
  def allowed_scopes(scopes: list[str] | Literal["*"]):
72
142
  """
73
143
  A decorator that attaches a list of required scopes to a view function
@@ -6,6 +6,7 @@ from jwt import decode, PyJWKClient
6
6
 
7
7
  jwks_client = PyJWKClient(getattr(settings, "JWKS_URL", ""))
8
8
 
9
+ User = get_user_model()
9
10
 
10
11
  class TokenPayload(TypedDict, total=False):
11
12
  exp: int
@@ -43,35 +44,68 @@ def decode_jwt(token: str) -> TokenPayload:
43
44
  )
44
45
 
45
46
 
46
- def create_or_update_user(username: str, payload: TokenPayload):
47
+ def get_user_data_from_payload(payload: TokenPayload) -> dict:
47
48
  """
48
- Create or update a user based on the JWT payload.
49
+ Extract user data from the JWT payload.
49
50
  """
50
- user_model = get_user_model()
51
51
  user_data = {
52
52
  "first_name": payload.get("given_name") or "",
53
53
  "last_name": payload.get("family_name") or "",
54
54
  "email": payload.get("email") or "",
55
55
  "is_staff": payload.get("is_staff", False),
56
56
  }
57
- if hasattr(user_model, "is_demo"):
57
+
58
+ if hasattr(User, "is_demo"):
58
59
  user_data["is_demo"] = payload.get("is_demo", False)
59
60
 
60
- user = user_model.objects.filter(username=username).first()
61
- if user:
62
- update_needed = False
61
+ return user_data
62
+
63
+
64
+ def update_user_from_user_data(user, user_data: dict) -> bool:
65
+ update_needed = False
66
+
67
+ for field, value in user_data.items():
68
+ if getattr(user, field) != value:
69
+ setattr(user, field, value)
70
+ update_needed = True
63
71
 
64
- for field, value in user_data.items():
65
- if getattr(user, field) != value:
66
- setattr(user, field, value)
67
- update_needed = True
72
+ return update_needed
73
+
74
+
75
+ def create_or_update_user(username: str, payload: TokenPayload):
76
+ """
77
+ Create or update a user based on the JWT payload.
78
+ """
79
+ user_data = get_user_data_from_payload(payload)
80
+
81
+ user = User.objects.filter(username=username).first()
82
+ if user:
83
+ update_needed = update_user_from_user_data(user, user_data)
68
84
 
69
85
  if update_needed:
70
86
  user.save(update_fields=list(user_data.keys()))
71
87
 
72
88
  return user
73
89
  else:
74
- return user_model.objects.create(
90
+ return User.objects.create(
91
+ username=username,
92
+ **user_data,
93
+ )
94
+
95
+
96
+ async def acreate_or_update_user(username: str, payload: TokenPayload):
97
+ user_data = get_user_data_from_payload(payload)
98
+
99
+ user = await User.objects.filter(username=username).afirst()
100
+ if user:
101
+ update_needed = update_user_from_user_data(user, user_data)
102
+
103
+ if update_needed:
104
+ await user.asave(update_fields=list(user_data.keys()))
105
+
106
+ return user
107
+ else:
108
+ return await User.objects.acreate(
75
109
  username=username,
76
110
  **user_data,
77
111
  )
@@ -1,9 +1,11 @@
1
1
  from django.apps import apps
2
2
  from django.conf import settings
3
+ from django.db import models
3
4
  from keycloak import KeycloakAdmin
4
5
  from keycloak import KeycloakOpenIDConnection
5
6
  from keycloak.exceptions import KeycloakGetError
6
7
 
8
+
7
9
  def _get_user_group_model():
8
10
  """
9
11
  Dynamically get the UserGroup model.
@@ -76,7 +78,7 @@ class KeycloakService:
76
78
  return KeycloakAdmin(connection=keycloak_connection)
77
79
 
78
80
  def _process_group_recursively(
79
- self, group, existing_groups_by_id, reported_group_ids
81
+ self, group: dict, existing_groups_by_id: dict[str, models.Model], reported_group_ids: set[str]
80
82
  ):
81
83
  group_id = str(group["id"])
82
84
  reported_group_ids.add(group_id)
@@ -100,6 +102,74 @@ class KeycloakService:
100
102
  )
101
103
 
102
104
 
105
+ class KeycloakServiceAsync(KeycloakService):
106
+ """
107
+ Async version of KeycloakService.
108
+ """
109
+
110
+ async def sync_user_groups(self, raise_exceptions: bool = False):
111
+ print("Syncing user groups from Keycloak...")
112
+
113
+ try:
114
+ groups = self._keycloak_admin.get_groups(full_hierarchy=True)
115
+ except KeycloakGetError as e:
116
+ print(f"Failed to fetch groups from Keycloak: {str(e)}")
117
+ if raise_exceptions:
118
+ raise e
119
+
120
+ return
121
+
122
+ # Process existing and new groups
123
+ existing_groups = self._user_group_model.objects.all()
124
+ existing_groups_by_id = {
125
+ str(group.id): group async for group in existing_groups
126
+ }
127
+
128
+ reported_group_ids = set()
129
+ for group in groups:
130
+ await self._process_group_recursively(
131
+ group, existing_groups_by_id, reported_group_ids
132
+ )
133
+
134
+ # Identify deleted groups
135
+ deleted_groups = self._user_group_model.objects.exclude(
136
+ id__in=reported_group_ids
137
+ )
138
+ if await deleted_groups.aexists():
139
+ paths = [
140
+ path async for path in deleted_groups.values_list("path", flat=True)
141
+ ]
142
+ print(f"Deleting groups no longer present in Keycloak: {paths}")
143
+
144
+ await deleted_groups.adelete()
145
+
146
+ async def _process_group_recursively(
147
+ self, group: dict, existing_groups_by_id: dict[str, models.Model], reported_group_ids: set[str]
148
+ ):
149
+ group_id = str(group["id"])
150
+ reported_group_ids.add(group_id)
151
+
152
+ if group_id in existing_groups_by_id:
153
+ existing_group = existing_groups_by_id[group_id]
154
+ if existing_group.path != group["path"]:
155
+ print(
156
+ f"Updating group path from {existing_group.path} to {group['path']}..."
157
+ )
158
+ existing_group.path = group["path"]
159
+ await existing_group.asave()
160
+ else:
161
+ print(f"Creating new group with path {group['path']}...")
162
+ await self._user_group_model.objects.acreate(
163
+ id=group_id, path=group["path"]
164
+ )
165
+
166
+ if subgroups := group.get("subGroups"):
167
+ for subgroup in subgroups:
168
+ await self._process_group_recursively(
169
+ subgroup, existing_groups_by_id, reported_group_ids
170
+ )
171
+
172
+
103
173
  class AuthServiceBase:
104
174
  @staticmethod
105
175
  def _get_all_level_paths(path: str) -> list[str]:
@@ -128,3 +198,21 @@ class AuthServiceBase:
128
198
  user_groups = UserGroup.objects.filter(path__in=all_group_paths)
129
199
 
130
200
  return user_groups
201
+
202
+
203
+ class AuthServiceBaseAsync(AuthServiceBase):
204
+ @classmethod
205
+ async def _get_user_groups_from_paths(cls, group_paths: list[str]):
206
+ all_group_paths = set()
207
+ for path in group_paths:
208
+ all_group_paths.update(cls._get_all_level_paths(path))
209
+
210
+ UserGroup = _get_user_group_model()
211
+ user_groups = UserGroup.objects.filter(path__in=all_group_paths)
212
+
213
+ # If a group is missing/has been renamed in Keycloak, sync the groups
214
+ if await user_groups.acount() != len(all_group_paths):
215
+ await KeycloakServiceAsync().sync_user_groups()
216
+ user_groups = UserGroup.objects.filter(path__in=all_group_paths)
217
+
218
+ return user_groups