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.
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/PKG-INFO +6 -6
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/pyproject.toml +6 -6
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/conftest.py +48 -1
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_api_auth.py +1 -1
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_bootstrap.py +0 -11
- simple_module_users-0.0.14/tests/test_bootstrap_resolution.py +72 -0
- simple_module_users-0.0.14/tests/test_invite_reuse.py +135 -0
- simple_module_users-0.0.14/tests/test_negative_authz.py +84 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_rate_limit.py +1 -1
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_service_admin.py +1 -1
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_service.py +2 -2
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_views.py +46 -1
- simple_module_users-0.0.14/users/admin/__init__.py +1 -0
- simple_module_users-0.0.12/users/endpoints/api_admin.py → simple_module_users-0.0.14/users/admin/api.py +2 -6
- simple_module_users-0.0.14/users/admin/views.py +126 -0
- simple_module_users-0.0.14/users/auth_local/__init__.py +4 -0
- {simple_module_users-0.0.12/users/endpoints → simple_module_users-0.0.14/users/auth_local}/api.py +9 -59
- simple_module_users-0.0.14/users/auth_local/views.py +104 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/bootstrap.py +19 -10
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/deps.py +2 -2
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/module.py +47 -9
- simple_module_users-0.0.14/users/oauth/__init__.py +5 -0
- simple_module_users-0.0.12/users/endpoints/api_oauth.py → simple_module_users-0.0.14/users/oauth/api.py +8 -4
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/Index.tsx +3 -3
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/state.py +2 -1
- simple_module_users-0.0.12/users/endpoints/__init__.py +0 -1
- simple_module_users-0.0.12/users/endpoints/views.py +0 -228
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/.gitignore +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/LICENSE +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/README.md +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/package.json +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/tsconfig.json +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/__init__.py +0 -0
- {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/admin}/service.py +0 -0
- {simple_module_users-0.0.12/users → simple_module_users-0.0.14/users/auth_local}/rate_limit.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/backend.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/cli.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/constants.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/exceptions.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/manager.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/middleware.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/_base.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/role.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/user.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/models/user_role.py +0 -0
- /simple_module_users-0.0.12/users/oauth.py → /simple_module_users-0.0.14/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/py.typed +0 -0
- {simple_module_users-0.0.12 → simple_module_users-0.0.14}/users/roles_cache.py +0 -0
- {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.
|
|
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.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
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.
|
|
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.
|
|
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.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
|
|
@@ -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.
|
|
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",
|