simple-module-users 0.0.38__tar.gz → 0.0.40__tar.gz

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 (260) hide show
  1. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/PKG-INFO +9 -9
  2. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/package.json +1 -1
  3. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/pyproject.toml +9 -9
  4. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/_users_app_builders.py +5 -0
  5. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/_users_oauth_support.py +7 -4
  6. simple_module_users-0.0.40/tests/demo/test_users_demo_credentials.py +147 -0
  7. simple_module_users-0.0.38/tests/test_demo_guard.py → simple_module_users-0.0.40/tests/demo/test_users_demo_guard.py +1 -1
  8. simple_module_users-0.0.38/tests/test_admin_resend_invite.py → simple_module_users-0.0.40/tests/invites/test_users_admin_resend_invite.py +2 -2
  9. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/invites}/test_users_bulk_invite.py +2 -2
  10. simple_module_users-0.0.40/tests/lockout/test_users_lockout_checkpoint.py +114 -0
  11. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/lockout}/test_users_lockout_service.py +9 -9
  12. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_bearer_token_age.py +5 -3
  13. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_bearer_token_expiry.py +4 -2
  14. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_service_admin_crud.py +5 -1
  15. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_sessions_revoke.py +1 -0
  16. simple_module_users-0.0.40/tests/test_users_admin_effective_permissions.py +70 -0
  17. simple_module_users-0.0.40/tests/test_users_admin_privilege_guards.py +167 -0
  18. simple_module_users-0.0.40/tests/test_users_admin_role_grant_rules.py +56 -0
  19. simple_module_users-0.0.40/tests/test_users_default_role.py +75 -0
  20. simple_module_users-0.0.40/tests/test_users_deletion_events.py +196 -0
  21. simple_module_users-0.0.40/tests/test_users_domain_errors.py +40 -0
  22. simple_module_users-0.0.40/tests/test_users_login_session_rotation.py +136 -0
  23. simple_module_users-0.0.38/tests/test_user_manager.py → simple_module_users-0.0.40/tests/test_users_manager.py +0 -33
  24. simple_module_users-0.0.40/tests/test_users_oauth_email_claim.py +144 -0
  25. simple_module_users-0.0.40/tests/test_users_oauth_provider_identity.py +65 -0
  26. simple_module_users-0.0.40/tests/test_users_refresh_reuse.py +74 -0
  27. simple_module_users-0.0.40/tests/test_users_register_single_connection.py +55 -0
  28. simple_module_users-0.0.40/tests/test_users_startup_role.py +83 -0
  29. simple_module_users-0.0.40/tests/test_users_token_hash_migration.py +60 -0
  30. simple_module_users-0.0.40/tests/test_users_token_pruning.py +176 -0
  31. simple_module_users-0.0.40/tests/test_users_token_storage.py +104 -0
  32. simple_module_users-0.0.40/tests/test_users_update_schema.py +11 -0
  33. simple_module_users-0.0.40/tests-js/users_VerifyEmail.test.tsx +65 -0
  34. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/api.py +15 -6
  35. simple_module_users-0.0.40/users/admin/events.py +11 -0
  36. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/links.py +2 -1
  37. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/queries.py +8 -2
  38. simple_module_users-0.0.40/users/admin/safety.py +153 -0
  39. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/service.py +29 -19
  40. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/api.py +7 -2
  41. simple_module_users-0.0.38/users/auth_local/lockout.py → simple_module_users-0.0.40/users/auth_local/lockout/__init__.py +38 -121
  42. simple_module_users-0.0.40/users/auth_local/lockout/attempt.py +87 -0
  43. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/self_account.py +5 -4
  44. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/token_api.py +58 -24
  45. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/token_preview.py +1 -1
  46. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/views.py +4 -2
  47. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/bootstrap.py +31 -27
  48. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/constants.py +6 -0
  49. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/contracts/events.py +10 -5
  50. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/contracts/schemas.py +0 -3
  51. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/db_adapter.py +11 -1
  52. simple_module_users-0.0.38/users/demo.py → simple_module_users-0.0.40/users/demo/__init__.py +7 -4
  53. simple_module_users-0.0.38/users/auth_local/demo_api.py → simple_module_users-0.0.40/users/demo/api.py +1 -1
  54. simple_module_users-0.0.38/users/demo_guard.py → simple_module_users-0.0.40/users/demo/guard.py +39 -13
  55. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/deps.py +5 -2
  56. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/directory.py +26 -25
  57. simple_module_users-0.0.40/users/errors.py +29 -0
  58. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/exceptions.py +27 -4
  59. simple_module_users-0.0.40/users/invites/__init__.py +1 -0
  60. simple_module_users-0.0.38/users/admin/bulk_invite.py → simple_module_users-0.0.40/users/invites/bulk.py +23 -39
  61. simple_module_users-0.0.38/users/admin/resend_invite.py → simple_module_users-0.0.40/users/invites/resend.py +5 -5
  62. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/manager.py +11 -9
  63. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/access_token.py +1 -1
  64. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/refresh_token.py +2 -1
  65. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/module.py +9 -8
  66. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/oauth/api.py +18 -19
  67. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/oauth/providers.py +41 -1
  68. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/useEditUserForm.ts +2 -16
  69. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/VerifyEmail.tsx +16 -3
  70. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/api.generated.ts +1 -1
  71. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/provider.py +2 -1
  72. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/registration.py +17 -11
  73. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/session_revocation.py +3 -1
  74. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/shared_props.py +1 -1
  75. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/startup.py +18 -6
  76. simple_module_users-0.0.40/users/token_storage.py +51 -0
  77. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/token_strategy.py +17 -5
  78. simple_module_users-0.0.38/tests/test_users_default_role.py +0 -82
  79. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/.gitignore +0 -0
  80. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/LICENSE +0 -0
  81. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/README.md +0 -0
  82. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/.gitkeep +0 -0
  83. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/_demo_support.py +0 -0
  84. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/_middleware_support.py +0 -0
  85. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/conftest.py +0 -0
  86. /simple_module_users-0.0.38/tests/test_demo_account.py → /simple_module_users-0.0.40/tests/demo/test_users_demo_account.py +0 -0
  87. /simple_module_users-0.0.38/tests/test_demo_login.py → /simple_module_users-0.0.40/tests/demo/test_users_demo_login.py +0 -0
  88. /simple_module_users-0.0.38/tests/test_bulk_invite_message.py → /simple_module_users-0.0.40/tests/invites/test_users_bulk_invite_message.py +0 -0
  89. /simple_module_users-0.0.38/tests/test_invite_flow.py → /simple_module_users-0.0.40/tests/invites/test_users_invite_flow.py +0 -0
  90. /simple_module_users-0.0.38/tests/test_invite_reuse.py → /simple_module_users-0.0.40/tests/invites/test_users_invite_reuse.py +0 -0
  91. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/invites}/test_users_list_invites.py +0 -0
  92. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/lockout}/test_users_admin_unlock.py +0 -0
  93. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/lockout}/test_users_lockout_columns.py +0 -0
  94. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/lockout}/test_users_lockout_enumeration.py +0 -0
  95. {simple_module_users-0.0.38/tests → simple_module_users-0.0.40/tests/lockout}/test_users_login_lockout.py +0 -0
  96. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_access_token_model.py +0 -0
  97. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_activity_summary.py +0 -0
  98. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_api_admin.py +0 -0
  99. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_api_admin_crud.py +0 -0
  100. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_api_admin_filters.py +0 -0
  101. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_api_admin_pagination_bounds.py +0 -0
  102. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_api_auth.py +0 -0
  103. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_auth_screens_props.py +0 -0
  104. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_backend.py +0 -0
  105. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_bootstrap.py +0 -0
  106. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_bootstrap_resolution.py +0 -0
  107. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_cli.py +0 -0
  108. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_constants.py +0 -0
  109. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_db_adapter.py +0 -0
  110. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_edit_recent_activity.py +0 -0
  111. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_external_users.py +0 -0
  112. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_legacy_cached_tenant.py +0 -0
  113. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_legacy_session_expiry.py +0 -0
  114. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_login_redirect_root_mount.py +0 -0
  115. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_mailer.py +0 -0
  116. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_negative_authz.py +0 -0
  117. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_oauth.py +0 -0
  118. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_oauth_routes.py +0 -0
  119. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_profile_password.py +0 -0
  120. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_rate_limit.py +0 -0
  121. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_remember_me.py +0 -0
  122. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_revocation_broadcast.py +0 -0
  123. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_role_model.py +0 -0
  124. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_service_admin.py +0 -0
  125. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_session_expiry.py +0 -0
  126. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_session_revocation.py +0 -0
  127. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_session_version_atomic.py +0 -0
  128. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_session_version_cache.py +0 -0
  129. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_session_version_failopen.py +0 -0
  130. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_session_version_hardening.py +0 -0
  131. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_settings.py +0 -0
  132. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_token_api.py +0 -0
  133. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_user_model.py +0 -0
  134. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_user_role_model.py +0 -0
  135. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_user_service.py +0 -0
  136. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_user_state.py +0 -0
  137. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_admin_session_revocation.py +0 -0
  138. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_bearer_scheme.py +0 -0
  139. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_deps.py +0 -0
  140. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_directory.py +0 -0
  141. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_login_deep_link.py +0 -0
  142. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_mailer_core_sender.py +0 -0
  143. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_middleware.py +0 -0
  144. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_middleware_public_paths.py +0 -0
  145. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_middleware_resolvers.py +0 -0
  146. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_oauth_signup_gate.py +0 -0
  147. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_oauth_unverified_link.py +0 -0
  148. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_password_change_keeps_api.py +0 -0
  149. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_provider.py +0 -0
  150. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_register_role_durable.py +0 -0
  151. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_require_verification.py +0 -0
  152. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_reset_link_origin.py +0 -0
  153. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_seed_role_migration.py +0 -0
  154. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_setup_lock.py +0 -0
  155. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_setup_wizard.py +0 -0
  156. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_users_shared_props.py +0 -0
  157. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_views.py +0 -0
  158. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests/test_views_admin.py +0 -0
  159. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/AccountCardLocked.test.tsx +0 -0
  160. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/EmailChipInput.test.tsx +0 -0
  161. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/LoginForm.test.tsx +0 -0
  162. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/deriveState.test.ts +0 -0
  163. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/invite-emails.test.ts +0 -0
  164. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/registerErrors.test.ts +0 -0
  165. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/users_Login.test.tsx +0 -0
  166. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/users_Register.test.tsx +0 -0
  167. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/users_actions.test.tsx +0 -0
  168. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/users_fixtures.ts +0 -0
  169. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tests-js/users_profile_cards.test.tsx +0 -0
  170. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/tsconfig.json +0 -0
  171. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/__init__.py +0 -0
  172. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/__init__.py +0 -0
  173. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/IndexFilters.tsx +0 -0
  174. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/RolesTab.tsx +0 -0
  175. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/StatusPill.tsx +0 -0
  176. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/UserCards.tsx +0 -0
  177. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/UserRow.tsx +0 -0
  178. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/UserRowMenu.tsx +0 -0
  179. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/UsersEmpty.tsx +0 -0
  180. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/UsersTable.tsx +0 -0
  181. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/useUserRowActions.ts +0 -0
  182. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/components/user-list-item.ts +0 -0
  183. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/recent_activity.py +0 -0
  184. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/user_state.py +0 -0
  185. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/admin/views.py +0 -0
  186. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/audit.py +0 -0
  187. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/__init__.py +0 -0
  188. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/AuthField.tsx +0 -0
  189. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/AuthIntro.tsx +0 -0
  190. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/AuthStateCard.tsx +0 -0
  191. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/DemoSignIn.tsx +0 -0
  192. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/InviteSummary.tsx +0 -0
  193. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/LoginForm.tsx +0 -0
  194. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/PasswordCard.tsx +0 -0
  195. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/PasswordFields.tsx +0 -0
  196. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/PreferencesCard.tsx +0 -0
  197. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  198. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/ResendVerification.tsx +0 -0
  199. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  200. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/SessionsCard.tsx +0 -0
  201. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  202. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/components/profile-user.ts +0 -0
  203. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/rate_limit.py +0 -0
  204. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/auth_local/registerErrors.ts +0 -0
  205. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/backend.py +0 -0
  206. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/cli.py +0 -0
  207. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/contracts/__init__.py +0 -0
  208. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/contracts/roles.py +0 -0
  209. /simple_module_users-0.0.38/users/auth_local/invite_preview.py → /simple_module_users-0.0.40/users/invites/preview.py +0 -0
  210. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/locales/de.json +0 -0
  211. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/locales/en.json +0 -0
  212. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/mailer/__init__.py +0 -0
  213. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/mailer/templated.py +0 -0
  214. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/mailer/templates/.gitkeep +0 -0
  215. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/mailer/templates/invite.txt +0 -0
  216. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/mailer/templates/reset_password.txt +0 -0
  217. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/mailer/templates/verify_email.txt +0 -0
  218. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/middleware.py +0 -0
  219. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/__init__.py +0 -0
  220. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/_base.py +0 -0
  221. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/oauth_account.py +0 -0
  222. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/role.py +0 -0
  223. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/user.py +0 -0
  224. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/models/user_role.py +0 -0
  225. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/oauth/__init__.py +0 -0
  226. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/.gitkeep +0 -0
  227. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/AcceptInvite.tsx +0 -0
  228. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/ForgotPassword.tsx +0 -0
  229. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Login.tsx +0 -0
  230. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Profile.tsx +0 -0
  231. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Register.tsx +0 -0
  232. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/ResetPassword.tsx +0 -0
  233. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/AddPeople.tsx +0 -0
  234. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/Edit.tsx +0 -0
  235. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/Index.tsx +0 -0
  236. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/AccountCard.tsx +0 -0
  237. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  238. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/DangerZone.tsx +0 -0
  239. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/DetailsCard.tsx +0 -0
  240. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  241. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  242. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/InviteFields.tsx +0 -0
  243. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/InviteResults.tsx +0 -0
  244. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/MailerBanner.tsx +0 -0
  245. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  246. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/RolePicker.tsx +0 -0
  247. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/UserStats.tsx +0 -0
  248. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/useAddPeople.ts +0 -0
  249. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/components/useUserActions.ts +0 -0
  250. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/Users/invite-emails.ts +0 -0
  251. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/api.ts +0 -0
  252. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/failureMessage.ts +0 -0
  253. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/pages/signIn.ts +0 -0
  254. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/py.typed +0 -0
  255. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/roles_cache.py +0 -0
  256. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/session_version_cache.py +0 -0
  257. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/settings.py +0 -0
  258. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/setup.py +0 -0
  259. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/setup_action.py +0 -0
  260. {simple_module_users-0.0.38 → simple_module_users-0.0.40}/users/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.38
