simple-module-users 0.0.12__tar.gz → 0.0.14__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 (98) hide show
  1. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/pyproject.toml +6 -6
  3. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/conftest.py +48 -1
  4. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_api_auth.py +1 -1
  5. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_bootstrap.py +0 -11
  6. simple_module_users-0.0.14/tests/test_bootstrap_resolution.py +72 -0
  7. simple_module_users-0.0.14/tests/test_invite_reuse.py +135 -0
  8. simple_module_users-0.0.14/tests/test_negative_authz.py +84 -0
  9. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_rate_limit.py +1 -1
  10. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_service_admin.py +1 -1
  11. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_service.py +2 -2
  12. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_views.py +46 -1
  13. simple_module_users-0.0.14/users/admin/__init__.py +1 -0
  14. simple_module_users-0.0.12/users/endpoints/api_admin.py → simple_module_users-0.0.14/users/admin/api.py +2 -6
  15. simple_module_users-0.0.14/users/admin/views.py +126 -0
  16. simple_module_users-0.0.14/users/auth_local/__init__.py +4 -0
  17. {simple_module_users-0.0.12/users/endpoints → simple_module_users-0.0.14/users/auth_local}/api.py +9 -59
  18. simple_module_users-0.0.14/users/auth_local/views.py +104 -0
  19. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/bootstrap.py +19 -10
  20. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/deps.py +2 -2
  21. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/module.py +47 -9
  22. simple_module_users-0.0.14/users/oauth/__init__.py +5 -0
  23. simple_module_users-0.0.12/users/endpoints/api_oauth.py → simple_module_users-0.0.14/users/oauth/api.py +8 -4
  24. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/Index.tsx +3 -3
  25. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/state.py +2 -1
  26. simple_module_users-0.0.12/users/endpoints/__init__.py +0 -1
  27. simple_module_users-0.0.12/users/endpoints/views.py +0 -228
  28. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/.gitignore +0 -0
  29. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/LICENSE +0 -0
  30. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/README.md +0 -0
  31. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/package.json +0 -0
  32. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/.gitkeep +0 -0
  33. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/_middleware_support.py +0 -0
  34. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_access_token_model.py +0 -0
  35. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_api_admin.py +0 -0
  36. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_api_admin_filters.py +0 -0
  37. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_backend.py +0 -0
  38. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_cli.py +0 -0
  39. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_constants.py +0 -0
  40. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_db_adapter.py +0 -0
  41. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_invite_flow.py +0 -0
  42. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_mailer.py +0 -0
  43. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_oauth.py +0 -0
  44. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_role_model.py +0 -0
  45. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_settings.py +0 -0
  46. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_manager.py +0 -0
  47. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_model.py +0 -0
  48. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_role_model.py +0 -0
  49. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_users_deps.py +0 -0
  50. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_users_middleware.py +0 -0
  51. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_users_middleware_public_paths.py +0 -0
  52. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_views_admin.py +0 -0
  53. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tsconfig.json +0 -0
  54. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/__init__.py +0 -0
  55. {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/components/IndexFilters.tsx +0 -0
  56. {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/components/RolesTab.tsx +0 -0
  57. {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/components/UserRow.tsx +0 -0
  58. {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/service.py +0 -0
  59. {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/auth_local}/rate_limit.py +0 -0
  60. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/backend.py +0 -0
  61. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/cli.py +0 -0
  62. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/constants.py +0 -0
  63. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/contracts/__init__.py +0 -0
  64. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/contracts/events.py +0 -0
  65. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/contracts/schemas.py +0 -0
  66. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/db_adapter.py +0 -0
  67. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/exceptions.py +0 -0
  68. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/__init__.py +0 -0
  69. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/console.py +0 -0
  70. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/smtp.py +0 -0
  71. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/.gitkeep +0 -0
  72. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/invite.txt +0 -0
  73. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/reset_password.txt +0 -0
  74. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/verify_email.txt +0 -0
  75. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/manager.py +0 -0
  76. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/middleware.py +0 -0
  77. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/__init__.py +0 -0
  78. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/_base.py +0 -0
  79. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/access_token.py +0 -0
  80. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/oauth_account.py +0 -0
  81. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/role.py +0 -0
  82. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/user.py +0 -0
  83. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/user_role.py +0 -0
  84. /simple_module_users-0.0.12/users/oauth.py → /simple_module_users-0.0.14/users/oauth/providers.py +0 -0
  85. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/.gitkeep +0 -0
  86. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/AcceptInvite.tsx +0 -0
  87. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/ForgotPassword.tsx +0 -0
  88. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Login.tsx +0 -0
  89. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Profile.tsx +0 -0
  90. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Register.tsx +0 -0
  91. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/ResetPassword.tsx +0 -0
  92. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/Edit.tsx +0 -0
  93. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/Invite.tsx +0 -0
  94. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  95. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/VerifyEmail.tsx +0 -0
  96. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/py.typed +0 -0
  97. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/roles_cache.py +0 -0
  98. {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_users
3
- Version: 0.0.12
3
+ Version: 0.0.14
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
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: aiosmtplib>=3.0
25
25
  Requires-Dist: cachetools>=5.3
26
26
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.12
28
- Requires-Dist: simple-module-core==0.0.12
29
- Requires-Dist: simple-module-db==0.0.12
30
- Requires-Dist: simple-module-hosting==0.0.12
31
- Requires-Dist: simple-module-settings==0.0.12
27
+ Requires-Dist: simple-module-auth==0.0.14
28
+ Requires-Dist: simple-module-core==0.0.14
29
+ Requires-Dist: simple-module-db==0.0.14
30
+ Requires-Dist: simple-module-hosting==0.0.14
31
+ Requires-Dist: simple-module-settings==0.0.14
32
32
  Requires-Dist: typer>=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.12"
3
+ version = "0.0.14"
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,11 +21,11 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.12",
25
- "simple_module_db==0.0.12",
26
- "simple_module_hosting==0.0.12",
27
- "simple_module_settings==0.0.12",
28
- "simple_module_auth==0.0.12",
24
+ "simple_module_core==0.0.14",
25
+ "simple_module_db==0.0.14",
26
+ "simple_module_hosting==0.0.14",
27
+ "simple_module_settings==0.0.14",
28
+ "simple_module_auth==0.0.14",
29
29
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
30
30
  # fields after construction (see reconfigure_cookie_transport in backend.py).
31
31
  # Bumping the major version requires re-checking those field names.
@@ -29,11 +29,16 @@ def _isolate_users_env(monkeypatch):
29
29
  After the env→DB migration ``UsersSettings()`` no longer reads these
30
30
  values, so this is belt-and-braces: it keeps old shell exports from
31
31
  muddying any ``SM_ENVIRONMENT`` checks or from being misread during
32
- developer spelunking.
32
+ developer spelunking. Also stubs out
33
+ ``users.bootstrap._read_dotenv_bootstrap_vars`` so the developer's local
34
+ ``.env`` can't seed bootstrap creds into tests that exercise the resolver.
33
35
  """
36
+ from users import bootstrap as bootstrap_module
37
+
34
38
  for key in list(os.environ):
35
39
  if key.startswith("SM_USERS_"):
36
40
  monkeypatch.delenv(key, raising=False)
41
+ monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
37
42
 
38
43
 
39
44
  # ---------------------------------------------------------------------------
@@ -202,6 +207,26 @@ async def _make_admin_user(app):
202
207
  return user
203
208
 
204
209
 
210
+ async def _make_standard_user(app, email: str = "user@example.com"):
211
+ """Seed a non-admin User with the standard ``user`` role.
212
+
213
+ Used by the negative-authz tests to confirm endpoints protected by
214
+ ``RequiresPermission(...)`` reject authenticated-but-non-admin callers.
215
+ """
216
+ from users.bootstrap import create_standard_user
217
+ from users.models import User
218
+
219
+ async with app.state.sm.db.session_factory() as session:
220
+ result = await create_standard_user(
221
+ session,
222
+ email=email,
223
+ password="UserPass1!",
224
+ full_name="Regular User",
225
+ )
226
+ user: User = result.user
227
+ return user
228
+
229
+
205
230
  @pytest.fixture
206
231
  async def admin_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
207
232
  """Client with a signed local-user session cookie (admin role)."""
@@ -219,6 +244,28 @@ async def admin_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
219
244
  yield c
220
245
 
221
246
 
247
+ @pytest.fixture
248
+ async def user_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
249
+ """Client with a signed session cookie for an authenticated non-admin user.
250
+
251
+ Counterpart to ``admin_client`` — every endpoint behind
252
+ ``RequiresPermission(...)`` should answer with 403 for this caller, since
253
+ the default role map only grants the wildcard to ``admin``.
254
+ """
255
+ user = await _make_standard_user(users_app)
256
+ cookie = forge_session_cookie(
257
+ str(users_app.state.sm.settings.secret_key),
258
+ {"user_id": str(user.id)},
259
+ )
260
+ transport = httpx.ASGITransport(app=users_app)
261
+ async with httpx.AsyncClient(
262
+ transport=transport,
263
+ base_url="http://testserver",
264
+ cookies={"session": cookie},
265
+ ) as c:
266
+ yield c
267
+
268
+
222
269
  # ---------------------------------------------------------------------------
223
270
  # DB session fixture scoped to users_app
224
271
  # ---------------------------------------------------------------------------
@@ -198,7 +198,7 @@ class TestAuthThroughputLimit:
198
198
  self, anon_client, users_app, users_db
199
199
  ):
200
200
  """After the configured attempt budget, /forgot-password returns 429."""
201
- from users.rate_limit import ThroughputLimiter
201
+ from users.auth_local.rate_limit import ThroughputLimiter
202
202
 
203
203
  # Tighten the limit for the test so we don't need to hit 10 real endpoints
204
204
  users_app.state.users.auth_throughput_limiter = ThroughputLimiter(
@@ -8,9 +8,7 @@ import pytest
8
8
  from fastapi_users.password import PasswordHelper
9
9
  from sqlalchemy import select
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
- from users import bootstrap as bootstrap_module
12
11
  from users.bootstrap import (
13
- BOOTSTRAP_ENV_KEYS,
14
12
  CreateAdminResult,
15
13
  bootstrap_admin_from_env,
16
14
  create_admin,
@@ -19,15 +17,6 @@ from users.constants import ADMIN_ROLE_ID
19
17
  from users.models import Role, User, UserRole
20
18
  from users.settings import UsersSettings
21
19
 
22
-
23
- @pytest.fixture(autouse=True)
24
- def _isolate_from_repo_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
25
- """Prevent the developer's ``.env`` from leaking bootstrap vars into tests."""
26
- monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
27
- for key in BOOTSTRAP_ENV_KEYS.values():
28
- monkeypatch.delenv(key, raising=False)
29
-
30
-
31
20
  # ---------------------------------------------------------------------------
32
21
  # Helpers
33
22
  # ---------------------------------------------------------------------------
@@ -0,0 +1,72 @@
1
+ """Tests for ``resolve_bootstrap_credentials`` — the three-tier resolver shared
2
+ between the boot-time admin seeder and the login-page dev-quick-fill UI.
3
+
4
+ Regression coverage for issue #159: previously the login page only consulted
5
+ ``UsersSettings`` + ``os.environ``, so an admin seeded via the ``.env``
6
+ fallback was created but the dev-quick-login button never showed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+ from users import bootstrap as bootstrap_module
13
+ from users.bootstrap import BOOTSTRAP_ENV_KEYS, resolve_bootstrap_credentials
14
+ from users.settings import UsersSettings
15
+
16
+
17
+ def _bare_users_settings(**overrides: str) -> UsersSettings:
18
+ """UsersSettings with all required-field defaults filled in."""
19
+ defaults = {
20
+ "reset_password_token_secret": "test-secret",
21
+ "verification_token_secret": "test-secret",
22
+ }
23
+ defaults.update(overrides)
24
+ return UsersSettings(**defaults)
25
+
26
+
27
+ def test_prefers_settings_over_environ(monkeypatch: pytest.MonkeyPatch) -> None:
28
+ """A non-empty UsersSettings field beats the same key on ``os.environ``."""
29
+ monkeypatch.setenv("SM_USERS_BOOTSTRAP_EMAIL", "env@example.com")
30
+ settings = _bare_users_settings(bootstrap_email="settings@example.com")
31
+
32
+ resolved = resolve_bootstrap_credentials(settings)
33
+
34
+ assert resolved["bootstrap_email"] == "settings@example.com"
35
+
36
+
37
+ def test_falls_back_to_environ(monkeypatch: pytest.MonkeyPatch) -> None:
38
+ """An empty UsersSettings field falls through to ``os.environ``."""
39
+ monkeypatch.setenv("SM_USERS_BOOTSTRAP_EMAIL", "env@example.com")
40
+ settings = _bare_users_settings()
41
+
42
+ resolved = resolve_bootstrap_credentials(settings)
43
+
44
+ assert resolved["bootstrap_email"] == "env@example.com"
45
+
46
+
47
+ def test_falls_back_to_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
48
+ """When settings + ``os.environ`` are empty, ``.env`` is consulted last."""
49
+ monkeypatch.setattr(
50
+ bootstrap_module,
51
+ "_read_dotenv_bootstrap_vars",
52
+ lambda: {
53
+ "SM_USERS_BOOTSTRAP_EMAIL": "dotenv@example.com",
54
+ "SM_USERS_BOOTSTRAP_PASSWORD": "DotenvPass1!",
55
+ },
56
+ )
57
+ settings = _bare_users_settings()
58
+
59
+ resolved = resolve_bootstrap_credentials(settings)
60
+
61
+ assert resolved["bootstrap_email"] == "dotenv@example.com"
62
+ assert resolved["bootstrap_password"] == "DotenvPass1!"
63
+
64
+
65
+ def test_returns_empty_strings_when_nothing_set() -> None:
66
+ """All four keys are present in the result even when unresolved."""
67
+ settings = _bare_users_settings()
68
+
69
+ resolved = resolve_bootstrap_credentials(settings)
70
+
71
+ assert set(resolved) == set(BOOTSTRAP_ENV_KEYS)
72
+ assert all(v == "" for v in resolved.values())
@@ -0,0 +1,135 @@
1
+ """Invite + password-reset token-lifecycle regressions.
2
+
3
+ The existing ``test_invite_flow`` covers the golden path and rejects a junk
4
+ token; these tests add:
5
+
6
+ * Reusing a verified invite token must be rejected (single-use).
7
+ * Generating a reset link for a user produces a token that survives one
8
+ consume cycle and is rejected after the password has been changed (the
9
+ token's hash incorporates ``user.hashed_password``).
10
+ * Acceptance for an already-disabled user does not log them in.
11
+
12
+ If any of these regressed, a stolen invite/reset link could be reused
13
+ arbitrarily — exactly the scenario the audit flagged.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+
20
+ import pytest
21
+
22
+
23
+ async def _send_invite_and_grab_token(admin_client, anon_client, caplog, email: str) -> str:
24
+ """Issue an invite and pull the token out of the ConsoleMailer log line."""
25
+ with caplog.at_level(logging.INFO, logger="users.mailer"):
26
+ resp = await admin_client.post(
27
+ "/api/users/admin/invite",
28
+ json={"email": email, "role_names": ["user"]},
29
+ )
30
+ assert resp.status_code == 201, resp.text
31
+ records = [r for r in caplog.records if r.getMessage() == "users.invite.email"]
32
+ assert records, "ConsoleMailer didn't log an invite.email record"
33
+ link = records[-1].link # type: ignore[attr-defined]
34
+ return link.split("token=", 1)[1]
35
+
36
+
37
+ @pytest.mark.anyio
38
+ async def test_invite_token_is_single_use(admin_client, anon_client, caplog):
39
+ """Accepting the same invite token twice must fail the second time.
40
+
41
+ Verify tokens in fastapi-users flip ``is_verified`` on the user; the
42
+ re-use attempt raises ``UserAlreadyVerified``, which the endpoint maps
43
+ to 400 INVITE_BAD_TOKEN. A regression that re-issued the same JWT or
44
+ forgot to re-check state would let a stolen link be replayed.
45
+ """
46
+ token = await _send_invite_and_grab_token(
47
+ admin_client, anon_client, caplog, "single@example.com"
48
+ )
49
+
50
+ first = await anon_client.post(
51
+ "/api/users/auth/accept-invite",
52
+ json={"token": token, "password": "FirstUseSecret1!"},
53
+ )
54
+ assert first.status_code == 204, first.text
55
+
56
+ # Same token, same user, but already verified.
57
+ second = await anon_client.post(
58
+ "/api/users/auth/accept-invite",
59
+ json={"token": token, "password": "DifferentSecret2!"},
60
+ )
61
+ assert second.status_code == 400, second.text
62
+ assert second.json()["detail"] == "INVITE_BAD_TOKEN"
63
+
64
+
65
+ @pytest.mark.anyio
66
+ async def test_reset_token_invalidated_after_password_change(admin_client, anon_client, caplog):
67
+ """A reset-password token must no longer work once the user's hash changes.
68
+
69
+ fastapi-users binds reset tokens to ``user.hashed_password`` so any change
70
+ (including the reset itself, or a manual password update) revokes every
71
+ outstanding reset token. Without this, a leaked link would stay live
72
+ forever.
73
+ """
74
+ # Step 1: create a user via invite, log them in with a known password.
75
+ token = await _send_invite_and_grab_token(
76
+ admin_client, anon_client, caplog, "resetme@example.com"
77
+ )
78
+ await anon_client.post(
79
+ "/api/users/auth/accept-invite",
80
+ json={"token": token, "password": "InitialPass1!"},
81
+ )
82
+
83
+ # Step 2: admin mints a reset link.
84
+ listing = await admin_client.get("/api/users/admin")
85
+ target = next(u for u in listing.json() if u["email"] == "resetme@example.com")
86
+ reset = await admin_client.post(f"/api/users/admin/{target['id']}/reset-password-link")
87
+ assert reset.status_code == 200
88
+ reset_token = reset.json()["link"].split("token=", 1)[1]
89
+
90
+ # Step 3: user changes password via that token (consumes it).
91
+ used = await anon_client.post(
92
+ "/api/users/auth/reset-password",
93
+ json={"token": reset_token, "password": "PostResetPass1!"},
94
+ )
95
+ assert used.status_code in (200, 204), used.text
96
+
97
+ # Step 4: reuse the SAME reset token after password rotated — must fail.
98
+ replay = await anon_client.post(
99
+ "/api/users/auth/reset-password",
100
+ json={"token": reset_token, "password": "ReplayedPass1!"},
101
+ )
102
+ assert replay.status_code in (400, 401), (
103
+ f"Replayed reset token returned {replay.status_code}, expected 4xx. Body: {replay.text!r}"
104
+ )
105
+
106
+
107
+ @pytest.mark.anyio
108
+ async def test_disabled_user_cannot_accept_their_invite(admin_client, anon_client, caplog):
109
+ """Inviting + disabling before acceptance must keep the user out."""
110
+ token = await _send_invite_and_grab_token(
111
+ admin_client, anon_client, caplog, "blocked@example.com"
112
+ )
113
+
114
+ # Admin disables the freshly-invited user before they accept.
115
+ listing = await admin_client.get("/api/users/admin")
116
+ target = next(u for u in listing.json() if u["email"] == "blocked@example.com")
117
+ disable = await admin_client.patch(f"/api/users/admin/{target['id']}/disable")
118
+ assert disable.status_code == 200
119
+ assert disable.json()["is_active"] is False
120
+
121
+ # Token verifies the email but the user is inactive — fastapi-users'
122
+ # subsequent login step (or the session middleware's user-load) must
123
+ # refuse to issue a valid session.
124
+ resp = await anon_client.post(
125
+ "/api/users/auth/accept-invite",
126
+ json={"token": token, "password": "ShouldNotMatter1!"},
127
+ )
128
+ # Either the verify path itself refuses, or the subsequent login does;
129
+ # in both cases the user must not be authenticated afterwards. Probe
130
+ # /me with the post-response cookies to confirm.
131
+ me = await anon_client.get("/api/users/me", follow_redirects=False)
132
+ assert me.status_code in (302, 401), (
133
+ f"Disabled user appears authenticated after accept-invite "
134
+ f"(status={resp.status_code}, /me status={me.status_code})"
135
+ )
@@ -0,0 +1,84 @@
1
+ """Negative-authorization sweep across every endpoint behind RequiresPermission.
2
+
3
+ Every endpoint guarded by ``RequiresPermission(...)`` must answer 403 when the
4
+ caller is authenticated but not an admin. The decorator presence alone isn't
5
+ enough — even one missing ``Depends(...)`` would leak admin-only data to
6
+ ordinary users.
7
+
8
+ The matrix below is exhaustive across the modules that ship with the framework
9
+ (users, permissions, settings, feature_flags, background_tasks, file_storage).
10
+ A new protected endpoint should be added here at the same time as it gains its
11
+ ``RequiresPermission`` dependency.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+
18
+ import pytest
19
+
20
+ _FAKE_ID = uuid.uuid4()
21
+ _PROTECTED_ENDPOINTS: tuple[tuple[str, str, dict | None], ...] = (
22
+ # users — admin sub-router
23
+ ("GET", "/api/users/admin", None),
24
+ ("POST", "/api/users/admin/invite", {"email": "x@y.test", "role_names": []}),
25
+ ("PATCH", f"/api/users/admin/{_FAKE_ID}/disable", None),
26
+ ("PATCH", f"/api/users/admin/{_FAKE_ID}/enable", None),
27
+ ("PUT", f"/api/users/admin/{_FAKE_ID}/roles", {"role_names": []}),
28
+ ("PATCH", f"/api/users/admin/{_FAKE_ID}/verify", None),
29
+ ("POST", f"/api/users/admin/{_FAKE_ID}/reset-password-link", None),
30
+ # permissions — root GET lists registered groups (PERM_VIEW)
31
+ ("GET", "/api/permissions/", None),
32
+ ("GET", f"/api/permissions/roles/{_FAKE_ID}", None),
33
+ ("PUT", f"/api/permissions/roles/{_FAKE_ID}", {"permissions": []}),
34
+ ("GET", f"/api/permissions/users/{_FAKE_ID}", None),
35
+ ("PUT", f"/api/permissions/users/{_FAKE_ID}", {"permissions": []}),
36
+ # settings — both the scoped CRUD and the module-config endpoints
37
+ ("GET", "/api/settings/", None),
38
+ ("POST", "/api/settings/", {"key": "x", "value": "1", "value_type": "string"}),
39
+ ("PUT", "/api/settings/system/anykey", {"value": "1", "value_type": "string"}),
40
+ ("DELETE", "/api/settings/system/anykey", None),
41
+ ("GET", "/api/settings/modules", None),
42
+ ("PUT", "/api/settings/modules/users", {}),
43
+ ("DELETE", "/api/settings/modules/users/allow_signup", None),
44
+ # feature flags
45
+ ("GET", "/api/feature_flags/", None),
46
+ ("PUT", "/api/feature_flags/anyflag", {"enabled": True}),
47
+ ("DELETE", "/api/feature_flags/anyflag", None),
48
+ # background tasks (admin router under /admin)
49
+ ("GET", "/api/background_tasks/admin/executions", None),
50
+ ("GET", "/api/background_tasks/admin/workers", None),
51
+ ("POST", f"/api/background_tasks/admin/executions/{_FAKE_ID}/retry", None),
52
+ # file_storage's list/upload/download/delete are deliberately granted to
53
+ # the standard `user` role, so they're NOT in the negative-authz matrix.
54
+ )
55
+
56
+
57
+ @pytest.mark.anyio
58
+ @pytest.mark.parametrize(("method", "path", "json_body"), _PROTECTED_ENDPOINTS)
59
+ async def test_protected_endpoint_rejects_non_admin(
60
+ user_client, method: str, path: str, json_body: dict | None
61
+ ) -> None:
62
+ """A logged-in non-admin user must be answered 403 by every protected route.
63
+
64
+ A regression here means the ``Depends(RequiresPermission(...))`` was dropped
65
+ or a non-admin role gained a wildcard mapping it shouldn't have.
66
+ """
67
+ resp = await user_client.request(method, path, json=json_body, follow_redirects=False)
68
+ assert resp.status_code == 403, (
69
+ f"{method} {path} returned {resp.status_code}, "
70
+ f"expected 403 for non-admin caller (body: {resp.text!r})"
71
+ )
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_admin_endpoints_still_pass_for_admin(admin_client) -> None:
76
+ """Sanity check: the same routes work for an admin caller.
77
+
78
+ Without this anchor a global regression that returned 403 for everyone
79
+ would still satisfy the parametrized 403 assertion above.
80
+ """
81
+ resp = await admin_client.get("/api/users/admin")
82
+ assert resp.status_code == 200
83
+ resp = await admin_client.get("/api/feature_flags/")
84
+ assert resp.status_code == 200
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import pytest
6
- from users.rate_limit import LoginRateLimiter, ThroughputLimiter
6
+ from users.auth_local.rate_limit import LoginRateLimiter, ThroughputLimiter
7
7
 
8
8
 
9
9
  @pytest.fixture
@@ -10,10 +10,10 @@ import pytest
10
10
 
11
11
  def _build_service(session, users_app):
12
12
  """Build a UserService directly (bypass FastAPI Depends)."""
13
+ from users.admin.service import UserService
13
14
  from users.db_adapter import UserDatabaseWithRoles
14
15
  from users.manager import UserManager
15
16
  from users.models import User
16
- from users.service import UserService
17
17
 
18
18
  user_db = UserDatabaseWithRoles(session, User)
19
19
  manager = UserManager(
@@ -9,10 +9,10 @@ import pytest
9
9
 
10
10
  def _build_service(session, users_app):
11
11
  """Build a UserService directly (bypass FastAPI Depends)."""
12
+ from users.admin.service import UserService
12
13
  from users.db_adapter import UserDatabaseWithRoles
13
14
  from users.manager import UserManager
14
15
  from users.models import User
15
- from users.service import UserService
16
16
 
17
17
  user_db = UserDatabaseWithRoles(session, User)
18
18
  manager = UserManager(
@@ -77,10 +77,10 @@ async def test_get_list_item_unknown_user_raises_user_not_found(users_app):
77
77
  async def test_to_list_item_includes_created_at(users_app):
78
78
  """`UserListItem` carries `created_at` sourced from AuditMixin."""
79
79
  from fastapi_users.password import PasswordHelper
80
+ from users.admin.service import UserService
80
81
  from users.db_adapter import UserDatabaseWithRoles
81
82
  from users.manager import UserManager
82
83
  from users.models import User
83
- from users.service import UserService
84
84
 
85
85
  async with users_app.state.sm.db.session_factory() as session:
86
86
  user = User(
@@ -67,6 +67,51 @@ class TestLoginPage:
67
67
  data = resp.json()
68
68
  assert data["component"] == "Users/Login"
69
69
 
70
+ @pytest.mark.anyio
71
+ async def test_dev_accounts_empty_outside_development(self, anon_client):
72
+ """``dev_accounts`` MUST NOT surface bootstrap creds in non-dev envs."""
73
+ resp = await anon_client.get(
74
+ "/users/login",
75
+ headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
76
+ )
77
+ assert resp.json()["props"]["dev_accounts"] == []
78
+
79
+ @pytest.mark.anyio
80
+ async def test_dev_accounts_resolved_via_dotenv_fallback(
81
+ self, anon_client, users_app, monkeypatch
82
+ ):
83
+ """Regression for #159: login_page surfaces creds seeded only via .env.
84
+
85
+ With ``environment=development`` and bootstrap vars present only in
86
+ ``.env`` (not on settings, not on ``os.environ``), the buttons must
87
+ still appear — otherwise the admin gets seeded by the boot-time hook
88
+ but the dev-quick-login UX silently breaks.
89
+ """
90
+ from users import bootstrap as bootstrap_module
91
+
92
+ monkeypatch.setattr(users_app.state.sm.settings, "environment", "development")
93
+ monkeypatch.setattr(
94
+ bootstrap_module,
95
+ "_read_dotenv_bootstrap_vars",
96
+ lambda: {
97
+ "SM_USERS_BOOTSTRAP_EMAIL": "admin@example.com",
98
+ "SM_USERS_BOOTSTRAP_PASSWORD": "AdminPass1!",
99
+ "SM_USERS_BOOTSTRAP_USER_EMAIL": "user@example.com",
100
+ "SM_USERS_BOOTSTRAP_USER_PASSWORD": "UserPass1!",
101
+ },
102
+ )
103
+
104
+ resp = await anon_client.get(
105
+ "/users/login",
106
+ headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
107
+ )
108
+
109
+ dev_accounts = resp.json()["props"]["dev_accounts"]
110
+ assert dev_accounts == [
111
+ {"label": "Admin", "email": "admin@example.com", "password": "AdminPass1!"},
112
+ {"label": "User", "email": "user@example.com", "password": "UserPass1!"},
113
+ ]
114
+
70
115
 
71
116
  class TestRegisterPage:
72
117
  @pytest.mark.anyio
@@ -187,7 +232,7 @@ async def test_admin_edit_page_unknown_user_returns_404(admin_client):
187
232
  @pytest.mark.anyio
188
233
  async def test_roles_payload_returns_id_name_dicts(users_app):
189
234
  """Helper reads the roles cache and returns id/name dicts in cache order."""
190
- from users.endpoints.views import _roles_payload
235
+ from users.admin.views import _roles_payload
191
236
 
192
237
  payload = await _roles_payload(users_app)
193
238
 
@@ -0,0 +1 @@
1
+ """Admin user management. Intentionally empty — import via fully-qualified paths."""
@@ -1,8 +1,4 @@
1
- """Admin REST endpoints for the users module.
2
-
3
- Split out of :mod:`.api` to keep per-file complexity manageable. Mounted
4
- into the main ``router`` via ``include_router`` at the bottom of ``api.py``.
5
- """
1
+ """Admin REST endpoints for the users module."""
6
2
 
7
3
  from __future__ import annotations
8
4
 
@@ -13,6 +9,7 @@ from fastapi import status as http_status
13
9
  from simple_module_core.events import EventBus
14
10
  from simple_module_hosting.permissions import RequiresPermission
15
11
 
12
+ from users.admin.service import UserService
16
13
  from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
17
14
  from users.contracts.events import RoleAssigned, UserDisabled, UserInvited
18
15
  from users.contracts.schemas import (
@@ -23,7 +20,6 @@ from users.contracts.schemas import (
23
20
  )
24
21
  from users.deps import get_event_bus, get_mailer, get_user_service
25
22
  from users.exceptions import UserNotFoundError
26
- from users.service import UserService
27
23
 
28
24
  admin_router = APIRouter(
29
25
  prefix="/admin",