simple-module-users 0.0.39__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.39 → simple_module_users-0.0.40}/PKG-INFO +7 -7
  2. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/package.json +1 -1
  3. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/pyproject.toml +7 -7
  4. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/_users_oauth_support.py +7 -4
  5. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/demo/test_users_demo_credentials.py +10 -2
  6. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_bearer_token_age.py +5 -3
  7. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_bearer_token_expiry.py +4 -2
  8. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_service_admin_crud.py +5 -1
  9. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_sessions_revoke.py +1 -0
  10. simple_module_users-0.0.40/tests/test_users_admin_effective_permissions.py +70 -0
  11. simple_module_users-0.0.40/tests/test_users_admin_privilege_guards.py +167 -0
  12. simple_module_users-0.0.40/tests/test_users_admin_role_grant_rules.py +56 -0
  13. simple_module_users-0.0.40/tests/test_users_deletion_events.py +196 -0
  14. simple_module_users-0.0.40/tests/test_users_login_session_rotation.py +136 -0
  15. simple_module_users-0.0.40/tests/test_users_oauth_email_claim.py +144 -0
  16. simple_module_users-0.0.40/tests/test_users_oauth_provider_identity.py +65 -0
  17. simple_module_users-0.0.40/tests/test_users_refresh_reuse.py +74 -0
  18. simple_module_users-0.0.40/tests/test_users_token_hash_migration.py +60 -0
  19. simple_module_users-0.0.40/tests/test_users_token_pruning.py +176 -0
  20. simple_module_users-0.0.40/tests/test_users_token_storage.py +104 -0
  21. simple_module_users-0.0.40/tests/test_users_update_schema.py +11 -0
  22. simple_module_users-0.0.40/tests-js/users_VerifyEmail.test.tsx +65 -0
  23. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/api.py +13 -4
  24. simple_module_users-0.0.40/users/admin/events.py +11 -0
  25. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/queries.py +6 -1
  26. simple_module_users-0.0.40/users/admin/safety.py +153 -0
  27. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/service.py +24 -15
  28. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/api.py +4 -2
  29. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/lockout/__init__.py +2 -5
  30. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/token_api.py +37 -18
  31. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/views.py +3 -1
  32. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/constants.py +6 -0
  33. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/contracts/events.py +10 -5
  34. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/contracts/schemas.py +0 -3
  35. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/db_adapter.py +11 -1
  36. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/deps.py +5 -2
  37. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/directory.py +13 -2
  38. simple_module_users-0.0.40/users/errors.py +29 -0
  39. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/exceptions.py +8 -0
  40. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/invites/bulk.py +5 -1
  41. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/manager.py +11 -9
  42. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/access_token.py +1 -1
  43. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/refresh_token.py +2 -1
  44. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/oauth/api.py +18 -19
  45. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/oauth/providers.py +41 -1
  46. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/VerifyEmail.tsx +16 -3
  47. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/api.generated.ts +1 -1
  48. simple_module_users-0.0.40/users/token_storage.py +51 -0
  49. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/token_strategy.py +13 -2
  50. simple_module_users-0.0.39/users/errors.py +0 -19
  51. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/.gitignore +0 -0
  52. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/LICENSE +0 -0
  53. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/README.md +0 -0
  54. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/.gitkeep +0 -0
  55. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/_demo_support.py +0 -0
  56. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/_middleware_support.py +0 -0
  57. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/_users_app_builders.py +0 -0
  58. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/conftest.py +0 -0
  59. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/demo/test_users_demo_account.py +0 -0
  60. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/demo/test_users_demo_guard.py +0 -0
  61. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/demo/test_users_demo_login.py +0 -0
  62. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/invites/test_users_admin_resend_invite.py +0 -0
  63. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/invites/test_users_bulk_invite.py +0 -0
  64. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/invites/test_users_bulk_invite_message.py +0 -0
  65. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/invites/test_users_invite_flow.py +0 -0
  66. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/invites/test_users_invite_reuse.py +0 -0
  67. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/invites/test_users_list_invites.py +0 -0
  68. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/lockout/test_users_admin_unlock.py +0 -0
  69. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/lockout/test_users_lockout_checkpoint.py +0 -0
  70. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/lockout/test_users_lockout_columns.py +0 -0
  71. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/lockout/test_users_lockout_enumeration.py +0 -0
  72. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/lockout/test_users_lockout_service.py +0 -0
  73. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/lockout/test_users_login_lockout.py +0 -0
  74. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_access_token_model.py +0 -0
  75. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_activity_summary.py +0 -0
  76. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_api_admin.py +0 -0
  77. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_api_admin_crud.py +0 -0
  78. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_api_admin_filters.py +0 -0
  79. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_api_admin_pagination_bounds.py +0 -0
  80. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_api_auth.py +0 -0
  81. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_auth_screens_props.py +0 -0
  82. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_backend.py +0 -0
  83. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_bootstrap.py +0 -0
  84. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_bootstrap_resolution.py +0 -0
  85. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_cli.py +0 -0
  86. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_constants.py +0 -0
  87. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_db_adapter.py +0 -0
  88. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_edit_recent_activity.py +0 -0
  89. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_external_users.py +0 -0
  90. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_legacy_cached_tenant.py +0 -0
  91. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_legacy_session_expiry.py +0 -0
  92. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_login_redirect_root_mount.py +0 -0
  93. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_mailer.py +0 -0
  94. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_negative_authz.py +0 -0
  95. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_oauth.py +0 -0
  96. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_oauth_routes.py +0 -0
  97. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_profile_password.py +0 -0
  98. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_rate_limit.py +0 -0
  99. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_remember_me.py +0 -0
  100. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_revocation_broadcast.py +0 -0
  101. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_role_model.py +0 -0
  102. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_service_admin.py +0 -0
  103. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_session_expiry.py +0 -0
  104. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_session_revocation.py +0 -0
  105. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_session_version_atomic.py +0 -0
  106. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_session_version_cache.py +0 -0
  107. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_session_version_failopen.py +0 -0
  108. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_session_version_hardening.py +0 -0
  109. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_settings.py +0 -0
  110. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_token_api.py +0 -0
  111. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_user_model.py +0 -0
  112. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_user_role_model.py +0 -0
  113. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_user_service.py +0 -0
  114. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_user_state.py +0 -0
  115. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_admin_session_revocation.py +0 -0
  116. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_bearer_scheme.py +0 -0
  117. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_default_role.py +0 -0
  118. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_deps.py +0 -0
  119. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_directory.py +0 -0
  120. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_domain_errors.py +0 -0
  121. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_login_deep_link.py +0 -0
  122. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_mailer_core_sender.py +0 -0
  123. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_manager.py +0 -0
  124. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_middleware.py +0 -0
  125. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_middleware_public_paths.py +0 -0
  126. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_middleware_resolvers.py +0 -0
  127. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_oauth_signup_gate.py +0 -0
  128. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_oauth_unverified_link.py +0 -0
  129. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_password_change_keeps_api.py +0 -0
  130. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_provider.py +0 -0
  131. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_register_role_durable.py +0 -0
  132. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_register_single_connection.py +0 -0
  133. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_require_verification.py +0 -0
  134. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_reset_link_origin.py +0 -0
  135. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_seed_role_migration.py +0 -0
  136. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_setup_lock.py +0 -0
  137. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_setup_wizard.py +0 -0
  138. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_shared_props.py +0 -0
  139. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_users_startup_role.py +0 -0
  140. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_views.py +0 -0
  141. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests/test_views_admin.py +0 -0
  142. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/AccountCardLocked.test.tsx +0 -0
  143. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/EmailChipInput.test.tsx +0 -0
  144. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/LoginForm.test.tsx +0 -0
  145. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/deriveState.test.ts +0 -0
  146. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/invite-emails.test.ts +0 -0
  147. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/registerErrors.test.ts +0 -0
  148. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/users_Login.test.tsx +0 -0
  149. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/users_Register.test.tsx +0 -0
  150. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/users_actions.test.tsx +0 -0
  151. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/users_fixtures.ts +0 -0
  152. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tests-js/users_profile_cards.test.tsx +0 -0
  153. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/tsconfig.json +0 -0
  154. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/__init__.py +0 -0
  155. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/__init__.py +0 -0
  156. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/IndexFilters.tsx +0 -0
  157. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/RolesTab.tsx +0 -0
  158. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/StatusPill.tsx +0 -0
  159. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/UserCards.tsx +0 -0
  160. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/UserRow.tsx +0 -0
  161. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/UserRowMenu.tsx +0 -0
  162. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/UsersEmpty.tsx +0 -0
  163. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/UsersTable.tsx +0 -0
  164. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/useUserRowActions.ts +0 -0
  165. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/components/user-list-item.ts +0 -0
  166. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/links.py +0 -0
  167. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/recent_activity.py +0 -0
  168. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/user_state.py +0 -0
  169. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/admin/views.py +0 -0
  170. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/audit.py +0 -0
  171. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/__init__.py +0 -0
  172. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/AuthField.tsx +0 -0
  173. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/AuthIntro.tsx +0 -0
  174. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/AuthStateCard.tsx +0 -0
  175. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/DemoSignIn.tsx +0 -0
  176. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/InviteSummary.tsx +0 -0
  177. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/LoginForm.tsx +0 -0
  178. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/PasswordCard.tsx +0 -0
  179. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/PasswordFields.tsx +0 -0
  180. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/PreferencesCard.tsx +0 -0
  181. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  182. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/ResendVerification.tsx +0 -0
  183. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  184. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/SessionsCard.tsx +0 -0
  185. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  186. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/components/profile-user.ts +0 -0
  187. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/lockout/attempt.py +0 -0
  188. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/rate_limit.py +0 -0
  189. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/registerErrors.ts +0 -0
  190. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/self_account.py +0 -0
  191. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/auth_local/token_preview.py +0 -0
  192. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/backend.py +0 -0
  193. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/bootstrap.py +0 -0
  194. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/cli.py +0 -0
  195. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/contracts/__init__.py +0 -0
  196. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/contracts/roles.py +0 -0
  197. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/demo/__init__.py +0 -0
  198. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/demo/api.py +0 -0
  199. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/demo/guard.py +0 -0
  200. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/invites/__init__.py +0 -0
  201. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/invites/preview.py +0 -0
  202. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/invites/resend.py +0 -0
  203. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/locales/de.json +0 -0
  204. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/locales/en.json +0 -0
  205. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/mailer/__init__.py +0 -0
  206. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/mailer/templated.py +0 -0
  207. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/mailer/templates/.gitkeep +0 -0
  208. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/mailer/templates/invite.txt +0 -0
  209. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/mailer/templates/reset_password.txt +0 -0
  210. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/mailer/templates/verify_email.txt +0 -0
  211. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/middleware.py +0 -0
  212. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/__init__.py +0 -0
  213. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/_base.py +0 -0
  214. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/oauth_account.py +0 -0
  215. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/role.py +0 -0
  216. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/user.py +0 -0
  217. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/models/user_role.py +0 -0
  218. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/module.py +0 -0
  219. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/oauth/__init__.py +0 -0
  220. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/.gitkeep +0 -0
  221. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/AcceptInvite.tsx +0 -0
  222. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/ForgotPassword.tsx +0 -0
  223. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Login.tsx +0 -0
  224. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Profile.tsx +0 -0
  225. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Register.tsx +0 -0
  226. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/ResetPassword.tsx +0 -0
  227. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/AddPeople.tsx +0 -0
  228. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/Edit.tsx +0 -0
  229. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/Index.tsx +0 -0
  230. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/AccountCard.tsx +0 -0
  231. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  232. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/DangerZone.tsx +0 -0
  233. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/DetailsCard.tsx +0 -0
  234. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  235. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  236. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/InviteFields.tsx +0 -0
  237. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/InviteResults.tsx +0 -0
  238. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/MailerBanner.tsx +0 -0
  239. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  240. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/RolePicker.tsx +0 -0
  241. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/UserStats.tsx +0 -0
  242. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/useAddPeople.ts +0 -0
  243. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/useEditUserForm.ts +0 -0
  244. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/components/useUserActions.ts +0 -0
  245. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/Users/invite-emails.ts +0 -0
  246. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/api.ts +0 -0
  247. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/failureMessage.ts +0 -0
  248. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/pages/signIn.ts +0 -0
  249. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/provider.py +0 -0
  250. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/py.typed +0 -0
  251. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/registration.py +0 -0
  252. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/roles_cache.py +0 -0
  253. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/session_revocation.py +0 -0
  254. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/session_version_cache.py +0 -0
  255. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/settings.py +0 -0
  256. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/setup.py +0 -0
  257. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/setup_action.py +0 -0
  258. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/shared_props.py +0 -0
  259. {simple_module_users-0.0.39 → simple_module_users-0.0.40}/users/startup.py +0 -0
  260. {simple_module_users-0.0.39 → 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.39
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
@@ -23,12 +23,12 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
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.39
27
- Requires-Dist: simple-module-core==0.0.39
28
- Requires-Dist: simple-module-db==0.0.39
29
- Requires-Dist: simple-module-hosting==0.0.39
30
- Requires-Dist: simple-module-mail==0.0.39
31
- Requires-Dist: simple-module-settings==0.0.39
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
32
  Requires-Dist: typer<0.28,>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/users",
3
- "version": "0.0.39",
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.39"
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,14 +21,14 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.39",
25
- "simple_module_db==0.0.39",
26
- "simple_module_hosting==0.0.39",
27
- "simple_module_settings==0.0.39",
28
- "simple_module_auth==0.0.39",
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.39",
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.
@@ -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
 
@@ -7,6 +7,7 @@ from api_tokens.service import ApiTokenService
7
7
  from simple_module_test import forge_session_cookie
8
8
  from users.auth_local.token_api import _create_token_pair
9
9
  from users.demo import ensure_demo_users
10
+ from users.models import User
10
11
 
11
12
 
12
13
  @pytest.fixture
@@ -37,11 +38,18 @@ async def test_demo_token_writes_follow_read_only_setting(
37
38
  demo_app.state.users.settings.demo_read_only = read_only
38
39
  headers = {"Authorization": f"Bearer {demo_credentials[credential]}"}
39
40
  # A successful read proves this credential actually resolves the demo identity.
40
- target = demo_app.state.users.demo_user_ids[1]
41
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)
42
50
  response = await demo_client.patch(
43
51
  f"/api/users/admin/{target}",
44
- json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
52
+ json={"email": "plain@example.com", "full_name": "Changed"},
45
53
  headers=headers,
46
54
  )
47
55
  assert response.status_code == (403 if read_only else 200), response.text
@@ -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
@@ -0,0 +1,167 @@
1
+ """Delegated user management must not grant access to stronger accounts (#624)."""
2
+
3
+ import httpx
4
+ import pytest
5
+ from fastapi import FastAPI
6
+ from simple_module_test import forge_session_cookie
7
+ from sqlalchemy import select
8
+ from test_api_admin import _make_user
9
+ from test_service_admin import _build_service
10
+ from users.errors import install_exception_handlers
11
+ from users.exceptions import AdminAccountSafetyError, AdminPermissionError
12
+ from users.models import Role, User, UserRole
13
+
14
+ pytestmark = pytest.mark.anyio
15
+
16
+
17
+ @pytest.mark.parametrize(
18
+ "error, status", [(AdminPermissionError, 403), (AdminAccountSafetyError, 400)]
19
+ )
20
+ async def test_admin_guard_http_status(error, status):
21
+ app = FastAPI()
22
+ install_exception_handlers(app)
23
+
24
+ @app.post("/guarded")
25
+ async def guarded():
26
+ raise error("Refused")
27
+
28
+ async with httpx.AsyncClient(
29
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
30
+ ) as client:
31
+ response = await client.post("/guarded")
32
+ assert response.status_code == status
33
+ assert response.json() == {"detail": "Refused"}
34
+
35
+
36
+ @pytest.fixture
37
+ async def delegated_client(users_app, users_db):
38
+ users_db.add(Role(name="manager"))
39
+ users_db.add(Role(name="powerful"))
40
+ await users_db.commit()
41
+ users_app.state.sm.permissions.map_role("manager", ["users.manage"])
42
+ users_app.state.sm.permissions.map_role("powerful", ["files.delete"])
43
+ actor = await _make_user(users_db, "manager@example.com", role_names=["manager", "user"])
44
+ cookie = forge_session_cookie(
45
+ str(users_app.state.sm.settings.secret_key), {"user_id": str(actor.id)}
46
+ )
47
+ async with httpx.AsyncClient(
48
+ transport=httpx.ASGITransport(app=users_app),
49
+ base_url="http://testserver",
50
+ cookies={"session": cookie},
51
+ ) as client:
52
+ yield client, actor.id
53
+
54
+
55
+ @pytest.mark.parametrize("role", ["admin", "powerful"])
56
+ @pytest.mark.parametrize("operation", ["roles", "create", "invite", "bulk"])
57
+ async def test_delegate_cannot_grant_unheld_role(delegated_client, users_db, operation, role):
58
+ client, actor_id = delegated_client
59
+ data = {"role_names": [role]}
60
+ if operation == "roles":
61
+ response = await client.put(f"/api/users/admin/{actor_id}/roles", json=data)
62
+ else:
63
+ data.update(email="escalated@example.com", password="SecurePass1!")
64
+ path = {"create": "", "invite": "/invite", "bulk": "/invite/bulk"}[operation]
65
+ if operation == "bulk":
66
+ data["emails"] = [data.pop("email")]
67
+ response = await client.post(f"/api/users/admin{path}", json=data)
68
+ assert response.status_code == 403
69
+ assert (
70
+ await users_db.execute(select(User).where(User.email == "escalated@example.com"))
71
+ ).scalar_one_or_none() is None
72
+ roles = (
73
+ (
74
+ await users_db.execute(
75
+ select(Role.name).join(UserRole).where(UserRole.user_id == actor_id)
76
+ )
77
+ )
78
+ .scalars()
79
+ .all()
80
+ )
81
+ assert set(roles) == {"manager", "user"}
82
+
83
+
84
+ @pytest.mark.parametrize("kind", ["admin", "superuser", "powerful"])
85
+ @pytest.mark.parametrize("operation", ["reset", "roles", "disable", "email", "delete"])
86
+ async def test_delegate_cannot_control_privileged_target(
87
+ delegated_client, users_db, kind, operation
88
+ ):
89
+ client, _ = delegated_client
90
+ target = await _make_user(
91
+ users_db, "protected@example.com", role_names=[] if kind == "superuser" else [kind]
92
+ )
93
+ if kind == "superuser":
94
+ target.is_superuser = True
95
+ await users_db.commit()
96
+ url = f"/api/users/admin/{target.id}"
97
+ if operation == "reset":
98
+ response = await client.post(f"{url}/reset-password-link")
99
+ elif operation == "roles":
100
+ response = await client.put(f"{url}/roles", json={"role_names": ["user"]})
101
+ elif operation == "disable":
102
+ response = await client.patch(f"{url}/disable")
103
+ elif operation == "email":
104
+ response = await client.patch(url, json={"email": "stolen@example.com"})
105
+ else:
106
+ response = await client.delete(url)
107
+ assert response.status_code == 403
108
+ await users_db.refresh(target)
109
+ assert target.email == "protected@example.com"
110
+ assert target.is_active is True
111
+
112
+
113
+ async def test_delegate_can_manage_ordinary_user(delegated_client, users_db):
114
+ client, _ = delegated_client
115
+ target = await _make_user(users_db, "ordinary@example.com", role_names=["user"])
116
+ url = f"/api/users/admin/{target.id}"
117
+ response = await client.put(f"{url}/roles", json={"role_names": ["user"]})
118
+ assert response.status_code == 200
119
+ response = await client.post(f"{url}/reset-password-link")
120
+ assert response.status_code == 200
121
+ assert "token=" in response.json()["link"]
122
+ response = await client.patch(f"{url}/disable")
123
+ assert response.status_code == 200
124
+
125
+
126
+ @pytest.mark.parametrize("operation", ["disable", "roles"])
127
+ async def test_admin_cannot_disable_or_demote_self(admin_client, users_db, operation):
128
+ admin = (
129
+ await users_db.execute(select(User).where(User.email == "admin@example.com"))
130
+ ).scalar_one()
131
+ # Self-protection also applies when another administrator exists.
132
+ await _make_user(users_db, "other-admin@example.com", role_names=["admin"])
133
+ url = f"/api/users/admin/{admin.id}/{operation}"
134
+ if operation == "roles":
135
+ response = await admin_client.put(url, json={"role_names": ["user"]})
136
+ else:
137
+ response = await admin_client.patch(url)
138
+ assert response.status_code == 400
139
+ await users_db.refresh(admin)
140
+ assert admin.is_active is True
141
+ assert (
142
+ await users_db.execute(select(Role.name).join(UserRole).where(UserRole.user_id == admin.id))
143
+ ).scalars().all() == ["admin"]
144
+
145
+
146
+ @pytest.mark.parametrize("operation", ["disable", "roles", "delete"])
147
+ async def test_service_preserves_last_active_admin(users_app, users_db, operation):
148
+ admin = (
149
+ await users_db.execute(select(User).where(User.email == "admin@example.com"))
150
+ ).scalar_one()
151
+ inactive = await _make_user(users_db, "inactive-admin@example.com", role_names=["admin"])
152
+ inactive.is_active = False
153
+ await users_db.commit()
154
+ # The legacy superuser flag alone grants no framework admin permissions.
155
+ roleless = await _make_user(users_db, "roleless-superuser@example.com")
156
+ roleless.is_superuser = True
157
+ await users_db.commit()
158
+ service = _build_service(users_db, users_app)
159
+ with pytest.raises(AdminAccountSafetyError):
160
+ if operation == "roles":
161
+ await service.set_roles(admin.id, ["user"])
162
+ elif operation == "delete":
163
+ await service.delete_user(admin.id)
164
+ else:
165
+ await service.disable(admin.id)
166
+ await users_db.refresh(admin)
167
+ assert admin.is_active is True
@@ -0,0 +1,56 @@
1
+ """Who may grant which roles (#624): compared by effective permissions, not role names."""
2
+
3
+ import pytest
4
+ from auth.contracts.schemas import UserContext
5
+ from simple_module_hosting.permissions import apply_scope_cap, resolve_permissions
6
+ from users.admin.safety import check_role_assignment
7
+ from users.exceptions import AdminPermissionError
8
+
9
+ ROLE_MAP = {
10
+ "admin": ["*"],
11
+ "helpdesk": ["users.manage"],
12
+ "editor": ["pages.edit"],
13
+ "billing": ["billing.manage"],
14
+ "files": ["files.delete"],
15
+ "empty": [],
16
+ }
17
+
18
+
19
+ def _check(roles, wanted, scopes=None):
20
+ actor = UserContext(id="a", email="a@x", name="a", roles=roles, scopes=scopes)
21
+ held = apply_scope_cap(resolve_permissions(roles, ROLE_MAP), actor)
22
+ check_role_assignment(actor, wanted, held, ROLE_MAP)
23
+
24
+
25
+ def test_full_admin_grants_admin():
26
+ _check(["admin"], ["admin", "editor"])
27
+
28
+
29
+ def test_admin_scoped_token_cannot_grant_admin():
30
+ with pytest.raises(AdminPermissionError):
31
+ _check(["admin"], ["admin"], scopes=["users.manage"])
32
+
33
+
34
+ def test_admin_scoped_token_grants_only_roles_within_its_scopes():
35
+ _check(["admin"], ["helpdesk", "empty"], scopes=["users.manage"])
36
+ with pytest.raises(AdminPermissionError):
37
+ _check(["admin"], ["files"], scopes=["users.manage"])
38
+
39
+
40
+ def test_delegate_grants_roles_its_permissions_cover():
41
+ _check(["helpdesk", "editor"], ["editor", "empty"])
42
+ for wanted in (["admin"], ["billing"], ["files"]):
43
+ with pytest.raises(AdminPermissionError):
44
+ _check(["helpdesk", "editor"], wanted)
45
+
46
+
47
+ def test_delegate_holding_equivalent_permissions_via_another_role_may_grant():
48
+ ROLE_MAP["twin"] = ["pages.edit"]
49
+ try:
50
+ _check(["editor"], ["twin"])
51
+ finally:
52
+ del ROLE_MAP["twin"]
53
+
54
+
55
+ def test_trusted_in_process_caller_is_unrestricted():
56
+ check_role_assignment(None, ["admin"], set(), ROLE_MAP)