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.
- amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +56 -2
- amsdal/__about__.py +1 -1
- amsdal/__init__.py +20 -0
- amsdal/__init__.pyi +9 -0
- amsdal/__migrations__/0000_initial.py +23 -190
- amsdal/__migrations__/0001_create_class_file.py +61 -0
- amsdal/__migrations__/0002_create_class_file.py +109 -0
- amsdal/__migrations__/0003_update_class_file.py +91 -0
- amsdal/__migrations__/0004_update_class_file.py +45 -0
- amsdal/cloud/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/client.cpython-311-darwin.so +0 -0
- amsdal/cloud/constants.cpython-311-darwin.so +0 -0
- amsdal/cloud/enums.cpython-311-darwin.so +0 -0
- amsdal/cloud/models/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/models/base.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_allowlist_ip.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_basic_auth.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_dependency.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_secret.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/base.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/create_deploy.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/create_env.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/create_session.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_allowlist_ip.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_basic_auth.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_dependency.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_env.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_secret.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/destroy_deploy.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/expose_db.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/get_monitoring_info.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_dependencies.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_deploys.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_envs.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_secrets.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/manager.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/signup_action.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/update_deploy.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/base.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/credentials.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/credentials.pyi +0 -1
- amsdal/cloud/services/auth/manager.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/signup_service.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/token.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/token.pyi +0 -1
- amsdal/configs/main.py +40 -20
- amsdal/configs/main.pyi +19 -18
- amsdal/contrib/__init__.cpython-311-darwin.so +0 -0
- amsdal/contrib/auth/errors.py +36 -0
- amsdal/contrib/auth/errors.pyi +12 -0
- amsdal/contrib/auth/fixtures/basic_permissions.json +64 -0
- amsdal/contrib/auth/lifecycle/consumer.py +13 -13
- amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
- amsdal/contrib/auth/migrations/0000_initial.py +69 -31
- amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
- amsdal/contrib/auth/models/__init__.py +1 -0
- amsdal/contrib/auth/models/backup_code.py +85 -0
- amsdal/contrib/auth/models/email_mfa_device.py +108 -0
- amsdal/contrib/auth/models/login_session.py +235 -0
- amsdal/contrib/auth/models/mfa_device.py +86 -0
- amsdal/contrib/auth/models/permission.py +23 -0
- amsdal/contrib/auth/models/sms_device.py +113 -0
- amsdal/contrib/auth/models/totp_device.py +58 -0
- amsdal/contrib/auth/models/user.py +156 -0
- amsdal/contrib/auth/services/__init__.py +1 -0
- amsdal/contrib/auth/services/mfa_device_service.py +544 -0
- amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
- amsdal/contrib/auth/services/totp_service.py +358 -0
- amsdal/contrib/auth/services/totp_service.pyi +158 -0
- amsdal/contrib/auth/settings.py +8 -0
- amsdal/contrib/auth/settings.pyi +8 -0
- amsdal/contrib/auth/transactions/__init__.py +1 -0
- amsdal/contrib/auth/transactions/mfa_device_transactions.py +463 -0
- amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
- amsdal/contrib/auth/transactions/totp_transactions.py +206 -0
- amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
- amsdal/contrib/auth/utils/__init__.py +0 -0
- amsdal/contrib/auth/utils/__init__.pyi +0 -0
- amsdal/contrib/auth/utils/mfa.py +257 -0
- amsdal/contrib/auth/utils/mfa.pyi +119 -0
- amsdal/contrib/frontend_configs/conversion/convert.py +85 -25
- amsdal/contrib/frontend_configs/conversion/convert.pyi +0 -1
- amsdal/contrib/frontend_configs/lifecycle/consumer.py +32 -13
- amsdal/contrib/frontend_configs/lifecycle/consumer.pyi +1 -1
- amsdal/contrib/frontend_configs/migrations/0000_initial.py +167 -195
- amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
- amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
- amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
- amsdal/contrib/frontend_configs/models/__init__.py +0 -0
- amsdal/contrib/frontend_configs/models/frontend_activator_config.py +22 -0
- amsdal/contrib/frontend_configs/models/frontend_config_async_validator.py +11 -0
- amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +110 -0
- amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
- amsdal/contrib/frontend_configs/models/frontend_config_group_validator.py +21 -0
- amsdal/contrib/frontend_configs/models/frontend_config_option.py +12 -0
- amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.py +17 -0
- amsdal/contrib/frontend_configs/models/frontend_config_slider_option.py +13 -0
- amsdal/contrib/frontend_configs/models/frontend_config_text_mask.py +14 -0
- amsdal/contrib/frontend_configs/models/frontend_config_validator.py +28 -0
- amsdal/contrib/frontend_configs/models/frontend_control_config.py +110 -0
- amsdal/contrib/frontend_configs/models/frontend_model_config.py +14 -0
- amsdal/errors.py +0 -3
- amsdal/errors.pyi +0 -1
- amsdal/fixtures/__init__.cpython-311-darwin.so +0 -0
- amsdal/fixtures/manager.cpython-311-darwin.so +0 -0
- amsdal/fixtures/manager.pyi +73 -123
- amsdal/fixtures/utils.cpython-311-darwin.so +0 -0
- amsdal/fixtures/utils.pyi +9 -0
- amsdal/manager.cpython-311-darwin.so +0 -0
- amsdal/manager.pyi +9 -96
- amsdal/mixins/__init__.cpython-311-darwin.so +0 -0
- amsdal/mixins/class_versions_mixin.cpython-311-darwin.so +0 -0
- amsdal/models/__init__.py +19 -0
- amsdal/models/core/__init__.py +0 -0
- amsdal/models/core/class_object.py +38 -0
- amsdal/models/core/class_property.py +26 -0
- amsdal/models/core/file.py +243 -0
- amsdal/models/core/fixture.py +25 -0
- amsdal/models/core/option.py +11 -0
- amsdal/models/core/storage_metadata.py +15 -0
- amsdal/models/core/validator.py +12 -0
- amsdal/models/mixins.py +31 -0
- amsdal/models/types/__init__.py +0 -0
- amsdal/models/types/object.py +26 -0
- amsdal/queryset/__init__.py +21 -0
- amsdal/queryset/__init__.pyi +6 -0
- amsdal/schemas/core/class_object/model.json +20 -0
- amsdal/schemas/core/class_property/model.json +19 -0
- amsdal/schemas/core/file/properties/from_file.py +1 -1
- amsdal/schemas/core/file/properties/validate_data.py +3 -4
- amsdal/schemas/core/storage_metadata/model.json +52 -0
- amsdal/schemas/interfaces.py +25 -0
- amsdal/schemas/interfaces.pyi +20 -0
- amsdal/schemas/manager.cpython-311-darwin.so +0 -0
- amsdal/schemas/manager.py +0 -116
- amsdal/schemas/manager.pyi +0 -65
- amsdal/schemas/mixins/__init__.py +0 -0
- amsdal/schemas/mixins/__init__.pyi +0 -0
- amsdal/schemas/mixins/check_dependencies_mixin.py +130 -0
- amsdal/schemas/mixins/check_dependencies_mixin.pyi +45 -0
- amsdal/schemas/mixins/verify_schemas_mixin.py +96 -0
- amsdal/schemas/mixins/verify_schemas_mixin.pyi +33 -0
- amsdal/schemas/repository.py +84 -0
- amsdal/schemas/repository.pyi +22 -0
- amsdal/schemas/utils.py +16 -0
- amsdal/schemas/utils.pyi +10 -0
- amsdal/services/__init__.py +11 -0
- amsdal/services/__init__.pyi +4 -0
- amsdal/services/external_connections.py +262 -0
- amsdal/services/external_connections.pyi +190 -0
- amsdal/services/external_model_generator.py +350 -0
- amsdal/services/external_model_generator.pyi +134 -0
- amsdal/services/transaction_execution.cpython-311-darwin.so +0 -0
- amsdal/services/transaction_execution.pyi +2 -1
- amsdal/storages/__init__.py +20 -0
- amsdal/storages/__init__.pyi +8 -0
- amsdal/storages/file_system.py +214 -0
- amsdal/storages/file_system.pyi +36 -0
- amsdal/transactions/__init__.py +13 -0
- amsdal/transactions/__init__.pyi +4 -0
- amsdal/utils/rollback/__init__.py +99 -54
- amsdal/utils/rollback/__init__.pyi +6 -0
- amsdal/utils/tests/enums.py +0 -2
- amsdal/utils/tests/helpers.py +253 -231
- amsdal/utils/tests/migrations.py +157 -0
- {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/METADATA +17 -10
- amsdal-0.5.29.dist-info/RECORD +276 -0
- {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/WHEEL +1 -1
- amsdal/__migrations__/0001_datetime_type.py +0 -18
- amsdal/__migrations__/0002_fixture_order.py +0 -40
- amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -56
- amsdal/contrib/auth/models/login_session/hooks/pre_init.py +0 -68
- amsdal/contrib/auth/models/login_session/model.json +0 -23
- amsdal/contrib/auth/models/login_session/modifiers/display_name.py +0 -11
- amsdal/contrib/auth/models/permission/fixtures/basic_permissions.json +0 -62
- amsdal/contrib/auth/models/permission/model.json +0 -18
- amsdal/contrib/auth/models/permission/modifiers/display_name.py +0 -11
- amsdal/contrib/auth/models/user/hooks/post_init.py +0 -76
- amsdal/contrib/auth/models/user/hooks/pre_create.py +0 -8
- amsdal/contrib/auth/models/user/model.json +0 -25
- amsdal/contrib/auth/models/user/modifiers/display_name.py +0 -19
- amsdal/contrib/frontend_configs/models/frontend_activator_config/model.json +0 -11
- amsdal/contrib/frontend_configs/models/frontend_config_async_validator/model.json +0 -11
- amsdal/contrib/frontend_configs/models/frontend_config_group_validator/model.json +0 -52
- amsdal/contrib/frontend_configs/models/frontend_config_option/model.json +0 -15
- amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/model.json +0 -6
- amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/properties/model_dump.py +0 -13
- amsdal/contrib/frontend_configs/models/frontend_config_slider_option/model.json +0 -19
- amsdal/contrib/frontend_configs/models/frontend_config_text_mask/model.json +0 -26
- amsdal/contrib/frontend_configs/models/frontend_config_validator/model.json +0 -41
- amsdal/contrib/frontend_configs/models/frontend_control_config/model.json +0 -250
- amsdal/contrib/frontend_configs/models/frontend_model_config/fixtures/permissions.json +0 -24
- amsdal/contrib/frontend_configs/models/frontend_model_config/model.json +0 -17
- amsdal/contrib/frontend_configs/models/frontent_config_control_action/model.json +0 -54
- amsdal/contrib/frontend_configs/models/frontent_config_control_action/properties/action_validate.py +0 -33
- amsdal/migration/__init__.cpython-311-darwin.so +0 -0
- amsdal/migration/base_migration_schemas.cpython-311-darwin.so +0 -0
- amsdal/migration/base_migration_schemas.pyi +0 -120
- amsdal/migration/data_classes.cpython-311-darwin.so +0 -0
- amsdal/migration/data_classes.pyi +0 -172
- amsdal/migration/executors/__init__.cpython-311-darwin.so +0 -0
- amsdal/migration/executors/base.cpython-311-darwin.so +0 -0
- amsdal/migration/executors/base.pyi +0 -118
- amsdal/migration/executors/default_executor.cpython-311-darwin.so +0 -0
- amsdal/migration/executors/default_executor.pyi +0 -184
- amsdal/migration/executors/state_executor.cpython-311-darwin.so +0 -0
- amsdal/migration/executors/state_executor.pyi +0 -78
- amsdal/migration/file_migration_executor.cpython-311-darwin.so +0 -0
- amsdal/migration/file_migration_executor.pyi +0 -68
- amsdal/migration/file_migration_generator.cpython-311-darwin.so +0 -0
- amsdal/migration/file_migration_generator.pyi +0 -139
- amsdal/migration/file_migration_store.cpython-311-darwin.so +0 -0
- amsdal/migration/file_migration_store.pyi +0 -61
- amsdal/migration/file_migration_writer.cpython-311-darwin.so +0 -0
- amsdal/migration/file_migration_writer.pyi +0 -73
- amsdal/migration/migrations.cpython-311-darwin.so +0 -0
- amsdal/migration/migrations.pyi +0 -166
- amsdal/migration/migrations_loader.cpython-311-darwin.so +0 -0
- amsdal/migration/migrations_loader.pyi +0 -32
- amsdal/migration/schemas_loaders.cpython-311-darwin.so +0 -0
- amsdal/migration/schemas_loaders.pyi +0 -37
- amsdal/migration/templates/data_migration.tmpl +0 -18
- amsdal/migration/templates/dict_validator.tmpl +0 -4
- amsdal/migration/templates/migration.tmpl +0 -6
- amsdal/migration/templates/model_class.tmpl +0 -8
- amsdal/migration/templates/model_class_layout.tmpl +0 -24
- amsdal/migration/templates/options_validator.tmpl +0 -4
- amsdal/migration/utils.cpython-311-darwin.so +0 -0
- amsdal/migration/utils.pyi +0 -58
- amsdal/mixins/build_mixin.cpython-311-darwin.so +0 -0
- amsdal/mixins/build_mixin.pyi +0 -78
- amsdal/schemas/core/class_object_meta/model.json +0 -59
- amsdal/schemas/core/class_property_meta/model.json +0 -23
- amsdal/services/__init__.cpython-311-darwin.so +0 -0
- amsdal-0.3.3.dist-info/RECORD +0 -257
- amsdal-0.3.3.dist-info/licenses/LICENSE.txt +0 -107
- /amsdal/{migration → contrib/auth/services}/__init__.pyi +0 -0
- /amsdal/{migration/executors → contrib/auth/transactions}/__init__.pyi +0 -0
- {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info/licenses}/LICENSE.txt +0 -0
- {amsdal-0.3.3.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()
|