python-ddd-framework 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_ddd_framework/application/runtime.py +46 -19
- python_ddd_framework/application_services/bindings.py +0 -64
- python_ddd_framework/application_services/dispatcher.py +1 -1
- python_ddd_framework/application_services/invocation.py +7 -500
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/module.py +1 -1
- python_ddd_framework/background_execution/local.py +20 -1
- python_ddd_framework/background_execution/processes.py +11 -1
- python_ddd_framework/background_jobs/execution.py +1 -1
- python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
- python_ddd_framework/background_workers/execution.py +1 -1
- python_ddd_framework/background_workers/runtime.py +11 -2
- python_ddd_framework/caching/unit_of_work.py +2 -4
- python_ddd_framework/cli/project.py +11 -10
- python_ddd_framework/developer_kit/generation.py +46 -36
- python_ddd_framework/developer_kit/publication.py +442 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
- python_ddd_framework/diagnostics/source.py +14 -1
- python_ddd_framework/domain/aggregates.py +5 -12
- python_ddd_framework/domain/event_values.py +124 -0
- python_ddd_framework/events/runtime.py +2 -7
- python_ddd_framework/events/unit_of_work.py +1 -4
- python_ddd_framework/fastapi/action.py +2 -1
- python_ddd_framework/fastapi/adapter.py +11 -117
- python_ddd_framework/fastapi/application_services.py +17 -402
- python_ddd_framework/fastapi/error_handlers.py +128 -0
- python_ddd_framework/fastapi/realtime/runtime.py +1 -1
- python_ddd_framework/fastapi/request_context.py +2 -5
- python_ddd_framework/fastapi/routing.py +4 -10
- python_ddd_framework/fastapi/service_endpoints.py +182 -0
- python_ddd_framework/fastapi/service_routes.py +237 -0
- python_ddd_framework/fastapi/transfer.py +1 -1
- python_ddd_framework/hosted_services/bridge.py +76 -21
- python_ddd_framework/hosted_services/contracts.py +5 -1
- python_ddd_framework/hosted_services/runtime.py +71 -17
- python_ddd_framework/identity/__init__.py +21 -13
- python_ddd_framework/identity/application.py +12 -66
- python_ddd_framework/identity/contracts.py +38 -411
- python_ddd_framework/identity/domain_rules.py +17 -0
- python_ddd_framework/identity/dtos.py +152 -0
- python_ddd_framework/identity/extensions.py +9 -0
- python_ddd_framework/identity/management.py +28 -25
- python_ddd_framework/identity/models.py +86 -0
- python_ddd_framework/identity/module.py +3 -4
- python_ddd_framework/identity/options.py +54 -0
- python_ddd_framework/identity/seeding.py +59 -0
- python_ddd_framework/identity/services.py +1 -1
- python_ddd_framework/identity/sqlalchemy/module.py +9 -6
- python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
- python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
- python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
- python_ddd_framework/identity/stores.py +120 -0
- python_ddd_framework/identity/tokens.py +1 -1
- python_ddd_framework/invocation/callables.py +1 -1
- python_ddd_framework/invocation/dispatcher.py +1 -1
- python_ddd_framework/invocation/entrypoints.py +2 -1
- python_ddd_framework/invocation/function_runtime.py +2 -2
- python_ddd_framework/invocation/interception.py +1 -1
- python_ddd_framework/invocation/managed_proxy.py +2 -1
- python_ddd_framework/invocation/managed_services.py +8 -14
- python_ddd_framework/invocation/methods.py +2 -7
- python_ddd_framework/invocation/module.py +1 -1
- python_ddd_framework/invocation/runtime.py +483 -0
- python_ddd_framework/invocation/scopes.py +91 -0
- python_ddd_framework/lifecycle/participants.py +4 -0
- python_ddd_framework/messaging/channel.py +35 -8
- python_ddd_framework/messaging/contracts.py +9 -0
- python_ddd_framework/messaging/module.py +13 -0
- python_ddd_framework/messaging/runtime.py +17 -2
- python_ddd_framework/modularity/discovery.py +2 -7
- python_ddd_framework/modularity/graph.py +2 -8
- python_ddd_framework/observability/logging.py +1 -1
- python_ddd_framework/observability/tracing.py +1 -1
- python_ddd_framework/redis/cache.py +2 -0
- python_ddd_framework/redis/notification_runtime.py +1 -1
- python_ddd_framework/services/arbitration.py +1 -20
- python_ddd_framework/services/composition.py +71 -2
- python_ddd_framework/services/provider.py +23 -1
- python_ddd_framework/services/relocation.py +96 -0
- python_ddd_framework/settings/refresh.py +1 -1
- python_ddd_framework/settings/sqlalchemy/store.py +1 -1
- python_ddd_framework/sqlalchemy/metadata.py +11 -413
- python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
- python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
- python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
- python_ddd_framework/sqlalchemy/migration.py +2 -1
- python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
- python_ddd_framework/sqlalchemy/module.py +2 -1
- python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
- python_ddd_framework/unit_of_work/contracts.py +68 -7
- python_ddd_framework/unit_of_work/manager.py +4 -7
- python_ddd_framework/unit_of_work/module.py +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -18,13 +18,13 @@ from ..authorization import (
|
|
|
18
18
|
from .contracts import (
|
|
19
19
|
IDENTITY_ROLES_MANAGE,
|
|
20
20
|
IDENTITY_USERS_MANAGE,
|
|
21
|
+
IdentityManagementApplicationService,
|
|
22
|
+
)
|
|
23
|
+
from .domain_rules import _normalize_name
|
|
24
|
+
from .dtos import (
|
|
21
25
|
CreateRoleCommand,
|
|
22
26
|
CreateUserCommand,
|
|
23
27
|
IdentityConcurrencyCommand,
|
|
24
|
-
IdentityManagementApplicationService,
|
|
25
|
-
IdentityRepository,
|
|
26
|
-
IdentityRole,
|
|
27
|
-
IdentityUser,
|
|
28
28
|
RolePermissionsView,
|
|
29
29
|
RoleView,
|
|
30
30
|
SetPasswordCommand,
|
|
@@ -35,31 +35,34 @@ from .contracts import (
|
|
|
35
35
|
UpdateUserCommand,
|
|
36
36
|
UserRolesView,
|
|
37
37
|
UserView,
|
|
38
|
-
_normalize_name,
|
|
39
38
|
)
|
|
40
39
|
from .errors import (
|
|
41
40
|
IdentityConflictError,
|
|
42
41
|
IdentityResourceNotFoundError,
|
|
43
42
|
)
|
|
43
|
+
from .models import IdentityRole, IdentityUser
|
|
44
44
|
from .passwords import PasswordHasher
|
|
45
|
+
from .stores import IdentityCredentialStore, IdentityManagementStore
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
@application_service(provides=IdentityManagementApplicationService)
|
|
48
49
|
class DefaultIdentityManagementApplicationService(ApplicationService):
|
|
49
50
|
def __init__(
|
|
50
51
|
self,
|
|
51
|
-
|
|
52
|
+
store: IdentityManagementStore,
|
|
53
|
+
credentials: IdentityCredentialStore,
|
|
52
54
|
passwords: PasswordHasher,
|
|
53
55
|
permissions: PermissionCatalog,
|
|
54
56
|
) -> None:
|
|
55
|
-
self.
|
|
57
|
+
self._store = store
|
|
58
|
+
self._credentials = credentials
|
|
56
59
|
self._passwords = passwords
|
|
57
60
|
self._permissions = permissions
|
|
58
61
|
|
|
59
62
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
60
63
|
async def create_user(self, command: CreateUserCommand) -> UserView:
|
|
61
64
|
normalized = _normalize_name(command.username, role="username")
|
|
62
|
-
if await self.
|
|
65
|
+
if await self._store.find_user_by_normalized_username(normalized) is not None:
|
|
63
66
|
raise IdentityConflictError(resource="user")
|
|
64
67
|
user = IdentityUser(
|
|
65
68
|
user_id=uuid4(),
|
|
@@ -72,7 +75,7 @@ class DefaultIdentityManagementApplicationService(ApplicationService):
|
|
|
72
75
|
permission_version=0,
|
|
73
76
|
extra_properties=command.extra_properties,
|
|
74
77
|
)
|
|
75
|
-
await self.
|
|
78
|
+
await self._store.create_user(user)
|
|
76
79
|
return UserView(
|
|
77
80
|
user_id=user.user_id,
|
|
78
81
|
username=user.username,
|
|
@@ -84,62 +87,62 @@ class DefaultIdentityManagementApplicationService(ApplicationService):
|
|
|
84
87
|
|
|
85
88
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
86
89
|
async def get_users(self, query: PagedResultRequestDto) -> PagedResult[UserView]:
|
|
87
|
-
return await self.
|
|
90
|
+
return await self._store.list_users(query)
|
|
88
91
|
|
|
89
92
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
90
93
|
async def get_user(self, user_id: UUID) -> UserView:
|
|
91
|
-
return await self.
|
|
94
|
+
return await self._store.get_user_view(user_id)
|
|
92
95
|
|
|
93
96
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
94
97
|
async def update_user(self, user_id: UUID, command: UpdateUserCommand) -> UserView:
|
|
95
|
-
return await self.
|
|
98
|
+
return await self._store.update_user(user_id, command)
|
|
96
99
|
|
|
97
100
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
98
101
|
async def set_user_active(self, user_id: UUID, command: SetUserActiveCommand) -> UserView:
|
|
99
|
-
return await self.
|
|
102
|
+
return await self._store.set_user_active(user_id, command)
|
|
100
103
|
|
|
101
104
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
102
105
|
async def delete_user(self, user_id: UUID, command: IdentityConcurrencyCommand) -> None:
|
|
103
|
-
await self.
|
|
106
|
+
await self._store.delete_user(user_id, command.concurrency_version)
|
|
104
107
|
|
|
105
108
|
@authorize(IDENTITY_USERS_MANAGE)
|
|
106
109
|
async def set_password(self, user_id: UUID, command: SetPasswordCommand) -> None:
|
|
107
110
|
password_hash = await self._passwords.hash(command.new_password.get_secret_value())
|
|
108
|
-
await self.
|
|
111
|
+
await self._credentials.set_password(user_id, password_hash, command.concurrency_version)
|
|
109
112
|
|
|
110
113
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
111
114
|
async def create_role(self, command: CreateRoleCommand) -> RoleView:
|
|
112
115
|
normalized = _normalize_name(command.name, role="role name")
|
|
113
|
-
if await self.
|
|
116
|
+
if await self._store.find_role_by_normalized_name(normalized) is not None:
|
|
114
117
|
raise IdentityConflictError(resource="role")
|
|
115
118
|
role = IdentityRole(uuid4(), command.name.strip(), normalized, 0)
|
|
116
|
-
await self.
|
|
119
|
+
await self._store.create_role(role)
|
|
117
120
|
return RoleView(role.role_id, role.name, (), role.concurrency_version)
|
|
118
121
|
|
|
119
122
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
120
123
|
async def get_roles(self, query: PagedResultRequestDto) -> PagedResult[RoleView]:
|
|
121
|
-
return await self.
|
|
124
|
+
return await self._store.list_roles(query)
|
|
122
125
|
|
|
123
126
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
124
127
|
async def get_role(self, role_id: UUID) -> RoleView:
|
|
125
|
-
return await self.
|
|
128
|
+
return await self._store.get_role(role_id)
|
|
126
129
|
|
|
127
130
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
128
131
|
async def update_role(self, role_id: UUID, command: UpdateRoleCommand) -> RoleView:
|
|
129
|
-
return await self.
|
|
132
|
+
return await self._store.update_role(role_id, command)
|
|
130
133
|
|
|
131
134
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
132
135
|
async def delete_role(self, role_id: UUID, command: IdentityConcurrencyCommand) -> None:
|
|
133
|
-
await self.
|
|
136
|
+
await self._store.delete_role(role_id, command.concurrency_version)
|
|
134
137
|
|
|
135
138
|
@authorize(IDENTITY_USERS_MANAGE, IDENTITY_ROLES_MANAGE)
|
|
136
139
|
async def get_user_roles(self, user_id: UUID) -> UserRolesView:
|
|
137
|
-
user = await self.
|
|
140
|
+
user = await self._store.get_user_view(user_id)
|
|
138
141
|
return UserRolesView(role_ids=user.role_ids, concurrency_version=user.concurrency_version)
|
|
139
142
|
|
|
140
143
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
141
144
|
async def get_role_permissions(self, role_id: UUID) -> RolePermissionsView:
|
|
142
|
-
role = await self.
|
|
145
|
+
role = await self._store.get_role(role_id)
|
|
143
146
|
return RolePermissionsView(
|
|
144
147
|
permissions=role.permissions, concurrency_version=role.concurrency_version
|
|
145
148
|
)
|
|
@@ -147,7 +150,7 @@ class DefaultIdentityManagementApplicationService(ApplicationService):
|
|
|
147
150
|
@authorize(IDENTITY_USERS_MANAGE, IDENTITY_ROLES_MANAGE)
|
|
148
151
|
async def set_user_roles(self, user_id: UUID, command: SetUserRolesCommand) -> UserRolesView:
|
|
149
152
|
role_ids = tuple(dict.fromkeys(command.role_ids))
|
|
150
|
-
return await self.
|
|
153
|
+
return await self._store.set_user_roles(user_id, role_ids, command.concurrency_version)
|
|
151
154
|
|
|
152
155
|
@authorize(IDENTITY_ROLES_MANAGE)
|
|
153
156
|
async def set_role_permissions(
|
|
@@ -159,7 +162,7 @@ class DefaultIdentityManagementApplicationService(ApplicationService):
|
|
|
159
162
|
unknown = tuple(item for item in names if item not in self._permissions)
|
|
160
163
|
if unknown:
|
|
161
164
|
raise IdentityResourceNotFoundError(resource="permission")
|
|
162
|
-
return await self.
|
|
165
|
+
return await self._store.set_role_permissions(
|
|
163
166
|
role_id,
|
|
164
167
|
tuple(item.value for item in names),
|
|
165
168
|
command.concurrency_version,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Identity 用户、角色与 refresh 会话的不可变领域值。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from ..extensions.contracts import ExtensionProperties
|
|
11
|
+
from .domain_rules import _check_version, _normalize_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class IdentityUser:
|
|
16
|
+
user_id: UUID
|
|
17
|
+
username: str
|
|
18
|
+
normalized_username: str
|
|
19
|
+
password_hash: str
|
|
20
|
+
is_active: bool
|
|
21
|
+
failed_access_count: int
|
|
22
|
+
lockout_end: datetime | None
|
|
23
|
+
permission_version: int
|
|
24
|
+
extra_properties: ExtensionProperties = field(default_factory=ExtensionProperties)
|
|
25
|
+
concurrency_version: int = 0
|
|
26
|
+
|
|
27
|
+
def renamed(self, username: str, expected_version: int) -> IdentityUser:
|
|
28
|
+
_check_version(self.concurrency_version, expected_version)
|
|
29
|
+
return replace(
|
|
30
|
+
self,
|
|
31
|
+
username=username.strip(),
|
|
32
|
+
normalized_username=_normalize_name(username, role="username"),
|
|
33
|
+
concurrency_version=self.concurrency_version + 1,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def with_active(self, is_active: bool, expected_version: int) -> IdentityUser:
|
|
37
|
+
_check_version(self.concurrency_version, expected_version)
|
|
38
|
+
return replace(
|
|
39
|
+
self,
|
|
40
|
+
is_active=is_active,
|
|
41
|
+
concurrency_version=self.concurrency_version + (self.is_active != is_active),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class IdentityRole:
|
|
47
|
+
role_id: UUID
|
|
48
|
+
name: str
|
|
49
|
+
normalized_name: str
|
|
50
|
+
permission_version: int
|
|
51
|
+
concurrency_version: int = 0
|
|
52
|
+
|
|
53
|
+
def renamed(self, name: str, expected_version: int) -> IdentityRole:
|
|
54
|
+
_check_version(self.concurrency_version, expected_version)
|
|
55
|
+
return replace(
|
|
56
|
+
self,
|
|
57
|
+
name=name.strip(),
|
|
58
|
+
normalized_name=_normalize_name(name, role="role name"),
|
|
59
|
+
concurrency_version=self.concurrency_version + 1,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class RefreshSession:
|
|
65
|
+
session_id: UUID
|
|
66
|
+
user_id: UUID
|
|
67
|
+
family_id: UUID
|
|
68
|
+
token_hash: str
|
|
69
|
+
created_at: datetime
|
|
70
|
+
family_created_at: datetime
|
|
71
|
+
last_used_at: datetime
|
|
72
|
+
revoked_at: datetime | None = None
|
|
73
|
+
replaced_by_session_id: UUID | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RefreshRotationStatus(Enum):
|
|
77
|
+
ROTATED = "rotated"
|
|
78
|
+
NOT_FOUND = "not_found"
|
|
79
|
+
REUSED = "reused"
|
|
80
|
+
EXPIRED = "expired"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class RefreshRotationResult:
|
|
85
|
+
status: RefreshRotationStatus
|
|
86
|
+
session: RefreshSession | None
|
|
@@ -10,10 +10,8 @@ from ..extensions.contracts import ExtensionPoints
|
|
|
10
10
|
from ..lifecycle import ConfigureContext
|
|
11
11
|
from ..modularity import AppModule, ModuleRef
|
|
12
12
|
from ..seeding.module import DataSeedingModule
|
|
13
|
-
from .
|
|
14
|
-
|
|
15
|
-
IdentityOptions,
|
|
16
|
-
)
|
|
13
|
+
from .extensions import IDENTITY_USER_EXTENSION
|
|
14
|
+
from .options import IdentityOptions
|
|
17
15
|
from .passwords import PasswordHasher
|
|
18
16
|
from .services import (
|
|
19
17
|
IdentityAuthenticator,
|
|
@@ -32,6 +30,7 @@ class IdentityModule(AppModule):
|
|
|
32
30
|
scan_packages = (
|
|
33
31
|
"python_ddd_framework.identity.application",
|
|
34
32
|
"python_ddd_framework.identity.management",
|
|
33
|
+
"python_ddd_framework.identity.seeding",
|
|
35
34
|
"python_ddd_framework.identity.permissions",
|
|
36
35
|
)
|
|
37
36
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Identity 的 Host 配置与 keyring 校验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from ..options import BaseOptions
|
|
11
|
+
from .domain_rules import _normalize_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IdentityOptions(BaseOptions):
|
|
15
|
+
issuer: str
|
|
16
|
+
audience: str
|
|
17
|
+
active_kid: str
|
|
18
|
+
keys: Mapping[str, SecretStr]
|
|
19
|
+
access_token_lifetime: timedelta = Field(default=timedelta(minutes=15), ge=timedelta(seconds=1))
|
|
20
|
+
refresh_inactivity_lifetime: timedelta = Field(default=timedelta(days=30), gt=timedelta(0))
|
|
21
|
+
refresh_absolute_lifetime: timedelta = Field(default=timedelta(days=90), gt=timedelta(0))
|
|
22
|
+
failed_attempt_limit: int = Field(default=5, ge=1)
|
|
23
|
+
lockout_duration: timedelta = Field(default=timedelta(minutes=15), ge=timedelta(seconds=1))
|
|
24
|
+
seed_admin_username: str | None = None
|
|
25
|
+
seed_admin_password: SecretStr | None = None
|
|
26
|
+
|
|
27
|
+
@field_validator("issuer", "audience", "active_kid")
|
|
28
|
+
@classmethod
|
|
29
|
+
def _validate_identifier(cls, value: str) -> str:
|
|
30
|
+
if not value or value != value.strip():
|
|
31
|
+
raise ValueError("JWT identifier must be a non-empty trimmed string")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
@field_validator("keys")
|
|
35
|
+
@classmethod
|
|
36
|
+
def _validate_keys(cls, value: Mapping[str, SecretStr]) -> Mapping[str, SecretStr]:
|
|
37
|
+
if not value:
|
|
38
|
+
raise ValueError("JWT keyring must contain at least one key")
|
|
39
|
+
for kid, secret in value.items():
|
|
40
|
+
if not kid or kid != kid.strip():
|
|
41
|
+
raise ValueError("JWT kid must be a non-empty trimmed string")
|
|
42
|
+
if len(secret.get_secret_value().encode("utf-8")) < 32:
|
|
43
|
+
raise ValueError("HS256 keys must contain at least 256 bits")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def _validate_active_and_seed(self) -> IdentityOptions:
|
|
48
|
+
if self.active_kid not in self.keys:
|
|
49
|
+
raise ValueError("active_kid must reference the JWT keyring")
|
|
50
|
+
if (self.seed_admin_username is None) != (self.seed_admin_password is None):
|
|
51
|
+
raise ValueError("seed administrator username and password must be configured together")
|
|
52
|
+
if self.seed_admin_username is not None:
|
|
53
|
+
_normalize_name(self.seed_admin_username, role="seed administrator username")
|
|
54
|
+
return self
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""管理员初始化贡献;发现和每项独立 UoW 由 DataSeedingModule 管理。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from ..authorization import PermissionCatalog
|
|
8
|
+
from ..options import Options
|
|
9
|
+
from ..seeding.contracts import DataSeedContributor
|
|
10
|
+
from .domain_rules import _normalize_name
|
|
11
|
+
from .models import IdentityRole, IdentityUser
|
|
12
|
+
from .options import IdentityOptions
|
|
13
|
+
from .passwords import PasswordHasher
|
|
14
|
+
from .stores import IdentityManagementStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IdentityAdminSeedContributor(DataSeedContributor):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
store: IdentityManagementStore,
|
|
21
|
+
passwords: PasswordHasher,
|
|
22
|
+
permissions: PermissionCatalog,
|
|
23
|
+
options: Options[IdentityOptions],
|
|
24
|
+
) -> None:
|
|
25
|
+
self._store = store
|
|
26
|
+
self._passwords = passwords
|
|
27
|
+
self._permissions = permissions
|
|
28
|
+
self._options = options.value
|
|
29
|
+
|
|
30
|
+
async def seed(self) -> None:
|
|
31
|
+
username = self._options.seed_admin_username
|
|
32
|
+
password = self._options.seed_admin_password
|
|
33
|
+
if username is None or password is None:
|
|
34
|
+
return
|
|
35
|
+
normalized_username = _normalize_name(username, role="seed administrator username")
|
|
36
|
+
user = await self._store.find_user_by_normalized_username(normalized_username)
|
|
37
|
+
if user is None:
|
|
38
|
+
user = IdentityUser(
|
|
39
|
+
uuid4(),
|
|
40
|
+
username.strip(),
|
|
41
|
+
normalized_username,
|
|
42
|
+
await self._passwords.hash(password.get_secret_value()),
|
|
43
|
+
True,
|
|
44
|
+
0,
|
|
45
|
+
None,
|
|
46
|
+
0,
|
|
47
|
+
)
|
|
48
|
+
await self._store.create_user(user)
|
|
49
|
+
normalized_role = "administrator"
|
|
50
|
+
role = await self._store.find_role_by_normalized_name(normalized_role)
|
|
51
|
+
if role is None:
|
|
52
|
+
role = IdentityRole(uuid4(), "Administrator", normalized_role, 0)
|
|
53
|
+
await self._store.create_role(role)
|
|
54
|
+
await self._store.set_role_permissions(
|
|
55
|
+
role.role_id,
|
|
56
|
+
tuple(item.name.value for item in self._permissions.definitions),
|
|
57
|
+
role.concurrency_version,
|
|
58
|
+
)
|
|
59
|
+
await self._store.set_user_roles(user.user_id, (role.role_id,), user.concurrency_version)
|
|
@@ -23,8 +23,8 @@ from ..caching import (
|
|
|
23
23
|
cache_name,
|
|
24
24
|
)
|
|
25
25
|
from ..options import Options
|
|
26
|
-
from .contracts import IdentityAccessStore
|
|
27
26
|
from .errors import InvalidCredentialsError
|
|
27
|
+
from .stores import IdentityAccessStore
|
|
28
28
|
from .tokens import JwtTokenService
|
|
29
29
|
|
|
30
30
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from dishka import Provider, Scope
|
|
5
|
+
from dishka import AnyOf, Provider, Scope
|
|
6
6
|
|
|
7
7
|
from ...lifecycle import ConfigureContext
|
|
8
8
|
from ...modularity import AppModule
|
|
@@ -12,14 +12,15 @@ from ...sqlalchemy import (
|
|
|
12
12
|
SqlAlchemyPersistenceModule,
|
|
13
13
|
contributes_sqlalchemy_models,
|
|
14
14
|
)
|
|
15
|
-
from ..
|
|
16
|
-
|
|
15
|
+
from ..extensions import IDENTITY_USER_EXTENSION
|
|
16
|
+
from ..module import IdentityModule
|
|
17
|
+
from ..stores import (
|
|
17
18
|
IdentityAccessStore,
|
|
19
|
+
IdentityCredentialStore,
|
|
18
20
|
IdentityLoginStore,
|
|
19
|
-
|
|
21
|
+
IdentityManagementStore,
|
|
20
22
|
RefreshSessionStore,
|
|
21
23
|
)
|
|
22
|
-
from ..module import IdentityModule
|
|
23
24
|
from .models import IdentityBase, UserRow, UserRowValues
|
|
24
25
|
from .repository import SqlAlchemyIdentityRepository
|
|
25
26
|
from .stores import (
|
|
@@ -50,7 +51,9 @@ class SqlAlchemyIdentityModule(AppModule):
|
|
|
50
51
|
provider = Provider()
|
|
51
52
|
provider.provide(
|
|
52
53
|
SqlAlchemyIdentityRepository,
|
|
53
|
-
provides=
|
|
54
|
+
provides=AnyOf[
|
|
55
|
+
SqlAlchemyIdentityRepository, IdentityCredentialStore, IdentityManagementStore
|
|
56
|
+
],
|
|
54
57
|
scope=Scope.REQUEST,
|
|
55
58
|
)
|
|
56
59
|
provider.provide(
|
|
@@ -12,10 +12,8 @@ from ...application_services import PagedResult, PagedResultRequestDto
|
|
|
12
12
|
from ...domain import OptimisticConcurrencyError
|
|
13
13
|
from ...extensions import ExtensionProperties
|
|
14
14
|
from ...sqlalchemy import SqlAlchemyModelCatalog, SqlAlchemySessionProvider
|
|
15
|
-
from ..
|
|
16
|
-
|
|
17
|
-
IdentityRole,
|
|
18
|
-
IdentityUser,
|
|
15
|
+
from ..domain_rules import _check_version
|
|
16
|
+
from ..dtos import (
|
|
19
17
|
RolePermissionsView,
|
|
20
18
|
RoleView,
|
|
21
19
|
SetUserActiveCommand,
|
|
@@ -23,9 +21,10 @@ from ..contracts import (
|
|
|
23
21
|
UpdateUserCommand,
|
|
24
22
|
UserRolesView,
|
|
25
23
|
UserView,
|
|
26
|
-
_check_version,
|
|
27
24
|
)
|
|
28
25
|
from ..errors import IdentityConflictError, IdentityResourceNotFoundError
|
|
26
|
+
from ..extensions import IDENTITY_USER_EXTENSION
|
|
27
|
+
from ..models import IdentityRole, IdentityUser
|
|
29
28
|
from .models import (
|
|
30
29
|
RolePermissionRow,
|
|
31
30
|
RoleRow,
|
|
@@ -9,9 +9,7 @@ from uuid import UUID
|
|
|
9
9
|
from sqlalchemy import func, select
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
11
|
|
|
12
|
-
from ..
|
|
13
|
-
IdentityOptions,
|
|
14
|
-
)
|
|
12
|
+
from ..options import IdentityOptions
|
|
15
13
|
from .models import (
|
|
16
14
|
RefreshSessionRow,
|
|
17
15
|
)
|
|
@@ -11,14 +11,9 @@ from ...authorization import CurrentUser, RolePermissionVersion
|
|
|
11
11
|
from ...options import Options
|
|
12
12
|
from ...sqlalchemy import FrameworkAsyncSession, SqlAlchemyModelCatalog
|
|
13
13
|
from ...sqlalchemy.runtime import _SqlAlchemyEngineRegistry
|
|
14
|
-
from ..
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
IdentityUser,
|
|
18
|
-
RefreshRotationResult,
|
|
19
|
-
RefreshRotationStatus,
|
|
20
|
-
RefreshSession,
|
|
21
|
-
)
|
|
14
|
+
from ..extensions import IDENTITY_USER_EXTENSION
|
|
15
|
+
from ..models import IdentityUser, RefreshRotationResult, RefreshRotationStatus, RefreshSession
|
|
16
|
+
from ..options import IdentityOptions
|
|
22
17
|
from .models import (
|
|
23
18
|
RefreshSessionRow,
|
|
24
19
|
RolePermissionRow,
|
|
@@ -80,13 +75,14 @@ class SqlAlchemyIdentityAccessStore:
|
|
|
80
75
|
.order_by(RoleRow.role_id)
|
|
81
76
|
)
|
|
82
77
|
).all()
|
|
78
|
+
# 驱动 UUID 子类止于持久化边界;调用方可将身份值放入稳定领域事件。
|
|
83
79
|
return CurrentUser(
|
|
84
|
-
user_id=user.user_id,
|
|
85
|
-
session_id=refresh.session_id,
|
|
80
|
+
user_id=UUID(bytes=user.user_id.bytes),
|
|
81
|
+
session_id=UUID(bytes=refresh.session_id.bytes),
|
|
86
82
|
username=user.username,
|
|
87
83
|
permission_version=user.permission_version,
|
|
88
84
|
role_versions=tuple(
|
|
89
|
-
RolePermissionVersion(role_id=role_id, version=version)
|
|
85
|
+
RolePermissionVersion(role_id=UUID(bytes=role_id.bytes), version=version)
|
|
90
86
|
for role_id, version in role_rows
|
|
91
87
|
),
|
|
92
88
|
)
|
|
@@ -222,26 +218,9 @@ class SqlAlchemyRefreshSessionStore:
|
|
|
222
218
|
now: datetime,
|
|
223
219
|
) -> RefreshRotationResult:
|
|
224
220
|
async with self._session() as session, session.begin():
|
|
225
|
-
|
|
226
|
-
await session.execute(
|
|
227
|
-
select(RefreshSessionRow.user_id, RefreshSessionRow.family_id).where(
|
|
228
|
-
RefreshSessionRow.token_hash == token_hash
|
|
229
|
-
)
|
|
230
|
-
)
|
|
231
|
-
).one_or_none()
|
|
232
|
-
if identity is None:
|
|
233
|
-
return RefreshRotationResult(RefreshRotationStatus.NOT_FOUND, None)
|
|
234
|
-
user_id, family_id = identity
|
|
235
|
-
await _lock_identity_user(session, user_id)
|
|
236
|
-
await _lock_refresh_family(session, family_id)
|
|
237
|
-
current = await session.scalar(
|
|
238
|
-
select(RefreshSessionRow)
|
|
239
|
-
.where(RefreshSessionRow.token_hash == token_hash)
|
|
240
|
-
.with_for_update()
|
|
241
|
-
)
|
|
221
|
+
current = await self._locked_session(session, token_hash)
|
|
242
222
|
if current is None:
|
|
243
223
|
return RefreshRotationResult(RefreshRotationStatus.NOT_FOUND, None)
|
|
244
|
-
assert current.family_id == family_id
|
|
245
224
|
if current.revoked_at is not None or current.replaced_by_session_id is not None:
|
|
246
225
|
await _revoke_family(session, current.family_id, now=now)
|
|
247
226
|
return RefreshRotationResult(RefreshRotationStatus.REUSED, None)
|
|
@@ -266,27 +245,36 @@ class SqlAlchemyRefreshSessionStore:
|
|
|
266
245
|
|
|
267
246
|
async def revoke(self, token_hash: str, *, now: datetime) -> None:
|
|
268
247
|
async with self._session() as session, session.begin():
|
|
269
|
-
|
|
270
|
-
await session.execute(
|
|
271
|
-
select(RefreshSessionRow.user_id, RefreshSessionRow.family_id).where(
|
|
272
|
-
RefreshSessionRow.token_hash == token_hash
|
|
273
|
-
)
|
|
274
|
-
)
|
|
275
|
-
).one_or_none()
|
|
276
|
-
if identity is None:
|
|
277
|
-
return
|
|
278
|
-
user_id, family_id = identity
|
|
279
|
-
await _lock_identity_user(session, user_id)
|
|
280
|
-
await _lock_refresh_family(session, family_id)
|
|
281
|
-
current = await session.scalar(
|
|
282
|
-
select(RefreshSessionRow)
|
|
283
|
-
.where(RefreshSessionRow.token_hash == token_hash)
|
|
284
|
-
.with_for_update()
|
|
285
|
-
)
|
|
248
|
+
current = await self._locked_session(session, token_hash)
|
|
286
249
|
if current is not None:
|
|
287
|
-
assert current.family_id == family_id
|
|
288
250
|
await _revoke_family(session, current.family_id, now=now)
|
|
289
251
|
|
|
252
|
+
async def _locked_session(
|
|
253
|
+
self, session: FrameworkAsyncSession, token_hash: str
|
|
254
|
+
) -> RefreshSessionRow | None:
|
|
255
|
+
identity = (
|
|
256
|
+
await session.execute(
|
|
257
|
+
select(RefreshSessionRow.user_id, RefreshSessionRow.family_id).where(
|
|
258
|
+
RefreshSessionRow.token_hash == token_hash
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
).one_or_none()
|
|
262
|
+
if identity is None:
|
|
263
|
+
return None
|
|
264
|
+
user_id, family_id = identity
|
|
265
|
+
# 身份预读不授予变更权;user gate → family advisory → row 后重读,
|
|
266
|
+
# 保持凭据撤销与 refresh 的共同锁顺序,业务分支由 rotate/revoke 决定。
|
|
267
|
+
await _lock_identity_user(session, user_id)
|
|
268
|
+
await _lock_refresh_family(session, family_id)
|
|
269
|
+
current = await session.scalar(
|
|
270
|
+
select(RefreshSessionRow)
|
|
271
|
+
.where(RefreshSessionRow.token_hash == token_hash)
|
|
272
|
+
.with_for_update()
|
|
273
|
+
)
|
|
274
|
+
if current is not None:
|
|
275
|
+
assert current.family_id == family_id
|
|
276
|
+
return current
|
|
277
|
+
|
|
290
278
|
def _session(self) -> FrameworkAsyncSession:
|
|
291
279
|
return FrameworkAsyncSession(self._engines.get("default"), expire_on_commit=False)
|
|
292
280
|
|