amsdal 0.4.13__cp312-cp312-macosx_10_13_universal2.whl → 0.5.33__cp312-cp312-macosx_10_13_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 (156) 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-312-darwin.so +0 -0
  9. amsdal/cloud/client.cpython-312-darwin.so +0 -0
  10. amsdal/cloud/constants.cpython-312-darwin.so +0 -0
  11. amsdal/cloud/enums.cpython-312-darwin.so +0 -0
  12. amsdal/cloud/models/__init__.cpython-312-darwin.so +0 -0
  13. amsdal/cloud/models/base.cpython-312-darwin.so +0 -0
  14. amsdal/cloud/services/__init__.cpython-312-darwin.so +0 -0
  15. amsdal/cloud/services/actions/__init__.cpython-312-darwin.so +0 -0
  16. amsdal/cloud/services/actions/add_allowlist_ip.cpython-312-darwin.so +0 -0
  17. amsdal/cloud/services/actions/add_basic_auth.cpython-312-darwin.so +0 -0
  18. amsdal/cloud/services/actions/add_dependency.cpython-312-darwin.so +0 -0
  19. amsdal/cloud/services/actions/add_secret.cpython-312-darwin.so +0 -0
  20. amsdal/cloud/services/actions/base.cpython-312-darwin.so +0 -0
  21. amsdal/cloud/services/actions/create_deploy.cpython-312-darwin.so +0 -0
  22. amsdal/cloud/services/actions/create_env.cpython-312-darwin.so +0 -0
  23. amsdal/cloud/services/actions/create_session.cpython-312-darwin.so +0 -0
  24. amsdal/cloud/services/actions/delete_allowlist_ip.cpython-312-darwin.so +0 -0
  25. amsdal/cloud/services/actions/delete_basic_auth.cpython-312-darwin.so +0 -0
  26. amsdal/cloud/services/actions/delete_dependency.cpython-312-darwin.so +0 -0
  27. amsdal/cloud/services/actions/delete_env.cpython-312-darwin.so +0 -0
  28. amsdal/cloud/services/actions/delete_secret.cpython-312-darwin.so +0 -0
  29. amsdal/cloud/services/actions/destroy_deploy.cpython-312-darwin.so +0 -0
  30. amsdal/cloud/services/actions/expose_db.cpython-312-darwin.so +0 -0
  31. amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-312-darwin.so +0 -0
  32. amsdal/cloud/services/actions/get_monitoring_info.cpython-312-darwin.so +0 -0
  33. amsdal/cloud/services/actions/list_dependencies.cpython-312-darwin.so +0 -0
  34. amsdal/cloud/services/actions/list_deploys.cpython-312-darwin.so +0 -0
  35. amsdal/cloud/services/actions/list_envs.cpython-312-darwin.so +0 -0
  36. amsdal/cloud/services/actions/list_secrets.cpython-312-darwin.so +0 -0
  37. amsdal/cloud/services/actions/manager.cpython-312-darwin.so +0 -0
  38. amsdal/cloud/services/actions/signup_action.cpython-312-darwin.so +0 -0
  39. amsdal/cloud/services/actions/update_deploy.cpython-312-darwin.so +0 -0
  40. amsdal/cloud/services/auth/__init__.cpython-312-darwin.so +0 -0
  41. amsdal/cloud/services/auth/base.cpython-312-darwin.so +0 -0
  42. amsdal/cloud/services/auth/credentials.cpython-312-darwin.so +0 -0
  43. amsdal/cloud/services/auth/manager.cpython-312-darwin.so +0 -0
  44. amsdal/cloud/services/auth/signup_service.cpython-312-darwin.so +0 -0
  45. amsdal/cloud/services/auth/token.cpython-312-darwin.so +0 -0
  46. amsdal/configs/main.py +17 -1
  47. amsdal/configs/main.pyi +6 -1
  48. amsdal/contrib/__init__.cpython-312-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 +2 -2
  52. amsdal/contrib/auth/migrations/0000_initial.py +55 -52
  53. amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
  54. amsdal/contrib/auth/models/__init__.py +1 -0
  55. amsdal/contrib/auth/models/backup_code.py +85 -0
  56. amsdal/contrib/auth/models/email_mfa_device.py +108 -0
  57. amsdal/contrib/auth/models/login_session.py +117 -0
  58. amsdal/contrib/auth/models/mfa_device.py +86 -0
  59. amsdal/contrib/auth/models/sms_device.py +113 -0
  60. amsdal/contrib/auth/models/totp_device.py +58 -0
  61. amsdal/contrib/auth/models/user.py +50 -0
  62. amsdal/contrib/auth/services/__init__.py +1 -0
  63. amsdal/contrib/auth/services/mfa_device_service.py +544 -0
  64. amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
  65. amsdal/contrib/auth/services/totp_service.py +358 -0
  66. amsdal/contrib/auth/services/totp_service.pyi +158 -0
  67. amsdal/contrib/auth/settings.py +8 -0
  68. amsdal/contrib/auth/settings.pyi +8 -0
  69. amsdal/contrib/auth/transactions/__init__.py +1 -0
  70. amsdal/contrib/auth/transactions/mfa_device_transactions.py +458 -0
  71. amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
  72. amsdal/contrib/auth/transactions/totp_transactions.py +203 -0
  73. amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
  74. amsdal/contrib/auth/utils/mfa.py +257 -0
  75. amsdal/contrib/auth/utils/mfa.pyi +119 -0
  76. amsdal/contrib/frontend_configs/conversion/convert.py +24 -0
  77. amsdal/contrib/frontend_configs/migrations/0000_initial.py +154 -183
  78. amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
  79. amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
  80. amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
  81. amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +57 -1
  82. amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
  83. amsdal/contrib/frontend_configs/models/frontend_control_config.py +69 -46
  84. amsdal/fixtures/__init__.cpython-312-darwin.so +0 -0
  85. amsdal/fixtures/manager.cpython-312-darwin.so +0 -0
  86. amsdal/fixtures/utils.cpython-312-darwin.so +0 -0
  87. amsdal/manager.cpython-312-darwin.so +0 -0
  88. amsdal/mixins/__init__.cpython-312-darwin.so +0 -0
  89. amsdal/mixins/class_versions_mixin.cpython-312-darwin.so +0 -0
  90. amsdal/models/core/class_object.py +7 -6
  91. amsdal/models/core/class_property.py +7 -1
  92. amsdal/models/core/file.py +168 -81
  93. amsdal/models/core/storage_metadata.py +15 -0
  94. amsdal/models/mixins.py +31 -0
  95. amsdal/models/types/object.py +3 -3
  96. amsdal/schemas/core/class_object/model.json +20 -0
  97. amsdal/schemas/core/class_property/model.json +19 -0
  98. amsdal/schemas/core/file/properties/validate_data.py +2 -3
  99. amsdal/schemas/core/storage_metadata/model.json +52 -0
  100. amsdal/schemas/manager.cpython-312-darwin.so +0 -0
  101. amsdal/schemas/mixins/check_dependencies_mixin.py +8 -3
  102. amsdal/services/__init__.py +11 -0
  103. amsdal/services/__init__.pyi +4 -0
  104. amsdal/services/external_connections.py +262 -0
  105. amsdal/services/external_connections.pyi +190 -0
  106. amsdal/services/external_model_generator.py +350 -0
  107. amsdal/services/external_model_generator.pyi +134 -0
  108. amsdal/services/transaction_execution.cpython-312-darwin.so +0 -0
  109. amsdal/storages/__init__.py +20 -0
  110. amsdal/storages/__init__.pyi +8 -0
  111. amsdal/storages/file_system.py +214 -0
  112. amsdal/storages/file_system.pyi +36 -0
  113. amsdal/utils/tests/enums.py +0 -2
  114. amsdal/utils/tests/helpers.py +213 -381
  115. amsdal/utils/tests/migrations.py +157 -0
  116. {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/METADATA +17 -11
  117. {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/RECORD +124 -117
  118. amsdal/__migrations__/0001_datetime_type.py +0 -18
  119. amsdal/__migrations__/0002_fixture_order.py +0 -44
  120. amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -44
  121. amsdal/contrib/auth/models/login_session.pyi +0 -37
  122. amsdal/contrib/auth/models/permission.pyi +0 -18
  123. amsdal/contrib/auth/models/user.pyi +0 -46
  124. amsdal/contrib/frontend_configs/models/frontend_activator_config.pyi +0 -12
  125. amsdal/contrib/frontend_configs/models/frontend_config_async_validator.pyi +0 -7
  126. amsdal/contrib/frontend_configs/models/frontend_config_control_action.pyi +0 -32
  127. amsdal/contrib/frontend_configs/models/frontend_config_group_validator.pyi +0 -11
  128. amsdal/contrib/frontend_configs/models/frontend_config_option.pyi +0 -8
  129. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.pyi +0 -8
  130. amsdal/contrib/frontend_configs/models/frontend_config_slider_option.pyi +0 -9
  131. amsdal/contrib/frontend_configs/models/frontend_config_text_mask.pyi +0 -10
  132. amsdal/contrib/frontend_configs/models/frontend_config_validator.pyi +0 -15
  133. amsdal/contrib/frontend_configs/models/frontend_control_config.pyi +0 -35
  134. amsdal/contrib/frontend_configs/models/frontend_model_config.pyi +0 -9
  135. amsdal/models/__init__.pyi +0 -9
  136. amsdal/models/core/class_object.pyi +0 -24
  137. amsdal/models/core/class_object_meta.py +0 -26
  138. amsdal/models/core/class_object_meta.pyi +0 -15
  139. amsdal/models/core/class_property.pyi +0 -11
  140. amsdal/models/core/class_property_meta.py +0 -15
  141. amsdal/models/core/class_property_meta.pyi +0 -10
  142. amsdal/models/core/file.pyi +0 -104
  143. amsdal/models/core/fixture.pyi +0 -14
  144. amsdal/models/core/option.pyi +0 -8
  145. amsdal/models/core/validator.pyi +0 -8
  146. amsdal/models/types/object.pyi +0 -16
  147. amsdal/schemas/core/class_object_meta/model.json +0 -59
  148. amsdal/schemas/core/class_property_meta/model.json +0 -23
  149. amsdal/services/__init__.cpython-312-darwin.so +0 -0
  150. /amsdal/contrib/auth/{models → services}/__init__.pyi +0 -0
  151. /amsdal/contrib/{frontend_configs/models → auth/transactions}/__init__.pyi +0 -0
  152. /amsdal/{models/core/__init__.pyi → contrib/auth/utils/__init__.py} +0 -0
  153. /amsdal/{models/types → contrib/auth/utils}/__init__.pyi +0 -0
  154. {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/WHEEL +0 -0
  155. {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/licenses/LICENSE.txt +0 -0
  156. {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,158 @@
1
+ from amsdal.contrib.auth.errors import InvalidMFACodeError as InvalidMFACodeError, MFADeviceNotFoundError as MFADeviceNotFoundError, MFASetupError as MFASetupError, PermissionDeniedError as PermissionDeniedError, UserNotFoundError as UserNotFoundError
2
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice as TOTPDevice
3
+ from amsdal.contrib.auth.models.user import User as User
4
+ from amsdal.contrib.auth.settings import auth_settings as auth_settings
5
+ from amsdal.contrib.auth.utils.mfa import generate_qr_code_url as generate_qr_code_url, generate_totp_secret as generate_totp_secret
6
+ from amsdal_data.transactions.decorators import async_transaction, transaction
7
+
8
+ class TOTPService:
9
+ """Service for TOTP device two-step enrollment flow."""
10
+ @classmethod
11
+ def _is_admin(cls, user: User) -> bool:
12
+ """
13
+ Check if user has admin permissions (wildcard or MFADevice-specific).
14
+
15
+ Args:
16
+ user: The user to check.
17
+
18
+ Returns:
19
+ bool: True if user has admin permissions, False otherwise.
20
+ """
21
+ @classmethod
22
+ def _check_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
23
+ """
24
+ Check if current_user has permission for action on target_user.
25
+
26
+ Args:
27
+ current_user: The authenticated user making the request.
28
+ target_user_email: Email of the user being targeted.
29
+ action: The action being performed.
30
+
31
+ Raises:
32
+ PermissionDeniedError: If user lacks permission.
33
+ """
34
+ @classmethod
35
+ async def _acheck_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
36
+ """
37
+ Async version of _check_permission.
38
+
39
+ Args:
40
+ current_user: The authenticated user making the request.
41
+ target_user_email: Email of the user being targeted.
42
+ action: The action being performed.
43
+
44
+ Raises:
45
+ PermissionDeniedError: If user lacks permission.
46
+ """
47
+ @classmethod
48
+ @transaction
49
+ def setup_totp_device(cls, current_user: User, target_user_email: str, device_name: str, issuer: str | None = None) -> dict[str, str]:
50
+ """
51
+ Step 1: Setup TOTP device (generate secret, create unconfirmed device).
52
+
53
+ Args:
54
+ current_user: The authenticated user making the request.
55
+ target_user_email: Email of user to setup device for.
56
+ device_name: User-friendly name for the device.
57
+ issuer: TOTP issuer name (defaults to MFA_TOTP_ISSUER setting).
58
+
59
+ Returns:
60
+ dict with keys:
61
+ - secret: Base32 secret (show to user ONCE)
62
+ - qr_code_url: otpauth:// URL for QR code generation
63
+ - device_id: ID of unconfirmed device
64
+
65
+ Raises:
66
+ UserNotFoundError: If target user doesn't exist.
67
+ PermissionDeniedError: If user lacks permission.
68
+
69
+ Security Note:
70
+ Secret is returned ONLY during setup.
71
+ User must scan QR or manually enter secret.
72
+ Device is created with confirmed=False.
73
+ """
74
+ @classmethod
75
+ @async_transaction
76
+ async def asetup_totp_device(cls, current_user: User, target_user_email: str, device_name: str, issuer: str | None = None) -> dict[str, str]:
77
+ """
78
+ Async version of setup_totp_device.
79
+
80
+ Step 1: Setup TOTP device (generate secret, create unconfirmed device).
81
+
82
+ Args:
83
+ current_user: The authenticated user making the request.
84
+ target_user_email: Email of user to setup device for.
85
+ device_name: User-friendly name for the device.
86
+ issuer: TOTP issuer name (defaults to MFA_TOTP_ISSUER setting).
87
+
88
+ Returns:
89
+ dict with keys:
90
+ - secret: Base32 secret (show to user ONCE)
91
+ - qr_code_url: otpauth:// URL for QR code generation
92
+ - device_id: ID of unconfirmed device
93
+
94
+ Raises:
95
+ UserNotFoundError: If target user doesn't exist.
96
+ PermissionDeniedError: If user lacks permission.
97
+
98
+ Security Note:
99
+ Secret is returned ONLY during setup.
100
+ User must scan QR or manually enter secret.
101
+ Device is created with confirmed=False.
102
+ """
103
+ @classmethod
104
+ @transaction
105
+ def confirm_totp_device(cls, current_user: User, device_id: str, verification_code: str) -> TOTPDevice:
106
+ """
107
+ Step 2: Confirm TOTP device by verifying code.
108
+
109
+ Args:
110
+ current_user: The authenticated user making the request.
111
+ device_id: ID of unconfirmed device from setup step.
112
+ verification_code: 6-digit code from authenticator app.
113
+
114
+ Returns:
115
+ TOTPDevice: The confirmed device.
116
+
117
+ Raises:
118
+ PermissionDeniedError: If user lacks permission.
119
+ MFADeviceNotFoundError: If device doesn't exist.
120
+ InvalidMFACodeError: If verification code is incorrect.
121
+ MFASetupError: If device already confirmed.
122
+
123
+ Flow:
124
+ 1. Retrieve unconfirmed device
125
+ 2. Check ownership/permissions
126
+ 3. Verify code using device.verify_code()
127
+ 4. Mark device as confirmed=True
128
+ 5. Save and return
129
+ """
130
+ @classmethod
131
+ @async_transaction
132
+ async def aconfirm_totp_device(cls, current_user: User, device_id: str, verification_code: str) -> TOTPDevice:
133
+ """
134
+ Async version of confirm_totp_device.
135
+
136
+ Step 2: Confirm TOTP device by verifying code.
137
+
138
+ Args:
139
+ current_user: The authenticated user making the request.
140
+ device_id: ID of unconfirmed device from setup step.
141
+ verification_code: 6-digit code from authenticator app.
142
+
143
+ Returns:
144
+ TOTPDevice: The confirmed device.
145
+
146
+ Raises:
147
+ PermissionDeniedError: If user lacks permission.
148
+ MFADeviceNotFoundError: If device doesn't exist.
149
+ InvalidMFACodeError: If verification code is incorrect.
150
+ MFASetupError: If device already confirmed.
151
+
152
+ Flow:
153
+ 1. Retrieve unconfirmed device
154
+ 2. Check ownership/permissions
155
+ 3. Verify code using device.verify_code()
156
+ 4. Mark device as confirmed=True
157
+ 5. Save and return
158
+ """
@@ -16,6 +16,10 @@ class Settings(BaseSettings):
16
16
  AUTH_JWT_KEY (str | None): The key used for JWT authentication.
17
17
  AUTH_TOKEN_EXPIRATION (int): The expiration time for authentication tokens in seconds.
18
18
  REQUIRE_DEFAULT_AUTHORIZATION (bool): Flag to require default authorization.
19
+ REQUIRE_MFA_BY_DEFAULT (bool): Flag to require MFA for all users by default.
20
+ MFA_TOTP_ISSUER (str): The issuer name displayed in TOTP authenticator apps.
21
+ MFA_BACKUP_CODES_COUNT (int): Number of backup codes to generate per user.
22
+ MFA_EMAIL_CODE_EXPIRATION (int): Email MFA code expiration time in seconds.
19
23
  """
20
24
 
21
25
  model_config = SettingsConfigDict(
@@ -31,6 +35,10 @@ class Settings(BaseSettings):
31
35
  AUTH_JWT_KEY: str | None = None
32
36
  AUTH_TOKEN_EXPIRATION: int = 86400
33
37
  REQUIRE_DEFAULT_AUTHORIZATION: bool = True
38
+ REQUIRE_MFA_BY_DEFAULT: bool = False
39
+ MFA_TOTP_ISSUER: str = 'AMSDAL'
40
+ MFA_BACKUP_CODES_COUNT: int = 10
41
+ MFA_EMAIL_CODE_EXPIRATION: int = 300
34
42
 
35
43
 
36
44
  auth_settings = Settings()
@@ -15,6 +15,10 @@ class Settings(BaseSettings):
15
15
  AUTH_JWT_KEY (str | None): The key used for JWT authentication.
16
16
  AUTH_TOKEN_EXPIRATION (int): The expiration time for authentication tokens in seconds.
17
17
  REQUIRE_DEFAULT_AUTHORIZATION (bool): Flag to require default authorization.
18
+ REQUIRE_MFA_BY_DEFAULT (bool): Flag to require MFA for all users by default.
19
+ MFA_TOTP_ISSUER (str): The issuer name displayed in TOTP authenticator apps.
20
+ MFA_BACKUP_CODES_COUNT (int): Number of backup codes to generate per user.
21
+ MFA_EMAIL_CODE_EXPIRATION (int): Email MFA code expiration time in seconds.
18
22
  """
19
23
  model_config: Incomplete
20
24
  ADMIN_USER_EMAIL: str | None
@@ -22,5 +26,9 @@ class Settings(BaseSettings):
22
26
  AUTH_JWT_KEY: str | None
23
27
  AUTH_TOKEN_EXPIRATION: int
24
28
  REQUIRE_DEFAULT_AUTHORIZATION: bool
29
+ REQUIRE_MFA_BY_DEFAULT: bool
30
+ MFA_TOTP_ISSUER: str
31
+ MFA_BACKUP_CODES_COUNT: int
32
+ MFA_EMAIL_CODE_EXPIRATION: int
25
33
 
26
34
  auth_settings: Incomplete
@@ -0,0 +1 @@
1
+ """MFA transaction wrappers for API endpoints."""
@@ -0,0 +1,458 @@
1
+ """MFA device transaction wrappers for API endpoints."""
2
+
3
+ from amsdal_data.transactions.decorators import async_transaction
4
+ from amsdal_data.transactions.decorators import transaction
5
+ from pydantic import BaseModel
6
+ from pydantic import ConfigDict
7
+ from pydantic import Field
8
+
9
+ from amsdal.context.manager import AmsdalContextManager
10
+ from amsdal.contrib.auth.decorators import require_auth
11
+ from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice
12
+ from amsdal.contrib.auth.models.user import User
13
+ from amsdal.contrib.auth.services.mfa_device_service import MFADeviceService
14
+ from amsdal.contrib.auth.utils.mfa import DeviceType
15
+
16
+ # ============================================================================
17
+ # REQUEST/RESPONSE MODELS
18
+ # ============================================================================
19
+
20
+ TAGS = ['Auth', 'MFA']
21
+
22
+
23
+ class AddEmailDeviceRequest(BaseModel):
24
+ """Request model for adding email MFA device."""
25
+
26
+ target_user_email: str = Field(..., description='Email of user to add device for')
27
+ device_name: str = Field(..., description='User-friendly name for the device')
28
+ email: str | None = Field(None, description='Email for MFA codes (defaults to target_user_email)')
29
+
30
+
31
+ class EmailDeviceResponse(BaseModel):
32
+ """Response model for email MFA device."""
33
+
34
+ device_id: str = Field(..., description='Device ID')
35
+ user_email: str = Field(..., description='User email')
36
+ name: str = Field(..., description='Device name')
37
+ email: str = Field(..., description='Email where MFA codes are sent')
38
+ confirmed: bool = Field(..., description='Whether device is confirmed')
39
+
40
+ @classmethod
41
+ def from_device(cls, device: EmailMFADevice) -> 'EmailDeviceResponse':
42
+ """Create response from EmailMFADevice model."""
43
+ return cls(
44
+ device_id=device._object_id,
45
+ user_email=device.user_email,
46
+ name=device.name,
47
+ email=device.email,
48
+ confirmed=device.confirmed,
49
+ )
50
+
51
+
52
+ class AddBackupCodesRequest(BaseModel):
53
+ """Request model for adding backup codes."""
54
+
55
+ target_user_email: str = Field(..., description='Email of user to add codes for')
56
+ device_name: str = Field('Backup Codes', description='Name for the backup code set')
57
+ code_count: int | None = Field(None, description='Number of codes to generate (optional)', ge=1, le=20)
58
+
59
+
60
+ class AddBackupCodesResponse(BaseModel):
61
+ """Response model for adding backup codes."""
62
+
63
+ model_config = ConfigDict(
64
+ json_schema_extra={
65
+ 'example': {
66
+ 'device_count': 10,
67
+ 'codes': ['ABC123DEF456', 'GHI789JKL012', '...'],
68
+ },
69
+ },
70
+ )
71
+
72
+ device_count: int = Field(..., description='Number of backup codes generated')
73
+ codes: list[str] = Field(..., description='Plaintext backup codes (DISPLAY ONCE)')
74
+
75
+
76
+ class ListDevicesRequest(BaseModel):
77
+ """Request model for listing MFA devices."""
78
+
79
+ target_user_email: str = Field(..., description='Email of user to list devices for')
80
+ include_unconfirmed: bool = Field(False, description='Whether to include unconfirmed devices')
81
+
82
+
83
+ class DeviceInfo(BaseModel):
84
+ """Device information for list response."""
85
+
86
+ device_id: str = Field(..., description='Device ID')
87
+ name: str = Field(..., description='Device name')
88
+ confirmed: bool = Field(..., description='Whether device is confirmed')
89
+ device_type: str = Field(..., description='Type of device')
90
+
91
+
92
+ class ListDevicesResponse(BaseModel):
93
+ """Response model for listing MFA devices."""
94
+
95
+ totp_devices: list[DeviceInfo] = Field(default_factory=list, description='TOTP authenticator devices')
96
+ email_devices: list[DeviceInfo] = Field(default_factory=list, description='Email MFA devices')
97
+ sms_devices: list[DeviceInfo] = Field(default_factory=list, description='SMS MFA devices')
98
+ backup_codes: list[DeviceInfo] = Field(default_factory=list, description='Backup code devices')
99
+ total_count: int = Field(..., description='Total number of devices')
100
+
101
+ @classmethod
102
+ def from_device_dict(cls, devices_by_type: dict) -> 'ListDevicesResponse': # type: ignore[type-arg]
103
+ """Create response from service layer result."""
104
+ totp_devices = [
105
+ DeviceInfo(
106
+ device_id=device._object_id,
107
+ name=device.name,
108
+ confirmed=device.confirmed,
109
+ device_type='totp',
110
+ )
111
+ for device in devices_by_type.get(DeviceType.TOTP, [])
112
+ ]
113
+
114
+ email_devices = [
115
+ DeviceInfo(
116
+ device_id=device._object_id,
117
+ name=device.name,
118
+ confirmed=device.confirmed,
119
+ device_type='email',
120
+ )
121
+ for device in devices_by_type.get(DeviceType.EMAIL, [])
122
+ ]
123
+
124
+ sms_devices = [
125
+ DeviceInfo(
126
+ device_id=device._object_id,
127
+ name=device.name,
128
+ confirmed=device.confirmed,
129
+ device_type='sms',
130
+ )
131
+ for device in devices_by_type.get(DeviceType.SMS, [])
132
+ ]
133
+
134
+ backup_codes = [
135
+ DeviceInfo(
136
+ device_id=device._object_id,
137
+ name=device.name,
138
+ confirmed=device.confirmed,
139
+ device_type='backup_code',
140
+ )
141
+ for device in devices_by_type.get(DeviceType.BACKUP_CODE, [])
142
+ ]
143
+
144
+ total = len(totp_devices) + len(email_devices) + len(sms_devices) + len(backup_codes)
145
+
146
+ return cls(
147
+ totp_devices=totp_devices,
148
+ email_devices=email_devices,
149
+ sms_devices=sms_devices,
150
+ backup_codes=backup_codes,
151
+ total_count=total,
152
+ )
153
+
154
+
155
+ class RemoveDeviceRequest(BaseModel):
156
+ """Request model for removing MFA device."""
157
+
158
+ device_id: str = Field(..., description='ID of device to remove')
159
+ hard_delete: bool = Field(False, description='If True, permanently delete; if False, soft delete')
160
+
161
+
162
+ class RemoveDeviceResponse(BaseModel):
163
+ """Response model for removing MFA device."""
164
+
165
+ device_id: str = Field(..., description='ID of removed device')
166
+ deleted: bool = Field(..., description='Whether device was permanently deleted')
167
+
168
+
169
+ # ============================================================================
170
+ # TRANSACTION FUNCTIONS
171
+ # ============================================================================
172
+
173
+
174
+ def get_current_user() -> User:
175
+ """Helper to get current authenticated user from context."""
176
+ return AmsdalContextManager().get_context().get('request').user # type: ignore[union-attr]
177
+
178
+
179
+ @require_auth
180
+ @transaction(tags=TAGS) # type: ignore[call-arg]
181
+ def add_mfa_email_device_transaction(
182
+ target_user_email: str,
183
+ device_name: str,
184
+ email: str | None = None,
185
+ ) -> EmailDeviceResponse:
186
+ """
187
+ Add email MFA device for a user.
188
+
189
+ Email devices are automatically confirmed and can be used immediately.
190
+
191
+ Args:
192
+ current_user: The authenticated user making the request.
193
+ request: Email device creation request.
194
+
195
+ Returns:
196
+ EmailDeviceResponse: Created device details.
197
+
198
+ Raises:
199
+ UserNotFoundError: If target user doesn't exist.
200
+ PermissionDeniedError: If user lacks permission.
201
+ """
202
+
203
+ device = MFADeviceService.add_email_device( # type: ignore[call-arg]
204
+ current_user=get_current_user(),
205
+ target_user_email=target_user_email,
206
+ device_name=device_name,
207
+ email=email,
208
+ )
209
+
210
+ return EmailDeviceResponse.from_device(device)
211
+
212
+
213
+ @require_auth
214
+ @async_transaction(tags=TAGS) # type: ignore[call-arg]
215
+ async def aadd_mfa_email_device_transaction(
216
+ target_user_email: str,
217
+ device_name: str,
218
+ email: str | None = None,
219
+ ) -> EmailDeviceResponse:
220
+ """
221
+ Async version of add_email_device_transaction.
222
+
223
+ Add email MFA device for a user.
224
+
225
+ Args:
226
+ current_user: The authenticated user making the request.
227
+ request: Email device creation request.
228
+
229
+ Returns:
230
+ EmailDeviceResponse: Created device details.
231
+
232
+ Raises:
233
+ UserNotFoundError: If target user doesn't exist.
234
+ PermissionDeniedError: If user lacks permission.
235
+ """
236
+
237
+ device = await MFADeviceService.aadd_email_device( # type: ignore[call-arg]
238
+ current_user=get_current_user(),
239
+ target_user_email=target_user_email,
240
+ device_name=device_name,
241
+ email=email,
242
+ )
243
+
244
+ return EmailDeviceResponse.from_device(device)
245
+
246
+
247
+ @require_auth
248
+ @transaction(tags=TAGS) # type: ignore[call-arg]
249
+ def add_mfa_backup_codes_transaction(
250
+ target_user_email: str,
251
+ device_name: str,
252
+ code_count: int | None = None,
253
+ ) -> AddBackupCodesResponse:
254
+ """
255
+ Add backup codes for a user.
256
+
257
+ Backup codes are one-time use codes that can be used if primary
258
+ MFA methods are unavailable.
259
+
260
+ Security Note:
261
+ Plaintext codes are returned ONLY during creation.
262
+ Caller must display/send these to user immediately.
263
+ Codes cannot be retrieved later.
264
+
265
+ Args:
266
+ current_user: The authenticated user making the request.
267
+ request: Backup codes creation request.
268
+
269
+ Returns:
270
+ AddBackupCodesResponse: Contains plaintext backup codes.
271
+
272
+ Raises:
273
+ UserNotFoundError: If target user doesn't exist.
274
+ PermissionDeniedError: If user lacks permission.
275
+ """
276
+ devices, plaintext_codes = MFADeviceService.add_backup_codes( # type: ignore[call-arg]
277
+ current_user=get_current_user(),
278
+ target_user_email=target_user_email,
279
+ device_name=device_name,
280
+ code_count=code_count,
281
+ )
282
+
283
+ return AddBackupCodesResponse(
284
+ device_count=len(devices),
285
+ codes=plaintext_codes,
286
+ )
287
+
288
+
289
+ @require_auth
290
+ @async_transaction(tags=TAGS) # type: ignore[call-arg]
291
+ async def aadd_mfa_backup_codes_transaction(
292
+ target_user_email: str,
293
+ device_name: str,
294
+ code_count: int | None = None,
295
+ ) -> AddBackupCodesResponse:
296
+ """
297
+ Async version of add_backup_codes_transaction.
298
+
299
+ Add backup codes for a user.
300
+
301
+ Args:
302
+ current_user: The authenticated user making the request.
303
+ request: Backup codes creation request.
304
+
305
+ Returns:
306
+ AddBackupCodesResponse: Contains plaintext backup codes.
307
+
308
+ Raises:
309
+ UserNotFoundError: If target user doesn't exist.
310
+ PermissionDeniedError: If user lacks permission.
311
+ """
312
+
313
+ devices, plaintext_codes = await MFADeviceService.aadd_backup_codes( # type: ignore[call-arg]
314
+ current_user=get_current_user(),
315
+ target_user_email=target_user_email,
316
+ device_name=device_name,
317
+ code_count=code_count,
318
+ )
319
+
320
+ return AddBackupCodesResponse(
321
+ device_count=len(devices),
322
+ codes=plaintext_codes,
323
+ )
324
+
325
+
326
+ @require_auth
327
+ @transaction(tags=TAGS) # type: ignore[call-arg]
328
+ def list_mfa_devices_transaction(
329
+ target_user_email: str,
330
+ include_unconfirmed: bool = False, # noqa: FBT001, FBT002
331
+ ) -> ListDevicesResponse:
332
+ """
333
+ List all MFA devices for a user.
334
+
335
+ Returns devices grouped by type (TOTP, Email, SMS, Backup Codes).
336
+
337
+ Note: Read-only operation, no @transaction decorator needed.
338
+
339
+ Args:
340
+ current_user: The authenticated user making the request.
341
+ request: List devices request.
342
+
343
+ Returns:
344
+ ListDevicesResponse: Devices grouped by type.
345
+
346
+ Raises:
347
+ PermissionDeniedError: If user lacks permission.
348
+ """
349
+
350
+ devices_by_type = MFADeviceService.list_devices(
351
+ current_user=get_current_user(),
352
+ target_user_email=target_user_email,
353
+ include_unconfirmed=include_unconfirmed,
354
+ )
355
+
356
+ return ListDevicesResponse.from_device_dict(devices_by_type)
357
+
358
+
359
+ @require_auth
360
+ @async_transaction(tags=TAGS) # type: ignore[call-arg]
361
+ async def alist_mfa_devices_transaction(
362
+ target_user_email: str,
363
+ include_unconfirmed: bool = False, # noqa: FBT001, FBT002
364
+ ) -> ListDevicesResponse:
365
+ """
366
+ Async version of list_devices_transaction.
367
+
368
+ List all MFA devices for a user.
369
+
370
+ Args:
371
+ current_user: The authenticated user making the request.
372
+ request: List devices request.
373
+
374
+ Returns:
375
+ ListDevicesResponse: Devices grouped by type.
376
+
377
+ Raises:
378
+ PermissionDeniedError: If user lacks permission.
379
+ """
380
+
381
+ devices_by_type = await MFADeviceService.alist_devices(
382
+ current_user=get_current_user(),
383
+ target_user_email=target_user_email,
384
+ include_unconfirmed=include_unconfirmed,
385
+ )
386
+
387
+ return ListDevicesResponse.from_device_dict(devices_by_type)
388
+
389
+
390
+ @require_auth
391
+ @transaction(tags=TAGS) # type: ignore[call-arg]
392
+ def remove_mfa_device_transaction(
393
+ device_id: str,
394
+ hard_delete: bool = False, # noqa: FBT001, FBT002
395
+ ) -> RemoveDeviceResponse:
396
+ """
397
+ Remove (deactivate or delete) an MFA device.
398
+
399
+ By default performs soft delete (marks inactive) to preserve audit trail.
400
+ Use hard_delete=True to permanently remove the device.
401
+
402
+ Args:
403
+ current_user: The authenticated user making the request.
404
+ request: Remove device request.
405
+
406
+ Returns:
407
+ RemoveDeviceResponse: Removal confirmation.
408
+
409
+ Raises:
410
+ PermissionDeniedError: If user lacks permission.
411
+ MFADeviceNotFoundError: If device doesn't exist.
412
+ """
413
+
414
+ MFADeviceService.remove_device( # type: ignore[call-arg]
415
+ current_user=get_current_user(),
416
+ device_id=device_id,
417
+ hard_delete=hard_delete,
418
+ )
419
+
420
+ return RemoveDeviceResponse(
421
+ device_id=device_id,
422
+ deleted=hard_delete,
423
+ )
424
+
425
+
426
+ @require_auth
427
+ @async_transaction(tags=TAGS) # type: ignore[call-arg]
428
+ async def aremove_mfa_device_transaction(
429
+ device_id: str,
430
+ hard_delete: bool = False, # noqa: FBT001, FBT002
431
+ ) -> RemoveDeviceResponse:
432
+ """
433
+ Async version of remove_device_transaction.
434
+
435
+ Remove (deactivate or delete) an MFA device.
436
+
437
+ Args:
438
+ current_user: The authenticated user making the request.
439
+ request: Remove device request.
440
+
441
+ Returns:
442
+ RemoveDeviceResponse: Removal confirmation.
443
+
444
+ Raises:
445
+ PermissionDeniedError: If user lacks permission.
446
+ MFADeviceNotFoundError: If device doesn't exist.
447
+ """
448
+
449
+ await MFADeviceService.aremove_device( # type: ignore[call-arg]
450
+ current_user=get_current_user(),
451
+ device_id=device_id,
452
+ hard_delete=hard_delete,
453
+ )
454
+
455
+ return RemoveDeviceResponse(
456
+ device_id=device_id,
457
+ deleted=hard_delete,
458
+ )