amsdal 0.4.10__cp311-cp311-macosx_10_9_universal2.whl → 0.5.29__cp311-cp311-macosx_10_9_universal2.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.
Files changed (163) hide show
  1. amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +28 -0
  2. amsdal/__about__.py +1 -1
  3. amsdal/__migrations__/0000_initial.py +22 -203
  4. amsdal/__migrations__/0001_create_class_file.py +61 -0
  5. amsdal/__migrations__/0002_create_class_file.py +109 -0
  6. amsdal/__migrations__/0003_update_class_file.py +91 -0
  7. amsdal/__migrations__/0004_update_class_file.py +45 -0
  8. amsdal/cloud/__init__.cpython-311-darwin.so +0 -0
  9. amsdal/cloud/client.cpython-311-darwin.so +0 -0
  10. amsdal/cloud/constants.cpython-311-darwin.so +0 -0
  11. amsdal/cloud/enums.cpython-311-darwin.so +0 -0
  12. amsdal/cloud/models/__init__.cpython-311-darwin.so +0 -0
  13. amsdal/cloud/models/base.cpython-311-darwin.so +0 -0
  14. amsdal/cloud/services/__init__.cpython-311-darwin.so +0 -0
  15. amsdal/cloud/services/actions/__init__.cpython-311-darwin.so +0 -0
  16. amsdal/cloud/services/actions/add_allowlist_ip.cpython-311-darwin.so +0 -0
  17. amsdal/cloud/services/actions/add_basic_auth.cpython-311-darwin.so +0 -0
  18. amsdal/cloud/services/actions/add_dependency.cpython-311-darwin.so +0 -0
  19. amsdal/cloud/services/actions/add_secret.cpython-311-darwin.so +0 -0
  20. amsdal/cloud/services/actions/base.cpython-311-darwin.so +0 -0
  21. amsdal/cloud/services/actions/create_deploy.cpython-311-darwin.so +0 -0
  22. amsdal/cloud/services/actions/create_env.cpython-311-darwin.so +0 -0
  23. amsdal/cloud/services/actions/create_session.cpython-311-darwin.so +0 -0
  24. amsdal/cloud/services/actions/delete_allowlist_ip.cpython-311-darwin.so +0 -0
  25. amsdal/cloud/services/actions/delete_basic_auth.cpython-311-darwin.so +0 -0
  26. amsdal/cloud/services/actions/delete_dependency.cpython-311-darwin.so +0 -0
  27. amsdal/cloud/services/actions/delete_env.cpython-311-darwin.so +0 -0
  28. amsdal/cloud/services/actions/delete_secret.cpython-311-darwin.so +0 -0
  29. amsdal/cloud/services/actions/destroy_deploy.cpython-311-darwin.so +0 -0
  30. amsdal/cloud/services/actions/expose_db.cpython-311-darwin.so +0 -0
  31. amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-311-darwin.so +0 -0
  32. amsdal/cloud/services/actions/get_monitoring_info.cpython-311-darwin.so +0 -0
  33. amsdal/cloud/services/actions/list_dependencies.cpython-311-darwin.so +0 -0
  34. amsdal/cloud/services/actions/list_deploys.cpython-311-darwin.so +0 -0
  35. amsdal/cloud/services/actions/list_envs.cpython-311-darwin.so +0 -0
  36. amsdal/cloud/services/actions/list_secrets.cpython-311-darwin.so +0 -0
  37. amsdal/cloud/services/actions/manager.cpython-311-darwin.so +0 -0
  38. amsdal/cloud/services/actions/signup_action.cpython-311-darwin.so +0 -0
  39. amsdal/cloud/services/actions/update_deploy.cpython-311-darwin.so +0 -0
  40. amsdal/cloud/services/auth/__init__.cpython-311-darwin.so +0 -0
  41. amsdal/cloud/services/auth/base.cpython-311-darwin.so +0 -0
  42. amsdal/cloud/services/auth/credentials.cpython-311-darwin.so +0 -0
  43. amsdal/cloud/services/auth/manager.cpython-311-darwin.so +0 -0
  44. amsdal/cloud/services/auth/signup_service.cpython-311-darwin.so +0 -0
  45. amsdal/cloud/services/auth/token.cpython-311-darwin.so +0 -0
  46. amsdal/configs/main.py +17 -1
  47. amsdal/configs/main.pyi +7 -3
  48. amsdal/contrib/__init__.cpython-311-darwin.so +0 -0
  49. amsdal/contrib/auth/errors.py +36 -0
  50. amsdal/contrib/auth/errors.pyi +12 -0
  51. amsdal/contrib/auth/lifecycle/consumer.py +3 -3
  52. amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
  53. amsdal/contrib/auth/migrations/0000_initial.py +55 -52
  54. amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
  55. amsdal/contrib/auth/models/__init__.py +1 -0
  56. amsdal/contrib/auth/models/backup_code.py +85 -0
  57. amsdal/contrib/auth/models/email_mfa_device.py +108 -0
  58. amsdal/contrib/auth/models/login_session.py +117 -0
  59. amsdal/contrib/auth/models/mfa_device.py +86 -0
  60. amsdal/contrib/auth/models/sms_device.py +113 -0
  61. amsdal/contrib/auth/models/totp_device.py +58 -0
  62. amsdal/contrib/auth/models/user.py +50 -0
  63. amsdal/contrib/auth/services/__init__.py +1 -0
  64. amsdal/contrib/auth/services/mfa_device_service.py +544 -0
  65. amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
  66. amsdal/contrib/auth/services/totp_service.py +358 -0
  67. amsdal/contrib/auth/services/totp_service.pyi +158 -0
  68. amsdal/contrib/auth/settings.py +8 -0
  69. amsdal/contrib/auth/settings.pyi +8 -0
  70. amsdal/contrib/auth/transactions/__init__.py +1 -0
  71. amsdal/contrib/auth/transactions/mfa_device_transactions.py +463 -0
  72. amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
  73. amsdal/contrib/auth/transactions/totp_transactions.py +206 -0
  74. amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
  75. amsdal/contrib/auth/utils/mfa.py +257 -0
  76. amsdal/contrib/auth/utils/mfa.pyi +119 -0
  77. amsdal/contrib/frontend_configs/conversion/convert.py +32 -5
  78. amsdal/contrib/frontend_configs/migrations/0000_initial.py +154 -183
  79. amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
  80. amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
  81. amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
  82. amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +57 -1
  83. amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
  84. amsdal/contrib/frontend_configs/models/frontend_control_config.py +69 -46
  85. amsdal/fixtures/__init__.cpython-311-darwin.so +0 -0
  86. amsdal/fixtures/manager.cpython-311-darwin.so +0 -0
  87. amsdal/fixtures/utils.cpython-311-darwin.so +0 -0
  88. amsdal/manager.cpython-311-darwin.so +0 -0
  89. amsdal/manager.pyi +5 -0
  90. amsdal/mixins/__init__.cpython-311-darwin.so +0 -0
  91. amsdal/mixins/class_versions_mixin.cpython-311-darwin.so +0 -0
  92. amsdal/models/core/class_object.py +7 -6
  93. amsdal/models/core/class_property.py +7 -1
  94. amsdal/models/core/file.py +168 -81
  95. amsdal/models/core/storage_metadata.py +15 -0
  96. amsdal/models/mixins.py +31 -0
  97. amsdal/models/types/object.py +3 -3
  98. amsdal/schemas/core/class_object/model.json +20 -0
  99. amsdal/schemas/core/class_property/model.json +19 -0
  100. amsdal/schemas/core/file/properties/validate_data.py +2 -3
  101. amsdal/schemas/core/storage_metadata/model.json +52 -0
  102. amsdal/schemas/interfaces.pyi +1 -1
  103. amsdal/schemas/manager.cpython-311-darwin.so +0 -0
  104. amsdal/schemas/mixins/check_dependencies_mixin.py +23 -8
  105. amsdal/schemas/mixins/check_dependencies_mixin.pyi +5 -2
  106. amsdal/schemas/utils.pyi +2 -2
  107. amsdal/services/__init__.py +11 -0
  108. amsdal/services/__init__.pyi +4 -0
  109. amsdal/services/external_connections.py +262 -0
  110. amsdal/services/external_connections.pyi +190 -0
  111. amsdal/services/external_model_generator.py +350 -0
  112. amsdal/services/external_model_generator.pyi +134 -0
  113. amsdal/services/transaction_execution.cpython-311-darwin.so +0 -0
  114. amsdal/services/transaction_execution.pyi +1 -0
  115. amsdal/storages/__init__.py +20 -0
  116. amsdal/storages/__init__.pyi +8 -0
  117. amsdal/storages/file_system.py +214 -0
  118. amsdal/storages/file_system.pyi +36 -0
  119. amsdal/utils/rollback/__init__.pyi +6 -0
  120. amsdal/utils/tests/enums.py +0 -2
  121. amsdal/utils/tests/helpers.py +213 -381
  122. amsdal/utils/tests/migrations.py +157 -0
  123. {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/METADATA +13 -8
  124. {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/RECORD +131 -124
  125. {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/WHEEL +1 -1
  126. amsdal/__migrations__/0001_datetime_type.py +0 -18
  127. amsdal/__migrations__/0002_fixture_order.py +0 -44
  128. amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -44
  129. amsdal/contrib/auth/models/login_session.pyi +0 -37
  130. amsdal/contrib/auth/models/permission.pyi +0 -18
  131. amsdal/contrib/auth/models/user.pyi +0 -46
  132. amsdal/contrib/frontend_configs/models/frontend_activator_config.pyi +0 -12
  133. amsdal/contrib/frontend_configs/models/frontend_config_async_validator.pyi +0 -7
  134. amsdal/contrib/frontend_configs/models/frontend_config_control_action.pyi +0 -32
  135. amsdal/contrib/frontend_configs/models/frontend_config_group_validator.pyi +0 -11
  136. amsdal/contrib/frontend_configs/models/frontend_config_option.pyi +0 -8
  137. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.pyi +0 -8
  138. amsdal/contrib/frontend_configs/models/frontend_config_slider_option.pyi +0 -9
  139. amsdal/contrib/frontend_configs/models/frontend_config_text_mask.pyi +0 -10
  140. amsdal/contrib/frontend_configs/models/frontend_config_validator.pyi +0 -15
  141. amsdal/contrib/frontend_configs/models/frontend_control_config.pyi +0 -35
  142. amsdal/contrib/frontend_configs/models/frontend_model_config.pyi +0 -9
  143. amsdal/models/__init__.pyi +0 -9
  144. amsdal/models/core/class_object.pyi +0 -24
  145. amsdal/models/core/class_object_meta.py +0 -26
  146. amsdal/models/core/class_object_meta.pyi +0 -15
  147. amsdal/models/core/class_property.pyi +0 -11
  148. amsdal/models/core/class_property_meta.py +0 -15
  149. amsdal/models/core/class_property_meta.pyi +0 -10
  150. amsdal/models/core/file.pyi +0 -104
  151. amsdal/models/core/fixture.pyi +0 -14
  152. amsdal/models/core/option.pyi +0 -8
  153. amsdal/models/core/validator.pyi +0 -8
  154. amsdal/models/types/object.pyi +0 -16
  155. amsdal/schemas/core/class_object_meta/model.json +0 -59
  156. amsdal/schemas/core/class_property_meta/model.json +0 -23
  157. amsdal/services/__init__.cpython-311-darwin.so +0 -0
  158. /amsdal/contrib/auth/{models → services}/__init__.pyi +0 -0
  159. /amsdal/contrib/{frontend_configs/models → auth/transactions}/__init__.pyi +0 -0
  160. /amsdal/{models/core/__init__.pyi → contrib/auth/utils/__init__.py} +0 -0
  161. /amsdal/{models/types → contrib/auth/utils}/__init__.pyi +0 -0
  162. {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/licenses/LICENSE.txt +0 -0
  163. {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,544 @@
1
+ """MFA device management service for email, backup codes, listing, and removal."""
2
+
3
+ from amsdal_data.transactions.decorators import async_transaction
4
+ from amsdal_data.transactions.decorators import transaction
5
+ from amsdal_utils.models.enums import Versions
6
+
7
+ from amsdal.contrib.auth.errors import MFADeviceNotFoundError
8
+ from amsdal.contrib.auth.errors import PermissionDeniedError
9
+ from amsdal.contrib.auth.errors import UserNotFoundError
10
+ from amsdal.contrib.auth.models.backup_code import BackupCode
11
+ from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice
12
+ from amsdal.contrib.auth.models.mfa_device import MFADevice
13
+ from amsdal.contrib.auth.models.user import User
14
+ from amsdal.contrib.auth.settings import auth_settings
15
+ from amsdal.contrib.auth.utils.mfa import DeviceType
16
+ from amsdal.contrib.auth.utils.mfa import generate_backup_codes
17
+
18
+
19
+ class MFADeviceService:
20
+ """Service for general MFA device management operations."""
21
+
22
+ @classmethod
23
+ def _is_admin(cls, user: User) -> bool:
24
+ """
25
+ Check if user has admin permissions (wildcard or MFADevice-specific).
26
+
27
+ Args:
28
+ user: The user to check.
29
+
30
+ Returns:
31
+ bool: True if user has admin permissions, False otherwise.
32
+ """
33
+ if not user.permissions:
34
+ return False
35
+
36
+ for permission in user.permissions:
37
+ if permission.model == '*' and permission.action == '*':
38
+ return True
39
+ if permission.model == 'MFADevice' and permission.action in ('*', 'create', 'read', 'delete'):
40
+ return True
41
+
42
+ return False
43
+
44
+ @classmethod
45
+ def _check_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
46
+ """
47
+ Check if current_user has permission for action on target_user.
48
+
49
+ Args:
50
+ current_user: The authenticated user making the request.
51
+ target_user_email: Email of the user being targeted.
52
+ action: The action being performed.
53
+
54
+ Raises:
55
+ PermissionDeniedError: If user lacks permission.
56
+ """
57
+ # Same user can manage their own devices
58
+ if current_user.email == target_user_email:
59
+ return
60
+
61
+ # Admin can manage any user's devices
62
+ if cls._is_admin(current_user):
63
+ return
64
+
65
+ msg = f'User {current_user.email} does not have permission to {action} devices for {target_user_email}'
66
+ raise PermissionDeniedError(msg)
67
+
68
+ @classmethod
69
+ async def _acheck_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
70
+ """
71
+ Async version of _check_permission.
72
+
73
+ Args:
74
+ current_user: The authenticated user making the request.
75
+ target_user_email: Email of the user being targeted.
76
+ action: The action being performed.
77
+
78
+ Raises:
79
+ PermissionDeniedError: If user lacks permission.
80
+ """
81
+ # Same implementation as sync version (no async DB calls needed)
82
+ cls._check_permission(current_user, target_user_email, action)
83
+
84
+ @classmethod
85
+ @transaction
86
+ def add_email_device(
87
+ cls,
88
+ current_user: User,
89
+ target_user_email: str,
90
+ device_name: str,
91
+ email: str | None = None,
92
+ ) -> EmailMFADevice:
93
+ """
94
+ Add email MFA device for a user.
95
+
96
+ Args:
97
+ current_user: The authenticated user making the request.
98
+ target_user_email: Email of user to add device for.
99
+ device_name: User-friendly name for the device.
100
+ email: Email for MFA codes (defaults to target_user_email).
101
+
102
+ Returns:
103
+ EmailMFADevice: The created device.
104
+
105
+ Raises:
106
+ UserNotFoundError: If target user doesn't exist.
107
+ PermissionDeniedError: If user lacks permission.
108
+ """
109
+ # Verify target user exists FIRST
110
+ target_user = (
111
+ User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
112
+ .get_or_none()
113
+ .execute()
114
+ )
115
+ if target_user is None:
116
+ msg = f'User with email {target_user_email} not found'
117
+ raise UserNotFoundError(msg)
118
+
119
+ # Check permissions AFTER verifying user exists
120
+ cls._check_permission(current_user, target_user_email, 'create')
121
+
122
+ # Default email to target user's email
123
+ if email is None:
124
+ email = target_user_email
125
+
126
+ # Create email MFA device (auto-confirmed)
127
+ device = EmailMFADevice( # type: ignore[call-arg]
128
+ user_email=target_user_email,
129
+ name=device_name,
130
+ email=email,
131
+ )
132
+ device.save(force_insert=True)
133
+
134
+ return device
135
+
136
+ @classmethod
137
+ @async_transaction
138
+ async def aadd_email_device(
139
+ cls,
140
+ current_user: User,
141
+ target_user_email: str,
142
+ device_name: str,
143
+ email: str | None = None,
144
+ ) -> EmailMFADevice:
145
+ """
146
+ Async version of add_email_device.
147
+
148
+ Add email MFA device for a user.
149
+
150
+ Args:
151
+ current_user: The authenticated user making the request.
152
+ target_user_email: Email of user to add device for.
153
+ device_name: User-friendly name for the device.
154
+ email: Email for MFA codes (defaults to target_user_email).
155
+
156
+ Returns:
157
+ EmailMFADevice: The created device.
158
+
159
+ Raises:
160
+ UserNotFoundError: If target user doesn't exist.
161
+ PermissionDeniedError: If user lacks permission.
162
+ """
163
+ # Verify target user exists FIRST
164
+ target_user = (
165
+ await User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
166
+ .get_or_none()
167
+ .aexecute()
168
+ )
169
+ if target_user is None:
170
+ msg = f'User with email {target_user_email} not found'
171
+ raise UserNotFoundError(msg)
172
+
173
+ # Check permissions AFTER verifying user exists
174
+ await cls._acheck_permission(current_user, target_user_email, 'create')
175
+
176
+ # Default email to target user's email
177
+ if email is None:
178
+ email = target_user_email
179
+
180
+ # Create email MFA device (auto-confirmed)
181
+ device = EmailMFADevice( # type: ignore[call-arg]
182
+ user_email=target_user_email,
183
+ name=device_name,
184
+ email=email,
185
+ )
186
+ await device.asave(force_insert=True)
187
+
188
+ return device
189
+
190
+ @classmethod
191
+ @transaction
192
+ def add_backup_codes(
193
+ cls,
194
+ current_user: User,
195
+ target_user_email: str,
196
+ device_name: str = 'Backup Codes',
197
+ code_count: int | None = None,
198
+ ) -> tuple[list[BackupCode], list[str]]:
199
+ """
200
+ Add backup codes for a user.
201
+
202
+ Args:
203
+ current_user: The authenticated user making the request.
204
+ target_user_email: Email of user to add codes for.
205
+ device_name: Name for the backup code set.
206
+ code_count: Number of codes (defaults to MFA_BACKUP_CODES_COUNT setting).
207
+
208
+ Returns:
209
+ tuple[list[BackupCode], list[str]]: Device instances and plaintext codes.
210
+
211
+ Raises:
212
+ UserNotFoundError: If target user doesn't exist.
213
+ PermissionDeniedError: If user lacks permission.
214
+
215
+ Security Note:
216
+ Plaintext codes are returned ONLY during creation.
217
+ Caller must display/send these to user immediately.
218
+ Codes cannot be retrieved later.
219
+ """
220
+ # Verify target user exists FIRST
221
+ target_user = (
222
+ User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
223
+ .get_or_none()
224
+ .execute()
225
+ )
226
+ if target_user is None:
227
+ msg = f'User with email {target_user_email} not found'
228
+ raise UserNotFoundError(msg)
229
+
230
+ # Check permissions AFTER verifying user exists
231
+ cls._check_permission(current_user, target_user_email, 'create')
232
+
233
+ # Get code count from parameter or settings
234
+ if code_count is None:
235
+ code_count = auth_settings.MFA_BACKUP_CODES_COUNT
236
+
237
+ # Generate plaintext codes
238
+ plaintext_codes = generate_backup_codes(code_count)
239
+
240
+ # Create BackupCode instances for each code
241
+ devices = []
242
+ for code in plaintext_codes:
243
+ device = BackupCode( # type: ignore[call-arg]
244
+ user_email=target_user_email,
245
+ name=device_name,
246
+ code=code, # type: ignore[arg-type] # Will be hashed in post_init
247
+ )
248
+ device.save(force_insert=True)
249
+ devices.append(device)
250
+
251
+ return devices, plaintext_codes
252
+
253
+ @classmethod
254
+ @async_transaction
255
+ async def aadd_backup_codes(
256
+ cls,
257
+ current_user: User,
258
+ target_user_email: str,
259
+ device_name: str = 'Backup Codes',
260
+ code_count: int | None = None,
261
+ ) -> tuple[list[BackupCode], list[str]]:
262
+ """
263
+ Async version of add_backup_codes.
264
+
265
+ Add backup codes for a user.
266
+
267
+ Args:
268
+ current_user: The authenticated user making the request.
269
+ target_user_email: Email of user to add codes for.
270
+ device_name: Name for the backup code set.
271
+ code_count: Number of codes (defaults to MFA_BACKUP_CODES_COUNT setting).
272
+
273
+ Returns:
274
+ tuple[list[BackupCode], list[str]]: Device instances and plaintext codes.
275
+
276
+ Raises:
277
+ UserNotFoundError: If target user doesn't exist.
278
+ PermissionDeniedError: If user lacks permission.
279
+
280
+ Security Note:
281
+ Plaintext codes are returned ONLY during creation.
282
+ Caller must display/send these to user immediately.
283
+ Codes cannot be retrieved later.
284
+ """
285
+ # Verify target user exists FIRST
286
+ target_user = (
287
+ await User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
288
+ .get_or_none()
289
+ .aexecute()
290
+ )
291
+ if target_user is None:
292
+ msg = f'User with email {target_user_email} not found'
293
+ raise UserNotFoundError(msg)
294
+
295
+ # Check permissions AFTER verifying user exists
296
+ await cls._acheck_permission(current_user, target_user_email, 'create')
297
+
298
+ # Get code count from parameter or settings
299
+ if code_count is None:
300
+ code_count = auth_settings.MFA_BACKUP_CODES_COUNT
301
+
302
+ # Generate plaintext codes
303
+ plaintext_codes = generate_backup_codes(code_count)
304
+
305
+ # Create BackupCode instances for each code
306
+ devices = []
307
+ for code in plaintext_codes:
308
+ device = BackupCode( # type: ignore[call-arg]
309
+ user_email=target_user_email,
310
+ name=device_name,
311
+ code=code, # type: ignore[arg-type] # Will be hashed in post_init
312
+ )
313
+ await device.asave(force_insert=True)
314
+ devices.append(device)
315
+
316
+ return devices, plaintext_codes
317
+
318
+ @classmethod
319
+ def list_devices(
320
+ cls,
321
+ current_user: User,
322
+ target_user_email: str,
323
+ *,
324
+ include_unconfirmed: bool = False,
325
+ ) -> dict[DeviceType, list[MFADevice]]:
326
+ """
327
+ List all MFA devices for a user.
328
+
329
+ Args:
330
+ current_user: The authenticated user making the request.
331
+ target_user_email: Email of user to list devices for.
332
+ include_unconfirmed: Whether to include unconfirmed devices.
333
+
334
+ Returns:
335
+ dict[DeviceType, list[MFADevice]]: Devices grouped by type.
336
+
337
+ Raises:
338
+ PermissionDeniedError: If user lacks permission.
339
+
340
+ Note: Read-only operation, no transaction needed.
341
+ """
342
+ from amsdal.contrib.auth.models.sms_device import SMSDevice
343
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
344
+
345
+ # Check permissions
346
+ cls._check_permission(current_user, target_user_email, 'read')
347
+
348
+ # Query each device type separately (ORM pattern for inheritance)
349
+ result: dict[DeviceType, list[MFADevice]] = {}
350
+
351
+ for device_class, device_type in [
352
+ (TOTPDevice, DeviceType.TOTP),
353
+ (BackupCode, DeviceType.BACKUP_CODE),
354
+ (EmailMFADevice, DeviceType.EMAIL),
355
+ (SMSDevice, DeviceType.SMS),
356
+ ]:
357
+ # Build query for this device type
358
+ query = device_class.objects.filter( # type: ignore[attr-defined]
359
+ user_email=target_user_email,
360
+ is_active=True,
361
+ _address__object_version=Versions.LATEST,
362
+ )
363
+
364
+ # Add confirmed filter unless include_unconfirmed is True
365
+ if not include_unconfirmed:
366
+ query = query.filter(confirmed=True)
367
+
368
+ # Execute and store
369
+ result[device_type] = query.execute()
370
+
371
+ return result
372
+
373
+ @classmethod
374
+ async def alist_devices(
375
+ cls,
376
+ current_user: User,
377
+ target_user_email: str,
378
+ *,
379
+ include_unconfirmed: bool = False,
380
+ ) -> dict[DeviceType, list[MFADevice]]:
381
+ """
382
+ Async version of list_devices.
383
+
384
+ List all MFA devices for a user.
385
+
386
+ Args:
387
+ current_user: The authenticated user making the request.
388
+ target_user_email: Email of user to list devices for.
389
+ include_unconfirmed: Whether to include unconfirmed devices.
390
+
391
+ Returns:
392
+ dict[DeviceType, list[MFADevice]]: Devices grouped by type.
393
+
394
+ Raises:
395
+ PermissionDeniedError: If user lacks permission.
396
+
397
+ Note: Read-only operation, no transaction needed.
398
+ """
399
+ from amsdal.contrib.auth.models.sms_device import SMSDevice
400
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
401
+
402
+ # Check permissions
403
+ await cls._acheck_permission(current_user, target_user_email, 'read')
404
+
405
+ # Query each device type separately (ORM pattern for inheritance)
406
+ result: dict[DeviceType, list[MFADevice]] = {}
407
+
408
+ for device_class, device_type in [
409
+ (TOTPDevice, DeviceType.TOTP),
410
+ (BackupCode, DeviceType.BACKUP_CODE),
411
+ (EmailMFADevice, DeviceType.EMAIL),
412
+ (SMSDevice, DeviceType.SMS),
413
+ ]:
414
+ # Build query for this device type
415
+ query = device_class.objects.filter( # type: ignore[attr-defined]
416
+ user_email=target_user_email,
417
+ is_active=True,
418
+ _address__object_version=Versions.LATEST,
419
+ )
420
+
421
+ # Add confirmed filter unless include_unconfirmed is True
422
+ if not include_unconfirmed:
423
+ query = query.filter(confirmed=True)
424
+
425
+ # Execute and store
426
+ result[device_type] = await query.aexecute()
427
+
428
+ return result
429
+
430
+ @classmethod
431
+ @transaction
432
+ def remove_device(
433
+ cls,
434
+ current_user: User,
435
+ device_id: str,
436
+ *,
437
+ hard_delete: bool = False,
438
+ ) -> None:
439
+ """
440
+ Remove (deactivate or delete) an MFA device.
441
+
442
+ Args:
443
+ current_user: The authenticated user making the request.
444
+ device_id: ID of the device to remove.
445
+ hard_delete: If True, permanently delete; if False, soft delete (mark inactive).
446
+
447
+ Raises:
448
+ PermissionDeniedError: If user lacks permission.
449
+ MFADeviceNotFoundError: If device doesn't exist.
450
+
451
+ Security Note:
452
+ Soft delete (default) preserves audit trail.
453
+ Hard delete permanently removes device.
454
+ """
455
+ from amsdal.contrib.auth.models.sms_device import SMSDevice
456
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
457
+
458
+ # Try to find device in each device type class
459
+ device = None
460
+ for device_class in [TOTPDevice, BackupCode, EmailMFADevice, SMSDevice]:
461
+ device = (
462
+ device_class.objects.filter( # type: ignore[attr-defined]
463
+ _object_id=device_id, _address__object_version=Versions.LATEST
464
+ )
465
+ .get_or_none()
466
+ .execute()
467
+ )
468
+ if device is not None:
469
+ break
470
+
471
+ if device is None:
472
+ msg = f'MFA device with ID {device_id} not found'
473
+ raise MFADeviceNotFoundError(msg)
474
+
475
+ # Check permission using object-level permission
476
+ if not device.has_object_permission(current_user, 'delete'):
477
+ msg = f'User {current_user.email} does not have permission to remove device {device_id}'
478
+ raise PermissionDeniedError(msg)
479
+
480
+ # Hard delete or soft delete
481
+ if hard_delete:
482
+ device.delete()
483
+ else:
484
+ device.is_active = False
485
+ device.save()
486
+
487
+ @classmethod
488
+ @async_transaction
489
+ async def aremove_device(
490
+ cls,
491
+ current_user: User,
492
+ device_id: str,
493
+ *,
494
+ hard_delete: bool = False,
495
+ ) -> None:
496
+ """
497
+ Async version of remove_device.
498
+
499
+ Remove (deactivate or delete) an MFA device.
500
+
501
+ Args:
502
+ current_user: The authenticated user making the request.
503
+ device_id: ID of the device to remove.
504
+ hard_delete: If True, permanently delete; if False, soft delete (mark inactive).
505
+
506
+ Raises:
507
+ PermissionDeniedError: If user lacks permission.
508
+ MFADeviceNotFoundError: If device doesn't exist.
509
+
510
+ Security Note:
511
+ Soft delete (default) preserves audit trail.
512
+ Hard delete permanently removes device.
513
+ """
514
+ from amsdal.contrib.auth.models.sms_device import SMSDevice
515
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
516
+
517
+ # Try to find device in each device type class
518
+ device = None
519
+ for device_class in [TOTPDevice, BackupCode, EmailMFADevice, SMSDevice]:
520
+ device = (
521
+ await device_class.objects.filter( # type: ignore[attr-defined]
522
+ _object_id=device_id, _address__object_version=Versions.LATEST
523
+ )
524
+ .get_or_none()
525
+ .aexecute()
526
+ )
527
+ if device is not None:
528
+ break
529
+
530
+ if device is None:
531
+ msg = f'MFA device with ID {device_id} not found'
532
+ raise MFADeviceNotFoundError(msg)
533
+
534
+ # Check permission using object-level permission
535
+ if not device.has_object_permission(current_user, 'delete'):
536
+ msg = f'User {current_user.email} does not have permission to remove device {device_id}'
537
+ raise PermissionDeniedError(msg)
538
+
539
+ # Hard delete or soft delete
540
+ if hard_delete:
541
+ await device.adelete()
542
+ else:
543
+ device.is_active = False
544
+ await device.asave()