3
+ Version: 0.0.40
4
4
  Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,15 +21,15 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: cachetools>=5.3
24
+ Requires-Dist: cachetools<8,>=5.3
25
25
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
26
- Requires-Dist: simple-module-auth==0.0.38
27
- Requires-Dist: simple-module-core==0.0.38
28
- Requires-Dist: simple-module-db==0.0.38
29
- Requires-Dist: simple-module-hosting==0.0.38
30
- Requires-Dist: simple-module-mail==0.0.38
31
- Requires-Dist: simple-module-settings==0.0.38
32
- Requires-Dist: typer>=0.12
26
+ Requires-Dist: simple-module-auth==0.0.40
27
+ Requires-Dist: simple-module-core==0.0.40
28
+ Requires-Dist: simple-module-db==0.0.40
29
+ Requires-Dist: simple-module-hosting==0.0.40
30
+ Requires-Dist: simple-module-mail==0.0.40
31
+ Requires-Dist: simple-module-settings==0.0.40
32
+ Requires-Dist: typer<0.28,>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # simple_module_users
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/users",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Users module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.38"
3
+ version = "0.0.40"
4
4
  description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,20 +21,20 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.38",
25
- "simple_module_db==0.0.38",
26
- "simple_module_hosting==0.0.38",
27
- "simple_module_settings==0.0.38",
28
- "simple_module_auth==0.0.38",
24
+ "simple_module_core==0.0.40",
25
+ "simple_module_db==0.0.40",
26
+ "simple_module_hosting==0.0.40",
27
+ "simple_module_settings==0.0.40",
28
+ "simple_module_auth==0.0.40",
29
29
  # Sends through the framework mail sender; `mail` owns the SMTP transport
