amsdal 0.3.3__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 (244) hide show
  1. amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +56 -2
  2. amsdal/__about__.py +1 -1
  3. amsdal/__init__.py +20 -0
  4. amsdal/__init__.pyi +9 -0
  5. amsdal/__migrations__/0000_initial.py +23 -190
  6. amsdal/__migrations__/0001_create_class_file.py +61 -0
  7. amsdal/__migrations__/0002_create_class_file.py +109 -0
  8. amsdal/__migrations__/0003_update_class_file.py +91 -0
  9. amsdal/__migrations__/0004_update_class_file.py +45 -0
  10. amsdal/cloud/__init__.cpython-311-darwin.so +0 -0
  11. amsdal/cloud/client.cpython-311-darwin.so +0 -0
  12. amsdal/cloud/constants.cpython-311-darwin.so +0 -0
  13. amsdal/cloud/enums.cpython-311-darwin.so +0 -0
  14. amsdal/cloud/models/__init__.cpython-311-darwin.so +0 -0
  15. amsdal/cloud/models/base.cpython-311-darwin.so +0 -0
  16. amsdal/cloud/services/__init__.cpython-311-darwin.so +0 -0
  17. amsdal/cloud/services/actions/__init__.cpython-311-darwin.so +0 -0
  18. amsdal/cloud/services/actions/add_allowlist_ip.cpython-311-darwin.so +0 -0
  19. amsdal/cloud/services/actions/add_basic_auth.cpython-311-darwin.so +0 -0
  20. amsdal/cloud/services/actions/add_dependency.cpython-311-darwin.so +0 -0
  21. amsdal/cloud/services/actions/add_secret.cpython-311-darwin.so +0 -0
  22. amsdal/cloud/services/actions/base.cpython-311-darwin.so +0 -0
  23. amsdal/cloud/services/actions/create_deploy.cpython-311-darwin.so +0 -0
  24. amsdal/cloud/services/actions/create_env.cpython-311-darwin.so +0 -0
  25. amsdal/cloud/services/actions/create_session.cpython-311-darwin.so +0 -0
  26. amsdal/cloud/services/actions/delete_allowlist_ip.cpython-311-darwin.so +0 -0
  27. amsdal/cloud/services/actions/delete_basic_auth.cpython-311-darwin.so +0 -0
  28. amsdal/cloud/services/actions/delete_dependency.cpython-311-darwin.so +0 -0
  29. amsdal/cloud/services/actions/delete_env.cpython-311-darwin.so +0 -0
  30. amsdal/cloud/services/actions/delete_secret.cpython-311-darwin.so +0 -0
  31. amsdal/cloud/services/actions/destroy_deploy.cpython-311-darwin.so +0 -0
  32. amsdal/cloud/services/actions/expose_db.cpython-311-darwin.so +0 -0
  33. amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-311-darwin.so +0 -0
  34. amsdal/cloud/services/actions/get_monitoring_info.cpython-311-darwin.so +0 -0
  35. amsdal/cloud/services/actions/list_dependencies.cpython-311-darwin.so +0 -0
  36. amsdal/cloud/services/actions/list_deploys.cpython-311-darwin.so +0 -0
  37. amsdal/cloud/services/actions/list_envs.cpython-311-darwin.so +0 -0
  38. amsdal/cloud/services/actions/list_secrets.cpython-311-darwin.so +0 -0
  39. amsdal/cloud/services/actions/manager.cpython-311-darwin.so +0 -0
  40. amsdal/cloud/services/actions/signup_action.cpython-311-darwin.so +0 -0
  41. amsdal/cloud/services/actions/update_deploy.cpython-311-darwin.so +0 -0
  42. amsdal/cloud/services/auth/__init__.cpython-311-darwin.so +0 -0
  43. amsdal/cloud/services/auth/base.cpython-311-darwin.so +0 -0
  44. amsdal/cloud/services/auth/credentials.cpython-311-darwin.so +0 -0
  45. amsdal/cloud/services/auth/credentials.pyi +0 -1
  46. amsdal/cloud/services/auth/manager.cpython-311-darwin.so +0 -0
  47. amsdal/cloud/services/auth/signup_service.cpython-311-darwin.so +0 -0
  48. amsdal/cloud/services/auth/token.cpython-311-darwin.so +0 -0
  49. amsdal/cloud/services/auth/token.pyi +0 -1
  50. amsdal/configs/main.py +40 -20
  51. amsdal/configs/main.pyi +19 -18
  52. amsdal/contrib/__init__.cpython-311-darwin.so +0 -0
  53. amsdal/contrib/auth/errors.py +36 -0
  54. amsdal/contrib/auth/errors.pyi +12 -0
  55. amsdal/contrib/auth/fixtures/basic_permissions.json +64 -0
  56. amsdal/contrib/auth/lifecycle/consumer.py +13 -13
  57. amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
  58. amsdal/contrib/auth/migrations/0000_initial.py +69 -31
  59. amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
  60. amsdal/contrib/auth/models/__init__.py +1 -0
  61. amsdal/contrib/auth/models/backup_code.py +85 -0
  62. amsdal/contrib/auth/models/email_mfa_device.py +108 -0
  63. amsdal/contrib/auth/models/login_session.py +235 -0
  64. amsdal/contrib/auth/models/mfa_device.py +86 -0
  65. amsdal/contrib/auth/models/permission.py +23 -0
  66. amsdal/contrib/auth/models/sms_device.py +113 -0
  67. amsdal/contrib/auth/models/totp_device.py +58 -0
  68. amsdal/contrib/auth/models/user.py +156 -0
  69. amsdal/contrib/auth/services/__init__.py +1 -0
  70. amsdal/contrib/auth/services/mfa_device_service.py +544 -0
  71. amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
  72. amsdal/contrib/auth/services/totp_service.py +358 -0
  73. amsdal/contrib/auth/services/totp_service.pyi +158 -0
  74. amsdal/contrib/auth/settings.py +8 -0
  75. amsdal/contrib/auth/settings.pyi +8 -0
  76. amsdal/contrib/auth/transactions/__init__.py +1 -0
  77. amsdal/contrib/auth/transactions/mfa_device_transactions.py +463 -0
  78. amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
  79. amsdal/contrib/auth/transactions/totp_transactions.py +206 -0
  80. amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
  81. amsdal/contrib/auth/utils/__init__.py +0 -0
  82. amsdal/contrib/auth/utils/__init__.pyi +0 -0
  83. amsdal/contrib/auth/utils/mfa.py +257 -0
  84. amsdal/contrib/auth/utils/mfa.pyi +119 -0
  85. amsdal/contrib/frontend_configs/conversion/convert.py +85 -25
  86. amsdal/contrib/frontend_configs/conversion/convert.pyi +0 -1
  87. amsdal/contrib/frontend_configs/lifecycle/consumer.py +32 -13
  88. amsdal/contrib/frontend_configs/lifecycle/consumer.pyi +1 -1
  89. amsdal/contrib/frontend_configs/migrations/0000_initial.py +167 -195
  90. amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
  91. amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
  92. amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
  93. amsdal/contrib/frontend_configs/models/__init__.py +0 -0
  94. amsdal/contrib/frontend_configs/models/frontend_activator_config.py +22 -0
  95. amsdal/contrib/frontend_configs/models/frontend_config_async_validator.py +11 -0
  96. amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +110 -0
  97. amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
  98. amsdal/contrib/frontend_configs/models/frontend_config_group_validator.py +21 -0
  99. amsdal/contrib/frontend_configs/models/frontend_config_option.py +12 -0
  100. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.py +17 -0
  101. amsdal/contrib/frontend_configs/models/frontend_config_slider_option.py +13 -0
  102. amsdal/contrib/frontend_configs/models/frontend_config_text_mask.py +14 -0
  103. amsdal/contrib/frontend_configs/models/frontend_config_validator.py +28 -0
  104. amsdal/contrib/frontend_configs/models/frontend_control_config.py +110 -0
  105. amsdal/contrib/frontend_configs/models/frontend_model_config.py +14 -0
  106. amsdal/errors.py +0 -3
  107. amsdal/errors.pyi +0 -1
  108. amsdal/fixtures/__init__.cpython-311-darwin.so +0 -0
  109. amsdal/fixtures/manager.cpython-311-darwin.so +0 -0
  110. amsdal/fixtures/manager.pyi +73 -123
  111. amsdal/fixtures/utils.cpython-311-darwin.so +0 -0
  112. amsdal/fixtures/utils.pyi +9 -0
  113. amsdal/manager.cpython-311-darwin.so +0 -0
  114. amsdal/manager.pyi +9 -96
  115. amsdal/mixins/__init__.cpython-311-darwin.so +0 -0
  116. amsdal/mixins/class_versions_mixin.cpython-311-darwin.so +0 -0
  117. amsdal/models/__init__.py +19 -0
  118. amsdal/models/core/__init__.py +0 -0
  119. amsdal/models/core/class_object.py +38 -0
  120. amsdal/models/core/class_property.py +26 -0
  121. amsdal/models/core/file.py +243 -0
  122. amsdal/models/core/fixture.py +25 -0
  123. amsdal/models/core/option.py +11 -0
  124. amsdal/models/core/storage_metadata.py +15 -0
  125. amsdal/models/core/validator.py +12 -0
  126. amsdal/models/mixins.py +31 -0
  127. amsdal/models/types/__init__.py +0 -0
  128. amsdal/models/types/object.py +26 -0
  129. amsdal/queryset/__init__.py +21 -0
  130. amsdal/queryset/__init__.pyi +6 -0
  131. amsdal/schemas/core/class_object/model.json +20 -0
  132. amsdal/schemas/core/class_property/model.json +19 -0
  133. amsdal/schemas/core/file/properties/from_file.py +1 -1
  134. amsdal/schemas/core/file/properties/validate_data.py +3 -4
  135. amsdal/schemas/core/storage_metadata/model.json +52 -0
  136. amsdal/schemas/interfaces.py +25 -0
  137. amsdal/schemas/interfaces.pyi +20 -0
  138. amsdal/schemas/manager.cpython-311-darwin.so +0 -0
  139. amsdal/schemas/manager.py +0 -116
  140. amsdal/schemas/manager.pyi +0 -65
  141. amsdal/schemas/mixins/__init__.py +0 -0
  142. amsdal/schemas/mixins/__init__.pyi +0 -0
  143. amsdal/schemas/mixins/check_dependencies_mixin.py +130 -0
  144. amsdal/schemas/mixins/check_dependencies_mixin.pyi +45 -0
  145. amsdal/schemas/mixins/verify_schemas_mixin.py +96 -0
  146. amsdal/schemas/mixins/verify_schemas_mixin.pyi +33 -0
  147. amsdal/schemas/repository.py +84 -0
  148. amsdal/schemas/repository.pyi +22 -0
  149. amsdal/schemas/utils.py +16 -0
  150. amsdal/schemas/utils.pyi +10 -0
  151. amsdal/services/__init__.py +11 -0
  152. amsdal/services/__init__.pyi +4 -0
  153. amsdal/services/external_connections.py +262 -0
  154. amsdal/services/external_connections.pyi +190 -0
  155. amsdal/services/external_model_generator.py +350 -0
  156. amsdal/services/external_model_generator.pyi +134 -0
  157. amsdal/services/transaction_execution.cpython-311-darwin.so +0 -0
  158. amsdal/services/transaction_execution.pyi +2 -1
  159. amsdal/storages/__init__.py +20 -0
  160. amsdal/storages/__init__.pyi +8 -0
  161. amsdal/storages/file_system.py +214 -0
  162. amsdal/storages/file_system.pyi +36 -0
  163. amsdal/transactions/__init__.py +13 -0
  164. amsdal/transactions/__init__.pyi +4 -0
  165. amsdal/utils/rollback/__init__.py +99 -54
  166. amsdal/utils/rollback/__init__.pyi +6 -0
  167. amsdal/utils/tests/enums.py +0 -2
  168. amsdal/utils/tests/helpers.py +253 -231
  169. amsdal/utils/tests/migrations.py +157 -0
  170. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/METADATA +17 -10
  171. amsdal-0.5.29.dist-info/RECORD +276 -0
  172. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/WHEEL +1 -1
  173. amsdal/__migrations__/0001_datetime_type.py +0 -18
  174. amsdal/__migrations__/0002_fixture_order.py +0 -40
  175. amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -56
  176. amsdal/contrib/auth/models/login_session/hooks/pre_init.py +0 -68
  177. amsdal/contrib/auth/models/login_session/model.json +0 -23
  178. amsdal/contrib/auth/models/login_session/modifiers/display_name.py +0 -11
  179. amsdal/contrib/auth/models/permission/fixtures/basic_permissions.json +0 -62
  180. amsdal/contrib/auth/models/permission/model.json +0 -18
  181. amsdal/contrib/auth/models/permission/modifiers/display_name.py +0 -11
  182. amsdal/contrib/auth/models/user/hooks/post_init.py +0 -76
  183. amsdal/contrib/auth/models/user/hooks/pre_create.py +0 -8
  184. amsdal/contrib/auth/models/user/model.json +0 -25
  185. amsdal/contrib/auth/models/user/modifiers/display_name.py +0 -19
  186. amsdal/contrib/frontend_configs/models/frontend_activator_config/model.json +0 -11
  187. amsdal/contrib/frontend_configs/models/frontend_config_async_validator/model.json +0 -11
  188. amsdal/contrib/frontend_configs/models/frontend_config_group_validator/model.json +0 -52
  189. amsdal/contrib/frontend_configs/models/frontend_config_option/model.json +0 -15
  190. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/model.json +0 -6
  191. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/properties/model_dump.py +0 -13
  192. amsdal/contrib/frontend_configs/models/frontend_config_slider_option/model.json +0 -19
  193. amsdal/contrib/frontend_configs/models/frontend_config_text_mask/model.json +0 -26
  194. amsdal/contrib/frontend_configs/models/frontend_config_validator/model.json +0 -41
  195. amsdal/contrib/frontend_configs/models/frontend_control_config/model.json +0 -250
  196. amsdal/contrib/frontend_configs/models/frontend_model_config/fixtures/permissions.json +0 -24
  197. amsdal/contrib/frontend_configs/models/frontend_model_config/model.json +0 -17
  198. amsdal/contrib/frontend_configs/models/frontent_config_control_action/model.json +0 -54
  199. amsdal/contrib/frontend_configs/models/frontent_config_control_action/properties/action_validate.py +0 -33
  200. amsdal/migration/__init__.cpython-311-darwin.so +0 -0
  201. amsdal/migration/base_migration_schemas.cpython-311-darwin.so +0 -0
  202. amsdal/migration/base_migration_schemas.pyi +0 -120
  203. amsdal/migration/data_classes.cpython-311-darwin.so +0 -0
  204. amsdal/migration/data_classes.pyi +0 -172
  205. amsdal/migration/executors/__init__.cpython-311-darwin.so +0 -0
  206. amsdal/migration/executors/base.cpython-311-darwin.so +0 -0
  207. amsdal/migration/executors/base.pyi +0 -118
  208. amsdal/migration/executors/default_executor.cpython-311-darwin.so +0 -0
  209. amsdal/migration/executors/default_executor.pyi +0 -184
  210. amsdal/migration/executors/state_executor.cpython-311-darwin.so +0 -0
  211. amsdal/migration/executors/state_executor.pyi +0 -78
  212. amsdal/migration/file_migration_executor.cpython-311-darwin.so +0 -0
  213. amsdal/migration/file_migration_executor.pyi +0 -68
  214. amsdal/migration/file_migration_generator.cpython-311-darwin.so +0 -0
  215. amsdal/migration/file_migration_generator.pyi +0 -139
  216. amsdal/migration/file_migration_store.cpython-311-darwin.so +0 -0
  217. amsdal/migration/file_migration_store.pyi +0 -61
  218. amsdal/migration/file_migration_writer.cpython-311-darwin.so +0 -0
  219. amsdal/migration/file_migration_writer.pyi +0 -73
  220. amsdal/migration/migrations.cpython-311-darwin.so +0 -0
  221. amsdal/migration/migrations.pyi +0 -166
  222. amsdal/migration/migrations_loader.cpython-311-darwin.so +0 -0
  223. amsdal/migration/migrations_loader.pyi +0 -32
  224. amsdal/migration/schemas_loaders.cpython-311-darwin.so +0 -0
  225. amsdal/migration/schemas_loaders.pyi +0 -37
  226. amsdal/migration/templates/data_migration.tmpl +0 -18
  227. amsdal/migration/templates/dict_validator.tmpl +0 -4
  228. amsdal/migration/templates/migration.tmpl +0 -6
  229. amsdal/migration/templates/model_class.tmpl +0 -8
  230. amsdal/migration/templates/model_class_layout.tmpl +0 -24
  231. amsdal/migration/templates/options_validator.tmpl +0 -4
  232. amsdal/migration/utils.cpython-311-darwin.so +0 -0
  233. amsdal/migration/utils.pyi +0 -58
  234. amsdal/mixins/build_mixin.cpython-311-darwin.so +0 -0
  235. amsdal/mixins/build_mixin.pyi +0 -78
  236. amsdal/schemas/core/class_object_meta/model.json +0 -59
  237. amsdal/schemas/core/class_property_meta/model.json +0 -23
  238. amsdal/services/__init__.cpython-311-darwin.so +0 -0
  239. amsdal-0.3.3.dist-info/RECORD +0 -257
  240. amsdal-0.3.3.dist-info/licenses/LICENSE.txt +0 -107
  241. /amsdal/{migration → contrib/auth/services}/__init__.pyi +0 -0
  242. /amsdal/{migration/executors → contrib/auth/transactions}/__init__.pyi +0 -0
  243. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info/licenses}/LICENSE.txt +0 -0
  244. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,216 @@
