simple-module-users 0.0.39__tar.gz → 0.0.41__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.
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/PKG-INFO +7 -7
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/package.json +1 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/pyproject.toml +7 -7
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/_users_oauth_support.py +7 -4
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/demo/test_users_demo_credentials.py +10 -2
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_bearer_token_age.py +5 -3
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_bearer_token_expiry.py +32 -7
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_remember_me.py +27 -4
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_service_admin_crud.py +5 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_sessions_revoke.py +1 -0
- simple_module_users-0.0.41/tests/test_users_admin_effective_permissions.py +70 -0
- simple_module_users-0.0.41/tests/test_users_admin_privilege_guards.py +167 -0
- simple_module_users-0.0.41/tests/test_users_admin_role_grant_rules.py +56 -0
- simple_module_users-0.0.41/tests/test_users_deletion_events.py +196 -0
- simple_module_users-0.0.41/tests/test_users_login_session_rotation.py +136 -0
- simple_module_users-0.0.41/tests/test_users_me_credentials.py +46 -0
- simple_module_users-0.0.41/tests/test_users_oauth_email_claim.py +144 -0
- simple_module_users-0.0.41/tests/test_users_oauth_error_responses.py +111 -0
- simple_module_users-0.0.41/tests/test_users_oauth_login_errors.py +51 -0
- simple_module_users-0.0.41/tests/test_users_oauth_provider_identity.py +65 -0
- simple_module_users-0.0.41/tests/test_users_refresh_family_max_age.py +178 -0
- simple_module_users-0.0.41/tests/test_users_refresh_reuse.py +74 -0
- simple_module_users-0.0.41/tests/test_users_reset_password_retry.py +40 -0
- simple_module_users-0.0.41/tests/test_users_token_hash_migration.py +60 -0
- simple_module_users-0.0.41/tests/test_users_token_pruning.py +178 -0
- simple_module_users-0.0.41/tests/test_users_token_storage.py +104 -0
- simple_module_users-0.0.41/tests/test_users_update_schema.py +11 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/users_Login.test.tsx +16 -0
- simple_module_users-0.0.41/tests-js/users_ResetPassword.test.tsx +114 -0
- simple_module_users-0.0.41/tests-js/users_VerifyEmail.test.tsx +65 -0
- simple_module_users-0.0.41/tests-js/users_admin_error_details.test.tsx +112 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/api.py +13 -4
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/useUserRowActions.ts +10 -4
- simple_module_users-0.0.41/users/admin/events.py +11 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/queries.py +6 -1
- simple_module_users-0.0.41/users/admin/safety.py +153 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/service.py +24 -15
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/api.py +7 -5
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/lockout/__init__.py +2 -5
- simple_module_users-0.0.41/users/auth_local/resetPasswordError.ts +13 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/token_api.py +54 -18
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/views.py +6 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/constants.py +8 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/contracts/events.py +10 -5
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/contracts/schemas.py +0 -3
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/db_adapter.py +33 -2
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/deps.py +22 -2
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/directory.py +13 -2
- simple_module_users-0.0.41/users/errors.py +29 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/exceptions.py +8 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/invites/bulk.py +5 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/locales/de.json +14 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/locales/en.json +14 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/manager.py +11 -9
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/access_token.py +1 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/refresh_token.py +2 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/oauth/api.py +34 -40
- simple_module_users-0.0.41/users/oauth/errors.py +27 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/oauth/providers.py +41 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Login.tsx +11 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/ResetPassword.tsx +3 -1
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/DangerZone.tsx +3 -3
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/useEditUserForm.ts +16 -8
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/useUserActions.ts +6 -4
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/VerifyEmail.tsx +16 -3
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/api.generated.ts +1 -1
- simple_module_users-0.0.41/users/pages/failureMessage.ts +41 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/settings.py +4 -0
- simple_module_users-0.0.41/users/token_storage.py +74 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/token_strategy.py +13 -2
- simple_module_users-0.0.39/users/errors.py +0 -19
- simple_module_users-0.0.39/users/pages/failureMessage.ts +0 -16
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/.gitignore +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/LICENSE +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/README.md +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/conftest.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/demo/test_users_demo_account.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/demo/test_users_demo_guard.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/demo/test_users_demo_login.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/invites/test_users_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/invites/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/invites/test_users_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/invites/test_users_invite_flow.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/invites/test_users_invite_reuse.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/invites/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/lockout/test_users_admin_unlock.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_checkpoint.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_columns.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_enumeration.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/lockout/test_users_lockout_service.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/lockout/test_users_login_lockout.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_api_admin_pagination_bounds.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_legacy_cached_tenant.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_login_redirect_root_mount.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_session_version_atomic.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_admin_session_revocation.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_bearer_scheme.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_default_role.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_directory.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_domain_errors.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_mailer_core_sender.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_manager.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_oauth_signup_gate.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_oauth_unverified_link.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_password_change_keeps_api.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_register_role_durable.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_register_single_connection.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_require_verification.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_reset_link_origin.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_seed_role_migration.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_setup_lock.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_setup_wizard.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_users_startup_role.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_views.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/AccountCardLocked.test.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/LoginForm.test.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/registerErrors.test.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/users_Register.test.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/users_actions.test.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/users_fixtures.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests-js/users_profile_cards.test.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/tsconfig.json +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/links.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/admin/views.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/audit.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/LoginForm.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/lockout/attempt.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/registerErrors.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/self_account.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/backend.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/cli.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/contracts/roles.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/demo/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/demo/api.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/demo/guard.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/invites/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/invites/preview.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/invites/resend.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/mailer/templated.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/middleware.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/_base.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/role.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/user.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/module.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/MailerBanner.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/components/useAddPeople.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/api.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/pages/signIn.ts +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/provider.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/py.typed +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/registration.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/session_revocation.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/setup.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/setup_action.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/shared_props.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/users/startup.py +0 -0
- {simple_module_users-0.0.39 → simple_module_users-0.0.41}/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.
|
|
3
|
+
Version: 0.0.41
|
|
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.
|
|
27
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
30
|
-
Requires-Dist: simple-module-mail==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
26
|
+
Requires-Dist: simple-module-auth==0.0.41
|
|
27
|
+
Requires-Dist: simple-module-core==0.0.41
|
|
28
|
+
Requires-Dist: simple-module-db==0.0.41
|
|
29
|
+
Requires-Dist: simple-module-hosting==0.0.41
|
|
30
|
+
Requires-Dist: simple-module-mail==0.0.41
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.41
|
|
32
32
|
Requires-Dist: typer<0.28,>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.41"
|
|
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.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
24
|
+
"simple_module_core==0.0.41",
|
|
25
|
+
"simple_module_db==0.0.41",
|
|
26
|
+
"simple_module_hosting==0.0.41",
|
|
27
|
+
"simple_module_settings==0.0.41",
|
|
28
|
+
"simple_module_auth==0.0.41",
|
|
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.
|
|
31
|
+
"simple_module_mail==0.0.41",
|
|
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["
|
|
33
|
-
"
|
|
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/
|
|
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/
|
|
80
|
+
f"/api/users/auth/oidc/callback?code=abc&state={state}",
|
|
78
81
|
follow_redirects=False,
|
|
79
82
|
)
|
|
80
83
|
|
{simple_module_users-0.0.39 → simple_module_users-0.0.41}/tests/demo/test_users_demo_credentials.py
RENAMED
|
@@ -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":
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -31,6 +32,30 @@ _ME = "/api/users/me"
|
|
|
31
32
|
_CREDS = {"username": "admin@example.com", "password": "AdminPass1!"}
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
async def _cookie_strategy_user(app, client):
|
|
36
|
+
"""Resolve the ``sm_auth`` cookie through the cookie strategy itself.
|
|
37
|
+
|
|
38
|
+
``/api/users/me`` now resolves the request principal (session or bearer,
|
|
39
|
+
#658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row
|
|
40
|
+
the way ``fastapi_users.current_user`` does instead.
|
|
41
|
+
"""
|
|
42
|
+
from users.backend import build_strategy
|
|
43
|
+
from users.db_adapter import PruningAccessTokenDatabase
|
|
44
|
+
from users.models import UserAccessToken
|
|
45
|
+
|
|
46
|
+
async with app.state.sm.db.session_factory() as session:
|
|
47
|
+
|
|
48
|
+
class _Manager:
|
|
49
|
+
def parse_id(self, value):
|
|
50
|
+
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
|
51
|
+
|
|
52
|
+
async def get(self, user_id):
|
|
53
|
+
return await session.get(User, user_id)
|
|
54
|
+
|
|
55
|
+
strategy = build_strategy(PruningAccessTokenDatabase(session, UserAccessToken), 900)
|
|
56
|
+
return await strategy.read_token(client.cookies.get("sm_auth"), _Manager())
|
|
57
|
+
|
|
58
|
+
|
|
34
59
|
async def _tokens(app) -> list[UserAccessToken]:
|
|
35
60
|
async with app.state.sm.db.session_factory() as session:
|
|
36
61
|
return list((await session.execute(select(UserAccessToken))).scalars())
|
|
@@ -78,7 +103,7 @@ async def _expire(app, token: str) -> None:
|
|
|
78
103
|
out.
|
|
79
104
|
"""
|
|
80
105
|
async with app.state.sm.db.session_factory() as session:
|
|
81
|
-
row = await session.get(UserAccessToken, token)
|
|
106
|
+
row = await session.get(UserAccessToken, hashlib.sha256(token.encode()).hexdigest())
|
|
82
107
|
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
|
83
108
|
await session.commit()
|
|
84
109
|
|
|
@@ -128,15 +153,14 @@ class TestAnExpiredRowStopsAuthenticating:
|
|
|
128
153
|
async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client):
|
|
129
154
|
"""`fastapi_users.current_user`, via ExpiringDatabaseStrategy.
|
|
130
155
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
thirty-day read ceiling — only the deadline it was issued for has passed.
|
|
156
|
+
The row stays well inside the thirty-day read ceiling — only the
|
|
157
|
+
deadline it was issued for has passed.
|
|
134
158
|
"""
|
|
135
159
|
assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
|
|
136
|
-
assert
|
|
160
|
+
assert await _cookie_strategy_user(users_app, anon_client) is not None
|
|
137
161
|
|
|
138
162
|
await _expire_all(users_app)
|
|
139
|
-
assert
|
|
163
|
+
assert await _cookie_strategy_user(users_app, anon_client) is None
|
|
140
164
|
|
|
141
165
|
@pytest.mark.anyio
|
|
142
166
|
async def test_the_provider_path_refuses_it(self, users_app, anon_client):
|
|
@@ -234,7 +258,8 @@ class TestTheMintedCounterIsCurrent:
|
|
|
234
258
|
row = (
|
|
235
259
|
await session.execute(
|
|
236
260
|
select(UserAccessToken).where(
|
|
237
|
-
UserAccessToken.token
|
|
261
|
+
UserAccessToken.token
|
|
262
|
+
== hashlib.sha256(rotated.json()["access_token"].encode()).hexdigest()
|
|
238
263
|
)
|
|
239
264
|
)
|
|
240
265
|
).scalar_one()
|
|
@@ -77,6 +77,30 @@ async def _age_access_tokens(app, *, days: int) -> None:
|
|
|
77
77
|
await session.commit()
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
async def _cookie_strategy_user(app, client):
|
|
81
|
+
"""Resolve the ``sm_auth`` cookie through the cookie strategy itself.
|
|
82
|
+
|
|
83
|
+
``/api/users/me`` now resolves the request principal (session or bearer,
|
|
84
|
+
#658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row
|
|
85
|
+
the way ``fastapi_users.current_user`` does instead.
|
|
86
|
+
"""
|
|
87
|
+
from users.backend import build_strategy
|
|
88
|
+
from users.db_adapter import PruningAccessTokenDatabase
|
|
89
|
+
from users.models import UserAccessToken
|
|
90
|
+
|
|
91
|
+
async with app.state.sm.db.session_factory() as session:
|
|
92
|
+
|
|
93
|
+
class _Manager:
|
|
94
|
+
def parse_id(self, value):
|
|
95
|
+
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
|
96
|
+
|
|
97
|
+
async def get(self, user_id):
|
|
98
|
+
return await session.get(User, user_id)
|
|
99
|
+
|
|
100
|
+
strategy = build_strategy(PruningAccessTokenDatabase(session, UserAccessToken), 900)
|
|
101
|
+
return await strategy.read_token(client.cookies.get("sm_auth"), _Manager())
|
|
102
|
+
|
|
103
|
+
|
|
80
104
|
class TestRemembered:
|
|
81
105
|
@pytest.mark.anyio
|
|
82
106
|
async def test_the_auth_cookie_lasts_thirty_days(self, anon_client, users_db):
|
|
@@ -104,14 +128,13 @@ class TestRemembered:
|
|
|
104
128
|
know which sign-ins asked to be remembered — a row minted to last
|
|
105
129
|
thirty days therefore has to be accepted for thirty, or the cookie
|
|
106
130
|
outlives the credential it carries and the checkbox starts lying on
|
|
107
|
-
day fourteen.
|
|
108
|
-
``current_user``, which is the code path that reads the row.
|
|
131
|
+
day fourteen.
|
|
109
132
|
"""
|
|
110
133
|
await _make_user(users_db, "remember-token@example.com")
|
|
111
134
|
await _login(anon_client, "remember-token@example.com", remember=True)
|
|
112
135
|
|
|
113
136
|
await _age_access_tokens(users_app, days=29)
|
|
114
|
-
assert
|
|
137
|
+
assert await _cookie_strategy_user(users_app, anon_client) is not None
|
|
115
138
|
|
|
116
139
|
@pytest.mark.anyio
|
|
117
140
|
async def test_a_token_past_the_window_stops_being_read(self, anon_client, users_db, users_app):
|
|
@@ -121,7 +144,7 @@ class TestRemembered:
|
|
|
121
144
|
await _login(anon_client, "stale-token@example.com", remember=True)
|
|
122
145
|
|
|
123
146
|
await _age_access_tokens(users_app, days=31)
|
|
124
|
-
assert
|
|
147
|
+
assert await _cookie_strategy_user(users_app, anon_client) is None
|
|
125
148
|
|
|
126
149
|
|
|
127
150
|
class TestTheWindowSurvivesLaterRequests:
|
|
@@ -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(
|
|
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)
|
|
@@ -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)
|