python-platform 0.1.1__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 (344) hide show
  1. python_platform/__init__.py +445 -0
  2. python_platform/application/__init__.py +6 -0
  3. python_platform/application/build_spec.py +37 -0
  4. python_platform/application/builder.py +260 -0
  5. python_platform/application/composition.py +219 -0
  6. python_platform/application/runtime.py +546 -0
  7. python_platform/application/runtime_hooks.py +327 -0
  8. python_platform/application/state.py +216 -0
  9. python_platform/application_services/__init__.py +54 -0
  10. python_platform/application_services/catalog.py +500 -0
  11. python_platform/application_services/contracts.py +188 -0
  12. python_platform/application_services/dispatcher.py +423 -0
  13. python_platform/application_services/errors.py +89 -0
  14. python_platform/application_services/execution.py +199 -0
  15. python_platform/application_services/interceptors.py +44 -0
  16. python_platform/application_services/invocation.py +427 -0
  17. python_platform/application_services/policies.py +46 -0
  18. python_platform/application_services/seeding.py +72 -0
  19. python_platform/application_services/signature.py +49 -0
  20. python_platform/application_services/validation.py +84 -0
  21. python_platform/auditing/__init__.py +12 -0
  22. python_platform/auditing/contracts.py +39 -0
  23. python_platform/auditing/control.py +48 -0
  24. python_platform/auditing/sqlalchemy/__init__.py +6 -0
  25. python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
  26. python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
  27. python_platform/auditing/sqlalchemy/models.py +31 -0
  28. python_platform/auditing/sqlalchemy/module.py +32 -0
  29. python_platform/auditing/sqlalchemy/store.py +63 -0
  30. python_platform/authorization/__init__.py +38 -0
  31. python_platform/authorization/catalog.py +63 -0
  32. python_platform/authorization/contracts.py +241 -0
  33. python_platform/authorization/definition_discovery.py +81 -0
  34. python_platform/authorization/definitions.py +43 -0
  35. python_platform/authorization/errors.py +40 -0
  36. python_platform/background_execution/__init__.py +15 -0
  37. python_platform/background_execution/application.py +41 -0
  38. python_platform/background_execution/child.py +123 -0
  39. python_platform/background_execution/contracts.py +39 -0
  40. python_platform/background_execution/lifecycle.py +22 -0
  41. python_platform/background_execution/local.py +127 -0
  42. python_platform/background_execution/locks.py +31 -0
  43. python_platform/background_execution/management.py +17 -0
  44. python_platform/background_execution/module.py +10 -0
  45. python_platform/background_execution/permissions.py +14 -0
  46. python_platform/background_execution/processes.py +331 -0
  47. python_platform/background_jobs/__init__.py +31 -0
  48. python_platform/background_jobs/catalog.py +287 -0
  49. python_platform/background_jobs/contracts.py +202 -0
  50. python_platform/background_jobs/errors.py +28 -0
  51. python_platform/background_jobs/execution.py +201 -0
  52. python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
  53. python_platform/background_jobs/pgqueuer/__init__.py +6 -0
  54. python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
  55. python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
  56. python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
  57. python_platform/background_jobs/pgqueuer/module.py +102 -0
  58. python_platform/background_jobs/pgqueuer/options.py +70 -0
  59. python_platform/background_jobs/pgqueuer/runtime.py +479 -0
  60. python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
  61. python_platform/background_jobs/pgqueuer/supervision.py +201 -0
  62. python_platform/background_workers/__init__.py +22 -0
  63. python_platform/background_workers/catalog.py +96 -0
  64. python_platform/background_workers/contracts.py +124 -0
  65. python_platform/background_workers/errors.py +20 -0
  66. python_platform/background_workers/execution.py +133 -0
  67. python_platform/background_workers/runtime.py +203 -0
  68. python_platform/caching/__init__.py +11 -0
  69. python_platform/caching/catalog.py +73 -0
  70. python_platform/caching/contracts.py +73 -0
  71. python_platform/caching/errors.py +20 -0
  72. python_platform/cli/__init__.py +87 -0
  73. python_platform/cli/errors.py +5 -0
  74. python_platform/cli/project.py +69 -0
  75. python_platform/cli/runtime.py +68 -0
  76. python_platform/configuration/__init__.py +21 -0
  77. python_platform/configuration/composition.py +65 -0
  78. python_platform/configuration/contracts.py +68 -0
  79. python_platform/configuration/dotenv_source.py +51 -0
  80. python_platform/configuration/environment_source.py +38 -0
  81. python_platform/configuration/immutability.py +124 -0
  82. python_platform/configuration/input_shape.py +58 -0
  83. python_platform/configuration/merge.py +103 -0
  84. python_platform/configuration/root.py +215 -0
  85. python_platform/configuration/sources.py +519 -0
  86. python_platform/configuration/values.py +427 -0
  87. python_platform/configuration/yaml_source.py +51 -0
  88. python_platform/developer_kit/__init__.py +1 -0
  89. python_platform/developer_kit/generation.py +182 -0
  90. python_platform/developer_kit/project_metadata.py +45 -0
  91. python_platform/developer_kit/source.py +53 -0
  92. python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
  93. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
  94. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
  95. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
  96. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
  97. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
  98. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
  99. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
  100. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
  101. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
  102. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
  103. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
  104. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
  105. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
  106. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
  107. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
  108. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
  109. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
  110. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
  111. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
  112. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
  113. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
  114. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
  115. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
  116. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
  117. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
  118. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
  119. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
  120. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
  121. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
  122. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
  123. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
  124. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
  125. python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
  126. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
  127. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
  128. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
  129. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
  130. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
  131. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
  132. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
  133. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
  134. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
  135. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
  136. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
  137. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
  138. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
  139. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
  140. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
  141. python_platform/developer_kit/wiring.py +85 -0
  142. python_platform/diagnostics/__init__.py +23 -0
  143. python_platform/diagnostics/journal.py +174 -0
  144. python_platform/diagnostics/model.py +83 -0
  145. python_platform/diagnostics/source.py +25 -0
  146. python_platform/distributed_lock/__init__.py +6 -0
  147. python_platform/distributed_lock/contracts.py +20 -0
  148. python_platform/distributed_lock/options.py +19 -0
  149. python_platform/domain/__init__.py +13 -0
  150. python_platform/domain/aggregates.py +64 -0
  151. python_platform/domain/entities.py +33 -0
  152. python_platform/domain/errors.py +12 -0
  153. python_platform/domain/value_objects.py +31 -0
  154. python_platform/errors/__init__.py +75 -0
  155. python_platform/errors/base.py +13 -0
  156. python_platform/errors/business.py +53 -0
  157. python_platform/errors/configuration.py +119 -0
  158. python_platform/errors/diagnostics.py +12 -0
  159. python_platform/errors/lifecycle.py +164 -0
  160. python_platform/errors/modularity.py +100 -0
  161. python_platform/errors/services.py +100 -0
  162. python_platform/events/__init__.py +25 -0
  163. python_platform/events/aggregate.py +39 -0
  164. python_platform/events/catalog.py +136 -0
  165. python_platform/events/contracts.py +39 -0
  166. python_platform/events/contribution.py +128 -0
  167. python_platform/events/discovery.py +105 -0
  168. python_platform/events/errors.py +48 -0
  169. python_platform/events/runtime.py +192 -0
  170. python_platform/fastapi/__init__.py +49 -0
  171. python_platform/fastapi/action.py +175 -0
  172. python_platform/fastapi/adapter.py +381 -0
  173. python_platform/fastapi/application_services.py +647 -0
  174. python_platform/fastapi/background.py +42 -0
  175. python_platform/fastapi/contracts.py +278 -0
  176. python_platform/fastapi/errors.py +32 -0
  177. python_platform/fastapi/filters.py +111 -0
  178. python_platform/fastapi/health.py +44 -0
  179. python_platform/fastapi/http.py +55 -0
  180. python_platform/fastapi/http_router.py +212 -0
  181. python_platform/fastapi/manual_action.py +105 -0
  182. python_platform/fastapi/middleware.py +93 -0
  183. python_platform/fastapi/parameters.py +97 -0
  184. python_platform/fastapi/realtime/__init__.py +13 -0
  185. python_platform/fastapi/realtime/authentication.py +181 -0
  186. python_platform/fastapi/realtime/connection.py +166 -0
  187. python_platform/fastapi/realtime/module.py +41 -0
  188. python_platform/fastapi/realtime/options.py +50 -0
  189. python_platform/fastapi/realtime/runtime.py +423 -0
  190. python_platform/fastapi/request_context.py +244 -0
  191. python_platform/fastapi/route_integrity.py +88 -0
  192. python_platform/fastapi/routing.py +410 -0
  193. python_platform/fastapi/server.py +181 -0
  194. python_platform/fastapi/settings.py +40 -0
  195. python_platform/fastapi/tracing.py +62 -0
  196. python_platform/fastapi/transfer.py +128 -0
  197. python_platform/fastapi/upload_limits.py +63 -0
  198. python_platform/fastapi/uploads.py +94 -0
  199. python_platform/hosted_services/__init__.py +29 -0
  200. python_platform/hosted_services/bridge.py +420 -0
  201. python_platform/hosted_services/catalog.py +259 -0
  202. python_platform/hosted_services/contracts.py +55 -0
  203. python_platform/hosted_services/errors.py +53 -0
  204. python_platform/hosted_services/options.py +17 -0
  205. python_platform/hosted_services/runtime.py +267 -0
  206. python_platform/hosted_services/state.py +40 -0
  207. python_platform/hosting/__init__.py +4 -0
  208. python_platform/hosting/instance.py +40 -0
  209. python_platform/identity/__init__.py +73 -0
  210. python_platform/identity/application.py +287 -0
  211. python_platform/identity/contracts.py +283 -0
  212. python_platform/identity/errors.py +28 -0
  213. python_platform/identity/http_api.py +90 -0
  214. python_platform/identity/module.py +46 -0
  215. python_platform/identity/passwords.py +34 -0
  216. python_platform/identity/permissions.py +14 -0
  217. python_platform/identity/services.py +108 -0
  218. python_platform/identity/sqlalchemy/__init__.py +5 -0
  219. python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
  220. python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
  221. python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
  222. python_platform/identity/sqlalchemy/models.py +85 -0
  223. python_platform/identity/sqlalchemy/module.py +62 -0
  224. python_platform/identity/sqlalchemy/stores.py +526 -0
  225. python_platform/identity/tokens.py +106 -0
  226. python_platform/invocation/__init__.py +3 -0
  227. python_platform/invocation/callables.py +171 -0
  228. python_platform/invocation/contracts.py +33 -0
  229. python_platform/invocation/entries.py +54 -0
  230. python_platform/invocation/function_runtime.py +48 -0
  231. python_platform/invocation/interception.py +184 -0
  232. python_platform/lifecycle/__init__.py +25 -0
  233. python_platform/lifecycle/composition.py +35 -0
  234. python_platform/lifecycle/context.py +31 -0
  235. python_platform/lifecycle/runtime.py +43 -0
  236. python_platform/lifecycle/state.py +20 -0
  237. python_platform/modularity/__init__.py +15 -0
  238. python_platform/modularity/contracts.py +90 -0
  239. python_platform/modularity/discovery.py +143 -0
  240. python_platform/modularity/graph.py +334 -0
  241. python_platform/modularity/registry.py +100 -0
  242. python_platform/modularity/selection.py +16 -0
  243. python_platform/notifications/__init__.py +19 -0
  244. python_platform/notifications/catalog.py +46 -0
  245. python_platform/notifications/contracts.py +107 -0
  246. python_platform/observability/__init__.py +5 -0
  247. python_platform/observability/context.py +43 -0
  248. python_platform/observability/export.py +73 -0
  249. python_platform/observability/formatting.py +89 -0
  250. python_platform/observability/logging.py +118 -0
  251. python_platform/observability/options.py +45 -0
  252. python_platform/observability/tracing.py +80 -0
  253. python_platform/options/__init__.py +19 -0
  254. python_platform/options/aliases.py +331 -0
  255. python_platform/options/contribution.py +62 -0
  256. python_platform/options/immutability.py +48 -0
  257. python_platform/options/input_keys.py +214 -0
  258. python_platform/options/issues.py +335 -0
  259. python_platform/options/models.py +114 -0
  260. python_platform/options/registry.py +280 -0
  261. python_platform/options/schema.py +488 -0
  262. python_platform/options/validation.py +120 -0
  263. python_platform/py.typed +0 -0
  264. python_platform/realtime/__init__.py +12 -0
  265. python_platform/realtime/contracts.py +28 -0
  266. python_platform/realtime/diagnostics.py +37 -0
  267. python_platform/realtime/messages.py +113 -0
  268. python_platform/redis/__init__.py +6 -0
  269. python_platform/redis/distributed_lock.py +212 -0
  270. python_platform/redis/lease_lock.py +36 -0
  271. python_platform/redis/module.py +50 -0
  272. python_platform/redis/notification_runtime.py +182 -0
  273. python_platform/redis/notifications.py +25 -0
  274. python_platform/redis/options.py +35 -0
  275. python_platform/redis/runtime.py +116 -0
  276. python_platform/services/__init__.py +39 -0
  277. python_platform/services/application_bindings.py +221 -0
  278. python_platform/services/arbitration.py +381 -0
  279. python_platform/services/binding.py +102 -0
  280. python_platform/services/contribution.py +438 -0
  281. python_platform/services/convention.py +292 -0
  282. python_platform/services/convention_contracts.py +94 -0
  283. python_platform/services/exposure.py +23 -0
  284. python_platform/services/framework_provider.py +178 -0
  285. python_platform/services/native_graph.py +172 -0
  286. python_platform/services/provider.py +204 -0
  287. python_platform/services/registration.py +101 -0
  288. python_platform/services/runtime.py +298 -0
  289. python_platform/settings/__init__.py +30 -0
  290. python_platform/settings/application.py +95 -0
  291. python_platform/settings/binding.py +33 -0
  292. python_platform/settings/catalog.py +121 -0
  293. python_platform/settings/changes.py +14 -0
  294. python_platform/settings/contracts.py +115 -0
  295. python_platform/settings/definition_discovery.py +71 -0
  296. python_platform/settings/definitions.py +41 -0
  297. python_platform/settings/errors.py +22 -0
  298. python_platform/settings/handlers.py +43 -0
  299. python_platform/settings/management.py +57 -0
  300. python_platform/settings/manager.py +89 -0
  301. python_platform/settings/module.py +7 -0
  302. python_platform/settings/notifications.py +39 -0
  303. python_platform/settings/permissions.py +14 -0
  304. python_platform/settings/provider.py +74 -0
  305. python_platform/settings/refresh.py +140 -0
  306. python_platform/settings/refresh_module.py +45 -0
  307. python_platform/settings/sqlalchemy/__init__.py +5 -0
  308. python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
  309. python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
  310. python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
  311. python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
  312. python_platform/settings/sqlalchemy/models.py +36 -0
  313. python_platform/settings/sqlalchemy/module.py +36 -0
  314. python_platform/settings/sqlalchemy/store.py +86 -0
  315. python_platform/settings/store.py +16 -0
  316. python_platform/settings/values.py +39 -0
  317. python_platform/sqlalchemy/__init__.py +51 -0
  318. python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
  319. python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
  320. python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
  321. python_platform/sqlalchemy/auditing.py +48 -0
  322. python_platform/sqlalchemy/errors.py +85 -0
  323. python_platform/sqlalchemy/metadata.py +538 -0
  324. python_platform/sqlalchemy/migration.py +346 -0
  325. python_platform/sqlalchemy/module.py +48 -0
  326. python_platform/sqlalchemy/module_migration.py +148 -0
  327. python_platform/sqlalchemy/options.py +66 -0
  328. python_platform/sqlalchemy/repository.py +33 -0
  329. python_platform/sqlalchemy/runtime.py +85 -0
  330. python_platform/sqlalchemy/session_provider.py +45 -0
  331. python_platform/sqlalchemy/unit_of_work.py +97 -0
  332. python_platform/testing/__init__.py +5 -0
  333. python_platform/testing/runtime.py +112 -0
  334. python_platform/unit_of_work/__init__.py +14 -0
  335. python_platform/unit_of_work/contracts.py +223 -0
  336. python_platform/unit_of_work/errors.py +27 -0
  337. python_platform/unit_of_work/manager.py +210 -0
  338. python_platform/unit_of_work/options.py +92 -0
  339. python_platform-0.1.1.dist-info/METADATA +122 -0
  340. python_platform-0.1.1.dist-info/RECORD +344 -0
  341. python_platform-0.1.1.dist-info/WHEEL +4 -0
  342. python_platform-0.1.1.dist-info/entry_points.txt +9 -0
  343. python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
  344. python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