30
30
  # (GH #496), so an upgrade keeps delivering mail.
31
- "simple_module_mail==0.0.38",
31
+ "simple_module_mail==0.0.40",
32
32
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
33
33
  # fields after construction (see reconfigure_cookie_transport in backend.py).
34
34
  # Bumping the major version requires re-checking those field names.
35
35
  "fastapi-users[sqlalchemy,oauth]>=15,<16",
36
- "cachetools>=5.3",
37
- "typer>=0.12",
36
+ "cachetools>=5.3,<8",
37
+ "typer>=0.12,<0.28",
38
38
  ]
39
39
 
40
40
  [project.entry-points.simple_module]
@@ -84,6 +84,11 @@ async def _seed_roles(application) -> None:
84
84
  if "user" not in existing:
85
85
  session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user"))
86
86
  await session.commit()
87
+ # Direct inserts bypass the role service that would refresh the cache; startup
88
+ # already warmed it (with the role it seeds), so refresh like a real write would.
89
+ from users.roles_cache import refresh_roles_cache
90
+
91
+ await refresh_roles_cache(application)
87
92
 
88
93
 
89
94
  async def _seed_users_settings(
@@ -25,12 +25,15 @@ class _FakeOAuthClient:
25
25
  async def get_id_email(self, access_token: str) -> tuple[str, str]:
26
26
  return self._account_id, self._account_email
27
27
 
28
+ async def get_profile(self, access_token: str) -> dict:
29
+ return {"sub": self._account_id, "email": self._account_email, "email_verified": True}
30
+
28
31
 
29
32
  def install_fake_provider(app, account_id: str, account_email: str) -> None:
30
33
  from users.oauth import OAuthProvider
31
34
 
32
- app.state.users.oauth_clients["microsoft"] = OAuthProvider(
33
- "microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
35
+ app.state.users.oauth_clients["oidc"] = OAuthProvider(
36
+ "oidc", "OIDC", _FakeOAuthClient(account_id, account_email)
34
37
  )
35
38
 
36
39
 
@@ -70,11 +73,11 @@ async def seed_password_user(app, email: str, **fields) -> uuid.UUID:
70
73
 
71
74
  async def oauth_callback(client: httpx.AsyncClient) -> httpx.Response:
72
75
  """Drive /login then /callback; return the callback response."""
73
- login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
76
+ login = await client.get("/api/users/auth/oidc/login", follow_redirects=False)
74
77
  assert login.status_code == 302
75
78
  state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
76
79
  return await client.get(
77
- f"/api/users/auth/microsoft/callback?code=abc&state={state}",
80
+ f"/api/users/auth/oidc/callback?code=abc&state={state}",
78
81
  follow_redirects=False,
79
82
  )
80
83
 
@@ -0,0 +1,147 @@
1
+ """Demo read-only mode follows identities authenticated without demo cookies."""
2
+
3
+ import httpx
4
+ import pytest
5
+ from api_tokens.constants import TokenKind
6
+ from api_tokens.service import ApiTokenService
7
+ from simple_module_test import forge_session_cookie
8
+ from users.auth_local.token_api import _create_token_pair
9
+ from users.demo import ensure_demo_users
10
+ from users.models import User
11
+
12
+
13
+ @pytest.fixture
14
+ async def demo_credentials(demo_app):
15
+ settings = demo_app.state.users.settings
16
+ settings.demo_admin_password = "DemoPass1!"
17
+ await ensure_demo_users(demo_app)
18
+ user_id = demo_app.state.users.demo_user_ids[0]
19
+ async with demo_app.state.sm.db.session_factory() as db:
20
+ pair = await _create_token_pair(db, user_id, settings)
21
+ _, pat = await ApiTokenService(db).create(
22
+ kind=TokenKind.PERSONAL,
23
+ name="pre-existing demo PAT",
24
+ scopes=["users.manage", "api_tokens.use"],
25
+ expires_in_days=1,
26
+ max_lifetime_days=30,
27
+ owner_user_id=user_id,
28
+ )
29
+ await db.commit()
30
+ return {"bearer": pair.access_token, "pat": pat, "refresh": pair.refresh_token}
31
+
32
+
33
+ @pytest.mark.parametrize("credential", ["bearer", "pat"])
34
+ @pytest.mark.parametrize("read_only", [True, False])
35
+ async def test_demo_token_writes_follow_read_only_setting(
36
+ demo_app, demo_client, demo_credentials, credential, read_only
37
+ ):
38
+ demo_app.state.users.settings.demo_read_only = read_only
39
+ headers = {"Authorization": f"Bearer {demo_credentials[credential]}"}
40
+ # A successful read proves this credential actually resolves the demo identity.
41
+ assert (await demo_client.get("/api/users/admin", headers=headers)).status_code == 200
42
+ # A role-less account: the PAT's scopes (users.manage, api_tokens.use) cover it, so
43
+ # only the demo read-only gate decides. A target holding permissions beyond the
44
+ # token's scopes is refused regardless (#624).
45
+ async with demo_app.state.sm.db.session_factory() as db:
46
+ plain = User(email="plain@example.com", hashed_password=None, is_external=True)
47
+ db.add(plain)
48
+ await db.commit()
49
+ target = str(plain.id)
50
+ response = await demo_client.patch(
51
+ f"/api/users/admin/{target}",
52
+ json={"email": "plain@example.com", "full_name": "Changed"},
53
+ headers=headers,
54
+ )
55
+ assert response.status_code == (403 if read_only else 200), response.text
56
+ if read_only:
57
+ assert response.json() == {"detail": "DEMO_READ_ONLY"}
58
+ else:
59
+ assert response.json()["full_name"] == "Changed"
60
+
61
+
62
+ @pytest.mark.parametrize("credential", ["bearer", "pat"])
63
+ async def test_demo_token_inertia_write_uses_existing_refusal(
64
+ demo_app, demo_client, demo_credentials, credential
65
+ ):
66
+ response = await demo_client.patch(
67
+ f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
68
+ json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
69
+ headers={
70
+ "Authorization": f"Bearer {demo_credentials[credential]}",
71
+ "X-Inertia": "true",
72
+ "Referer": "http://testserver/admin/users/",
73
+ },
74
+ )
75
+ assert response.status_code == 409
76
+ assert response.headers["X-Inertia-Location"] == "http://testserver/admin/users/"
77
+
78
+
79
+ @pytest.mark.parametrize("operation", ["login", "refresh"])
80
+ async def test_demo_cannot_mint_token_pair_without_session(
81
+ demo_app, demo_client, demo_credentials, operation
82
+ ):
83
+ if operation == "login":
84
+ path = "/api/users/auth/token"
85
+ body = {"email": demo_app.state.users.settings.demo_admin_email, "password": "DemoPass1!"}
86
+ else:
87
+ path = "/api/users/auth/token/refresh"
88
+ body = {"refresh_token": demo_credentials["refresh"]}
89
+ response = await demo_client.post(path, json=body)
90
+ assert response.status_code == 403, response.text
91
+ assert response.json() == {"detail": "DEMO_READ_ONLY"}
92
+ # Refusal must not consume the refresh token or leave a login lockout slot.
93
+ demo_app.state.users.settings.demo_read_only = False
94
+ assert (await demo_client.post(path, json=body)).status_code == 200
95
+
96
+
97
+ async def test_demo_session_cannot_create_pat(demo_app):
98
+ cookie = forge_session_cookie(
99
+ str(demo_app.state.sm.settings.secret_key),
100
+ {"user_id": str(demo_app.state.users.demo_user_ids[0]), "sm_csrf_token": "csrf"},
101
+ )
102
+ async with httpx.AsyncClient(
103
+ transport=httpx.ASGITransport(app=demo_app),
104
+ base_url="http://testserver",
105
+ cookies={"session": cookie},
106
+ ) as client:
107
+ response = await client.post(
108
+ "/api/api_tokens/tokens/",
109
+ json={"name": "demo", "scopes": ["users.manage"], "expires_in_days": 1},
110
+ headers={"X-CSRF-Token": "csrf"},
111
+ )
112
+ assert response.status_code == 403
113
+ assert response.json() == {"detail": "DEMO_READ_ONLY"}
114
+
115
+
116
+ async def test_real_bearer_account_can_still_write_on_demo_instance(demo_app, demo_client):
117
+ response = await demo_client.post(
118
+ "/api/users/auth/token", json={"email": "admin@example.com", "password": "AdminPass1!"}
119
+ )
120
+ assert response.status_code == 200
121
+ response = await demo_client.patch(
122
+ f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
123
+ json={
124
+ "email": demo_app.state.users.settings.demo_user_email,
125
+ "full_name": "Real admin write",
126
+ },
127
+ headers={"Authorization": f"Bearer {response.json()['access_token']}"},
128
+ )
129
+ assert response.status_code == 200
130
+ assert response.json()["full_name"] == "Real admin write"
131
+
132
+
133
+ async def test_demo_token_login_still_checks_password(demo_app, demo_client, demo_credentials):
134
+ response = await demo_client.post(
135
+ "/api/users/auth/token",
136
+ json={"email": demo_app.state.users.settings.demo_admin_email, "password": "wrong"},
137
+ )
138
+ assert response.status_code == 401
139
+
140
+
141
+ @pytest.mark.parametrize("credential", ["bearer", "pat"])
142
+ async def test_demo_token_can_still_logout(demo_client, demo_credentials, credential):
143
+ response = await demo_client.post(
144
+ "/api/users/auth/logout",
145
+ headers={"Authorization": f"Bearer {demo_credentials[credential]}"},
146
+ )
147
+ assert response.status_code == 204
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import httpx
10
10
  from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME
11
11
  from users.demo import SESSION_DEMO_KEY
12
- from users.demo_guard import DEMO_READ_ONLY_DETAIL
12
+ from users.demo.guard import DEMO_READ_ONLY_DETAIL
13
13
 
14
14
 
15
15
  def _demo_route(role: str) -> str:
@@ -202,7 +202,7 @@ class TestMailerFailureIsNotEchoed:
202
202
  async def test_the_admin_gets_the_link_and_a_generic_reason(
203
203
  self, admin_client, users_app, caplog
204
204
  ):
205
- from users.admin.bulk_invite import MAILER_FAILURE_DETAIL
205
+ from users.invites.bulk import MAILER_FAILURE_DETAIL
206
206
 
207
207
  class _BrokenMailer(_SpyMailer):
208
208
  async def send_invite(self, email, token, invited_by_name, message=None):
@@ -212,7 +212,7 @@ class TestMailerFailureIsNotEchoed:
212
212
  original = users_app.state.users.mailer
213
213
  users_app.state.users.mailer = _BrokenMailer()
214
214
  try:
215
- with caplog.at_level(logging.ERROR, logger="users.admin.resend_invite"):
215
+ with caplog.at_level(logging.ERROR, logger="users.invites.resend"):
216
216
  resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
217
217
  finally:
218
218
  users_app.state.users.mailer = original
@@ -139,7 +139,7 @@ class TestBulkInvite:
139
139
 
140
140
  async def test_address_count_is_capped(self, authenticated_client: httpx.AsyncClient):
141
141
  """One submit must not be able to mint unbounded live invite tokens."""
142
- from users.admin.bulk_invite import MAX_ADDRESSES
142
+ from users.invites.bulk import MAX_ADDRESSES
143
143
 
144
144
  emails = [f"bulk{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
145
145
  resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
@@ -151,7 +151,7 @@ class TestBulkInvite:
151
151
  ):
152
152
  """Silent truncation reports "100 invites sent" while 5 people are
153
153
  never contacted, with nothing on screen saying so."""
154
- from users.admin.bulk_invite import MAX_ADDRESSES
154
+ from users.invites.bulk import MAX_ADDRESSES
155
155
 
156
156
  emails = [f"over{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
157
157
  resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
@@ -0,0 +1,114 @@
1
+ """Checkpoint isolation on file SQLite, where overlapping writers cannot hide."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ import pytest
6
+ from _users_app_builders import ALICE, _build_users_app, _client, _make_standard_user, user_row
7
+ from pwdlib.hashers.bcrypt import BcryptHasher
8
+ from simple_module_core.rate_limit import InProcessWindowStore
9
+ from sqlalchemy import select
10
+ from users.auth_local.lockout import LoginAttempt, admit
11
+ from users.models import Role, User
12
+
13
+
14
+ @pytest.fixture
15
+ async def checkpoint_app(monkeypatch, tmp_path):
16
+ app, context = await _build_users_app(
17
+ monkeypatch,
18
+ allow_signup=False,
19
+ database_url=f"sqlite+aiosqlite:///{tmp_path / 'auth-checkpoint.db'}",
20
+ )
21
+ await _make_standard_user(app, ALICE)
22
+ try:
23
+ yield app
24
+ finally:
25
+ await context.__aexit__(None, None, None)
26
+
27
+
28
+ async def test_admission_never_flushes_or_commits_unrelated_request_work(checkpoint_app):
29
+ observed = []
30
+ factory = checkpoint_app.state.sm.db.session_factory
31
+ attempt = LoginAttempt.build(
32
+ ALICE,
33
+ "1.2.3.4",
34
+ InProcessWindowStore(),
35
+ checkpoint_app.state.users.settings,
36
+ datetime.now(UTC),
37
+ )
38
+ async with factory() as request:
39
+ pending = Role(name="pending-request-role")
40
+ request.add(pending)
41
+ request.on_commit(lambda: observed.append("request"))
42
+ admission = await admit(request, attempt)
43
+ assert admission.count == 1
44
+ assert pending in request.new
45
+ assert observed == []
46
+ await request.rollback()
47
+ assert (await user_row(checkpoint_app, ALICE)).failed_login_count == 1
48
+ async with factory() as reader:
49
+ assert (
50
+ await reader.execute(select(Role).where(Role.name == "pending-request-role"))
51
+ ).scalar_one_or_none() is None
52
+
53
+
54
+ async def test_unverified_legacy_password_releases_slot_without_overlapping_writers(checkpoint_app):
55
+ factory = checkpoint_app.state.sm.db.session_factory
56
+ async with factory() as session:
57
+ user = (await session.execute(select(User).where(User.email == ALICE))).scalar_one()
58
+ user.is_verified = False
59
+ user.hashed_password = BcryptHasher(rounds=4).hash("UserPass1!")
60
+ await session.commit()
61
+ checkpoint_app.state.users.settings = checkpoint_app.state.users.settings.model_copy(
62
+ update={"require_verification": True}
63
+ )
64
+ async with _client(checkpoint_app, "1.2.3.4") as client:
65
+ response = await client.post(
66
+ "/api/users/auth/token", json={"email": ALICE, "password": "UserPass1!"}
67
+ )
68
+ assert response.status_code == 400, response.text
69
+ assert response.json()["detail"] == "LOGIN_USER_NOT_VERIFIED"
70
+ assert (await user_row(checkpoint_app, ALICE)).failed_login_count == 0
71
+
72
+
73
+ @pytest.mark.parametrize("kind", ["cookie", "token"])
74
+ async def test_unverified_logins_release_request_connections_before_checkpoint(
75
+ checkpoint_app, monkeypatch, kind
76
+ ):
77
+ import asyncio
78
+
79
+ from users.auth_local import api, token_api
80
+
81
+ # SQLite's default pool has five connections plus ten overflow connections.
82
+ emails = [f"unverified-{i}@example.com" for i in range(15)]
83
+ for email in emails:
84
+ await _make_standard_user(checkpoint_app, email)
85
+ async with checkpoint_app.state.sm.db.session_factory() as session:
86
+ for user in (await session.execute(select(User).where(User.email.in_(emails)))).scalars():
87
+ user.is_verified = False
88
+ await session.commit()
89
+ checkpoint_app.state.users.settings = checkpoint_app.state.users.settings.model_copy(
90
+ update={"require_verification": True}
91
+ )
92
+ endpoint = api if kind == "cookie" else token_api
93
+ real_release = endpoint.release
94
+ ready = asyncio.Barrier(len(emails))
95
+
96
+ async def together(session, attempt, admission):
97
+ await ready.wait()
98
+ return await real_release(session, attempt, admission)
99
+
100
+ monkeypatch.setattr(endpoint, "release", together)
101
+
102
+ async def login(email):
103
+ async with _client(checkpoint_app, "1.2.3.4") as client:
104
+ if kind == "cookie":
105
+ return await client.post(
106
+ "/api/users/auth/login", data={"username": email, "password": "UserPass1!"}
107
+ )
108
+ return await client.post(
109
+ "/api/users/auth/token", json={"email": email, "password": "UserPass1!"}
110
+ )
111
+
112
+ responses = await asyncio.wait_for(asyncio.gather(*(login(email) for email in emails)), 5)
113
+ assert [response.status_code for response in responses] == [400] * 15
114
+ assert all(response.json()["detail"] == "LOGIN_USER_NOT_VERIFIED" for response in responses)
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from datetime import UTC, datetime, timedelta
6
6
 
7
7
  from _users_app_builders import ALICE
8
- from fastapi import HTTPException
9
8
  from simple_module_core.rate_limit import InProcessWindowStore
10
9
  from sqlalchemy import select
11
10
  from users.auth_local import lockout
@@ -16,6 +15,7 @@ from users.auth_local.lockout import (
16
15
  record_failure,
17
16
  record_success,
18
17
  )
18
+ from users.exceptions import LoginLockedError
19
19
  from users.models import User
20
20
  from users.settings import UsersSettings
21
21
 
@@ -38,17 +38,17 @@ async def _fail(session, attempt: LoginAttempt) -> int:
38
38
  """One wrong password: admit, then settle. Returns the status it would answer."""
39
39
  try:
40
40
  admission = await admit(session, attempt)
41
- except HTTPException as exc:
42
- return exc.status_code
41
+ except LoginLockedError:
42
+ return 429
43
43
  locked = await record_failure(session, attempt, admission)
44
- return locked.status_code if locked else 400
44
+ return 429 if locked else 400
45
45
 
46
46
 
47
47
  async def _admit_or_status(session, attempt: LoginAttempt) -> Admission | int:
48
48
  try:
49
49
  return await admit(session, attempt)
50
- except HTTPException as exc:
51
- return exc.status_code
50
+ except LoginLockedError:
51
+ return 429
52
52
 
53
53
 
54
54
  async def _row(users_db, user) -> User:
@@ -160,7 +160,7 @@ async def test_success_mid_burst_does_not_lift_a_lock(users_db, alice) -> None:
160
160
  owner_slot = await admit(users_db, owner) # admitted before the lock
161
161
  attacker = _attempt(ip="10.0.0.3", store=store, account=3)
162
162
  locked = await record_failure(users_db, attacker, await admit(users_db, attacker))
163
- assert locked is not None and locked.status_code == 429
163
+ assert isinstance(locked, LoginLockedError)
164
164
 
165
165
  await record_success(users_db, owner, owner_slot) # the owner's password lands last
166
166
  await users_db.commit()
@@ -188,8 +188,8 @@ async def test_lock_landing_after_the_read_reports_the_time_left(users_db, alice
188
188
  mid = _attempt(now=T0 + timedelta(seconds=300), ip="9.9.9.9", account=3)
189
189
  try:
190
190
  await lockout._admit_account(users_db, mid, [None]) # read before the lock landed
191
- except HTTPException as exc:
192
- assert (exc.status_code, exc.headers) == (429, {"Retry-After": "600"})
191
+ except LoginLockedError as exc:
192
+ assert exc.retry_after == 600
193
193
  else:
194
194
  raise AssertionError("a locked row was admitted")
195
195
 
@@ -9,6 +9,7 @@ clause or the two disagree about what "expired" means.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import hashlib
12
13
  import uuid
13
14
  from datetime import UTC, datetime, timedelta
14
15
 
@@ -27,17 +28,18 @@ async def _mint(users_app) -> str:
27
28
  user_id = (
28
29
  await session.execute(select(User.id).where(User.email == "admin@example.com"))
29
30
  ).scalar_one()
30
- token = UserAccessToken(token=uuid.uuid4().hex, user_id=user_id)
31
+ secret = uuid.uuid4().hex
32
+ token = UserAccessToken(token=hashlib.sha256(secret.encode()).hexdigest(), user_id=user_id)
31
33
  session.add(token)
32
34
  await session.commit()
33
- return token.token
35
+ return secret
34
36
 
35
37
 
36
38
  async def _backdate(users_app, token: str, *, seconds: int) -> None:
37
39
  async with users_app.state.sm.db.session_factory() as session:
38
40
  await session.execute(
39
41
  update(UserAccessToken)
40
- .where(UserAccessToken.token == token)
42
+ .where(UserAccessToken.token == hashlib.sha256(token.encode()).hexdigest())
41
43
  .values(created_at=datetime.now(UTC) - timedelta(seconds=seconds))
42
44
  )
43
45
  await session.commit()
@@ -17,6 +17,7 @@ under, and both bearer read paths enforce them: ``ExpiringDatabaseStrategy``
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import hashlib
20
21
  import uuid
21
22
  from datetime import UTC, datetime, timedelta
22
23
 
@@ -78,7 +79,7 @@ async def _expire(app, token: str) -> None:
78
79
  out.
79
80
  """
80
81
  async with app.state.sm.db.session_factory() as session:
81
- row = await session.get(UserAccessToken, token)
82
+ row = await session.get(UserAccessToken, hashlib.sha256(token.encode()).hexdigest())
82
83
  row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
83
84
  await session.commit()
84
85
 
@@ -234,7 +235,8 @@ class TestTheMintedCounterIsCurrent:
234
235
  row = (
235
236
  await session.execute(
236
237
  select(UserAccessToken).where(
237
- UserAccessToken.token == rotated.json()["access_token"]
238
+ UserAccessToken.token
239
+ == hashlib.sha256(rotated.json()["access_token"].encode()).hexdigest()
238
240
  )
239
241
  )
240
242
  ).scalar_one()
@@ -144,7 +144,11 @@ async def test_delete_user_removes_user_and_all_child_rows(users_app):
144
144
  account_email="todelete@example.com",
145
145
  )
146
146
  )
147
- session.add(RefreshToken(user_id=user.id, expires_at=datetime.now(UTC) + timedelta(days=1)))
147
+ session.add(
148
+ RefreshToken(
149
+ token="a" * 64, user_id=user.id, expires_at=datetime.now(UTC) + timedelta(days=1)
150
+ )
151
+ )
148
152
  await session.flush()
149
153
 
150
154
  svc = _build_service(session, users_app)
@@ -104,6 +104,7 @@ class TestRevokeAll:
104
104
 
105
105
  async with users_app.state.sm.db.session_factory() as session:
106
106
  token = RefreshToken(
107
+ token="a" * 64,
107
108
  user_id=signed_in_admin,
108
109
  expires_at=datetime.now(UTC) + timedelta(days=1),
109
110
  )
@@ -0,0 +1,70 @@
1
+ """Delegated management compares effective permissions (#624): direct grants and token scopes."""
2
+
3
+ import httpx
4
+ import pytest
5
+ from api_tokens.constants import TokenKind
6
+ from api_tokens.service import ApiTokenService
7
+ from simple_module_test import forge_session_cookie
8
+ from sqlalchemy import select
9
+ from test_api_admin import _make_user
10
+ from users.models import Role, User
11
+
12
+ pytestmark = pytest.mark.anyio
13
+
14
+
15
+ async def _client(app, headers=None, cookies=None):
16
+ return httpx.AsyncClient(
17
+ transport=httpx.ASGITransport(app=app),
18
+ base_url="http://testserver",
19
+ headers=headers,
20
+ cookies=cookies,
21
+ )
22
+
23
+
24
+ async def test_delegate_cannot_manage_user_with_unheld_direct_grant(users_app, users_db):
25
+ users_db.add(Role(name="manager"))
26
+ await users_db.commit()
27
+ users_app.state.sm.permissions.map_role("manager", ["users.manage"])
28
+ actor = await _make_user(users_db, "manager@example.com", role_names=["manager", "user"])
29
+ target = await _make_user(users_db, "granted@example.com", role_names=["user"])
30
+ ordinary = await _make_user(users_db, "plain@example.com", role_names=["user"])
31
+
32
+ async def direct_grants(request, user):
33
+ return {"files.delete"} if user.id == str(target.id) else set()
34
+
35
+ users_app.state.sm.permissions.add_grant_source(direct_grants)
36
+ cookie = forge_session_cookie(
37
+ str(users_app.state.sm.settings.secret_key), {"user_id": str(actor.id)}
38
+ )
39
+ async with await _client(users_app, cookies={"session": cookie}) as client:
40
+ denied = await client.patch(f"/api/users/admin/{target.id}/disable")
41
+ allowed = await client.patch(f"/api/users/admin/{ordinary.id}/disable")
42
+ assert denied.status_code == 403
43
+ assert allowed.status_code == 200
44
+ await users_db.refresh(target)
45
+ assert target.is_active is True
46
+
47
+
48
+ async def test_admin_pat_cannot_grant_role_beyond_its_scopes(users_app, users_db):
49
+ users_db.add(Role(name="deleter"))
50
+ await users_db.commit()
51
+ users_app.state.sm.permissions.map_role("deleter", ["files.delete"])
52
+ admin = (
53
+ await users_db.execute(select(User).where(User.email == "admin@example.com"))
54
+ ).scalar_one()
55
+ target = await _make_user(users_db, "target@example.com", role_names=[])
56
+ _, pat = await ApiTokenService(users_db).create(
57
+ kind=TokenKind.PERSONAL,
58
+ name="scoped admin",
59
+ scopes=["users.manage", "api_tokens.use"],
60
+ expires_in_days=1,
61
+ max_lifetime_days=30,
62
+ owner_user_id=admin.id,
63
+ )
64
+ await users_db.commit()
65
+ async with await _client(users_app, headers={"Authorization": f"Bearer {pat}"}) as client:
66
+ url = f"/api/users/admin/{target.id}/roles"
67
+ beyond = await client.put(url, json={"role_names": ["deleter"]})
68
+ assert beyond.status_code == 403
69
+ within = await client.put(url, json={"role_names": []})
70
+ assert within.status_code == 200