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.
Files changed (104) hide show
  1. python_ddd_framework/application/runtime.py +46 -19
  2. python_ddd_framework/application_services/bindings.py +0 -64
  3. python_ddd_framework/application_services/dispatcher.py +1 -1
  4. python_ddd_framework/application_services/invocation.py +7 -500
  5. python_ddd_framework/application_services/module.py +16 -5
  6. python_ddd_framework/authorization/module.py +1 -1
  7. python_ddd_framework/background_execution/local.py +20 -1
  8. python_ddd_framework/background_execution/processes.py +11 -1
  9. python_ddd_framework/background_jobs/execution.py +1 -1
  10. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
  11. python_ddd_framework/background_workers/execution.py +1 -1
  12. python_ddd_framework/background_workers/runtime.py +11 -2
  13. python_ddd_framework/caching/unit_of_work.py +2 -4
  14. python_ddd_framework/cli/project.py +11 -10
  15. python_ddd_framework/developer_kit/generation.py +46 -36
  16. python_ddd_framework/developer_kit/publication.py +442 -0
  17. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
  18. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
  19. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
  20. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
  21. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
  22. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
  23. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
  24. python_ddd_framework/diagnostics/source.py +14 -1
  25. python_ddd_framework/domain/aggregates.py +5 -12
  26. python_ddd_framework/domain/event_values.py +124 -0
  27. python_ddd_framework/events/runtime.py +2 -7
  28. python_ddd_framework/events/unit_of_work.py +1 -4
  29. python_ddd_framework/fastapi/action.py +2 -1
  30. python_ddd_framework/fastapi/adapter.py +11 -117
  31. python_ddd_framework/fastapi/application_services.py +17 -402
  32. python_ddd_framework/fastapi/error_handlers.py +128 -0
  33. python_ddd_framework/fastapi/realtime/runtime.py +1 -1
  34. python_ddd_framework/fastapi/request_context.py +2 -5
  35. python_ddd_framework/fastapi/routing.py +4 -10
  36. python_ddd_framework/fastapi/service_endpoints.py +182 -0
  37. python_ddd_framework/fastapi/service_routes.py +237 -0
  38. python_ddd_framework/fastapi/transfer.py +1 -1
  39. python_ddd_framework/hosted_services/bridge.py +76 -21
  40. python_ddd_framework/hosted_services/contracts.py +5 -1
  41. python_ddd_framework/hosted_services/runtime.py +71 -17
  42. python_ddd_framework/identity/__init__.py +21 -13
  43. python_ddd_framework/identity/application.py +12 -66
  44. python_ddd_framework/identity/contracts.py +38 -411
  45. python_ddd_framework/identity/domain_rules.py +17 -0
  46. python_ddd_framework/identity/dtos.py +152 -0
  47. python_ddd_framework/identity/extensions.py +9 -0
  48. python_ddd_framework/identity/management.py +28 -25
  49. python_ddd_framework/identity/models.py +86 -0
  50. python_ddd_framework/identity/module.py +3 -4
  51. python_ddd_framework/identity/options.py +54 -0
  52. python_ddd_framework/identity/seeding.py +59 -0
  53. python_ddd_framework/identity/services.py +1 -1
  54. python_ddd_framework/identity/sqlalchemy/module.py +9 -6
  55. python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
  56. python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
  57. python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
  58. python_ddd_framework/identity/stores.py +120 -0
  59. python_ddd_framework/identity/tokens.py +1 -1
  60. python_ddd_framework/invocation/callables.py +1 -1
  61. python_ddd_framework/invocation/dispatcher.py +1 -1
  62. python_ddd_framework/invocation/entrypoints.py +2 -1
  63. python_ddd_framework/invocation/function_runtime.py +2 -2
  64. python_ddd_framework/invocation/interception.py +1 -1
  65. python_ddd_framework/invocation/managed_proxy.py +2 -1
  66. python_ddd_framework/invocation/managed_services.py +8 -14
  67. python_ddd_framework/invocation/methods.py +2 -7
  68. python_ddd_framework/invocation/module.py +1 -1
  69. python_ddd_framework/invocation/runtime.py +483 -0
  70. python_ddd_framework/invocation/scopes.py +91 -0
  71. python_ddd_framework/lifecycle/participants.py +4 -0
  72. python_ddd_framework/messaging/channel.py +35 -8
  73. python_ddd_framework/messaging/contracts.py +9 -0
  74. python_ddd_framework/messaging/module.py +13 -0
  75. python_ddd_framework/messaging/runtime.py +17 -2
  76. python_ddd_framework/modularity/discovery.py +2 -7
  77. python_ddd_framework/modularity/graph.py +2 -8
  78. python_ddd_framework/observability/logging.py +1 -1
  79. python_ddd_framework/observability/tracing.py +1 -1
  80. python_ddd_framework/redis/cache.py +2 -0
  81. python_ddd_framework/redis/notification_runtime.py +1 -1
  82. python_ddd_framework/services/arbitration.py +1 -20
  83. python_ddd_framework/services/composition.py +71 -2
  84. python_ddd_framework/services/provider.py +23 -1
  85. python_ddd_framework/services/relocation.py +96 -0
  86. python_ddd_framework/settings/refresh.py +1 -1
  87. python_ddd_framework/settings/sqlalchemy/store.py +1 -1
  88. python_ddd_framework/sqlalchemy/metadata.py +11 -413
  89. python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
  90. python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
  91. python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
  92. python_ddd_framework/sqlalchemy/migration.py +2 -1
  93. python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
  94. python_ddd_framework/sqlalchemy/module.py +2 -1
  95. python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
  96. python_ddd_framework/unit_of_work/contracts.py +68 -7
  97. python_ddd_framework/unit_of_work/manager.py +4 -7
  98. python_ddd_framework/unit_of_work/module.py +1 -1
  99. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
  100. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
  101. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
  102. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
  103. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
  104. {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
- repository: IdentityRepository,
52
+ store: IdentityManagementStore,
53
+ credentials: IdentityCredentialStore,
52
54
  passwords: PasswordHasher,
53
55
  permissions: PermissionCatalog,
54
56
  ) -> None:
55
- self._repository = repository
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._repository.find_user_by_normalized_username(normalized) is not None:
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._repository.create_user(user)
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._repository.list_users(query)
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._repository.get_user_view(user_id)
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._repository.update_user(user_id, command)
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._repository.set_user_active(user_id, command)
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._repository.delete_user(user_id, command.concurrency_version)
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._repository.set_password(user_id, password_hash, command.concurrency_version)
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._repository.find_role_by_normalized_name(normalized) is not None:
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._repository.create_role(role)
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._repository.list_roles(query)
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._repository.get_role(role_id)
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._repository.update_role(role_id, command)
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._repository.delete_role(role_id, command.concurrency_version)
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._repository.get_user_view(user_id)
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._repository.get_role(role_id)
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._repository.set_user_roles(user_id, role_ids, command.concurrency_version)
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._repository.set_role_permissions(
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 .contracts import (
14
- IDENTITY_USER_EXTENSION,
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 ..contracts import (
16
- IDENTITY_USER_EXTENSION,
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
- IdentityRepository,
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=IdentityRepository,
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 ..contracts import (
16
- IDENTITY_USER_EXTENSION,
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 ..contracts import (
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 ..contracts import (
15
- IDENTITY_USER_EXTENSION,
16
- IdentityOptions,
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
- identity = (
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
- identity = (
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