alpha-python 0.7.4__py3-none-any.whl → 0.7.6__py3-none-any.whl

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.
alpha/__init__.py CHANGED
@@ -7,6 +7,7 @@ from alpha.factories.logging_handler_factory import LoggingHandlerFactory
7
7
  from alpha.factories.model_class_factory import ModelClassFactory
8
8
  from alpha.domain.models.user import User
9
9
  from alpha.domain.models.group import Group
10
+ from alpha.domain.models.permission import Permission
10
11
  from alpha.domain.models.role import Role
11
12
  from alpha.domain.models.base_model import (
12
13
  BaseDomainModel,
@@ -123,6 +124,7 @@ __all__ = [
123
124
  "LifeCycleBase",
124
125
  "User",
125
126
  "Group",
127
+ "Permission",
126
128
  "Role",
127
129
  "OIDCConnector",
128
130
  "KeyCloakOIDCConnector",
@@ -78,14 +78,22 @@ class SqlAlchemyUnitOfWork:
78
78
 
79
79
  return self
80
80
 
81
- def __exit__(self, *args: Any) -> None:
82
- """Finalize the Unit of Work context."""
81
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
82
+ """Finalize the Unit of Work context.
83
+
84
+ Roll back only when leaving the context because of an exception.
85
+ Rolling back after a successful commit can expire ORM state and may
86
+ trigger DetachedInstanceError once the session is closed/detached.
87
+ """
83
88
  if not self._session:
84
89
  raise exceptions.DatabaseSessionError(
85
90
  "No active database session is defined"
86
91
  )
92
+
93
+ if exc_type is not None:
94
+ self._session.rollback()
95
+
87
96
  self._session.close()
88
- self.rollback()
89
97
  self._session = None # type: ignore
90
98
 
91
99
  def commit(self) -> None:
alpha/domain/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from alpha.domain.models.user import User
2
2
  from alpha.domain.models.group import Group
3
+ from alpha.domain.models.permission import Permission
3
4
  from alpha.domain.models.role import Role
4
5
  from alpha.domain.models.base_model import (
5
6
  BaseDomainModel,
@@ -17,5 +18,6 @@ __all__ = [
17
18
  "LifeCycleBase",
18
19
  "User",
19
20
  "Group",
21
+ "Permission",
20
22
  "Role",
21
23
  ]
@@ -1,5 +1,6 @@
1
1
  from alpha.domain.models.user import User
2
2
  from alpha.domain.models.group import Group
3
+ from alpha.domain.models.permission import Permission
3
4
  from alpha.domain.models.role import Role
4
5
  from alpha.domain.models.base_model import (
5
6
  BaseDomainModel,
@@ -17,5 +18,6 @@ __all__ = [
17
18
  "LifeCycleBase",
18
19
  "User",
19
20
  "Group",
21
+ "Permission",
20
22
  "Role",
21
23
  ]
@@ -39,6 +39,20 @@ class Group(LifeCycleBase, BaseDomainModel):
39
39
  permissions: list[str] = field(default_factory=list) # type: ignore
40
40
  is_active: bool = True
41
41
 
42
+ def __str__(self) -> str:
43
+ """Return the name-based representation of the Group instance."""
44
+ return self.name or ""
45
+
46
+ def __repr__(self) -> str:
47
+ """Return the official string representation of the Group instance."""
48
+ return (
49
+ "Group("
50
+ f"id={self.id!r}, "
51
+ f"name={self.name!r}, "
52
+ f"description={self.description!r}"
53
+ ")"
54
+ )
55
+
42
56
  def to_dict(self) -> dict[str, Any]:
43
57
  """Convert the Group instance to a dictionary.
44
58
 
@@ -47,12 +61,38 @@ class Group(LifeCycleBase, BaseDomainModel):
47
61
  dict[str, Any]
48
62
  A dictionary representation of the Group instance.
49
63
  """
64
+ permissions = cast(
65
+ list[str | dict[str, Any]],
66
+ [
67
+ p.to_dict() if hasattr(p, "to_dict") else p # type: ignore
68
+ for p in self.permissions
69
+ ],
70
+ )
71
+ created_at = (
72
+ self.created_at.isoformat()
73
+ if hasattr(self, "created_at") and self.created_at is not None
74
+ else None
75
+ )
76
+ modified_at = (
77
+ self.modified_at.isoformat()
78
+ if hasattr(self, "modified_at") and self.modified_at is not None
79
+ else None
80
+ )
81
+
50
82
  return {
51
83
  "id": self.id,
52
84
  "name": self.name,
53
85
  "description": self.description,
54
- "permissions": self.permissions,
86
+ "permissions": permissions,
55
87
  "is_active": self.is_active,
88
+ "created_by": self.created_by
89
+ if hasattr(self, "created_by") and self.created_by is not None
90
+ else None,
91
+ "created_at": created_at,
92
+ "modified_by": self.modified_by
93
+ if hasattr(self, "modified_by") and self.modified_by is not None
94
+ else None,
95
+ "modified_at": modified_at,
56
96
  }
57
97
 
58
98
  def update(self, obj: DomainModel) -> DomainModel:
@@ -0,0 +1,110 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime, timezone
3
+ from typing import Any, cast
4
+ from uuid import UUID
5
+
6
+ from alpha.domain.models.base_model import BaseDomainModel, DomainModel
7
+ from alpha.domain.models.life_cycle_base import LifeCycleBase
8
+
9
+
10
+ @dataclass(kw_only=True)
11
+ class Permission(LifeCycleBase, BaseDomainModel):
12
+ """Permission class representing a system permission. This class is used to
13
+ define and manage permissions. User and group permissions are handled
14
+ through this class.
15
+
16
+ Attributes
17
+ ----------
18
+ id
19
+ Unique identifier for the permission. Can be a UUID, integer, or
20
+ string.
21
+ name
22
+ Name of the permission.
23
+ description
24
+ Description for the purpose of the permission.
25
+ """
26
+
27
+ id: UUID | int | str | None = None
28
+ name: str
29
+ description: str = ""
30
+
31
+ def __str__(self) -> str:
32
+ """Return the name of the permission.
33
+
34
+ Returns
35
+ -------
36
+ self.name value
37
+ """
38
+ return self.name
39
+
40
+ def __repr__(self) -> str:
41
+ """Return the official string representation of the object.
42
+
43
+ Returns
44
+ -------
45
+ A string representing the Permission instance.
46
+ """
47
+ return (
48
+ f"Permission(name={self.name!r}, description={self.description!r})"
49
+ )
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ """Convert the Permission instance to a dictionary.
53
+
54
+ Returns
55
+ -------
56
+ dict[str, Any]
57
+ A dictionary representation of the Permission instance.
58
+ """
59
+ created_at = (
60
+ self.created_at.isoformat()
61
+ if hasattr(self, "created_at") and self.created_at is not None
62
+ else None
63
+ )
64
+ modified_at = (
65
+ self.modified_at.isoformat()
66
+ if hasattr(self, "modified_at") and self.modified_at is not None
67
+ else None
68
+ )
69
+
70
+ return {
71
+ "id": self.id,
72
+ "name": self.name,
73
+ "description": self.description,
74
+ "created_by": self.created_by
75
+ if hasattr(self, "created_by") and self.created_by is not None
76
+ else None,
77
+ "created_at": created_at,
78
+ "modified_by": self.modified_by
79
+ if hasattr(self, "modified_by") and self.modified_by is not None
80
+ else None,
81
+ "modified_at": modified_at,
82
+ }
83
+
84
+ def update(self, obj: DomainModel) -> DomainModel:
85
+ """Update the Permission instance with data from another Permission
86
+ instance.
87
+
88
+ Parameters
89
+ ----------
90
+ obj
91
+ Permission object to update from.
92
+
93
+ Returns
94
+ -------
95
+ DomainModel
96
+ The updated instance of the Permission.
97
+
98
+ Raises
99
+ ------
100
+ TypeError
101
+ If the provided object is not a Permission instance.
102
+ """
103
+ if not isinstance(obj, Permission):
104
+ raise TypeError("Permission.update expects a Permission instance.")
105
+
106
+ self.name = obj.name
107
+ self.description = obj.description
108
+ self.modified_at = datetime.now(tz=timezone.utc)
109
+
110
+ return cast(DomainModel, self)
@@ -72,6 +72,32 @@ class User(LifeCycleBase, BaseDomainModel):
72
72
  is_active: bool = True
73
73
  admin: bool = False
74
74
 
75
+ def __str__(self) -> str:
76
+ """Return the username of the user.
77
+
78
+ Returns
79
+ -------
80
+ self.username value
81
+ """
82
+ return self.username or ""
83
+
84
+ def __repr__(self) -> str:
85
+ """Return the official string representation of the object.
86
+
87
+ Returns
88
+ -------
89
+ A string representing the User instance.
90
+ """
91
+ return (
92
+ "User("
93
+ f"id={self.id}, "
94
+ f"username={self.username!r}, "
95
+ f"display_name={self.display_name!r}, "
96
+ f"permissions={self.permissions!r}, "
97
+ f"groups={self.groups!r}"
98
+ ")"
99
+ )
100
+
75
101
  @classmethod
76
102
  def from_identity(cls, identity: Identity) -> Self:
77
103
  """Create a User instance from an Identity instance.
@@ -101,6 +127,35 @@ class User(LifeCycleBase, BaseDomainModel):
101
127
  dict[str, Any]
102
128
  A dictionary representation of the User instance.
103
129
  """
130
+ permissions = cast(
131
+ list[str | dict[str, Any]],
132
+ [
133
+ permission.to_dict() # type: ignore
134
+ if hasattr(permission, "to_dict")
135
+ else permission
136
+ for permission in self.permissions
137
+ ],
138
+ )
139
+ groups = cast(
140
+ list[str | dict[str, Any]],
141
+ [
142
+ group.to_dict() # type: ignore
143
+ if hasattr(group, "to_dict")
144
+ else group
145
+ for group in self.groups
146
+ ],
147
+ )
148
+ created_at = (
149
+ self.created_at.isoformat()
150
+ if hasattr(self, "created_at") and self.created_at is not None
151
+ else None
152
+ )
153
+ modified_at = (
154
+ self.modified_at.isoformat()
155
+ if hasattr(self, "modified_at") and self.modified_at is not None
156
+ else None
157
+ )
158
+
104
159
  return {
105
160
  "id": self.id,
106
161
  "username": self.username,
@@ -109,10 +164,18 @@ class User(LifeCycleBase, BaseDomainModel):
109
164
  "email": self.email,
110
165
  "phone": self.phone,
111
166
  "display_name": self.display_name,
112
- "permissions": self.permissions,
113
- "groups": self.groups,
167
+ "permissions": permissions,
168
+ "groups": groups,
114
169
  "is_active": self.is_active,
115
170
  "admin": self.admin,
171
+ "created_by": self.created_by
172
+ if hasattr(self, "created_by") and self.created_by is not None
173
+ else None,
174
+ "created_at": created_at,
175
+ "modified_by": self.modified_by
176
+ if hasattr(self, "modified_by") and self.modified_by is not None
177
+ else None,
178
+ "modified_at": modified_at,
116
179
  }
117
180
 
118
181
  def update(self, obj: DomainModel) -> DomainModel:
@@ -140,4 +203,5 @@ class User(LifeCycleBase, BaseDomainModel):
140
203
  self.modified_at = datetime.now(tz=timezone.utc)
141
204
  self.is_active = obj.is_active
142
205
  self.admin = obj.admin
206
+
143
207
  return cast(DomainModel, self)
@@ -12,6 +12,7 @@ class JWTProviderMixin:
12
12
  """
13
13
 
14
14
  token_factory: TokenFactory | None = None
15
+ _identity_model: type[Identity] = Identity
15
16
 
16
17
  def validate(self, token: Token) -> Identity:
17
18
  """Validate a token and return the associated identity
@@ -43,7 +44,7 @@ class JWTProviderMixin:
43
44
  "Token payload does not contain mandatory 'subject' field"
44
45
  )
45
46
 
46
- return Identity.from_dict(payload)
47
+ return self._identity_model.from_dict(payload)
47
48
 
48
49
  def issue_token(self, identity: Identity) -> Token:
49
50
  """Issue a token for the given identity
@@ -57,6 +58,8 @@ class JWTProviderMixin:
57
58
  -------
58
59
  Token object
59
60
  """
61
+ identity = self._identity_model.from_dict(identity.to_dict())
62
+
60
63
  if not self.token_factory:
61
64
  raise exceptions.MissingDependencyException(
62
65
  "Token factory is not configured"
@@ -18,12 +18,14 @@ class DatabaseProvider(JWTProviderMixin):
18
18
 
19
19
  protocol = "database"
20
20
  token_factory: TokenFactory | None = None
21
+ _identity_model: type[Identity] = Identity
21
22
 
22
23
  def __init__(
23
24
  self,
24
25
  uow: UnitOfWork,
25
26
  token_factory: TokenFactory | None = None,
26
27
  password_factory: PasswordFactory | None = None,
28
+ identity_model: type[Identity] = Identity,
27
29
  user_name_attribute: str = "username",
28
30
  users_repository_name: str = "users",
29
31
  ) -> None:
@@ -42,6 +44,9 @@ class DatabaseProvider(JWTProviderMixin):
42
44
  Password factory instance to handle password hashing and
43
45
  verification, by default None. If None, a default PasswordFactory
44
46
  will be used.
47
+ identity_model
48
+ Identity model class to use for representing users, by default
49
+ Identity
45
50
  user_name_attribute
46
51
  Attribute name to identify the user, by default "username"
47
52
  users_repository_name
@@ -52,6 +57,7 @@ class DatabaseProvider(JWTProviderMixin):
52
57
  self._password_factory = password_factory or PasswordFactory()
53
58
  self._user_name_attribute = user_name_attribute
54
59
  self._users_repository_name = users_repository_name
60
+ self._identity_model = identity_model
55
61
 
56
62
  def authenticate(self, credentials: PasswordCredentials) -> Identity:
57
63
  """Authenticate a user using their credentials.
@@ -73,7 +79,7 @@ class DatabaseProvider(JWTProviderMixin):
73
79
  credentials=credentials, user_repository=users
74
80
  )
75
81
 
76
- return Identity.from_user(user)
82
+ return self._identity_model.from_user(user)
77
83
 
78
84
  def get_user(self, subject: str) -> Identity:
79
85
  """Retrieve a user by their subject identifier.
@@ -95,7 +101,7 @@ class DatabaseProvider(JWTProviderMixin):
95
101
  username=subject, user_repository=users, attribute_name="id"
96
102
  )
97
103
 
98
- return Identity.from_user(user)
104
+ return self._identity_model.from_user(user)
99
105
 
100
106
  def change_password(
101
107
  self, credentials: PasswordCredentials, new_password: str
@@ -33,6 +33,7 @@ class LDAPProvider(JWTProviderMixin):
33
33
 
34
34
  protocol = "ldap"
35
35
  token_factory: TokenFactory | None = None
36
+ _identity_model: type[Identity] = Identity
36
37
 
37
38
  def __init__(
38
39
  self,
@@ -45,8 +46,10 @@ class LDAPProvider(JWTProviderMixin):
45
46
  populate_groups: bool = True,
46
47
  populate_permissions: bool = False,
47
48
  populate_claims: bool = True,
49
+ flatten_claims: bool = True,
48
50
  auto_connect: bool = True,
49
51
  change_password_supported: bool = False,
52
+ identity_model: type[Identity] = Identity,
50
53
  additional_connector_params: dict[str, Any] | None = None,
51
54
  ) -> None:
52
55
  """Initialize LDAPProvider.
@@ -71,11 +74,17 @@ class LDAPProvider(JWTProviderMixin):
71
74
  Whether to populate permissions in the Identity, by default False
72
75
  populate_claims
73
76
  Whether to populate claims in the Identity, by default True
77
+ flatten_claims
78
+ Whether to flatten single-value claims in the Identity, by default
79
+ True
74
80
  auto_connect
75
81
  Whether to automatically connect using the connector, by default
76
82
  True
77
83
  change_password_supported
78
84
  Whether the provider supports changing passwords, by default False
85
+ identity_model
86
+ Identity model class to use for representing users, by default
87
+ Identity
79
88
  additional_connector_params
80
89
  Additional parameters to pass to the LDAP connection, by default
81
90
  {"receive_timeout": 5}
@@ -89,8 +98,10 @@ class LDAPProvider(JWTProviderMixin):
89
98
  self._populate_groups = populate_groups
90
99
  self._populate_permissions = populate_permissions
91
100
  self._populate_claims = populate_claims
101
+ self._flatten_claims = flatten_claims
92
102
  self._auto_connect = auto_connect
93
103
  self._change_password_supported = change_password_supported
104
+ self._identity_model = identity_model
94
105
  self._additional_connector_params = additional_connector_params or {
95
106
  "receive_timeout": 5
96
107
  }
@@ -291,12 +302,13 @@ class LDAPProvider(JWTProviderMixin):
291
302
  Identity object
292
303
  """
293
304
  entry_dict = cast(dict[str, Any], entry.entry_attributes_as_dict) # type: ignore
294
- identity = Identity.from_ldap_dict(
305
+ identity = self._identity_model.from_ldap_dict(
295
306
  entry=entry_dict,
296
307
  mappings=self._identity_mappings,
297
308
  populate_claims=self._populate_claims,
298
309
  populate_groups=self._populate_groups,
299
310
  populate_permissions=self._populate_permissions,
311
+ flatten_claims=self._flatten_claims,
300
312
  )
301
313
  return identity
302
314
 
@@ -324,6 +336,7 @@ class ADProvider(LDAPProvider):
324
336
  populate_groups: bool = True,
325
337
  populate_permissions: bool = False,
326
338
  populate_claims: bool = True,
339
+ flatten_claims: bool = True,
327
340
  auto_connect: bool = True,
328
341
  change_password_supported: bool = False,
329
342
  ) -> None:
@@ -356,6 +369,9 @@ class ADProvider(LDAPProvider):
356
369
  populate_claims
357
370
  Whether to populate claims on the :class:`Identity`, by default
358
371
  True.
372
+ flatten_claims
373
+ Whether to flatten single-value claims in the Identity, by default
374
+ True
359
375
  auto_connect
360
376
  Whether to automatically open the LDAP connection on first use, by
361
377
  default True.
@@ -373,6 +389,7 @@ class ADProvider(LDAPProvider):
373
389
  populate_groups=populate_groups,
374
390
  populate_permissions=populate_permissions,
375
391
  populate_claims=populate_claims,
392
+ flatten_claims=flatten_claims,
376
393
  auto_connect=auto_connect,
377
394
  change_password_supported=change_password_supported,
378
395
  )
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Mapping, Any, Self, Sequence, TYPE_CHECKING
4
+ from typing import Mapping, Any, Self, Sequence, TYPE_CHECKING, cast
5
5
  from datetime import datetime, timezone
6
6
 
7
7
  from alpha.domain.models.group import Group
@@ -125,7 +125,7 @@ class Identity:
125
125
  username: str | None
126
126
  email: str | None
127
127
  display_name: str | None
128
- groups: Sequence[str | Group]
128
+ groups: Sequence[str]
129
129
  permissions: Sequence[str]
130
130
  claims: Mapping[str, Any]
131
131
  issued_at: datetime
@@ -160,6 +160,7 @@ class Identity:
160
160
  populate_groups: bool = True,
161
161
  populate_permissions: bool = False,
162
162
  populate_claims: bool = True,
163
+ flatten_claims: bool = False,
163
164
  ) -> Identity:
164
165
  """Instantiate an Identity from an LDAP entry dictionary.
165
166
 
@@ -178,6 +179,9 @@ class Identity:
178
179
  populate_claims
179
180
  Whether to populate the claims dictionary from the LDAP entry, by
180
181
  default True
182
+ flatten_claims
183
+ Whether to replace claim lists containing a single value with that
184
+ value, by default False
181
185
 
182
186
  Returns
183
187
  -------
@@ -188,6 +192,12 @@ class Identity:
188
192
  if not username:
189
193
  username = cls._get_key(entry, mappings["subject"])
190
194
 
195
+ claims = (
196
+ cls._remove_password_from_claims(entry) if populate_claims else {}
197
+ )
198
+ if populate_claims and flatten_claims:
199
+ claims = cls.flatten_single_value_claims(claims)
200
+
191
201
  return cls(
192
202
  subject=cls._get_key(entry, mappings["subject"], ""),
193
203
  username=username,
@@ -204,11 +214,7 @@ class Identity:
204
214
  if populate_permissions
205
215
  else []
206
216
  ),
207
- claims=(
208
- cls._remove_password_from_claims(entry)
209
- if populate_claims
210
- else {}
211
- ),
217
+ claims=claims,
212
218
  issued_at=datetime.now(tz=timezone.utc),
213
219
  )
214
220
 
@@ -268,13 +274,16 @@ class Identity:
268
274
  An Identity instance populated with data from the User object.
269
275
  """
270
276
  subject = str(user.id) if user.id else user.username
277
+
271
278
  return cls(
272
279
  subject=subject, # type: ignore
273
280
  username=user.username,
274
281
  email=user.email,
275
282
  display_name=user.display_name,
276
- groups=user.groups or [],
277
- permissions=user.permissions or [],
283
+ groups=[str(group) for group in user.groups or []],
284
+ permissions=[
285
+ str(permission) for permission in user.permissions or []
286
+ ],
278
287
  claims={},
279
288
  issued_at=datetime.now(tz=timezone.utc),
280
289
  role=user.role, # type: ignore
@@ -318,10 +327,10 @@ class Identity:
318
327
  self.display_name = user.display_name
319
328
  for permission in user.permissions or []:
320
329
  self.permissions = self._append_on_sequence(
321
- self.permissions, permission
330
+ self.permissions, str(permission)
322
331
  )
323
332
  for group in user.groups or []:
324
- self.groups = self._append_on_sequence(self.groups, group)
333
+ self.groups = self._append_on_sequence(self.groups, str(group))
325
334
  self.role = user.role # type: ignore
326
335
  self.admin = user.admin
327
336
 
@@ -337,7 +346,7 @@ class Identity:
337
346
  for group in groups:
338
347
  for permission in group.permissions or []:
339
348
  self.permissions = self._append_on_sequence(
340
- self.permissions, permission
349
+ self.permissions, str(permission)
341
350
  )
342
351
 
343
352
  def to_dict(self) -> dict[str, Any]:
@@ -430,6 +439,37 @@ class Identity:
430
439
  }
431
440
  return filtered_claims
432
441
 
442
+ @staticmethod
443
+ def flatten_single_value_claims(
444
+ claims: Mapping[str, Any],
445
+ ) -> Mapping[str, Any]:
446
+ """Replace single-value claim lists with their contained value.
447
+
448
+ Password-related claims (keys containing "password", case-insensitive)
449
+ are removed before flattening.
450
+
451
+ Parameters
452
+ ----------
453
+ claims
454
+ Original claims dictionary.
455
+
456
+ Returns
457
+ -------
458
+ Mapping[str, Any]
459
+ A new claims dictionary with password-related keys removed and
460
+ single-value lists replaced by their contained value.
461
+ """
462
+ claims = Identity._remove_password_from_claims(claims)
463
+
464
+ return {
465
+ key: (
466
+ value[0]
467
+ if isinstance(value, list) and len(cast(list[Any], value)) == 1
468
+ else value
469
+ )
470
+ for key, value in claims.items()
471
+ }
472
+
433
473
  @staticmethod
434
474
  def _extract_groups(
435
475
  entry: Mapping[str, Any],
@@ -48,6 +48,7 @@ class OIDCProvider(JWTProviderMixin):
48
48
 
49
49
  protocol = "oidc"
50
50
  token_factory: TokenFactory | None = None
51
+ _identity_model: type[Identity] = Identity
51
52
 
52
53
  def __init__(
53
54
  self,
@@ -57,6 +58,8 @@ class OIDCProvider(JWTProviderMixin):
57
58
  populate_groups: bool = True,
58
59
  populate_permissions: bool = False,
59
60
  populate_claims: bool = False,
61
+ flatten_claims: bool = True,
62
+ identity_model: type[Identity] = Identity,
60
63
  change_password_supported: bool = False,
61
64
  ) -> None:
62
65
  """Initialize OIDCProvider.
@@ -79,6 +82,11 @@ class OIDCProvider(JWTProviderMixin):
79
82
  Whether to populate permissions on the Identity.
80
83
  populate_claims
81
84
  Whether to include raw claims on the Identity.
85
+ flatten_claims
86
+ Whether to flatten single-value claims in the Identity.
87
+ identity_model
88
+ Identity model class to use for representing users, by default
89
+ Identity
82
90
  change_password_supported
83
91
  Whether this provider supports changing passwords.
84
92
  """
@@ -90,6 +98,8 @@ class OIDCProvider(JWTProviderMixin):
90
98
  self._populate_groups = populate_groups
91
99
  self._populate_permissions = populate_permissions
92
100
  self._populate_claims = populate_claims
101
+ self._flatten_claims = flatten_claims
102
+ self._identity_model = identity_model
93
103
  self._change_password_supported = change_password_supported
94
104
 
95
105
  def authenticate(self, credentials: PasswordCredentials) -> Identity:
@@ -237,14 +247,17 @@ class OIDCProvider(JWTProviderMixin):
237
247
 
238
248
  audience = self._extract_audience(claims)
239
249
 
240
- identity = Identity(
250
+ if self._populate_claims and self._flatten_claims:
251
+ claims = self._identity_model.flatten_single_value_claims(claims)
252
+
253
+ identity = self._identity_model(
241
254
  subject=str(subject),
242
255
  username=username,
243
256
  email=self._get_claim(claims, "email"),
244
257
  display_name=self._get_claim(claims, "display_name"),
245
258
  groups=groups,
246
259
  permissions=permissions,
247
- claims=dict(claims) if self._populate_claims else {},
260
+ claims=claims if self._populate_claims else {},
248
261
  issued_at=issued_at,
249
262
  audience=audience,
250
263
  role=self._get_claim(claims, "role"),
@@ -403,6 +416,7 @@ class KeyCloakProvider(OIDCProvider):
403
416
  populate_groups: bool = True,
404
417
  populate_permissions: bool = False,
405
418
  populate_claims: bool = False,
419
+ flatten_claims: bool = True,
406
420
  change_password_supported: bool = False,
407
421
  ) -> None:
408
422
  """Initialize KeyCloakProvider.
@@ -427,6 +441,9 @@ class KeyCloakProvider(OIDCProvider):
427
441
  Whether to populate permissions on the Identity.
428
442
  populate_claims
429
443
  Whether to include raw claims on the Identity.
444
+ flatten_claims
445
+ Whether to flatten single-value claims when populating raw claims
446
+ on the Identity.
430
447
  change_password_supported
431
448
  Whether this provider supports changing passwords.
432
449
  """
@@ -437,5 +454,6 @@ class KeyCloakProvider(OIDCProvider):
437
454
  populate_groups=populate_groups,
438
455
  populate_permissions=populate_permissions,
439
456
  populate_claims=populate_claims,
457
+ flatten_claims=flatten_claims,
440
458
  change_password_supported=change_password_supported,
441
459
  )
@@ -74,6 +74,7 @@ class AuthenticationService:
74
74
  user_model: type[User] = User,
75
75
  group_model: type[Group] = Group,
76
76
  token_model: type[Token] = Token,
77
+ identity_model: type[Identity] = Identity,
77
78
  users_repository_name: str = "users",
78
79
  groups_repository_name: str = "groups",
79
80
  refresh_repository: RefreshRepository | None = None,
@@ -147,6 +148,9 @@ class AuthenticationService:
147
148
  Group model class to use for database operations, by default Group
148
149
  token_model
149
150
  Token model class to use for database operations, by default Token
151
+ identity_model
152
+ Identity model class to use for object creation and manipulation,
153
+ by default Identity
150
154
  users_repository_name
151
155
  Name of the user repository in the UnitOfWork, by default "users"
152
156
  groups_repository_name
@@ -206,6 +210,7 @@ class AuthenticationService:
206
210
  self._user_model = user_model
207
211
  self._group_model = group_model
208
212
  self._token_model = token_model
213
+ self._identity_model = identity_model
209
214
  self._users_repository_name = users_repository_name
210
215
  self._groups_repository_name = groups_repository_name
211
216
  self._refresh_repository = (
@@ -265,7 +270,7 @@ class AuthenticationService:
265
270
  and credentials.username == self._static_user.username
266
271
  and credentials.password == self._static_user.password
267
272
  ):
268
- identity = Identity.from_user(self._static_user)
273
+ identity = self._identity_model.from_user(self._static_user)
269
274
 
270
275
  # Use the identity provider to authenticate the user and retrieve their
271
276
  # identity
@@ -464,7 +469,7 @@ class AuthenticationService:
464
469
  payload = self._identity_provider.token_factory.get_payload(
465
470
  token=auth_token, options={"verify_exp": False}
466
471
  )
467
- identity = Identity.from_dict(payload)
472
+ identity = self._identity_model.from_dict(payload)
468
473
  except Exception:
469
474
  identity = None
470
475
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-python
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: Alpha is intended to be the first dependency you need to add to your Python application. It is a Python library which contains standard building blocks that can be used in applications that are used as APIs and/or make use of database interaction.
5
5
  Author-email: Bart Reijling <bart@reijling.eu>
6
6
  License-Expression: MIT
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
- Requires-Python: <3.15,>=3.11
19
+ Requires-Python: >=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: attrs>=25.4.0
@@ -1,20 +1,21 @@
1
- alpha/__init__.py,sha256=qrmxkrV0YW0X5wbGt3nKWk1SqzoiEH71n3VlaNwbRSI,6738
1
+ alpha/__init__.py,sha256=UAk8NgfhXqPTrNJH_bVTGPCuAHCp77vf63ycZ5FBnp0,6810
2
2
  alpha/cli.py,sha256=YTWM7lzmydYazXMJ6LULywvJTMHzvfTO6yNuPrUgHCY,5813
3
3
  alpha/encoder.py,sha256=dPFDfCofX2ehhOZuC7EEoj00l5sclE-BAvq01oQpo4o,2891
4
4
  alpha/exceptions.py,sha256=AHoFMPyHvjj6j_1X2TS40dSaFBzCDtgzAmucimSZjfc,5793
5
5
  alpha/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  alpha/adapters/__init__.py,sha256=pGpAtLuVs-jOyRShjO1BwSHpB5MyStJ3Hs9wPm49Q2s,201
7
7
  alpha/adapters/rest_api_unit_of_work.py,sha256=ttUjCaNW8wKL93O_dH4XfjSgwMOBhubdxcyt3G_1Qrk,3589
8
- alpha/adapters/sqla_unit_of_work.py,sha256=NSo1u2DXN4m8sGQpU044qrox24AMLgjUg9jV1Pwq9aI,3992
8
+ alpha/adapters/sqla_unit_of_work.py,sha256=eRpNc0EQC8RKQpqP8Sxmo0EHCn2ur5DRupLkhxye-wc,4304
9
9
  alpha/containers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  alpha/containers/container.py,sha256=NJW3ApUauhtwd2ifHGo0J6-F8eJwGdq3xAe5KGQXXqU,6509
11
- alpha/domain/__init__.py,sha256=W9QCx29uXMUdmfdWEC0ISeNwZ9OMS3zpCl-fHFFh8So,506
12
- alpha/domain/models/__init__.py,sha256=W9QCx29uXMUdmfdWEC0ISeNwZ9OMS3zpCl-fHFFh8So,506
11
+ alpha/domain/__init__.py,sha256=RNRqFDYw0KhFb4yPa9kC_otFGNH7wakpm3lk5y6iGVs,578
12
+ alpha/domain/models/__init__.py,sha256=RNRqFDYw0KhFb4yPa9kC_otFGNH7wakpm3lk5y6iGVs,578
13
13
  alpha/domain/models/base_model.py,sha256=eI6ff0qugsQhWZahBoGH7xWMVY9Mhss9XdGR3yecG28,1826
14
- alpha/domain/models/group.py,sha256=z00kYzPDs-3IZuXWO3hpw_uQYZqK3fcQzMROG5tgOxA,2790
14
+ alpha/domain/models/group.py,sha256=yrSDbt1MsQ-8rYl57jKlA-gJAn8q_qseT7T0LwgvFSs,4144
15
15
  alpha/domain/models/life_cycle_base.py,sha256=xRwcTPRR0pHwsBN_dyaN8d1AVuKOEF6iNCS0IWZntDY,875
16
+ alpha/domain/models/permission.py,sha256=-5wcJwS-_LVADpH2sr5wC6s2AqolS5JrdEdZ1jym9RI,3175
16
17
  alpha/domain/models/role.py,sha256=95cUc8VDB7o_IXJdTBF0KFJUyvOfRtpNkxTslzpVqIQ,4614
17
- alpha/domain/models/user.py,sha256=VnC6sBdw001ivaH3-SSuPCeeb6A6YYYHowMgfBXprG0,4952
18
+ alpha/domain/models/user.py,sha256=H64rNrjY9VV-ljux_HIEyxT0O4zlLaIXMzZLEyCKLZY,6868
18
19
  alpha/factories/__init__.py,sha256=baxoq0UY7UYust7NiR9wT954nJw5bfgSmOWvLjBt2C4,278
19
20
  alpha/factories/_type_conversion_matrix.py,sha256=mhMYpus6OFE0sZB0gQ-b1ndIOhiA1ScprvDa9tLANAM,5055
20
21
  alpha/factories/_type_mapping.py,sha256=f8cRfu8KUfw1ggY0Txs6fEX2e6GaXrsNcc2SCeYFRHM,789
@@ -98,16 +99,16 @@ alpha/interfaces/unit_of_work.py,sha256=P32wTGw4N54QV_x2BBusrApANmI88wgA7bLmC-1A
98
99
  alpha/interfaces/updatable.py,sha256=vGpxvqrn9dCMumP_Ty3EGs-bp3KiPG8l-v-9rfjOLSQ,472
99
100
  alpha/mixins/__init__.py,sha256=aPTNpi4JEzdsFW9Joq0ktgtoP2MJqNLOc1uRPxJkl4A,252
100
101
  alpha/mixins/group_lifecycle.py,sha256=MxfecqcGaAjtYlQxd-ryX29VYNJVoCBJXWXE3RsGPeQ,4464
101
- alpha/mixins/jwt_provider.py,sha256=SvBUNmCoJbDnWBCS1C8mD9sJ-08aZnwtOpySskYOGxA,1950
102
+ alpha/mixins/jwt_provider.py,sha256=b_-9_AAVgG6HZ3vGnS8g6S_caIW5Wn4AssM95B-5mhk,2080
102
103
  alpha/mixins/user_lifecycle.py,sha256=iQPHnAobRhNJyZ5is0WfKFpOKy0y_9w_VNiMwQxYp4U,5418
103
104
  alpha/providers/__init__.py,sha256=DM0Zu_hYNc9JI7cKVQD7J84lwpv5-GNTntMzM1k_ZPI,938
104
105
  alpha/providers/api_key_provider.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
105
- alpha/providers/database_provider.py,sha256=Q0qAdJYgEg1hjVHUenB-Qy-pW69E5p0cZ5IMP_JWE9Q,7974
106
- alpha/providers/ldap_provider.py,sha256=R3jCammHPS8Krd4G6W1t-nT7RAmPW-JCoirhJwdr2Bo,13542
107
- alpha/providers/oidc_provider.py,sha256=t1ftGcDjZuopuvb0qyPexFdNzhsKhiatxnfLb2HBP8g,13812
106
+ alpha/providers/database_provider.py,sha256=uzNTXUHL1uRQNwsAujgEVqjuL5su_HKDSqmp-zqZunM,8261
107
+ alpha/providers/ldap_provider.py,sha256=nZhSvLXl-dWN6bEBQ6UO8r-xpb_QzY2m7mkzQA7aigc,14267
108
+ alpha/providers/oidc_provider.py,sha256=8-VWHzWYXpgwfqi64hnMYcT7Hp1p8Gr8I1EIPDgY6nA,14603
108
109
  alpha/providers/models/__init__.py,sha256=SI-qTA3JMOxXx4o1h7frk8x3PRj0g_GSCEbLzyvw7CI,409
109
110
  alpha/providers/models/credentials.py,sha256=IjGm1MpnUrwnTo1Of5eacNtkh72Iqj2uKTsoyq1po_o,1304
110
- alpha/providers/models/identity.py,sha256=wAG8BfQtn33ieKunZl3PzzQNorK6I-2LVZin8uTA1L8,14993
111
+ alpha/providers/models/identity.py,sha256=M8b7uiaVYvtCgGV2nxEWrPPgbxUiWPbI-tGcMmaHtbE,16285
111
112
  alpha/providers/models/token.py,sha256=xLiE-bWufbcWgkKpar5R2VAE8YhjyhWF44yN0Me9sJw,4793
112
113
  alpha/repositories/__init__.py,sha256=i4kyfLbLSHUzrqDF9Q96CgEaK-srPtEIspUD6uUjElk,787
113
114
  alpha/repositories/rest_api_repository.py,sha256=BC_M5_HPwWKwMRsHI2PNCmqu0SC5Tw9ZGhZ7eaLIFgw,48702
@@ -120,7 +121,7 @@ alpha/repositories/refresh/database_repository.py,sha256=b5Z0_nVairZOaqQ6OBb6_X2
120
121
  alpha/repositories/refresh/file_repository.py,sha256=EoygSFD7XLzSmnY1W2d3pdlNbQ0rcxwhRGNrlbMH62g,5490
121
122
  alpha/repositories/refresh/memory_repository.py,sha256=r0P2DVgTTDSqV8g1OaNf8knqpiysb-LaWW0pdS2wZaQ,3926
122
123
  alpha/services/__init__.py,sha256=yeUbS-N0127DSejbCAv9jbdKuHRa_H5Ue0CtsWf4ghI,224
123
- alpha/services/authentication_service.py,sha256=tSdMxnwL8vXKA0mw960yWT66r-DKD93Z0Ppf9UScq_U,29592
124
+ alpha/services/authentication_service.py,sha256=X1CPvXel_R9PRBlwh-6RDJdodgPq1MbbiUUUKnAMc3Y,29846
124
125
  alpha/services/user_lifecycle_management.py,sha256=yj0ITtD_WOCVo54KaNSC29XPixxdKi9ArvCMeQm6WKA,2696
125
126
  alpha/services/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
126
127
  alpha/services/models/cookie.py,sha256=vxNLPtMmOGlxFLKdccxEV7hd_mPKmFCEao8SczNOilo,132
@@ -143,9 +144,9 @@ alpha/utils/openapi_test/models.py,sha256=POqDD2v2VqPmVLnbvgZ0kVlVzL9Are1oFHmuyy
143
144
  alpha/utils/openapi_test/orm.py,sha256=Py95GV_0e7wI1MYDZR1_EHkvTgev6eNo70SVmnD7kGc,3045
144
145
  alpha/utils/openapi_test/response.py,sha256=IxbQ6Nw258LLViHkgfjOpc7zWGTP8ofVOX1zrwDoR50,270
145
146
  alpha/utils/openapi_test/service.py,sha256=ycrEUlQmygKthnOhEiir-wtbPFi5cT-bepVclaL9FAk,5003
146
- alpha_python-0.7.4.dist-info/licenses/LICENSE,sha256=5KwEqC3KUoH4lVXgZ9tGriKOl-LGxHkXBWo16mFmAYM,1070
147
- alpha_python-0.7.4.dist-info/METADATA,sha256=lezhQhf7GAVLhYWKZL8XK2qUcxu_qwI5-MDaac3oFGU,8645
148
- alpha_python-0.7.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
149
- alpha_python-0.7.4.dist-info/entry_points.txt,sha256=LBEXdcofOugYYdZ46nz5Dxj_aj1QbRBkumfPGhy-GXI,41
150
- alpha_python-0.7.4.dist-info/top_level.txt,sha256=tqmNnOmi2RSSiPo99C03fD5Cc3r9za9xTjPAoQC1EGA,6
151
- alpha_python-0.7.4.dist-info/RECORD,,
147
+ alpha_python-0.7.6.dist-info/licenses/LICENSE,sha256=5KwEqC3KUoH4lVXgZ9tGriKOl-LGxHkXBWo16mFmAYM,1070
148
+ alpha_python-0.7.6.dist-info/METADATA,sha256=7cdXcm4nOikzottZr80DfxfLbr95LIAX9TSmX_b3xas,8639
149
+ alpha_python-0.7.6.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
150
+ alpha_python-0.7.6.dist-info/entry_points.txt,sha256=LBEXdcofOugYYdZ46nz5Dxj_aj1QbRBkumfPGhy-GXI,41
151
+ alpha_python-0.7.6.dist-info/top_level.txt,sha256=tqmNnOmi2RSSiPo99C03fD5Cc3r9za9xTjPAoQC1EGA,6
152
+ alpha_python-0.7.6.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5