1
+ from amsdal.contrib.auth.errors import MFADeviceNotFoundError as MFADeviceNotFoundError, PermissionDeniedError as PermissionDeniedError, UserNotFoundError as UserNotFoundError
2
+ from amsdal.contrib.auth.models.backup_code import BackupCode as BackupCode
3
+ from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice as EmailMFADevice
4
+ from amsdal.contrib.auth.models.mfa_device import MFADevice as MFADevice
5
+ from amsdal.contrib.auth.models.user import User as User
6
+ from amsdal.contrib.auth.settings import auth_settings as auth_settings
7
+ from amsdal.contrib.auth.utils.mfa import DeviceType as DeviceType, generate_backup_codes as generate_backup_codes
8
+ from amsdal_data.transactions.decorators import async_transaction, transaction
9
+
10
+ class MFADeviceService:
11
+ """Service for general MFA device management operations."""
12
+ @classmethod
13
+ def _is_admin(cls, user: User) -> bool:
14
+ """
15
+ Check if user has admin permissions (wildcard or MFADevice-specific).
16
+
17
+ Args:
18
+ user: The user to check.
19
+
20
+ Returns:
21
+ bool: True if user has admin permissions, False otherwise.
22
+ """
23
+ @classmethod
24
+ def _check_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
25
+ """
26
+ Check if current_user has permission for action on target_user.
27
+
28
+ Args:
29
+ current_user: The authenticated user making the request.
30
+ target_user_email: Email of the user being targeted.
31
+ action: The action being performed.
32
+
33
+ Raises:
34
+ PermissionDeniedError: If user lacks permission.
35
+ """
36
+ @classmethod
37
+ async def _acheck_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
38
+ """
39
+ Async version of _check_permission.
40
+
41
+ Args:
42
+ current_user: The authenticated user making the request.
43
+ target_user_email: Email of the user being targeted.
44
+ action: The action being performed.
45
+
46
+ Raises:
47
+ PermissionDeniedError: If user lacks permission.
48
+ """
49
+ @classmethod
50
+ @transaction
51
+ def add_email_device(cls, current_user: User, target_user_email: str, device_name: str, email: str | None = None) -> EmailMFADevice:
52
+ """
53
+ Add email MFA device for a user.
54
+
55
+ Args:
56
+ current_user: The authenticated user making the request.
57
+ target_user_email: Email of user to add device for.
58
+ device_name: User-friendly name for the device.
59
+ email: Email for MFA codes (defaults to target_user_email).
60
+
61
+ Returns:
62
+ EmailMFADevice: The created device.
63
+
64
+ Raises:
65
+ UserNotFoundError: If target user doesn't exist.
66
+ PermissionDeniedError: If user lacks permission.
67
+ """
68
+ @classmethod
69
+ @async_transaction
70
+ async def aadd_email_device(cls, current_user: User, target_user_email: str, device_name: str, email: str | None = None) -> EmailMFADevice:
71
+ """
72
+ Async version of add_email_device.
73
+
74
+ Add email MFA device for a user.
75
+
76
+ Args:
77
+ current_user: The authenticated user making the request.
78
+ target_user_email: Email of user to add device for.
79
+ device_name: User-friendly name for the device.
80
+ email: Email for MFA codes (defaults to target_user_email).
81
+
82
+ Returns:
83
+ EmailMFADevice: The created device.
84
+
85
+ Raises:
86
+ UserNotFoundError: If target user doesn't exist.
87
+ PermissionDeniedError: If user lacks permission.
88
+ """
89
+ @classmethod
90
+ @transaction
91
+ def add_backup_codes(cls, current_user: User, target_user_email: str, device_name: str = 'Backup Codes', code_count: int | None = None) -> tuple[list[BackupCode], list[str]]:
92
+ """
93
+ Add backup codes for a user.
94
+
95
+ Args:
96
+ current_user: The authenticated user making the request.
97
+ target_user_email: Email of user to add codes for.
98
+ device_name: Name for the backup code set.
99
+ code_count: Number of codes (defaults to MFA_BACKUP_CODES_COUNT setting).
100
+
101
+ Returns:
102
+ tuple[list[BackupCode], list[str]]: Device instances and plaintext codes.
103
+
104
+ Raises:
105
+ UserNotFoundError: If target user doesn't exist.
106
+ PermissionDeniedError: If user lacks permission.
107
+
108
+ Security Note:
109
+ Plaintext codes are returned ONLY during creation.
110
+ Caller must display/send these to user immediately.
111
+ Codes cannot be retrieved later.
112
+ """
113
+ @classmethod
114
+ @async_transaction
115
+ async def aadd_backup_codes(cls, current_user: User, target_user_email: str, device_name: str = 'Backup Codes', code_count: int | None = None) -> tuple[list[BackupCode], list[str]]:
116
+ """
117
+ Async version of add_backup_codes.
118
+
119
+ Add backup codes for a user.
120
+
121
+ Args:
122
+ current_user: The authenticated user making the request.
123
+ target_user_email: Email of user to add codes for.
124
+ device_name: Name for the backup code set.
125
+ code_count: Number of codes (defaults to MFA_BACKUP_CODES_COUNT setting).
126
+
127
+ Returns:
128
+ tuple[list[BackupCode], list[str]]: Device instances and plaintext codes.
129
+
130
+ Raises:
131
+ UserNotFoundError: If target user doesn't exist.
132
+ PermissionDeniedError: If user lacks permission.
133
+
134
+ Security Note:
135
+ Plaintext codes are returned ONLY during creation.
136
+ Caller must display/send these to user immediately.
137
+ Codes cannot be retrieved later.
138
+ """
139
+ @classmethod
140
+ def list_devices(cls, current_user: User, target_user_email: str, *, include_unconfirmed: bool = False) -> dict[DeviceType, list[MFADevice]]:
141
+ """
142
+ List all MFA devices for a user.
143
+
144
+ Args:
145
+ current_user: The authenticated user making the request.
146
+ target_user_email: Email of user to list devices for.
147
+ include_unconfirmed: Whether to include unconfirmed devices.
148
+
149
+ Returns:
150
+ dict[DeviceType, list[MFADevice]]: Devices grouped by type.
151
+
152
+ Raises:
153
+ PermissionDeniedError: If user lacks permission.
154
+
155
+ Note: Read-only operation, no transaction needed.
156
+ """
157
+ @classmethod
158
+ async def alist_devices(cls, current_user: User, target_user_email: str, *, include_unconfirmed: bool = False) -> dict[DeviceType, list[MFADevice]]:
159
+ """
160
+ Async version of list_devices.
161
+
162
+ List all MFA devices for a user.
163
+
164
+ Args:
165
+ current_user: The authenticated user making the request.
166
+ target_user_email: Email of user to list devices for.
167
+ include_unconfirmed: Whether to include unconfirmed devices.
168
+
169
+ Returns:
170
+ dict[DeviceType, list[MFADevice]]: Devices grouped by type.
171
+
172
+ Raises:
173
+ PermissionDeniedError: If user lacks permission.
174
+
175
+ Note: Read-only operation, no transaction needed.
176
+ """
177
+ @classmethod
178
+ @transaction
179
+ def remove_device(cls, current_user: User, device_id: str, *, hard_delete: bool = False) -> None:
180
+ """
181
+ Remove (deactivate or delete) an MFA device.
182
+
183
+ Args:
184
+ current_user: The authenticated user making the request.
185
+ device_id: ID of the device to remove.
186
+ hard_delete: If True, permanently delete; if False, soft delete (mark inactive).
187
+
188
+ Raises:
189
+ PermissionDeniedError: If user lacks permission.
190
+ MFADeviceNotFoundError: If device doesn't exist.
191
+
192
+ Security Note:
193
+ Soft delete (default) preserves audit trail.
194
+ Hard delete permanently removes device.
195
+ """
196
+ @classmethod
197
+ @async_transaction
198
+ async def aremove_device(cls, current_user: User, device_id: str, *, hard_delete: bool = False) -> None:
199
+ """
200
+ Async version of remove_device.
201
+
202
+ Remove (deactivate or delete) an MFA device.
203
+
204
+ Args:
205
+ current_user: The authenticated user making the request.
206
+ device_id: ID of the device to remove.
207
+ hard_delete: If True, permanently delete; if False, soft delete (mark inactive).
208
+
209
+ Raises:
210
+ PermissionDeniedError: If user lacks permission.
211
+ MFADeviceNotFoundError: If device doesn't exist.
212
+
213
+ Security Note:
214
+ Soft delete (default) preserves audit trail.
215
+ Hard delete permanently removes device.
216
+ """
@@ -0,0 +1,358 @@
1
+ """TOTP device enrollment and confirmation service."""
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 InvalidMFACodeError
8
+ from amsdal.contrib.auth.errors import MFADeviceNotFoundError
9
+ from amsdal.contrib.auth.errors import MFASetupError
10
+ from amsdal.contrib.auth.errors import PermissionDeniedError
11
+ from amsdal.contrib.auth.errors import UserNotFoundError
12
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
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 generate_qr_code_url
16
+ from amsdal.contrib.auth.utils.mfa import generate_totp_secret
17
+
18
+
19
+ class TOTPService:
20
+ """Service for TOTP device two-step enrollment flow."""
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', 'update'):
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 setup_totp_device(
87
+ cls,
88
+ current_user: User,
89
+ target_user_email: str,
90
+ device_name: str,
91
+ issuer: str | None = None,
92
+ ) -> dict[str, str]:
93
+ """
94
+ Step 1: Setup TOTP device (generate secret, create unconfirmed device).
95
+
96
+ Args:
97
+ current_user: The authenticated user making the request.
98
+ target_user_email: Email of user to setup device for.
99
+ device_name: User-friendly name for the device.
100
+ issuer: TOTP issuer name (defaults to MFA_TOTP_ISSUER setting).
101
+
102
+ Returns:
103
+ dict with keys:
104
+ - secret: Base32 secret (show to user ONCE)
105
+ - qr_code_url: otpauth:// URL for QR code generation
106
+ - device_id: ID of unconfirmed device
107
+
108
+ Raises:
109
+ UserNotFoundError: If target user doesn't exist.
110
+ PermissionDeniedError: If user lacks permission.
111
+
112
+ Security Note:
113
+ Secret is returned ONLY during setup.
114
+ User must scan QR or manually enter secret.
115
+ Device is created with confirmed=False.
116
+ """
117
+ # Verify target user exists FIRST
118
+ target_user = (
119
+ User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
120
+ .get_or_none()
121
+ .execute()
122
+ )
123
+ if target_user is None:
124
+ msg = f'User with email {target_user_email} not found'
125
+ raise UserNotFoundError(msg)
126
+
127
+ # Check permissions AFTER verifying user exists
128
+ cls._check_permission(current_user, target_user_email, 'create')
129
+
130
+ # Generate TOTP secret
131
+ secret = generate_totp_secret()
132
+
133
+ # Generate QR code URL
134
+ if issuer is None:
135
+ issuer = auth_settings.MFA_TOTP_ISSUER
136
+
137
+ qr_code_url = generate_qr_code_url(secret, target_user_email, issuer)
138
+
139
+ # Create unconfirmed TOTP device
140
+ device = TOTPDevice( # type: ignore[call-arg]
141
+ user_email=target_user_email,
142
+ name=device_name,
143
+ secret=secret,
144
+ qr_code_url=qr_code_url,
145
+ confirmed=False, # Explicitly set as unconfirmed
146
+ )
147
+ device.save(force_insert=True)
148
+
149
+ # Return secret, QR code URL, and device ID
150
+ return {
151
+ 'secret': secret,
152
+ 'qr_code_url': qr_code_url,
153
+ 'device_id': device._object_id,
154
+ }
155
+
156
+ @classmethod
157
+ @async_transaction
158
+ async def asetup_totp_device(
159
+ cls,
160
+ current_user: User,
161
+ target_user_email: str,
162
+ device_name: str,
163
+ issuer: str | None = None,
164
+ ) -> dict[str, str]:
165
+ """
166
+ Async version of setup_totp_device.
167
+
168
+ Step 1: Setup TOTP device (generate secret, create unconfirmed device).
169
+
170
+ Args:
171
+ current_user: The authenticated user making the request.
172
+ target_user_email: Email of user to setup device for.
173
+ device_name: User-friendly name for the device.
174
+ issuer: TOTP issuer name (defaults to MFA_TOTP_ISSUER setting).
175
+
176
+ Returns:
177
+ dict with keys:
178
+ - secret: Base32 secret (show to user ONCE)
179
+ - qr_code_url: otpauth:// URL for QR code generation
180
+ - device_id: ID of unconfirmed device
181
+
182
+ Raises:
183
+ UserNotFoundError: If target user doesn't exist.
184
+ PermissionDeniedError: If user lacks permission.
185
+
186
+ Security Note:
187
+ Secret is returned ONLY during setup.
188
+ User must scan QR or manually enter secret.
189
+ Device is created with confirmed=False.
190
+ """
191
+ # Verify target user exists FIRST
192
+ target_user = (
193
+ await User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
194
+ .get_or_none()
195
+ .aexecute()
196
+ )
197
+ if target_user is None:
198
+ msg = f'User with email {target_user_email} not found'
199
+ raise UserNotFoundError(msg)
200
+
201
+ # Check permissions AFTER verifying user exists
202
+ await cls._acheck_permission(current_user, target_user_email, 'create')
203
+
204
+ # Generate TOTP secret
205
+ secret = generate_totp_secret()
206
+
207
+ # Generate QR code URL
208
+ if issuer is None:
209
+ issuer = auth_settings.MFA_TOTP_ISSUER
210
+
211
+ qr_code_url = generate_qr_code_url(secret, target_user_email, issuer)
212
+
213
+ # Create unconfirmed TOTP device
214
+ device = TOTPDevice( # type: ignore[call-arg]
215
+ user_email=target_user_email,
216
+ name=device_name,
217
+ secret=secret,
218
+ qr_code_url=qr_code_url,
219
+ confirmed=False, # Explicitly set as unconfirmed
220
+ )
221
+ await device.asave(force_insert=True)
222
+
223
+ # Return secret, QR code URL, and device ID
224
+ return {
225
+ 'secret': secret,
226
+ 'qr_code_url': qr_code_url,
227
+ 'device_id': device._object_id,
228
+ }
229
+
230
+ @classmethod
231
+ @transaction
232
+ def confirm_totp_device(
233
+ cls,
234
+ current_user: User,
235
+ device_id: str,
236
+ verification_code: str,
237
+ ) -> TOTPDevice:
238
+ """
239
+ Step 2: Confirm TOTP device by verifying code.
240
+
241
+ Args:
242
+ current_user: The authenticated user making the request.
243
+ device_id: ID of unconfirmed device from setup step.
244
+ verification_code: 6-digit code from authenticator app.
245
+
246
+ Returns:
247
+ TOTPDevice: The confirmed device.
248
+
249
+ Raises:
250
+ PermissionDeniedError: If user lacks permission.
251
+ MFADeviceNotFoundError: If device doesn't exist.
252
+ InvalidMFACodeError: If verification code is incorrect.
253
+ MFASetupError: If device already confirmed.
254
+
255
+ Flow:
256
+ 1. Retrieve unconfirmed device
257
+ 2. Check ownership/permissions
258
+ 3. Verify code using device.verify_code()
259
+ 4. Mark device as confirmed=True
260
+ 5. Save and return
261
+ """
262
+ # Retrieve device by ID
263
+ device = (
264
+ TOTPDevice.objects.filter(_object_id=device_id, _address__object_version=Versions.LATEST)
265
+ .get_or_none()
266
+ .execute()
267
+ )
268
+
269
+ if device is None:
270
+ msg = f'TOTP device with ID {device_id} not found'
271
+ raise MFADeviceNotFoundError(msg)
272
+
273
+ # Check permissions (ownership or admin)
274
+ if not device.has_object_permission(current_user, 'update'):
275
+ msg = f'User {current_user.email} does not have permission to confirm device {device_id}'
276
+ raise PermissionDeniedError(msg)
277
+
278
+ # Verify device not already confirmed
279
+ if device.confirmed:
280
+ msg = f'Device {device_id} is already confirmed'
281
+ raise MFASetupError(msg)
282
+
283
+ # Verify code
284
+ if not device.verify_code(verification_code):
285
+ msg = 'Invalid verification code'
286
+ raise InvalidMFACodeError(msg)
287
+
288
+ # Mark as confirmed
289
+ device.confirmed = True
290
+ device.save()
291
+
292
+ return device
293
+
294
+ @classmethod
295
+ @async_transaction
296
+ async def aconfirm_totp_device(
297
+ cls,
298
+ current_user: User,
299
+ device_id: str,
300
+ verification_code: str,
301
+ ) -> TOTPDevice:
302
+ """
303
+ Async version of confirm_totp_device.
304
+
305
+ Step 2: Confirm TOTP device by verifying code.
306
+
307
+ Args:
308
+ current_user: The authenticated user making the request.
309
+ device_id: ID of unconfirmed device from setup step.
310
+ verification_code: 6-digit code from authenticator app.
311
+
312
+ Returns:
313
+ TOTPDevice: The confirmed device.
314
+
315
+ Raises:
316
+ PermissionDeniedError: If user lacks permission.
317
+ MFADeviceNotFoundError: If device doesn't exist.
318
+ InvalidMFACodeError: If verification code is incorrect.
319
+ MFASetupError: If device already confirmed.
320
+
321
+ Flow:
322
+ 1. Retrieve unconfirmed device
323
+ 2. Check ownership/permissions
324
+ 3. Verify code using device.verify_code()
325
+ 4. Mark device as confirmed=True
326
+ 5. Save and return
327
+ """
328
+ # Retrieve device by ID
329
+ device = (
330
+ await TOTPDevice.objects.filter(_object_id=device_id, _address__object_version=Versions.LATEST)
331
+ .get_or_none()
332
+ .aexecute()
333
+ )
334
+
335
+ if device is None:
336
+ msg = f'TOTP device with ID {device_id} not found'
337
+ raise MFADeviceNotFoundError(msg)
338
+
339
+ # Check permissions (ownership or admin)
340
+ if not device.has_object_permission(current_user, 'update'):
341
+ msg = f'User {current_user.email} does not have permission to confirm device {device_id}'
342
+ raise PermissionDeniedError(msg)
343
+
344
+ # Verify device not already confirmed
345
+ if device.confirmed:
346
+ msg = f'Device {device_id} is already confirmed'
347
+ raise MFASetupError(msg)
348
+
349
+ # Verify code
350
+ if not device.verify_code(verification_code):
351
+ msg = 'Invalid verification code'
352
+ raise InvalidMFACodeError(msg)
353
+
354
+ # Mark as confirmed
355
+ device.confirmed = True
356
+ await device.asave()
357
+
358
+ return device