@@ -0,0 +1,526 @@
1
+ """Identity repository、request access check 与独立 refresh/login 事务。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ from hashlib import blake2b
7
+ from uuid import UUID
8
+
9
+ from sqlalchemy import func, select
10
+ from sqlalchemy.exc import IntegrityError
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from ...authorization import CurrentUser, RolePermissionVersion
14
+ from ...sqlalchemy import PlatformAsyncSession, SqlAlchemySessionProvider
15
+ from ...sqlalchemy.runtime import _SqlAlchemyEngineRegistry
16
+ from ..contracts import (
17
+ IdentityRole,
18
+ IdentityUser,
19
+ RefreshRotationResult,
20
+ RefreshRotationStatus,
21
+ RefreshSession,
22
+ RoleView,
23
+ UserView,
24
+ )
25
+ from ..errors import IdentityConflictError, IdentityResourceNotFoundError
26
+ from ..tokens import REFRESH_ABSOLUTE_LIFETIME, REFRESH_INACTIVITY_LIFETIME
27
+ from .models import (
28
+ RefreshSessionRow,
29
+ RolePermissionRow,
30
+ RoleRow,
31
+ UserRoleRow,
32
+ UserRow,
33
+ )
34
+
35
+ _REFRESH_FAMILY_LOCK_PERSON = b"pyplat.rfam.v1"
36
+
37
+
38
+ class SqlAlchemyIdentityRepository:
39
+ __slots__ = ("_provider",)
40
+
41
+ def __init__(self, provider: SqlAlchemySessionProvider) -> None:
42
+ self._provider = provider
43
+
44
+ async def find_user_by_normalized_username(
45
+ self,
46
+ normalized_username: str,
47
+ ) -> IdentityUser | None:
48
+ async with self._provider.operation("find_user_by_normalized_username"):
49
+ session = await self._provider.get_session()
50
+ statement = select(UserRow).where(UserRow.normalized_username == normalized_username)
51
+ row = await session.scalar(statement)
52
+ return None if row is None else _user_value(row)
53
+
54
+ async def create_user(self, user: IdentityUser) -> None:
55
+ async with self._provider.operation("create_user"):
56
+ session = await self._provider.get_session()
57
+ session.add(
58
+ UserRow(
59
+ user_id=user.user_id,
60
+ username=user.username,
61
+ normalized_username=user.normalized_username,
62
+ password_hash=user.password_hash,
63
+ is_active=user.is_active,
64
+ failed_access_count=user.failed_access_count,
65
+ lockout_end=user.lockout_end,
66
+ permission_version=user.permission_version,
67
+ )
68
+ )
69
+ await self._flush_unique("user")
70
+
71
+ async def list_users(self) -> tuple[UserView, ...]:
72
+ async with self._provider.operation("list_users"):
73
+ session = await self._provider.get_session()
74
+ rows = tuple(
75
+ (await session.scalars(select(UserRow).order_by(UserRow.normalized_username))).all()
76
+ )
77
+ role_rows = (
78
+ await session.execute(
79
+ select(UserRoleRow.user_id, UserRoleRow.role_id).order_by(
80
+ UserRoleRow.user_id, UserRoleRow.role_id
81
+ )
82
+ )
83
+ ).all()
84
+ roles_by_user: dict[UUID, list[UUID]] = {}
85
+ for user_id, role_id in role_rows:
86
+ roles_by_user.setdefault(user_id, []).append(role_id)
87
+ return tuple(
88
+ UserView(
89
+ user_id=row.user_id,
90
+ username=row.username,
91
+ is_active=row.is_active,
92
+ role_ids=tuple(roles_by_user.get(row.user_id, ())),
93
+ )
94
+ for row in rows
95
+ )
96
+
97
+ async def create_role(self, role: IdentityRole) -> None:
98
+ async with self._provider.operation("create_role"):
99
+ session = await self._provider.get_session()
100
+ session.add(
101
+ RoleRow(
102
+ role_id=role.role_id,
103
+ name=role.name,
104
+ normalized_name=role.normalized_name,
105
+ permission_version=role.permission_version,
106
+ )
107
+ )
108
+ await self._flush_unique("role")
109
+
110
+ async def find_role_by_normalized_name(self, normalized_name: str) -> IdentityRole | None:
111
+ async with self._provider.operation("find_role_by_normalized_name"):
112
+ session = await self._provider.get_session()
113
+ row = await session.scalar(
114
+ select(RoleRow).where(RoleRow.normalized_name == normalized_name)
115
+ )
116
+ return None if row is None else _role_value(row)
117
+
118
+ async def list_roles(self) -> tuple[RoleView, ...]:
119
+ async with self._provider.operation("list_roles"):
120
+ session = await self._provider.get_session()
121
+ roles = tuple(
122
+ (await session.scalars(select(RoleRow).order_by(RoleRow.normalized_name))).all()
123
+ )
124
+ permission_rows = (
125
+ await session.execute(
126
+ select(RolePermissionRow.role_id, RolePermissionRow.permission_name).order_by(
127
+ RolePermissionRow.role_id, RolePermissionRow.permission_name
128
+ )
129
+ )
130
+ ).all()
131
+ permissions_by_role: dict[UUID, list[str]] = {}
132
+ for role_id, permission_name in permission_rows:
133
+ permissions_by_role.setdefault(role_id, []).append(permission_name)
134
+ return tuple(
135
+ RoleView(
136
+ role_id=row.role_id,
137
+ name=row.name,
138
+ permissions=tuple(permissions_by_role.get(row.role_id, ())),
139
+ )
140
+ for row in roles
141
+ )
142
+
143
+ async def set_user_roles(self, user_id: UUID, role_ids: tuple[UUID, ...]) -> None:
144
+ async with self._provider.operation("set_user_roles"):
145
+ session = await self._provider.get_session()
146
+ user = await self._require_user(user_id, for_update=True)
147
+ requested = frozenset(role_ids)
148
+ roles = tuple(
149
+ (
150
+ await session.scalars(
151
+ select(RoleRow).where(RoleRow.role_id.in_(requested)).with_for_update()
152
+ )
153
+ ).all()
154
+ )
155
+ if len(roles) != len(requested):
156
+ raise IdentityResourceNotFoundError(resource="role")
157
+ existing = tuple(
158
+ (
159
+ await session.scalars(
160
+ select(UserRoleRow).where(UserRoleRow.user_id == user_id).with_for_update()
161
+ )
162
+ ).all()
163
+ )
164
+ current = frozenset(item.role_id for item in existing)
165
+ if current == requested:
166
+ return
167
+ for item in existing:
168
+ if item.role_id not in requested:
169
+ await session.delete(item)
170
+ for role_id in sorted(requested - current):
171
+ session.add(UserRoleRow(user_id=user_id, role_id=role_id))
172
+ user.permission_version += 1
173
+
174
+ async def set_role_permissions(self, role_id: UUID, permissions: tuple[str, ...]) -> None:
175
+ async with self._provider.operation("set_role_permissions"):
176
+ session = await self._provider.get_session()
177
+ role = await self._require_role(role_id, for_update=True)
178
+ requested = frozenset(permissions)
179
+ existing = tuple(
180
+ (
181
+ await session.scalars(
182
+ select(RolePermissionRow)
183
+ .where(RolePermissionRow.role_id == role_id)
184
+ .with_for_update()
185
+ )
186
+ ).all()
187
+ )
188
+ current = frozenset(item.permission_name for item in existing)
189
+ if current == requested:
190
+ return
191
+ for item in existing:
192
+ if item.permission_name not in requested:
193
+ await session.delete(item)
194
+ for permission in sorted(requested - current):
195
+ session.add(RolePermissionRow(role_id=role_id, permission_name=permission))
196
+ role.permission_version += 1
197
+
198
+ async def _require_user(self, user_id: UUID, *, for_update: bool) -> UserRow:
199
+ session = await self._provider.get_session()
200
+ statement = select(UserRow).where(UserRow.user_id == user_id)
201
+ if for_update:
202
+ statement = statement.with_for_update()
203
+ row = await session.scalar(statement)
204
+ if row is None:
205
+ raise IdentityResourceNotFoundError(resource="user")
206
+ return row
207
+
208
+ async def _require_role(self, role_id: UUID, *, for_update: bool) -> RoleRow:
209
+ session = await self._provider.get_session()
210
+ statement = select(RoleRow).where(RoleRow.role_id == role_id)
211
+ if for_update:
212
+ statement = statement.with_for_update()
213
+ row = await session.scalar(statement)
214
+ if row is None:
215
+ raise IdentityResourceNotFoundError(resource="role")
216
+ return row
217
+
218
+ async def _flush_unique(self, resource: str) -> None:
219
+ session = await self._provider.get_session()
220
+ try:
221
+ await session.flush()
222
+ except IntegrityError:
223
+ raise IdentityConflictError(resource=resource) from None
224
+
225
+
226
+ class SqlAlchemyIdentityAccessStore:
227
+ __slots__ = ("_engines",)
228
+
229
+ def __init__(self, engines: _SqlAlchemyEngineRegistry) -> None:
230
+ self._engines = engines
231
+
232
+ async def load_current_user(
233
+ self,
234
+ user_id: UUID,
235
+ session_id: UUID,
236
+ now: datetime,
237
+ ) -> CurrentUser | None:
238
+ async with self._session() as session:
239
+ user = await session.scalar(
240
+ select(UserRow).where(UserRow.user_id == user_id, UserRow.is_active.is_(True))
241
+ )
242
+ refresh = await session.scalar(
243
+ select(RefreshSessionRow).where(
244
+ RefreshSessionRow.session_id == session_id,
245
+ RefreshSessionRow.user_id == user_id,
246
+ )
247
+ )
248
+ if user is None or refresh is None or not _session_is_active(refresh, now):
249
+ return None
250
+ role_rows = (
251
+ await session.execute(
252
+ select(RoleRow.role_id, RoleRow.permission_version)
253
+ .join(UserRoleRow, UserRoleRow.role_id == RoleRow.role_id)
254
+ .where(UserRoleRow.user_id == user_id)
255
+ .order_by(RoleRow.role_id)
256
+ )
257
+ ).all()
258
+ return CurrentUser(
259
+ user_id=user.user_id,
260
+ session_id=refresh.session_id,
261
+ username=user.username,
262
+ permission_version=user.permission_version,
263
+ role_versions=tuple(
264
+ RolePermissionVersion(role_id=role_id, version=version)
265
+ for role_id, version in role_rows
266
+ ),
267
+ )
268
+
269
+ async def permissions_for_roles(
270
+ self,
271
+ role_versions: tuple[RolePermissionVersion, ...],
272
+ ) -> frozenset[str]:
273
+ if not role_versions:
274
+ return frozenset()
275
+ role_ids = tuple(item.role_id for item in role_versions)
276
+ expected = {item.role_id: item.version for item in role_versions}
277
+ async with self._session() as session:
278
+ actual_rows = (
279
+ await session.execute(
280
+ select(RoleRow.role_id, RoleRow.permission_version).where(
281
+ RoleRow.role_id.in_(role_ids)
282
+ )
283
+ )
284
+ ).all()
285
+ actual: dict[UUID, int] = dict((role_id, version) for role_id, version in actual_rows)
286
+ # Cache miss 时仍重新核对版本;并发权限修改不能把旧 allow 写进新版本 key。
287
+ if actual != expected:
288
+ return frozenset()
289
+ permissions = (
290
+ await session.scalars(
291
+ select(RolePermissionRow.permission_name).where(
292
+ RolePermissionRow.role_id.in_(role_ids)
293
+ )
294
+ )
295
+ ).all()
296
+ return frozenset(permissions)
297
+
298
+ def _session(self) -> PlatformAsyncSession:
299
+ return PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
300
+
301
+
302
+ class SqlAlchemyIdentityLoginStore:
303
+ __slots__ = ("_engines",)
304
+
305
+ def __init__(self, engines: _SqlAlchemyEngineRegistry) -> None:
306
+ self._engines = engines
307
+
308
+ async def find_user(self, normalized_username: str) -> IdentityUser | None:
309
+ async with self._session() as session:
310
+ row = await session.scalar(
311
+ select(UserRow).where(UserRow.normalized_username == normalized_username)
312
+ )
313
+ return None if row is None else _user_value(row)
314
+
315
+ async def record_failure(
316
+ self,
317
+ user_id: UUID,
318
+ *,
319
+ now: datetime,
320
+ attempt_limit: int,
321
+ lockout_duration_seconds: int,
322
+ ) -> None:
323
+ async with self._session() as session, session.begin():
324
+ row = await session.scalar(
325
+ select(UserRow).where(UserRow.user_id == user_id).with_for_update()
326
+ )
327
+ if row is None or not row.is_active:
328
+ return
329
+ count = (
330
+ 0
331
+ if row.lockout_end is not None and row.lockout_end <= now
332
+ else row.failed_access_count
333
+ )
334
+ count += 1
335
+ row.failed_access_count = count
336
+ row.lockout_end = (
337
+ now + timedelta(seconds=lockout_duration_seconds)
338
+ if count >= attempt_limit
339
+ else None
340
+ )
341
+
342
+ async def complete_success(
343
+ self,
344
+ user_id: UUID,
345
+ *,
346
+ expected_password_hash: str,
347
+ updated_password_hash: str | None,
348
+ session: RefreshSession,
349
+ now: datetime,
350
+ ) -> bool:
351
+ async with self._session() as database, database.begin():
352
+ row = await database.scalar(
353
+ select(UserRow).where(UserRow.user_id == user_id).with_for_update()
354
+ )
355
+ if (
356
+ row is None
357
+ or not row.is_active
358
+ or row.password_hash != expected_password_hash
359
+ or (row.lockout_end is not None and row.lockout_end > now)
360
+ ):
361
+ return False
362
+ row.failed_access_count = 0
363
+ row.lockout_end = None
364
+ if updated_password_hash is not None:
365
+ row.password_hash = updated_password_hash
366
+ database.add(_refresh_row(session))
367
+ return True
368
+
369
+ def _session(self) -> PlatformAsyncSession:
370
+ return PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
371
+
372
+
373
+ class SqlAlchemyRefreshSessionStore:
374
+ __slots__ = ("_engines",)
375
+
376
+ def __init__(self, engines: _SqlAlchemyEngineRegistry) -> None:
377
+ self._engines = engines
378
+
379
+ async def rotate(
380
+ self,
381
+ token_hash: str,
382
+ *,
383
+ replacement_id: UUID,
384
+ replacement_hash: str,
385
+ now: datetime,
386
+ ) -> RefreshRotationResult:
387
+ async with self._session() as session, session.begin():
388
+ family_id = await session.scalar(
389
+ select(RefreshSessionRow.family_id).where(
390
+ RefreshSessionRow.token_hash == token_hash
391
+ )
392
+ )
393
+ if family_id is None:
394
+ return RefreshRotationResult(RefreshRotationStatus.NOT_FOUND, None)
395
+ await _lock_refresh_family(session, family_id)
396
+ current = await session.scalar(
397
+ select(RefreshSessionRow)
398
+ .where(RefreshSessionRow.token_hash == token_hash)
399
+ .with_for_update()
400
+ )
401
+ if current is None:
402
+ return RefreshRotationResult(RefreshRotationStatus.NOT_FOUND, None)
403
+ assert current.family_id == family_id
404
+ if current.revoked_at is not None or current.replaced_by_session_id is not None:
405
+ await _revoke_family(session, current.family_id, now=now)
406
+ return RefreshRotationResult(RefreshRotationStatus.REUSED, None)
407
+ if not _session_is_active(current, now):
408
+ await _revoke_family(session, current.family_id, now=now)
409
+ return RefreshRotationResult(RefreshRotationStatus.EXPIRED, None)
410
+
411
+ replacement = RefreshSession(
412
+ session_id=replacement_id,
413
+ user_id=current.user_id,
414
+ family_id=current.family_id,
415
+ token_hash=replacement_hash,
416
+ created_at=now,
417
+ family_created_at=current.family_created_at,
418
+ last_used_at=now,
419
+ )
420
+ current.revoked_at = now
421
+ current.replaced_by_session_id = replacement_id
422
+ current.last_used_at = now
423
+ session.add(_refresh_row(replacement))
424
+ return RefreshRotationResult(RefreshRotationStatus.ROTATED, replacement)
425
+
426
+ async def revoke(self, token_hash: str, *, now: datetime) -> None:
427
+ async with self._session() as session, session.begin():
428
+ family_id = await session.scalar(
429
+ select(RefreshSessionRow.family_id).where(
430
+ RefreshSessionRow.token_hash == token_hash
431
+ )
432
+ )
433
+ if family_id is None:
434
+ return
435
+ await _lock_refresh_family(session, family_id)
436
+ current = await session.scalar(
437
+ select(RefreshSessionRow)
438
+ .where(RefreshSessionRow.token_hash == token_hash)
439
+ .with_for_update()
440
+ )
441
+ if current is not None:
442
+ assert current.family_id == family_id
443
+ await _revoke_family(session, current.family_id, now=now)
444
+
445
+ def _session(self) -> PlatformAsyncSession:
446
+ return PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
447
+
448
+
449
+ async def _revoke_family(
450
+ session: AsyncSession,
451
+ family_id: UUID,
452
+ *,
453
+ now: datetime,
454
+ ) -> None:
455
+ rows = tuple(
456
+ (
457
+ await session.scalars(
458
+ select(RefreshSessionRow)
459
+ .where(RefreshSessionRow.family_id == family_id)
460
+ .with_for_update()
461
+ )
462
+ ).all()
463
+ )
464
+ for row in rows:
465
+ if row.revoked_at is None:
466
+ row.revoked_at = now
467
+
468
+
469
+ async def _lock_refresh_family(session: AsyncSession, family_id: UUID) -> None:
470
+ # family 没有可锁的稳定 parent row;transaction advisory lock 在同一短事务内提供
471
+ # family -> token/family rows 的统一顺序,并随 commit/rollback 自动释放。
472
+ await session.scalar(select(func.pg_advisory_xact_lock(_refresh_family_lock_key(family_id))))
473
+
474
+
475
+ def _refresh_family_lock_key(family_id: UUID) -> int:
476
+ digest = blake2b(
477
+ family_id.bytes,
478
+ digest_size=8,
479
+ person=_REFRESH_FAMILY_LOCK_PERSON,
480
+ ).digest()
481
+ return int.from_bytes(digest, byteorder="big", signed=True)
482
+
483
+
484
+ def _session_is_active(row: RefreshSessionRow, now: datetime) -> bool:
485
+ return (
486
+ row.revoked_at is None
487
+ and row.replaced_by_session_id is None
488
+ and now - row.last_used_at < REFRESH_INACTIVITY_LIFETIME
489
+ and now - row.family_created_at < REFRESH_ABSOLUTE_LIFETIME
490
+ )
491
+
492
+
493
+ def _user_value(row: UserRow) -> IdentityUser:
494
+ return IdentityUser(
495
+ user_id=row.user_id,
496
+ username=row.username,
497
+ normalized_username=row.normalized_username,
498
+ password_hash=row.password_hash,
499
+ is_active=row.is_active,
500
+ failed_access_count=row.failed_access_count,
501
+ lockout_end=row.lockout_end,
502
+ permission_version=row.permission_version,
503
+ )
504
+
505
+
506
+ def _role_value(row: RoleRow) -> IdentityRole:
507
+ return IdentityRole(
508
+ role_id=row.role_id,
509
+ name=row.name,
510
+ normalized_name=row.normalized_name,
511
+ permission_version=row.permission_version,
512
+ )
513
+
514
+
515
+ def _refresh_row(value: RefreshSession) -> RefreshSessionRow:
516
+ return RefreshSessionRow(
517
+ session_id=value.session_id,
518
+ user_id=value.user_id,
519
+ family_id=value.family_id,
520
+ token_hash=value.token_hash,
521
+ created_at=value.created_at,
522
+ family_created_at=value.family_created_at,
523
+ last_used_at=value.last_used_at,
524
+ revoked_at=value.revoked_at,
525
+ replaced_by_session_id=value.replaced_by_session_id,
526
+ )
@@ -0,0 +1,106 @@
1
+ """固定 HS256、完整 required claims 与 Host-owned keyring 的 JWT boundary。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime, timedelta
9
+ from uuid import UUID, uuid4
10
+
11
+ import jwt
12
+
13
+ from ..options import Options
14
+ from .contracts import IdentityOptions
15
+ from .errors import InvalidCredentialsError
16
+
17
+ ACCESS_TOKEN_LIFETIME = timedelta(minutes=15)
18
+ REFRESH_INACTIVITY_LIFETIME = timedelta(days=30)
19
+ REFRESH_ABSOLUTE_LIFETIME = timedelta(days=90)
20
+ ACCESS_TOKEN_TYPE = "access"
21
+ BEARER_TOKEN_TYPE = "Bearer"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class AccessTokenClaims:
26
+ user_id: UUID
27
+ session_id: UUID
28
+
29
+
30
+ class JwtTokenService:
31
+ __slots__ = ("_options",)
32
+
33
+ def __init__(self, options: Options[IdentityOptions]) -> None:
34
+ self._options = options.value
35
+
36
+ def issue(self, user_id: UUID, session_id: UUID, *, now: datetime) -> str:
37
+ issued = _as_utc(now)
38
+ payload = {
39
+ "iss": self._options.issuer,
40
+ "aud": self._options.audience,
41
+ "sub": str(user_id),
42
+ "sid": str(session_id),
43
+ "jti": str(uuid4()),
44
+ "iat": issued,
45
+ "nbf": issued,
46
+ "exp": issued + ACCESS_TOKEN_LIFETIME,
47
+ "typ": ACCESS_TOKEN_TYPE,
48
+ }
49
+ key = self._options.keys[self._options.active_kid].get_secret_value()
50
+ return jwt.encode(
51
+ payload,
52
+ key,
53
+ algorithm="HS256",
54
+ headers={"kid": self._options.active_kid, "typ": "JWT"},
55
+ )
56
+
57
+ def verify(self, token: str) -> AccessTokenClaims:
58
+ try:
59
+ header = jwt.get_unverified_header(token)
60
+ if header.get("alg") != "HS256":
61
+ raise InvalidCredentialsError
62
+ kid = header.get("kid")
63
+ if not isinstance(kid, str) or kid not in self._options.keys:
64
+ raise InvalidCredentialsError
65
+ payload = jwt.decode(
66
+ token,
67
+ self._options.keys[kid].get_secret_value(),
68
+ algorithms=["HS256"],
69
+ issuer=self._options.issuer,
70
+ audience=self._options.audience,
71
+ options={
72
+ "require": ["iss", "aud", "sub", "sid", "jti", "iat", "nbf", "exp", "typ"]
73
+ },
74
+ )
75
+ if payload.get("typ") != ACCESS_TOKEN_TYPE:
76
+ raise InvalidCredentialsError
77
+ user_id = UUID(_required_string_claim(payload, "sub"))
78
+ session_id = UUID(_required_string_claim(payload, "sid"))
79
+ UUID(_required_string_claim(payload, "jti"))
80
+ except InvalidCredentialsError:
81
+ raise
82
+ except Exception:
83
+ raise InvalidCredentialsError from None
84
+ return AccessTokenClaims(user_id=user_id, session_id=session_id)
85
+
86
+
87
+ def new_refresh_token() -> str:
88
+ # token_urlsafe(32) 以 32 random bytes 提供 256-bit entropy。
89
+ return secrets.token_urlsafe(32)
90
+
91
+
92
+ def hash_refresh_token(token: str) -> str:
93
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
94
+
95
+
96
+ def _required_string_claim(payload: dict[str, object], name: str) -> str:
97
+ value = payload.get(name)
98
+ if not isinstance(value, str) or not value:
99
+ raise InvalidCredentialsError
100
+ return value
101
+
102
+
103
+ def _as_utc(value: datetime) -> datetime:
104
+ if value.tzinfo is None:
105
+ raise ValueError("security timestamps must be timezone-aware")
106
+ return value.astimezone(UTC)
@@ -0,0 +1,3 @@
1
+ from .contracts import ApplicationInvocationContext, BackgroundSystemCaller
2
+
3
+ __all__ = ("ApplicationInvocationContext", "BackgroundSystemCaller")