diff --git a/.dockerignore b/.dockerignore index 0a65bc360..2396defa0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,7 +33,7 @@ host/static/dist/ # Secrets / local data # Anywhere in the context, not just the root. `credentials*` stays root-only: -# recursive, it would also drop source modules such as users/credentials.py. +# recursive, it would also drop source modules such as users/accounts/credentials.py. **/.env* !**/.env.example **/*.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index dd951856c..1dc8f1559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ All notable changes to this project are documented in this file. The format is b - `site_lock` follows the module layout: `site_lock.core.{constants,settings,state}` and `site_lock.web.{middleware,page,rate_limit}` (the unlock template moves with the page). `site_lock.settings` still imports, with a `DeprecationWarning`, until 0.0.46. - `sharing` follows the module layout: `sharing.core.{constants,settings,services,service,policy,audience,tenant_cleanup,user_cleanup}`, `sharing.web.{deps,present,endpoints}`, and the page components and types move to `ui/`. `sharing.policy`, `sharing.constants` and `sharing.endpoints.api` still import, with a `DeprecationWarning`, until 0.0.46; new code imports `sharing.core.policy`. - `background_tasks` follows the module layout: `background_tasks.core.*` (constants, settings, services, config guards, health, invalidation, `sync_db`, tracking), `background_tasks.worker.*` (`celery_app`, `runner`, signals, task routing, heartbeats, worker settings, fleet inspection), `background_tasks.{executions,outbox,logs,web}.*`; the page components and TS helpers move to `ui/`. `background_tasks.{runner,celery_app,settings,sync_db,tenant_context,config_guards,task_routing,worker_settings,outbox_transport,outbox_worker}` still import, with a `DeprecationWarning`, until 0.0.46, so existing `scripts/run_worker.py` files keep working. Celery task names, durable event and handler names and the package-root exports are unchanged; module-name loggers move with their modules. +- `users` follows the module layout: `users.core.*` (constants, settings, state, errors, exceptions, administrators, bootstrap, startup, data compatibility, directory, audit, roles cache, setup), `users.accounts.*` (backend, DB adapter, manager, registration, provider, token storage and strategy, credentials, reset and session revocation, session-version cache) and `users.web.{deps,shared_props}`; the `failureMessage` and `signIn` helpers move to `ui/utils/`. `users.constants`, `users.settings`, `users.state`, `users.bootstrap`, `users.deps`, `users.directory` and `users.middleware` (the `auth.web.middleware` re-export) still import, with a `DeprecationWarning`, until 0.0.46. Durable names, permission names, i18n keys and settings keys are unchanged; loggers named from `__name__` change with the module path. - `dashboard` follows the module layout: `dashboard.core.constants`, `dashboard.web.{endpoints,stats,doctor}`, and the page components move from `pages/components/` to `ui/components/`. Nothing outside the module imported the old paths, so no aliases remain. - `feature_flags` follows the module layout: `feature_flags.core.{constants,service,invalidation,tenant_cleanup}`, `feature_flags.web.{deps,endpoints}`, and the page components and types move to `ui/`. `feature_flags.constants` still imports, with a `DeprecationWarning`, until 0.0.46. - `keycloak` follows the module layout: `keycloak.core.{constants,settings,state}`, `keycloak.identity.{provider,oidc,renewal,directory,persistence,tokens,jwks}` and `keycloak.web.endpoints`. `keycloak.{constants,settings,provider,jwks,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index cac04f53d..8c2cde0bf 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -192,8 +192,8 @@ Module settings should: ```python class UsersModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: - from users.settings import UsersSettings - from users.state import UsersState + from users.core.settings import UsersSettings + from users.core.state import UsersState app.state.users = UsersState(settings=UsersSettings()) ``` diff --git a/docs/framework/invalidation.md b/docs/framework/invalidation.md index 7c9a2aa0d..c834fc97c 100644 --- a/docs/framework/invalidation.md +++ b/docs/framework/invalidation.md @@ -10,7 +10,7 @@ Several modules cache a row that is cheap to read and expensive to read *often*: | Cache | Read on | Expires after | Owner | |---|---|---|---| -| `User.session_version` | nearly every authenticated request | 30s | `users.session_version_cache` | +| `User.session_version` | nearly every authenticated request | 30s | `users.accounts.session_version_cache` | | aggregate storage size | the files index | 30s | `file_storage` | | hydrated settings objects | every request that reads a setting | 30s periodic reload | `settings` | | role permission overlays | every permission check | 30s periodic reload | `permissions` | @@ -53,7 +53,7 @@ Channel naming is `.`. `Invalidation.key` is `None` for "forget t There is no `unsubscribe`, and subscribing the same callable twice runs it twice — subscriptions are made once at boot and last the life of the process. A handler may call `subscribe` during dispatch; the new handler takes effect from the *next* publish, because `_dispatch` walks a snapshot (it used to walk the live list, which let a self-re-arming handler wedge the event loop). -**The key is a string on the wire.** If your cache is keyed by anything else — `users` keys by `uuid.UUID` — convert on the way in, or the handler pops a key that is not there and the whole mechanism is a silent no-op that unit tests of the publisher still pass. See `users.session_revocation._cache_key`. +**The key is a string on the wire.** If your cache is keyed by anything else — `users` keys by `uuid.UUID` — convert on the way in, or the handler pops a key that is not there and the whole mechanism is a silent no-op that unit tests of the publisher still pass. See `users.accounts.session_revocation._cache_key`. ## Publishing diff --git a/docs/framework/permissions.md b/docs/framework/permissions.md index 82c35c64c..d11f5ce5f 100644 --- a/docs/framework/permissions.md +++ b/docs/framework/permissions.md @@ -199,7 +199,7 @@ The `authenticated_client` fixture from the `simple_module_test` plugin seeds an async def test_create_requires_permission(client, db_session): # Create a user without `orders.create` (e.g. via the users # registration flow or by inserting a users.models.User row directly — - # users.bootstrap only ships create_admin). + # users.core.bootstrap only ships create_admin). ... # Sign in via the local-auth API to get the session cookie. r = await client.post("/api/users/auth/login", data={"username": "u@e.com", "password": "x"}) diff --git a/docs/framework/settings.md b/docs/framework/settings.md index d2eabf498..83c40abbe 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -146,7 +146,7 @@ async def users_config(request: Request): Or as a typed dependency — cleaner when you use it in many handlers: ```python -# modules/users/users/deps.py +# modules/users/users/web/deps.py from typing import Annotated from fastapi import Depends, Request diff --git a/docs/framework/user-directory.md b/docs/framework/user-directory.md index d382280a3..4f299afc1 100644 --- a/docs/framework/user-directory.md +++ b/docs/framework/user-directory.md @@ -46,7 +46,7 @@ The active auth provider installs its directory in `register_settings` with | Provider | Directory | Knows | |---|---|---| -| `users` | `users.directory.UsersDirectory` | every local account, roles via `users_user_role`, all three stats | +| `users` | `users.core.directory.UsersDirectory` | every local account, roles via `users_user_role`, all three stats | | `keycloak` | `keycloak.identity.directory.KeycloakDirectory` | users who have signed in at least once (`keycloak_user_cache`); roles `None`; `created_this_month` `None`; `entity_type` `None` | | none | `NullUserDirectory` | nobody; every stat `None`; `match_ids` `None` | diff --git a/docs/frontend/performance.md b/docs/frontend/performance.md index e51ebe910..6656ecbaf 100644 --- a/docs/frontend/performance.md +++ b/docs/frontend/performance.md @@ -118,7 +118,7 @@ async def test_list_query_count_does_not_grow_with_rows(app, admin_client, db): assert await _statements(app, admin_client) == few ``` -`modules/users/tests/test_users_admin_list_query_count.py` is a working copy. +`modules/users/tests/admin/queries/test_users_admin_list_query_count.py` is a working copy. ## The server diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 0007baabb..9c5c90bb9 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -721,7 +721,7 @@ behind head re-enters setup mode with its administrators intact. Every **An action that creates something unique must re-check under a lock.** The step check runs before your handler and outside its transaction, so two -concurrent requests can both pass it. `users.setup_action` shows the pattern: +concurrent requests can both pass it. `users.core.setup_action` shows the pattern: take a database lock (`pg_advisory_xact_lock` on Postgres, a no-op `UPDATE` that claims SQLite's write lock), re-check the predicate, then insert, all in one transaction. @@ -897,7 +897,7 @@ The package registers pytest fixtures via a `pytest11` entry_point — no | `db_state` / `engine` / `db_session` | Fresh in-memory `DatabaseState` per test; `db_session` creates every installed module's tables and stamps `alembic_version` at head. | | `app` | A full `create_app(settings)` with lifespan started/stopped. | | `client` | `httpx.AsyncClient` bound to the test app (anonymous). | -| `authenticated_client` | Same, with a seeded admin + signed session cookie. Requires the `users` module installed (seeds via `users.bootstrap`). | +| `authenticated_client` | Same, with a seeded admin + signed session cookie. Requires the `users` module installed (seeds via `users.core.bootstrap`). | Example test: diff --git a/docs/module-layout.md b/docs/module-layout.md index 1718844df..0eec7c497 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -228,4 +228,4 @@ when a later module PR moves files. | sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | | tenants | 3 (+6 shims) | 0 | follows this layout | -| users | 31 | 97 | flat | +| users | 3 (+7 shims) | 0 | follows this layout | diff --git a/docs/modules/users.md b/docs/modules/users.md index 089575985..c82996780 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -136,7 +136,7 @@ from users.contracts.events import ( | `UserRegistered`, `UserInvited`, `UserCreated`, `UserDeleted`, `UserDisabled`, `RoleAssigned` | Events — see [Events](#events). | Other modules read accounts through the [user directory](/framework/user-directory), -which this module installs as `users.directory.UsersDirectory` in +which this module installs as `users.core.directory.UsersDirectory` in `register_settings`; they should not import `users.models`. The one table read directly is `Role`, re-exported as `users.contracts.roles.Role` for the permissions role pages. The edit page's *Recent activity* card reads the core @@ -332,7 +332,7 @@ uv run smpy users create-admin --email admin@example.com --password changeme Programmatically: ```python -from users.bootstrap import create_admin +from users.core.bootstrap import create_admin result = await create_admin(db, email="admin@example.com", password="...") # result.user, result.created -> bool @@ -628,7 +628,7 @@ retire all sessions through the account version counter. ## Roles cache -`roles_cache.py` keeps an in-memory list of `RoleSummary(id, name)` on `app.state.users.roles_cache`. Refreshed at startup and on demand via `refresh_roles_cache(app)`. When the role set changes (the setup wizard's first administrator creates the `admin` role), `publish_roles_changed(app)` announces it on the `users.roles` invalidation channel: each subscribed worker empties its cache and `get_roles_cache` refills it on the next read (other workers hear it where the Redis invalidation transport is installed). Because delivery is not guaranteed, the cache also expires on its own after `TTL_SECONDS` (300 s, stamped as `roles_cache_loaded_at` on the users state) and is refetched on the next read. Used by the admin UI to render role pickers without hitting the DB on every request. +`core/roles_cache.py` keeps an in-memory list of `RoleSummary(id, name)` on `app.state.users.roles_cache`. Refreshed at startup and on demand via `refresh_roles_cache(app)`. When the role set changes (the setup wizard's first administrator creates the `admin` role), `publish_roles_changed(app)` announces it on the `users.roles` invalidation channel: each subscribed worker empties its cache and `get_roles_cache` refills it on the next read (other workers hear it where the Redis invalidation transport is installed). Because delivery is not guaranteed, the cache also expires on its own after `TTL_SECONDS` (300 s, stamped as `roles_cache_loaded_at` on the users state) and is refetched on the next read. Used by the admin UI to render role pickers without hitting the DB on every request. ## Branding the auth screens diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md index 00c66e7fd..017ed2884 100644 --- a/docs/testing/fixtures.md +++ b/docs/testing/fixtures.md @@ -102,11 +102,11 @@ assert r.json() == [] Same as `client`, but the fixture also: -1. Seeds an admin user via `users.bootstrap.create_admin(...)`. +1. Seeds an admin user via `users.core.bootstrap.create_admin(...)`. 2. Forges a signed session cookie for that user. 3. Attaches the cookie to the client. -> Because it seeds via `users.bootstrap`, this fixture requires the `users` module to be installed (the import is deferred to the fixture body, so the rest of the plugin loads without it). Apps scaffolded by `smpy` include `users`. +> Because it seeds via `users.core.bootstrap`, this fixture requires the `users` module to be installed (the import is deferred to the fixture body, so the rest of the plugin loads without it). Apps scaffolded by `smpy` include `users`. ```python @pytest.mark.asyncio @@ -162,7 +162,7 @@ FastAPI dependency overrides work as usual: ```python @pytest.mark.asyncio async def test_with_mock_mailer(app, authenticated_client): - from users.deps import _mailer + from users.web.deps import _mailer captured = [] diff --git a/framework/cli/simple_module_cli/templates/docker/dockerignore b/framework/cli/simple_module_cli/templates/docker/dockerignore index e4a51458c..5b8ce4493 100644 --- a/framework/cli/simple_module_cli/templates/docker/dockerignore +++ b/framework/cli/simple_module_cli/templates/docker/dockerignore @@ -12,7 +12,7 @@ __pycache__ *.db *.sqlite3 # Anywhere in the context, not just the root. `credentials*` stays root-only: -# recursive, it would also drop source modules such as users/credentials.py. +# recursive, it would also drop source modules such as users/accounts/credentials.py. **/.env* !**/.env.example **/*.pem diff --git a/framework/cli/tests/test_cli_docker_persistence_secrets.py b/framework/cli/tests/test_cli_docker_persistence_secrets.py index cdcc06104..88473698e 100644 --- a/framework/cli/tests/test_cli_docker_persistence_secrets.py +++ b/framework/cli/tests/test_cli_docker_persistence_secrets.py @@ -29,7 +29,11 @@ def test_cli_dockerignore_excludes_secret_variants(tmp_path, source): "host/secrets/token", ): assert _ignored(patterns, path), path - for path in (".env.example", "host/.env.example", "modules/users/users/credentials.py"): + for path in ( + ".env.example", + "host/.env.example", + "modules/users/users/accounts/credentials.py", + ): assert not _ignored(patterns, path), path diff --git a/framework/cli/tests/test_cli_prepare_data_migrations.py b/framework/cli/tests/test_cli_prepare_data_migrations.py index 17b074754..51d02d19a 100644 --- a/framework/cli/tests/test_cli_prepare_data_migrations.py +++ b/framework/cli/tests/test_cli_prepare_data_migrations.py @@ -180,6 +180,6 @@ def find_spec(self, fullname, path=None, target=None): def test_cli_recipe_ids_match_the_module_constants(): from mail.core.constants import UPGRADE_SETTINGS_NAMESPACE from simple_module_cli.migrations_cmd import _RECIPES - from users.constants import UPGRADE_HASHED_BEARER_TOKENS + from users.core.constants import UPGRADE_HASHED_BEARER_TOKENS assert set(_RECIPES) == {UPGRADE_SETTINGS_NAMESPACE, UPGRADE_HASHED_BEARER_TOKENS} diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index 611a976b1..c8e74d97c 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -166,7 +166,7 @@ async def test_env_py_uses_shared_helper(self, tmp_path): async def test_main_py_loads_dotenv_before_settings(self, tmp_path): """Regression for #158: scaffolded main.py must populate ``os.environ`` from ``.env`` *before* ``Settings`` is imported, otherwise framework - code reading ``os.environ`` directly (e.g. users.bootstrap's dotenv + code reading ``os.environ`` directly (e.g. users.core.bootstrap's dotenv fallback under uvicorn launched from ``host/``) silently misses values that pydantic-settings would have picked up. """ diff --git a/framework/core/simple_module_core/invalidation.py b/framework/core/simple_module_core/invalidation.py index ac0fb10d6..64e2dbbcd 100644 --- a/framework/core/simple_module_core/invalidation.py +++ b/framework/core/simple_module_core/invalidation.py @@ -86,7 +86,7 @@ class Invalidation: channel: str #: Which entry, or ``None`` for "the whole channel". A string because it has #: to survive JSON; a consumer keyed by something else converts on the way - #: back in (see ``users.session_revocation``). + #: back in (see ``users.accounts.session_revocation``). key: str | None = None #: The publishing process's :attr:`InvalidationBus.origin`. Lets a bus #: ignore its own broadcast coming back off the transport, which it has diff --git a/framework/testing/README.md b/framework/testing/README.md index fc8f7f795..8af6dc05e 100644 --- a/framework/testing/README.md +++ b/framework/testing/README.md @@ -22,7 +22,7 @@ All fixtures below are auto-registered via the `pytest11` entry point — instal - `db_state`, `engine`, `db_session` — fresh `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes. - `app` — a `create_app(settings)` instance with `lifespan` started and stopped. - `client` — an `httpx.AsyncClient` bound to the test app. -- `authenticated_client` — same but with an admin user seeded and a forged session cookie attached. **Requires the `users` module** to be installed (it seeds the admin via `users.bootstrap`); apps scaffolded by `smpy` include it. +- `authenticated_client` — same but with an admin user seeded and a forged session cookie attached. **Requires the `users` module** to be installed (it seeds the admin via `users.core.bootstrap`); apps scaffolded by `smpy` include it. - `tenant_client` — factory: `async with tenant_client("member") as (client, tenant_id, user_id)` signs in a fresh user who is an `owner` / `admin` / `member` (default `owner`) of a new tenant — or of `tenant_id=` when given — with that tenant active. **Requires the `users` and `tenants` modules.** ## Usage diff --git a/framework/testing/simple_module_test/__init__.py b/framework/testing/simple_module_test/__init__.py index 0a7377f2e..af1db74dc 100644 --- a/framework/testing/simple_module_test/__init__.py +++ b/framework/testing/simple_module_test/__init__.py @@ -22,7 +22,7 @@ real broker (cross-worker cache invalidation) and skips when the binary is absent. See ``simple_module_test.plugin`` (and ``simple_module_test.fixtures``) for their definitions. ``authenticated_client`` additionally requires the ``users`` -module to be installed — it seeds an admin via ``users.bootstrap``. +module to be installed — it seeds an admin via ``users.core.bootstrap``. ``tenant_client`` (``users`` + ``tenants``) is a factory yielding a client signed in as an ``owner``/``admin``/``member`` of a real tenant — see ``simple_module_test.tenant_client``. diff --git a/framework/testing/simple_module_test/fixtures.py b/framework/testing/simple_module_test/fixtures.py index 2cbf0adf2..7c5752118 100644 --- a/framework/testing/simple_module_test/fixtures.py +++ b/framework/testing/simple_module_test/fixtures.py @@ -12,7 +12,7 @@ Module-scope imports here reference only framework packages (``simple_module_core`` / ``simple_module_db`` / ``simple_module_hosting``), which are this package's declared dependencies. The one plugin-module coupling -— ``authenticated_client`` seeding an admin via ``users.bootstrap`` — is a lazy +— ``authenticated_client`` seeding an admin via ``users.core.bootstrap`` — is a lazy import inside the fixture body, so importing this module never requires the ``users`` module to be installed (mirroring ``plugin._bootstrap_eager_celery``, which soft-imports ``background_tasks``). @@ -146,7 +146,7 @@ async def _seed_setup_admin(application) -> None: there is nothing to satisfy. Use ``setup_pending_app`` to exercise the gate. """ try: - from users.bootstrap import create_admin + from users.core.bootstrap import create_admin except ImportError: return @@ -207,7 +207,7 @@ def _disable_env_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None: if key.startswith("SM_USERS_BOOTSTRAP_"): monkeypatch.delenv(key, raising=False) try: - from users import bootstrap as bootstrap_module + from users.core import bootstrap as bootstrap_module except ImportError: return # No local-accounts provider installed; nothing bootstraps. monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict) @@ -241,11 +241,11 @@ async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]: """HTTPX client with a signed session cookie carrying a seeded admin user's id. Requires the ``users`` module to be installed — it seeds the admin via - ``users.bootstrap.create_admin``. The import is deferred to here (not module + ``users.core.bootstrap.create_admin``. The import is deferred to here (not module scope) so this plugin imports cleanly without ``users``; a consumer app that uses this fixture without ``users`` gets a clear ImportError naming it. """ - from users.bootstrap import create_admin + from users.core.bootstrap import create_admin async with app.state.sm.db.session_factory() as session: # Idempotent: the `app` fixture already seeded this same admin so the diff --git a/framework/testing/simple_module_test/tenant_client.py b/framework/testing/simple_module_test/tenant_client.py index 857f99255..c9f0a79d5 100644 --- a/framework/testing/simple_module_test/tenant_client.py +++ b/framework/testing/simple_module_test/tenant_client.py @@ -45,7 +45,7 @@ class TenantClient(NamedTuple): async def create_user(app: Any, email: str) -> str: """A real, active, non-superuser ``users`` row with the ``user`` role; its id.""" - from users.bootstrap import ensure_user_role + from users.core.bootstrap import ensure_user_role from users.models import User, UserRole async with app.state.sm.db.session_factory() as session: diff --git a/host/tests/test_inactive_provider_public_paths.py b/host/tests/test_inactive_provider_public_paths.py index 20ac54366..51dfbf5aa 100644 --- a/host/tests/test_inactive_provider_public_paths.py +++ b/host/tests/test_inactive_provider_public_paths.py @@ -13,7 +13,7 @@ from keycloak.module import KeycloakModule from simple_module_core.discovery import select_auth_provider from simple_module_core.public_routes import PublicRouteRegistry -from users.constants import PUBLIC_PATH_PREFIXES as USERS_PREFIXES +from users.core.constants import PUBLIC_PATH_PREFIXES as USERS_PREFIXES from users.module import UsersModule diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 8813197f8..618b73cc5 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -1,7 +1,4 @@ -"""Ratchet package roots while existing modules migrate one PR at a time.""" - -# Keep the frozen filename inventory readable as whitespace-separated data. -# ruff: noqa: SIM905 +"""Keep every module package root to the layout in docs/module-layout.md.""" from __future__ import annotations @@ -45,6 +42,7 @@ "permissions": frozenset({"grants"}), "settings": frozenset({"module_settings"}), "tenants": frozenset({"invitations", "resolution"}), + "users": frozenset({"accounts", "admin", "auth_local", "demo", "invites", "mailer", "oauth"}), } # Plain re-export shims (#500) that predate the layout: public compat paths kept @@ -55,18 +53,7 @@ } # Frozen inventory, not a computed baseline: moves must remove their old entries. -LEGACY_FLAT: dict[str, frozenset[str]] = { - "users": frozenset( - """ - admin administrators.py audit.py auth_local backend.py bootstrap.py constants.py - credentials.py data_compatibility.py db_adapter.py demo deps.py directory.py - errors.py exceptions.py invites mailer manager.py middleware.py oauth provider.py - registration.py reset_revocation.py roles_cache.py session_revocation.py - session_version_cache.py settings.py setup.py setup_action.py shared_props.py - startup.py state.py token_storage.py token_strategy.py - """.split() - ), -} +LEGACY_FLAT: dict[str, frozenset[str]] = {} def _is_layout_shim(path: Path) -> bool: diff --git a/modules/api_tokens/tests/_api_tokens_support.py b/modules/api_tokens/tests/_api_tokens_support.py index 28b13628b..e9ae63c6b 100644 --- a/modules/api_tokens/tests/_api_tokens_support.py +++ b/modules/api_tokens/tests/_api_tokens_support.py @@ -44,7 +44,7 @@ async def join_tenant(app: Any, user_id: str, email: str, tenant_id: str | None async def make_admin(app: Any, email: str) -> str: - from users.bootstrap import create_admin + from users.core.bootstrap import create_admin async with app.state.sm.db.session_factory() as session: result = await create_admin(session, email=email, password="pw-123456", full_name="Admin") diff --git a/modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py index c9ee44718..96b2f9f52 100644 --- a/modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py +++ b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_resolver.py @@ -127,8 +127,8 @@ async def test_last_used_is_written_at_most_once_per_window(app, actor, bearer): async def test_users_provider_does_not_look_up_api_tokens(monkeypatch): """The provider steps aside for ``smpat_``/``smsk_`` without touching its store.""" - import users.token_strategy - from users.provider import UsersAuthProvider + import users.accounts.token_strategy + from users.accounts.provider import UsersAuthProvider calls: list[str] = [] @@ -136,7 +136,7 @@ async def spy(scope, token): calls.append(token) return - monkeypatch.setattr(users.token_strategy, "resolve_bearer", spy) + monkeypatch.setattr(users.accounts.token_strategy, "resolve_bearer", spy) provider = UsersAuthProvider() for credential in ( generate(TokenKind.PERSONAL).plaintext, diff --git a/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py b/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py index 5e0fd2ff7..446b94059 100644 --- a/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py +++ b/modules/api_tokens/tests/credentials/scopes/test_api_tokens_scopes.py @@ -25,7 +25,7 @@ async def test_admin_token_is_capped_by_its_scopes(actor, bearer): async def _set_roles(app, user_id: str, names: list[str]) -> None: from sqlalchemy import delete, select - from users.bootstrap import ensure_user_role + from users.core.bootstrap import ensure_user_role from users.models import Role, UserRole async with app.state.sm.db.session_factory() as session: diff --git a/modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py b/modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py index cbff69a96..f154cb582 100644 --- a/modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py +++ b/modules/api_tokens/tests/credentials/service/test_api_tokens_signup.py @@ -24,7 +24,7 @@ def settings(settings: Settings) -> Settings: async def _register(app, client) -> str: - from users.bootstrap import ensure_user_role + from users.core.bootstrap import ensure_user_role from users.models import User # Test schemas come from ``create_all``: seed the role the migration would. @@ -45,7 +45,7 @@ async def _register(app, client) -> str: async def test_registration_attaches_the_user_role(app, client): - from users.constants import USER_ROLE_NAME + from users.core.constants import USER_ROLE_NAME from users.models import Role, UserRole user_id = await _register(app, client) diff --git a/modules/audit_log/tests/entries/export/test_audit_log_export_csv.py b/modules/audit_log/tests/entries/export/test_audit_log_export_csv.py index 1388a797e..e786d9c90 100644 --- a/modules/audit_log/tests/entries/export/test_audit_log_export_csv.py +++ b/modules/audit_log/tests/entries/export/test_audit_log_export_csv.py @@ -264,7 +264,7 @@ async def test_a_signed_in_non_admin_is_refused(self, app) -> None: """Signed in is not the same as allowed to read the audit trail. The export is a second door onto the same rows as the browse screen, and a guard on one of the two doors is not a guard.""" - from users.bootstrap import create_standard_user + from users.core.bootstrap import create_standard_user async with app.state.sm.db.session_factory() as session: result = await create_standard_user( diff --git a/modules/audit_log/tests/entries/filters/test_audit_log_actor_filter_by_name.py b/modules/audit_log/tests/entries/filters/test_audit_log_actor_filter_by_name.py index e676d1e85..81e29ac38 100644 --- a/modules/audit_log/tests/entries/filters/test_audit_log_actor_filter_by_name.py +++ b/modules/audit_log/tests/entries/filters/test_audit_log_actor_filter_by_name.py @@ -154,7 +154,7 @@ async def test_the_filter_is_a_subquery_not_a_list_of_ids(self) -> None: that materialises ids passes the count test right up until the install is big enough to break it.""" from audit_log.entries.resolve import actor_filter - from users.directory import UsersDirectory + from users.core.directory import UsersDirectory rendered = str(actor_filter(UsersDirectory(), "okafor")) diff --git a/modules/audit_log/tests/entries/resolve/test_audit_log_entity_label_permission.py b/modules/audit_log/tests/entries/resolve/test_audit_log_entity_label_permission.py index 5a6ac7815..dfbc96d0f 100644 --- a/modules/audit_log/tests/entries/resolve/test_audit_log_entity_label_permission.py +++ b/modules/audit_log/tests/entries/resolve/test_audit_log_entity_label_permission.py @@ -32,7 +32,7 @@ from simple_module_db.stored_settings import Setting from simple_module_test import forge_session_cookie, inertia_headers from sqlalchemy.ext.asyncio import AsyncSession -from users.constants import PERM_USERS_MANAGE +from users.core.constants import PERM_USERS_MANAGE from users.models import User EXPORT_URL = "/api/audit_log/export.csv" diff --git a/modules/audit_log/tests/entries/resolve/test_audit_log_resolve.py b/modules/audit_log/tests/entries/resolve/test_audit_log_resolve.py index f1763a323..c052389cd 100644 --- a/modules/audit_log/tests/entries/resolve/test_audit_log_resolve.py +++ b/modules/audit_log/tests/entries/resolve/test_audit_log_resolve.py @@ -12,7 +12,7 @@ from simple_module_core.audit_links import AuditLink, AuditLinkRegistry from simple_module_core.user_directory import NullUserDirectory from sqlalchemy.ext.asyncio import AsyncSession -from users.directory import UsersDirectory +from users.core.directory import UsersDirectory from users.models import User _DIRECTORY = UsersDirectory() diff --git a/modules/auth/tests/oidc_common/authority/test_auth_oidc_authority.py b/modules/auth/tests/oidc_common/authority/test_auth_oidc_authority.py index e7b9f81e4..de41fe5f2 100644 --- a/modules/auth/tests/oidc_common/authority/test_auth_oidc_authority.py +++ b/modules/auth/tests/oidc_common/authority/test_auth_oidc_authority.py @@ -101,11 +101,11 @@ async def test_real_users_login_cookie_is_anonymous_after_provider_switch( db_session, ): from fastapi import FastAPI - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager + from users.accounts.provider import UsersAuthProvider, clear_session_version_cache + from users.core.settings import UsersSettings from users.models import OAuthAccount, Role, User - from users.provider import UsersAuthProvider, clear_session_version_cache - from users.settings import UsersSettings role = Role(name="admin") user = User(email="authority@example.com", is_verified=True, roles=[role]) diff --git a/modules/background_tasks/tests/web/api/test_background_tasks_users_ordering.py b/modules/background_tasks/tests/web/api/test_background_tasks_users_ordering.py index d2f8d21af..a04a8a604 100644 --- a/modules/background_tasks/tests/web/api/test_background_tasks_users_ordering.py +++ b/modules/background_tasks/tests/web/api/test_background_tasks_users_ordering.py @@ -10,8 +10,8 @@ from background_tasks.module import BackgroundTasksModule from simple_module_core.discovery import topological_sort +from users.accounts.session_revocation import CHANNEL from users.module import UsersModule -from users.session_revocation import CHANNEL def test_users_is_not_a_dependency(): diff --git a/modules/dashboard/tests/web/stats/test_dashboard_single_tenant.py b/modules/dashboard/tests/web/stats/test_dashboard_single_tenant.py index 0684c435e..daa5e63b2 100644 --- a/modules/dashboard/tests/web/stats/test_dashboard_single_tenant.py +++ b/modules/dashboard/tests/web/stats/test_dashboard_single_tenant.py @@ -35,7 +35,7 @@ def test_user_role_is_not_mapped_to_dashboard_view(app): async def _plain_user(app) -> str: - from users.bootstrap import create_standard_user + from users.core.bootstrap import create_standard_user async with app.state.sm.db.session_factory() as session: result = await create_standard_user(session, email="plain@example.com", password="x" * 12) diff --git a/modules/permissions/permissions/grants/source.py b/modules/permissions/permissions/grants/source.py index 945f6c9f2..42ede0565 100644 --- a/modules/permissions/permissions/grants/source.py +++ b/modules/permissions/permissions/grants/source.py @@ -8,7 +8,7 @@ other module's (GH #337). The source runs on every authenticated request, so it caches the same way -``users.session_version_cache`` does: a bounded TTL as the floor, and an +``users.accounts.session_version_cache`` does: a bounded TTL as the floor, and an ``InvalidationBus`` message so a change made in one worker drops the entry in every worker that has a transport. The worker that made the change always drops its own entry, because it is a local subscriber like any other. diff --git a/modules/permissions/tests/core/module/test_permissions_grant_search.py b/modules/permissions/tests/core/module/test_permissions_grant_search.py index 8c1762924..67317a545 100644 --- a/modules/permissions/tests/core/module/test_permissions_grant_search.py +++ b/modules/permissions/tests/core/module/test_permissions_grant_search.py @@ -6,7 +6,7 @@ from permissions.core.service import PermissionService from simple_module_core.permissions import PermissionRegistry from test_permissions_module import _seed_user -from users.directory import UsersDirectory +from users.core.directory import UsersDirectory class TestGrantSearchEscaping: diff --git a/modules/permissions/tests/core/module/test_permissions_module.py b/modules/permissions/tests/core/module/test_permissions_module.py index db78795cd..2de3e321b 100644 --- a/modules/permissions/tests/core/module/test_permissions_module.py +++ b/modules/permissions/tests/core/module/test_permissions_module.py @@ -13,7 +13,7 @@ from simple_module_core.permissions import PermissionRegistry from simple_module_db.transaction import finalize_session from sqlalchemy.ext.asyncio import AsyncSession -from users.directory import UsersDirectory +from users.core.directory import UsersDirectory # ── register_permissions hook ────────────────────────────────── @@ -38,7 +38,7 @@ def registry() -> PermissionRegistry: async def _seed_role(db: AsyncSession, *, name: str = "user") -> uuid.UUID: - from users.constants import USER_ROLE_ID + from users.core.constants import USER_ROLE_ID from users.models import Role role = Role(id=USER_ROLE_ID, name=name, description=f"{name} role") @@ -229,7 +229,7 @@ async def test_get_role_permissions_not_found(self, authenticated_client: httpx. async def test_put_and_get_role_permissions( self, authenticated_client: httpx.AsyncClient, app: FastAPI ): - from users.constants import USER_ROLE_ID, USER_ROLE_NAME + from users.core.constants import USER_ROLE_ID, USER_ROLE_NAME from users.models import Role async with app.state.sm.db.session_factory() as db: diff --git a/modules/permissions/tests/core/service/test_permissions_directory.py b/modules/permissions/tests/core/service/test_permissions_directory.py index c66543458..30ab5598b 100644 --- a/modules/permissions/tests/core/service/test_permissions_directory.py +++ b/modules/permissions/tests/core/service/test_permissions_directory.py @@ -60,7 +60,7 @@ async def test_without_a_directory_nobody_is_found(db_session: AsyncSession): def test_admin_role_name_comes_from_core(): - """The admin sync keys on core's ``ADMIN_ROLE``, not ``users.constants``.""" - from users.constants import ADMIN_ROLE_NAME + """The admin sync keys on core's ``ADMIN_ROLE``, not ``users.core.constants``.""" + from users.core.constants import ADMIN_ROLE_NAME assert ADMIN_ROLE == ADMIN_ROLE_NAME diff --git a/modules/permissions/tests/core/service/test_permissions_inheritance_sources.py b/modules/permissions/tests/core/service/test_permissions_inheritance_sources.py index 187e10b3b..3e77612c1 100644 --- a/modules/permissions/tests/core/service/test_permissions_inheritance_sources.py +++ b/modules/permissions/tests/core/service/test_permissions_inheritance_sources.py @@ -11,7 +11,7 @@ from permissions.core.service import PermissionService from simple_module_core.permissions import WILDCARD, PermissionRegistry from sqlalchemy.ext.asyncio import AsyncSession -from users.directory import UsersDirectory +from users.core.directory import UsersDirectory def _registry() -> PermissionRegistry: @@ -71,7 +71,7 @@ async def test_inherited_by_covers_keys_that_are_also_direct( """`inherited` drops direct duplicates; `inherited_by` must not, or a key granted both ways looks purely direct and revoking it silently leaves the role grant in place.""" - from users.constants import USER_ROLE_ID + from users.core.constants import USER_ROLE_ID from users.models import Role, User reg = _registry() diff --git a/modules/permissions/tests/core/service/test_permissions_role_overlay.py b/modules/permissions/tests/core/service/test_permissions_role_overlay.py index 597cb6384..92fa598cc 100644 --- a/modules/permissions/tests/core/service/test_permissions_role_overlay.py +++ b/modules/permissions/tests/core/service/test_permissions_role_overlay.py @@ -22,7 +22,7 @@ def test_overlay_replace_keeps_code_mapping_and_drops_removed_db_keys(): async def test_saving_a_role_keeps_its_code_mapped_grants( authenticated_client: httpx.AsyncClient, app: FastAPI ): - from users.constants import USER_ROLE_ID, USER_ROLE_NAME + from users.core.constants import USER_ROLE_ID, USER_ROLE_NAME from users.models import Role registry = app.state.sm.permissions diff --git a/modules/permissions/tests/core/service/test_permissions_role_update.py b/modules/permissions/tests/core/service/test_permissions_role_update.py index 1ec019e6f..9737084bb 100644 --- a/modules/permissions/tests/core/service/test_permissions_role_update.py +++ b/modules/permissions/tests/core/service/test_permissions_role_update.py @@ -11,7 +11,7 @@ class TestRolePermissionsAPI: async def test_put_requires_permissions_field( self, authenticated_client: httpx.AsyncClient, app: FastAPI ): - from users.constants import USER_ROLE_ID, USER_ROLE_NAME + from users.core.constants import USER_ROLE_ID, USER_ROLE_NAME from users.models import Role async with app.state.sm.db.session_factory() as db: diff --git a/modules/permissions/tests/core/service/test_permissions_runtime_sources.py b/modules/permissions/tests/core/service/test_permissions_runtime_sources.py index 3341cf36f..acc8e8548 100644 --- a/modules/permissions/tests/core/service/test_permissions_runtime_sources.py +++ b/modules/permissions/tests/core/service/test_permissions_runtime_sources.py @@ -9,7 +9,7 @@ async def test_source_permission_listed_grantable_and_invalidated( authenticated_client: httpx.AsyncClient, app: FastAPI ): - from users.constants import USER_ROLE_ID, USER_ROLE_NAME + from users.core.constants import USER_ROLE_ID, USER_ROLE_NAME from users.models import Role registry = app.state.sm.permissions @@ -64,7 +64,7 @@ async def test_saving_role_keeps_grants_of_currently_unregistered_source_keys( authenticated_client: httpx.AsyncClient, app: FastAPI ): from permissions.core.service import PermissionService - from users.constants import USER_ROLE_ID, USER_ROLE_NAME + from users.core.constants import USER_ROLE_ID, USER_ROLE_NAME from users.models import Role registry = app.state.sm.permissions diff --git a/modules/settings/tests/core/service/test_settings_generic_module_guard.py b/modules/settings/tests/core/service/test_settings_generic_module_guard.py index 7f753e526..a0d464ee1 100644 --- a/modules/settings/tests/core/service/test_settings_generic_module_guard.py +++ b/modules/settings/tests/core/service/test_settings_generic_module_guard.py @@ -52,7 +52,7 @@ def test_browse_does_not_suggest_module_keys(): from fastapi import FastAPI from settings.web.known_keys import build from simple_module_core.module_settings import register_module_settings - from users.settings import UsersSettings + from users.core.settings import UsersSettings app = FastAPI() app.state.sm = SimpleNamespace(modules=[]) diff --git a/modules/settings/tests/module_settings/fields/test_settings_field_sources.py b/modules/settings/tests/module_settings/fields/test_settings_field_sources.py index 46f8b63bf..dbc19d522 100644 --- a/modules/settings/tests/module_settings/fields/test_settings_field_sources.py +++ b/modules/settings/tests/module_settings/fields/test_settings_field_sources.py @@ -140,7 +140,7 @@ def _field(self, cls, name: str): return _field_view(name, cls(), "SM_USERS_", frozenset()) def test_an_int_named_like_a_secret_is_not_masked(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings field = self._field(UsersSettings, "reset_password_token_lifetime_seconds") assert field.is_secret is False @@ -149,7 +149,7 @@ def test_an_int_named_like_a_secret_is_not_masked(self): def test_a_real_string_secret_is_still_masked(self): from settings.module_settings.discovery import SECRET_MASK - from users.settings import UsersSettings + from users.core.settings import UsersSettings field = self._field(UsersSettings, "reset_password_token_secret") assert field.is_secret is True diff --git a/modules/settings/tests/module_settings/fields/test_settings_lenient_related_fields_validators.py b/modules/settings/tests/module_settings/fields/test_settings_lenient_related_fields_validators.py index b31f991c5..ca245e90b 100644 --- a/modules/settings/tests/module_settings/fields/test_settings_lenient_related_fields_validators.py +++ b/modules/settings/tests/module_settings/fields/test_settings_lenient_related_fields_validators.py @@ -15,7 +15,7 @@ from simple_module_core.stored_settings import construct_leniently from simple_module_hosting.host_settings import HostSettings from site_lock.core.settings import SiteLockSettings -from users.settings import UsersSettings +from users.core.settings import UsersSettings def _int(v: int) -> tuple[str, str]: diff --git a/modules/sharing/tests/web/endpoints/test_sharing_http.py b/modules/sharing/tests/web/endpoints/test_sharing_http.py index c09cb493f..a409e4eac 100644 --- a/modules/sharing/tests/web/endpoints/test_sharing_http.py +++ b/modules/sharing/tests/web/endpoints/test_sharing_http.py @@ -73,7 +73,7 @@ async def test_admin_page_lists_tenant_shares(note_app, tenant_client): from tenants.core.constants import SESSION_ACTIVE_TENANT from tenants.models import Membership from tenants.resolution.resolver import forget - from users.bootstrap import create_admin + from users.core.bootstrap import create_admin async with tenant_client(TenantRole.OWNER) as owner: async with tenant_client(TenantRole.MEMBER, tenant_id=owner.tenant_id) as friend: diff --git a/modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py b/modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py index 71b1727be..4ee0e6316 100644 --- a/modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py +++ b/modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py @@ -38,7 +38,7 @@ async def test_tenant_roles_reach_the_permission_registry(app): async def test_fixture_user_holds_the_platform_user_role(app, tenant_client): """The ``user`` Role row is seeded on demand, never silently skipped.""" from sqlalchemy import select - from users.constants import USER_ROLE_NAME + from users.core.constants import USER_ROLE_NAME from users.models import Role, UserRole async with tenant_client("member") as (_, _, user_id): diff --git a/modules/tenants/tests/core/service/test_tenants_role_permissions.py b/modules/tenants/tests/core/service/test_tenants_role_permissions.py index 41c328be1..db2704501 100644 --- a/modules/tenants/tests/core/service/test_tenants_role_permissions.py +++ b/modules/tenants/tests/core/service/test_tenants_role_permissions.py @@ -75,7 +75,7 @@ async def test_never_touches_tenant_roles(self, app, db_session): from permissions.core.service import PermissionService from permissions.models import RolePermission from sqlalchemy import select - from users.constants import ADMIN_ROLE_NAME + from users.core.constants import ADMIN_ROLE_NAME registry = app.state.sm.permissions before = {r: sorted(p) for r, p in registry.role_map.items() if is_tenant_role(r)} diff --git a/modules/users/tests-js/users_admin_error_details.test.tsx b/modules/users/tests-js/users_admin_error_details.test.tsx index 968d0e9f2..34a18394d 100644 --- a/modules/users/tests-js/users_admin_error_details.test.tsx +++ b/modules/users/tests-js/users_admin_error_details.test.tsx @@ -9,9 +9,9 @@ import { toast } from 'sonner'; import { useUserRowActions } from '../users/admin/components/useUserRowActions'; import de from '../users/locales/de.json'; import en from '../users/locales/en.json'; -import { adminFailureMessage } from '../users/pages/failureMessage'; import { useEditUserForm } from '../users/pages/Users/components/useEditUserForm'; import { useUserActions } from '../users/pages/Users/components/useUserActions'; +import { adminFailureMessage } from '../users/ui/utils/failureMessage'; import { json, stubFetch } from './users_fixtures'; afterEach(() => { diff --git a/modules/users/tests/_middleware_support.py b/modules/users/tests/_middleware_support.py index e0e4f9b4f..23c0f763e 100644 --- a/modules/users/tests/_middleware_support.py +++ b/modules/users/tests/_middleware_support.py @@ -19,7 +19,7 @@ from simple_module_test import forge_session_cookie from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse -from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID +from users.core.constants import ADMIN_ROLE_ID, USER_ROLE_ID SECRET_KEY = "test-secret-key-for-session-middleware" @@ -61,8 +61,8 @@ async def _default_handler(request: Request): app = FastAPI() app.state.sm = SimpleNamespace(db=db_state) from simple_module_core.public_routes import PublicRouteRegistry + from users.accounts.provider import UsersAuthProvider from users.module import UsersModule - from users.provider import UsersAuthProvider # The host fills this from every module's register_public_routes at boot. app.state.public_routes = PublicRouteRegistry() diff --git a/modules/users/tests/_users_admin_support.py b/modules/users/tests/_users_admin_support.py new file mode 100644 index 000000000..d5976be63 --- /dev/null +++ b/modules/users/tests/_users_admin_support.py @@ -0,0 +1,52 @@ +"""Shared builders for the admin API and service tests.""" + +from __future__ import annotations + +import uuid + +from fastapi_users.password import PasswordHelper +from users.models import Role, User, UserRole + +_pw = PasswordHelper() + + +async def _make_user(session, email, password="SecurePass1!", verified=True, role_names=None): + from sqlalchemy import select + + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=_pw.hash(password), + is_active=True, + is_superuser=False, + is_verified=verified, + ) + session.add(user) + await session.flush() + + if role_names: + roles = ( + (await session.execute(select(Role).where(Role.name.in_(role_names)))).scalars().all() + ) + for role in roles: + session.add(UserRole(user_id=user.id, role_id=role.id)) + + await session.commit() + await session.refresh(user) + return user + + +def _build_service(session, users_app_empty): + """Build a UserService directly (bypass FastAPI Depends).""" + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager + from users.admin.service import UserService + from users.models import User + + user_db = UserDatabaseWithRoles(session, User) + manager = UserManager( + user_db, + users_app_empty.state.users.mailer, + users_app_empty.state.users.settings, + ) + return UserService(session, manager) diff --git a/modules/users/tests/_users_app_builders.py b/modules/users/tests/_users_app_builders.py index b38667b13..759649dc8 100644 --- a/modules/users/tests/_users_app_builders.py +++ b/modules/users/tests/_users_app_builders.py @@ -9,7 +9,7 @@ import httpx from simple_module_hosting.settings import Settings -from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID +from users.core.constants import ADMIN_ROLE_ID, USER_ROLE_ID ALICE = "alice@example.com" @@ -86,7 +86,7 @@ async def _seed_roles(application) -> None: await session.commit() # Direct inserts bypass the role service that would refresh the cache; startup # already warmed it (with the role it seeds), so refresh like a real write would. - from users.roles_cache import refresh_roles_cache + from users.core.roles_cache import refresh_roles_cache await refresh_roles_cache(application) @@ -112,7 +112,7 @@ async def _seed_users_settings( async def _make_admin_user(app): """Seed an admin User + Role into app's DB and return the User row.""" - from users.bootstrap import create_admin + from users.core.bootstrap import create_admin from users.models import User async with app.state.sm.db.session_factory() as session: @@ -132,7 +132,7 @@ async def _make_standard_user(app, email: str = "user@example.com"): Used by the negative-authz tests to confirm endpoints protected by ``RequiresPermission(...)`` reject authenticated-but-non-admin callers. """ - from users.bootstrap import create_standard_user + from users.core.bootstrap import create_standard_user from users.models import User async with app.state.sm.db.session_factory() as session: diff --git a/modules/users/tests/test_backend.py b/modules/users/tests/accounts/backend/test_users_backend.py similarity index 81% rename from modules/users/tests/test_backend.py rename to modules/users/tests/accounts/backend/test_users_backend.py index b95748dba..152f3eafe 100644 --- a/modules/users/tests/test_backend.py +++ b/modules/users/tests/accounts/backend/test_users_backend.py @@ -6,7 +6,7 @@ def test_build_auth_backend_name(): - from users.backend import build_auth_backend, build_cookie_transport + from users.accounts.backend import build_auth_backend, build_cookie_transport transport = build_cookie_transport( cookie_name="sm_auth", @@ -20,7 +20,7 @@ def test_build_auth_backend_name(): def test_build_auth_backend_is_authentication_backend(): - from users.backend import build_auth_backend, build_cookie_transport + from users.accounts.backend import build_auth_backend, build_cookie_transport transport = build_cookie_transport( cookie_name="sm_auth", @@ -34,7 +34,7 @@ def test_build_auth_backend_is_authentication_backend(): def test_build_cookie_transport_sets_name(): - from users.backend import build_cookie_transport + from users.accounts.backend import build_cookie_transport transport = build_cookie_transport( cookie_name="my_cookie", @@ -51,7 +51,11 @@ def test_build_cookie_transport_sets_name(): def test_build_auth_backend_strategy_is_database_strategy(): """The get_strategy callable in the backend is get_database_strategy.""" - from users.backend import build_auth_backend, build_cookie_transport, get_database_strategy + from users.accounts.backend import ( + build_auth_backend, + build_cookie_transport, + get_database_strategy, + ) transport = build_cookie_transport("sm_auth", 86400, False, "lax") backend = build_auth_backend(transport) diff --git a/modules/users/tests/test_db_adapter.py b/modules/users/tests/accounts/db_adapter/test_users_db_adapter.py similarity index 88% rename from modules/users/tests/test_db_adapter.py rename to modules/users/tests/accounts/db_adapter/test_users_db_adapter.py index 7c089553d..0776239d8 100644 --- a/modules/users/tests/test_db_adapter.py +++ b/modules/users/tests/accounts/db_adapter/test_users_db_adapter.py @@ -6,7 +6,7 @@ import pytest from sqlalchemy import select -from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID +from users.core.constants import ADMIN_ROLE_ID, USER_ROLE_ID @pytest.fixture @@ -50,7 +50,7 @@ async def test_user(db_session, _seed_roles): @pytest.mark.anyio async def test_get_by_email_returns_user_with_roles(db_session, test_user): - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User user_id, email = test_user @@ -67,7 +67,7 @@ async def test_get_by_email_returns_user_with_roles(db_session, test_user): @pytest.mark.anyio async def test_get_returns_user_with_roles(db_session, test_user): - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User user_id, _ = test_user @@ -84,7 +84,7 @@ async def test_get_returns_user_with_roles(db_session, test_user): @pytest.mark.anyio async def test_get_by_email_case_insensitive(db_session, test_user): """get_by_email uses func.lower so lookup is case-insensitive.""" - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User _, email = test_user @@ -96,7 +96,7 @@ async def test_get_by_email_case_insensitive(db_session, test_user): @pytest.mark.anyio async def test_get_nonexistent_returns_none(db_session): - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User db = UserDatabaseWithRoles(db_session, User) @@ -106,7 +106,7 @@ async def test_get_nonexistent_returns_none(db_session): @pytest.mark.anyio async def test_get_by_email_nonexistent_returns_none(db_session): - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User db = UserDatabaseWithRoles(db_session, User) @@ -116,7 +116,7 @@ async def test_get_by_email_nonexistent_returns_none(db_session): @pytest.mark.anyio async def test_user_with_no_roles_returns_empty_list(db_session): - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User user_id = uuid.uuid4() diff --git a/modules/users/tests/test_users_default_role.py b/modules/users/tests/accounts/manager/test_users_default_role.py similarity index 93% rename from modules/users/tests/test_users_default_role.py rename to modules/users/tests/accounts/manager/test_users_default_role.py index ad00a5808..9f51f9909 100644 --- a/modules/users/tests/test_users_default_role.py +++ b/modules/users/tests/accounts/manager/test_users_default_role.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock from sqlalchemy import select -from users.constants import OAUTH_REGISTRATION_REQUEST_FLAG, USER_ROLE_ID -from users.db_adapter import UserDatabaseWithRoles -from users.manager import UserManager +from users.accounts.db_adapter import UserDatabaseWithRoles +from users.accounts.manager import UserManager +from users.core.constants import OAUTH_REGISTRATION_REQUEST_FLAG, USER_ROLE_ID from users.models import OAuthAccount, Role, User, UserRole diff --git a/modules/users/tests/test_users_email_race.py b/modules/users/tests/accounts/manager/test_users_email_race.py similarity index 89% rename from modules/users/tests/test_users_email_race.py rename to modules/users/tests/accounts/manager/test_users_email_race.py index 4dd1cb1ee..89e8e0569 100644 --- a/modules/users/tests/test_users_email_race.py +++ b/modules/users/tests/accounts/manager/test_users_email_race.py @@ -3,14 +3,14 @@ from __future__ import annotations import pytest +from _users_admin_support import _build_service from fastapi_users.exceptions import UserAlreadyExists from simple_module_db.callbacks import register_on_commit, run_on_commit_callbacks -from test_service_admin import _build_service @pytest.mark.anyio async def test_lost_create_race_keeps_other_pending_commit_work(users_app): - from users.db_adapter import UserDatabaseWithRoles + from users.accounts.db_adapter import UserDatabaseWithRoles from users.models import User ran = [] @@ -28,7 +28,7 @@ async def test_lost_create_race_keeps_other_pending_commit_work(users_app): @pytest.mark.anyio async def test_admin_email_edit_is_stored_lowercase(users_app): - from test_api_admin import _make_user + from _users_admin_support import _make_user async with users_app.state.sm.db.session_factory() as session: user = await _make_user(session, email="edit-me@example.com") @@ -39,8 +39,8 @@ async def test_admin_email_edit_is_stored_lowercase(users_app): @pytest.mark.anyio async def test_admin_email_edit_race_is_a_duplicate_error(users_app, monkeypatch): - from test_api_admin import _make_user - from users.exceptions import EmailAlreadyExistsError + from _users_admin_support import _make_user + from users.core.exceptions import EmailAlreadyExistsError async with users_app.state.sm.db.session_factory() as session: await _make_user(session, email="taken@example.com") @@ -66,7 +66,7 @@ async def precheck_misses(statement, *args, **kwargs): @pytest.mark.anyio async def test_create_admin_stores_email_lowercase(users_app): - from users.bootstrap import create_admin + from users.core.bootstrap import create_admin async with users_app.state.sm.db.session_factory() as session: result = await create_admin( diff --git a/modules/users/tests/test_users_email_uniqueness.py b/modules/users/tests/accounts/manager/test_users_email_uniqueness.py similarity index 98% rename from modules/users/tests/test_users_email_uniqueness.py rename to modules/users/tests/accounts/manager/test_users_email_uniqueness.py index 39ad793a0..2377dd42d 100644 --- a/modules/users/tests/test_users_email_uniqueness.py +++ b/modules/users/tests/accounts/manager/test_users_email_uniqueness.py @@ -42,7 +42,7 @@ async def test_users_duplicate_email_compatibility_is_read_only(duplicates): def _migration(): - path = Path(__file__).resolve().parents[3] / "host/migrations/versions" + path = Path(__file__).resolve().parents[5] / "host/migrations/versions" spec = importlib.util.spec_from_file_location( "users_email_migration", path / "e731a1b2c3d4_users_unique_lower_email.py" ) diff --git a/modules/users/tests/test_users_manager.py b/modules/users/tests/accounts/manager/test_users_manager.py similarity index 98% rename from modules/users/tests/test_users_manager.py rename to modules/users/tests/accounts/manager/test_users_manager.py index 69ebcaf78..f95566124 100644 --- a/modules/users/tests/test_users_manager.py +++ b/modules/users/tests/accounts/manager/test_users_manager.py @@ -17,7 +17,7 @@ @pytest.fixture def fake_settings(): - from users.settings import UsersSettings + from users.core.settings import UsersSettings return UsersSettings( verification_token_secret="test-verify-secret-at-least-32-bytes-long", @@ -58,7 +58,7 @@ def fake_user_db(): @pytest.fixture def manager(fake_settings, fake_mailer, fake_user_db): - from users.manager import UserManager + from users.accounts.manager import UserManager return UserManager(fake_user_db, fake_mailer, fake_settings) @@ -127,7 +127,7 @@ async def test_publishes_user_registered(self, manager, fake_user): # Pre-verified so the manager doesn't try to send a verify email. fake_user.is_verified = True - with patch("users.manager.attach_default_role", AsyncMock()): + with patch("users.accounts.manager.attach_default_role", AsyncMock()): await manager.on_after_register(fake_user, request) (event,) = bus.assert_published(UserRegistered) diff --git a/modules/users/tests/test_users_register_single_connection.py b/modules/users/tests/accounts/manager/test_users_register_single_connection.py similarity index 97% rename from modules/users/tests/test_users_register_single_connection.py rename to modules/users/tests/accounts/manager/test_users_register_single_connection.py index 6cbe51ee8..f38d1fa69 100644 --- a/modules/users/tests/test_users_register_single_connection.py +++ b/modules/users/tests/accounts/manager/test_users_register_single_connection.py @@ -6,7 +6,7 @@ from simple_module_hosting.settings import Settings from sqlalchemy import select from sqlalchemy.ext.asyncio import create_async_engine -from users.constants import USER_ROLE_NAME +from users.core.constants import USER_ROLE_NAME from users.models import Role, User, UserRole diff --git a/modules/users/tests/test_users_bearer_scheme.py b/modules/users/tests/accounts/provider/test_users_bearer_scheme.py similarity index 93% rename from modules/users/tests/test_users_bearer_scheme.py rename to modules/users/tests/accounts/provider/test_users_bearer_scheme.py index b8606a2e5..7c1178c2b 100644 --- a/modules/users/tests/test_users_bearer_scheme.py +++ b/modules/users/tests/accounts/provider/test_users_bearer_scheme.py @@ -28,7 +28,7 @@ def _request(app, header: str) -> Request: @pytest.mark.anyio @pytest.mark.parametrize("scheme", ["Bearer", "bearer", "BEARER", " bearer "]) async def test_any_scheme_casing_authenticates(users_app, anon_client, scheme): - from users.provider import UsersAuthProvider + from users.accounts.provider import UsersAuthProvider resp = await anon_client.post( _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"} @@ -47,7 +47,7 @@ async def test_any_scheme_casing_authenticates(users_app, anon_client, scheme): @pytest.mark.anyio async def test_another_scheme_is_not_a_bearer_request(users_app): - from users.provider import UsersAuthProvider + from users.accounts.provider import UsersAuthProvider request = _request(users_app, "Basic dXNlcjpwYXNz") assert not UsersAuthProvider().is_bearer_request(request) diff --git a/modules/users/tests/test_legacy_cached_tenant.py b/modules/users/tests/accounts/provider/test_users_legacy_cached_tenant.py similarity index 96% rename from modules/users/tests/test_legacy_cached_tenant.py rename to modules/users/tests/accounts/provider/test_users_legacy_cached_tenant.py index c5d65bfdf..59e938d26 100644 --- a/modules/users/tests/test_legacy_cached_tenant.py +++ b/modules/users/tests/accounts/provider/test_users_legacy_cached_tenant.py @@ -15,8 +15,8 @@ from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY from sqlalchemy import select from starlette.requests import Request +from users.accounts.provider import UsersAuthProvider from users.models import User -from users.provider import UsersAuthProvider async def _admin_id(users_app) -> str: diff --git a/modules/users/tests/test_users_provider.py b/modules/users/tests/accounts/provider/test_users_provider.py similarity index 96% rename from modules/users/tests/test_users_provider.py rename to modules/users/tests/accounts/provider/test_users_provider.py index 45c644eb6..854e5e563 100644 --- a/modules/users/tests/test_users_provider.py +++ b/modules/users/tests/accounts/provider/test_users_provider.py @@ -3,7 +3,7 @@ from __future__ import annotations from auth.contracts.provider import AuthProvider -from users.provider import UsersAuthProvider +from users.accounts.provider import UsersAuthProvider def test_users_provider_satisfies_protocol(): diff --git a/modules/users/tests/test_users_reset_credentials.py b/modules/users/tests/accounts/reset/test_users_reset_credentials.py similarity index 98% rename from modules/users/tests/test_users_reset_credentials.py rename to modules/users/tests/accounts/reset/test_users_reset_credentials.py index ffa6a9ffc..be057deb9 100644 --- a/modules/users/tests/test_users_reset_credentials.py +++ b/modules/users/tests/accounts/reset/test_users_reset_credentials.py @@ -5,8 +5,8 @@ import httpx import pytest from sqlmodel import select +from users.accounts.session_revocation import CHANNEL from users.models import RefreshToken, UserAccessToken -from users.session_revocation import CHANNEL @pytest.mark.parametrize("credential", ["refresh", "session", "access"]) @@ -64,7 +64,7 @@ async def test_reset_revokes_in_the_password_commit( admin_client, anon_client, users_app, monkeypatch ): """A failure after the password is stored must not leave old credentials valid.""" - from users.manager import UserManager + from users.accounts.manager import UserManager pair = await anon_client.post( "/api/users/auth/token", diff --git a/modules/users/tests/test_users_reset_link_origin.py b/modules/users/tests/accounts/reset/test_users_reset_link_origin.py similarity index 97% rename from modules/users/tests/test_users_reset_link_origin.py rename to modules/users/tests/accounts/reset/test_users_reset_link_origin.py index 1a4a6e498..806d6f6a7 100644 --- a/modules/users/tests/test_users_reset_link_origin.py +++ b/modules/users/tests/accounts/reset/test_users_reset_link_origin.py @@ -8,7 +8,7 @@ import pytest from _users_app_builders import _make_standard_user -from users.settings import UsersSettings +from users.core.settings import UsersSettings _STOCK = UsersSettings.model_fields["base_url"].default diff --git a/modules/users/tests/test_users_reset_password_retry.py b/modules/users/tests/accounts/reset/test_users_reset_password_retry.py similarity index 100% rename from modules/users/tests/test_users_reset_password_retry.py rename to modules/users/tests/accounts/reset/test_users_reset_password_retry.py diff --git a/modules/users/tests/test_revocation_broadcast.py b/modules/users/tests/accounts/revocation/test_users_revocation_broadcast.py similarity index 99% rename from modules/users/tests/test_revocation_broadcast.py rename to modules/users/tests/accounts/revocation/test_users_revocation_broadcast.py index ec532f78c..44007d99c 100644 --- a/modules/users/tests/test_revocation_broadcast.py +++ b/modules/users/tests/accounts/revocation/test_users_revocation_broadcast.py @@ -20,7 +20,7 @@ import pytest from sqlalchemy import select -from users.session_revocation import CHANNEL +from users.accounts.session_revocation import CHANNEL _REVOKE = "/api/users/me/sessions/revoke-all" _PASSWORD = "/api/users/me/password" diff --git a/modules/users/tests/test_session_revocation.py b/modules/users/tests/accounts/revocation/test_users_session_revocation.py similarity index 97% rename from modules/users/tests/test_session_revocation.py rename to modules/users/tests/accounts/revocation/test_users_session_revocation.py index abf4fe652..58d8e546f 100644 --- a/modules/users/tests/test_session_revocation.py +++ b/modules/users/tests/accounts/revocation/test_users_session_revocation.py @@ -19,8 +19,8 @@ import pytest from simple_module_core.invalidation import InvalidationBus -from users.session_revocation import CHANNEL, publish_revocation, subscribe -from users.session_version_cache import ( +from users.accounts.session_revocation import CHANNEL, publish_revocation, subscribe +from users.accounts.session_version_cache import ( clear_session_version_cache, peek_session_version, read_session_version, diff --git a/modules/users/tests/test_sessions_revoke.py b/modules/users/tests/accounts/revocation/test_users_sessions_revoke.py similarity index 100% rename from modules/users/tests/test_sessions_revoke.py rename to modules/users/tests/accounts/revocation/test_users_sessions_revoke.py diff --git a/modules/users/tests/test_session_version_cache.py b/modules/users/tests/accounts/session_version/test_users_session_version_cache.py similarity index 97% rename from modules/users/tests/test_session_version_cache.py rename to modules/users/tests/accounts/session_version/test_users_session_version_cache.py index e57488e06..ad90b4872 100644 --- a/modules/users/tests/test_session_version_cache.py +++ b/modules/users/tests/accounts/session_version/test_users_session_version_cache.py @@ -15,8 +15,8 @@ import uuid import pytest -from users import provider as provider_module -from users.provider import forget_session_version +from users.accounts import provider as provider_module +from users.accounts.provider import forget_session_version @pytest.fixture(autouse=True) diff --git a/modules/users/tests/test_session_version_failopen.py b/modules/users/tests/accounts/session_version/test_users_session_version_failopen.py similarity index 97% rename from modules/users/tests/test_session_version_failopen.py rename to modules/users/tests/accounts/session_version/test_users_session_version_failopen.py index 510f74015..5eb0b6926 100644 --- a/modules/users/tests/test_session_version_failopen.py +++ b/modules/users/tests/accounts/session_version/test_users_session_version_failopen.py @@ -29,14 +29,14 @@ import uuid import pytest -from users.constants import SESSION_VERSION_KEY -from users.provider import UsersAuthProvider -from users.session_version_cache import ( +from users.accounts.provider import UsersAuthProvider +from users.accounts.session_version_cache import ( clear_session_version_cache, configure_session_version_cache, read_session_version, store_session_version, ) +from users.core.constants import SESSION_VERSION_KEY _STAMPED = 3 _REVOKED_TO = 4 diff --git a/modules/users/tests/test_session_version_hardening.py b/modules/users/tests/accounts/session_version/test_users_session_version_hardening.py similarity index 97% rename from modules/users/tests/test_session_version_hardening.py rename to modules/users/tests/accounts/session_version/test_users_session_version_hardening.py index ab69cddae..c05d79581 100644 --- a/modules/users/tests/test_session_version_hardening.py +++ b/modules/users/tests/accounts/session_version/test_users_session_version_hardening.py @@ -10,8 +10,7 @@ import pytest from simple_module_db import RequestSession from sqlalchemy import select -from users.models import User -from users.session_version_cache import ( +from users.accounts.session_version_cache import ( SESSION_VERSION_TTL_SECONDS, clear_session_version_cache, configure_session_version_cache, @@ -19,6 +18,7 @@ session_version_ttl, store_session_version, ) +from users.models import User _CREDS = {"username": "admin@example.com", "password": "AdminPass1!"} @@ -68,7 +68,7 @@ def __getitem__(self, key): raise KeyError(key) monkeypatch.setattr( - "users.session_version_cache._SESSION_VERSIONS", _Vanishing(), raising=True + "users.accounts.session_version_cache._SESSION_VERSIONS", _Vanishing(), raising=True ) assert read_session_version("dana") == (False, None) @@ -184,7 +184,7 @@ async def test_a_rolled_back_bump_leaves_the_cache_intact(self, users_app): user_id = await self._admin_id(users_app) store_session_version(user_id, 3) - from users.session_version_cache import forget_session_version + from users.accounts.session_version_cache import forget_session_version async with users_app.state.sm.db.session_factory() as session: assert isinstance(session, RequestSession) @@ -234,7 +234,7 @@ def test_the_module_level_cache_is_the_one_the_helpers_use(): that name still points at the cache the rebuild replaced, which is exactly the trap this test exists to describe. """ - import users.session_version_cache as cache_module + import users.accounts.session_version_cache as cache_module configure_session_version_cache(7) store_session_version("dana", 1) diff --git a/modules/users/tests/test_legacy_session_expiry.py b/modules/users/tests/accounts/sessions/test_users_legacy_session_expiry.py similarity index 100% rename from modules/users/tests/test_legacy_session_expiry.py rename to modules/users/tests/accounts/sessions/test_users_legacy_session_expiry.py diff --git a/modules/users/tests/test_remember_me.py b/modules/users/tests/accounts/sessions/test_users_remember_me.py similarity index 98% rename from modules/users/tests/test_remember_me.py rename to modules/users/tests/accounts/sessions/test_users_remember_me.py index 4f23bbd2c..1b86584e3 100644 --- a/modules/users/tests/test_remember_me.py +++ b/modules/users/tests/accounts/sessions/test_users_remember_me.py @@ -84,8 +84,8 @@ async def _cookie_strategy_user(app, client): #658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row the way ``fastapi_users.current_user`` does instead. """ - from users.backend import build_strategy - from users.db_adapter import PruningAccessTokenDatabase + from users.accounts.backend import build_strategy + from users.accounts.db_adapter import PruningAccessTokenDatabase from users.models import UserAccessToken async with app.state.sm.db.session_factory() as session: diff --git a/modules/users/tests/test_session_expiry.py b/modules/users/tests/accounts/sessions/test_users_session_expiry.py similarity index 100% rename from modules/users/tests/test_session_expiry.py rename to modules/users/tests/accounts/sessions/test_users_session_expiry.py diff --git a/modules/users/tests/test_bearer_token_age.py b/modules/users/tests/accounts/tokens/test_users_bearer_token_age.py similarity index 98% rename from modules/users/tests/test_bearer_token_age.py rename to modules/users/tests/accounts/tokens/test_users_bearer_token_age.py index 37a136ec7..21b3f4fc1 100644 --- a/modules/users/tests/test_bearer_token_age.py +++ b/modules/users/tests/accounts/tokens/test_users_bearer_token_age.py @@ -16,7 +16,7 @@ import httpx import pytest from sqlalchemy import select, update -from users.backend import _TOKEN_LIFETIME_SECONDS +from users.accounts.backend import _TOKEN_LIFETIME_SECONDS from users.models import User, UserAccessToken _GUARDED = "/admin/users/" diff --git a/modules/users/tests/test_bearer_token_expiry.py b/modules/users/tests/accounts/tokens/test_users_bearer_token_expiry.py similarity index 97% rename from modules/users/tests/test_bearer_token_expiry.py rename to modules/users/tests/accounts/tokens/test_users_bearer_token_expiry.py index 4a6f89e79..ba69ade13 100644 --- a/modules/users/tests/test_bearer_token_expiry.py +++ b/modules/users/tests/accounts/tokens/test_users_bearer_token_expiry.py @@ -39,8 +39,8 @@ async def _cookie_strategy_user(app, client): #658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row the way ``fastapi_users.current_user`` does instead. """ - from users.backend import build_strategy - from users.db_adapter import PruningAccessTokenDatabase + from users.accounts.backend import build_strategy + from users.accounts.db_adapter import PruningAccessTokenDatabase from users.models import UserAccessToken async with app.state.sm.db.session_factory() as session: @@ -165,7 +165,7 @@ async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client) @pytest.mark.anyio async def test_the_provider_path_refuses_it(self, users_app, anon_client): """`UsersAuthProvider._resolve_bearer`, via AuthMiddleware.""" - from users.provider import UsersAuthProvider + from users.accounts.provider import UsersAuthProvider resp = await anon_client.post( _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"} @@ -186,7 +186,7 @@ class TestAPasswordChangeStrandsBearerTokens: @pytest.mark.anyio async def test_a_version_bump_strands_a_token_on_both_paths(self, users_app, anon_client): - from users.provider import UsersAuthProvider, forget_session_version + from users.accounts.provider import UsersAuthProvider, forget_session_version # One row per path: the sign-in writes the `sm_auth` row `current_user` # reads, and `/auth/token` mints the one the Bearer header carries. diff --git a/modules/users/tests/test_users_refresh_family_max_age.py b/modules/users/tests/accounts/tokens/test_users_refresh_family_max_age.py similarity index 97% rename from modules/users/tests/test_users_refresh_family_max_age.py rename to modules/users/tests/accounts/tokens/test_users_refresh_family_max_age.py index e169e15a1..6b6d30a07 100644 --- a/modules/users/tests/test_users_refresh_family_max_age.py +++ b/modules/users/tests/accounts/tokens/test_users_refresh_family_max_age.py @@ -10,11 +10,11 @@ from pydantic import ValidationError from sqlalchemy.orm import Session from starlette.responses import JSONResponse +from users.accounts.db_adapter import get_access_token_db +from users.accounts.token_storage import hash_token, prune_tokens from users.auth_local.token_api import RefreshRequest, TokenResponse, token_refresh -from users.db_adapter import get_access_token_db +from users.core.settings import UsersSettings from users.models import Base, RefreshToken, User, UserAccessToken -from users.settings import UsersSettings -from users.token_storage import hash_token, prune_tokens @pytest.fixture diff --git a/modules/users/tests/test_users_refresh_reuse.py b/modules/users/tests/accounts/tokens/test_users_refresh_reuse.py similarity index 100% rename from modules/users/tests/test_users_refresh_reuse.py rename to modules/users/tests/accounts/tokens/test_users_refresh_reuse.py diff --git a/modules/users/tests/test_users_token_pruning.py b/modules/users/tests/accounts/tokens/test_users_token_pruning.py similarity index 97% rename from modules/users/tests/test_users_token_pruning.py rename to modules/users/tests/accounts/tokens/test_users_token_pruning.py index 62b1c842b..afa8317ac 100644 --- a/modules/users/tests/test_users_token_pruning.py +++ b/modules/users/tests/accounts/tokens/test_users_token_pruning.py @@ -9,8 +9,8 @@ import sqlalchemy as sa from fastapi_users_db_sqlalchemy.generics import GUID from sqlalchemy.orm import Session +from users.accounts.token_storage import prune_tokens from users.models import RefreshToken, UserAccessToken -from users.token_storage import prune_tokens def test_users_pruning_is_bounded_scoped_and_keeps_active_family_evidence(): @@ -144,8 +144,8 @@ async def delete(self, row): def test_users_cookie_strategy_hashes_prunes_and_destroys_with_sync_sqlite(): - from users.backend import build_strategy - from users.db_adapter import PruningAccessTokenDatabase + from users.accounts.backend import build_strategy + from users.accounts.db_adapter import PruningAccessTokenDatabase engine = sa.create_engine("sqlite://") metadata = sa.MetaData() diff --git a/modules/users/tests/test_users_token_storage.py b/modules/users/tests/accounts/tokens/test_users_token_storage.py similarity index 100% rename from modules/users/tests/test_users_token_storage.py rename to modules/users/tests/accounts/tokens/test_users_token_storage.py diff --git a/modules/users/tests/test_activity_summary.py b/modules/users/tests/admin/activity/test_users_activity_summary.py similarity index 100% rename from modules/users/tests/test_activity_summary.py rename to modules/users/tests/admin/activity/test_users_activity_summary.py diff --git a/modules/users/tests/test_edit_recent_activity.py b/modules/users/tests/admin/activity/test_users_edit_recent_activity.py similarity index 99% rename from modules/users/tests/test_edit_recent_activity.py rename to modules/users/tests/admin/activity/test_users_edit_recent_activity.py index 4fc373425..cdf870aeb 100644 --- a/modules/users/tests/test_edit_recent_activity.py +++ b/modules/users/tests/admin/activity/test_users_edit_recent_activity.py @@ -73,8 +73,8 @@ async def test_an_entry_by_this_actor_is_summarised(self, admin_client, users_ap @pytest.mark.anyio async def test_only_this_actors_entries_appear(self, admin_client, users_app, users_db): + from _users_admin_support import _make_user from audit_log.models import AuditEntry - from test_api_admin import _make_user user_id = await _admin_id(users_app) other = await _make_user(users_db, email="someone-else@example.com") diff --git a/modules/users/tests/test_api_admin.py b/modules/users/tests/admin/api/test_users_api_admin.py similarity index 91% rename from modules/users/tests/test_api_admin.py rename to modules/users/tests/admin/api/test_users_api_admin.py index c703d2e36..d59ce2c20 100644 --- a/modules/users/tests/test_api_admin.py +++ b/modules/users/tests/admin/api/test_users_api_admin.py @@ -5,37 +5,7 @@ import uuid import pytest -from fastapi_users.password import PasswordHelper -from users.models import Role, User, UserRole - -_pw = PasswordHelper() - - -async def _make_user(session, email, password="SecurePass1!", verified=True, role_names=None): - from sqlalchemy import select - - user = User( - id=uuid.uuid4(), - email=email, - hashed_password=_pw.hash(password), - is_active=True, - is_superuser=False, - is_verified=verified, - ) - session.add(user) - await session.flush() - - if role_names: - roles = ( - (await session.execute(select(Role).where(Role.name.in_(role_names)))).scalars().all() - ) - for role in roles: - session.add(UserRole(user_id=user.id, role_id=role.id)) - - await session.commit() - await session.refresh(user) - return user - +from _users_admin_support import _make_user # --------------------------------------------------------------------------- # Admin list diff --git a/modules/users/tests/test_api_admin_crud.py b/modules/users/tests/admin/api/test_users_api_admin_crud.py similarity index 99% rename from modules/users/tests/test_api_admin_crud.py rename to modules/users/tests/admin/api/test_users_api_admin_crud.py index 5793d0d01..ec62b0aa6 100644 --- a/modules/users/tests/test_api_admin_crud.py +++ b/modules/users/tests/admin/api/test_users_api_admin_crud.py @@ -5,8 +5,8 @@ import uuid import pytest +from _users_admin_support import _make_user from sqlalchemy import select -from test_api_admin import _make_user from users.models import User, UserRole # --------------------------------------------------------------------------- diff --git a/modules/users/tests/test_api_admin_filters.py b/modules/users/tests/admin/api/test_users_api_admin_filters.py similarity index 98% rename from modules/users/tests/test_api_admin_filters.py rename to modules/users/tests/admin/api/test_users_api_admin_filters.py index def7af437..f7d49ff76 100644 --- a/modules/users/tests/test_api_admin_filters.py +++ b/modules/users/tests/admin/api/test_users_api_admin_filters.py @@ -5,7 +5,7 @@ import uuid import pytest -from test_api_admin import _make_user +from _users_admin_support import _make_user # --------------------------------------------------------------------------- # Admin list filters diff --git a/modules/users/tests/test_api_admin_pagination_bounds.py b/modules/users/tests/admin/api/test_users_api_admin_pagination_bounds.py similarity index 100% rename from modules/users/tests/test_api_admin_pagination_bounds.py rename to modules/users/tests/admin/api/test_users_api_admin_pagination_bounds.py diff --git a/modules/users/tests/test_users_admin_list_query_count.py b/modules/users/tests/admin/queries/test_users_admin_list_query_count.py similarity index 100% rename from modules/users/tests/test_users_admin_list_query_count.py rename to modules/users/tests/admin/queries/test_users_admin_list_query_count.py diff --git a/modules/users/tests/test_session_version_atomic.py b/modules/users/tests/admin/queries/test_users_session_version_atomic.py similarity index 100% rename from modules/users/tests/test_session_version_atomic.py rename to modules/users/tests/admin/queries/test_users_session_version_atomic.py diff --git a/modules/users/tests/test_users_admin_create_commit.py b/modules/users/tests/admin/service/test_users_admin_create_commit.py similarity index 100% rename from modules/users/tests/test_users_admin_create_commit.py rename to modules/users/tests/admin/service/test_users_admin_create_commit.py diff --git a/modules/users/tests/test_users_admin_effective_permissions.py b/modules/users/tests/admin/service/test_users_admin_effective_permissions.py similarity index 98% rename from modules/users/tests/test_users_admin_effective_permissions.py rename to modules/users/tests/admin/service/test_users_admin_effective_permissions.py index 24f3c03e2..2eb441df2 100644 --- a/modules/users/tests/test_users_admin_effective_permissions.py +++ b/modules/users/tests/admin/service/test_users_admin_effective_permissions.py @@ -2,11 +2,11 @@ import httpx import pytest +from _users_admin_support import _make_user from api_tokens.core.constants import TokenKind from api_tokens.credentials.service import ApiTokenService from simple_module_test import forge_session_cookie from sqlalchemy import select -from test_api_admin import _make_user from users.models import Role, User pytestmark = pytest.mark.anyio diff --git a/modules/users/tests/test_users_admin_privilege_guards.py b/modules/users/tests/admin/service/test_users_admin_privilege_guards.py similarity index 96% rename from modules/users/tests/test_users_admin_privilege_guards.py rename to modules/users/tests/admin/service/test_users_admin_privilege_guards.py index ce51fedf0..872c5017d 100644 --- a/modules/users/tests/test_users_admin_privilege_guards.py +++ b/modules/users/tests/admin/service/test_users_admin_privilege_guards.py @@ -2,13 +2,12 @@ import httpx import pytest +from _users_admin_support import _build_service, _make_user from fastapi import FastAPI from simple_module_test import forge_session_cookie from sqlalchemy import select -from test_api_admin import _make_user -from test_service_admin import _build_service -from users.errors import install_exception_handlers -from users.exceptions import AdminAccountSafetyError, AdminPermissionError +from users.core.errors import install_exception_handlers +from users.core.exceptions import AdminAccountSafetyError, AdminPermissionError from users.models import Role, User, UserRole pytestmark = pytest.mark.anyio diff --git a/modules/users/tests/test_users_admin_role_grant_rules.py b/modules/users/tests/admin/service/test_users_admin_role_grant_rules.py similarity index 97% rename from modules/users/tests/test_users_admin_role_grant_rules.py rename to modules/users/tests/admin/service/test_users_admin_role_grant_rules.py index 750e0caee..214ef5194 100644 --- a/modules/users/tests/test_users_admin_role_grant_rules.py +++ b/modules/users/tests/admin/service/test_users_admin_role_grant_rules.py @@ -4,7 +4,7 @@ from auth.contracts.schemas import UserContext from simple_module_hosting.permissions import apply_scope_cap, resolve_permissions from users.admin.safety import check_role_assignment -from users.exceptions import AdminPermissionError +from users.core.exceptions import AdminPermissionError ROLE_MAP = { "admin": ["*"], diff --git a/modules/users/tests/test_users_admin_session_revocation.py b/modules/users/tests/admin/service/test_users_admin_session_revocation.py similarity index 97% rename from modules/users/tests/test_users_admin_session_revocation.py rename to modules/users/tests/admin/service/test_users_admin_session_revocation.py index fc00dde34..29bb878f0 100644 --- a/modules/users/tests/test_users_admin_session_revocation.py +++ b/modules/users/tests/admin/service/test_users_admin_session_revocation.py @@ -3,8 +3,8 @@ import pytest from _users_app_builders import user_row from simple_module_core.invalidation import InvalidationBus -from users.session_revocation import subscribe -from users.session_version_cache import ( +from users.accounts.session_revocation import subscribe +from users.accounts.session_version_cache import ( clear_session_version_cache, read_session_version, store_session_version, diff --git a/modules/users/tests/test_users_admin_summary_cache.py b/modules/users/tests/admin/service/test_users_admin_summary_cache.py similarity index 98% rename from modules/users/tests/test_users_admin_summary_cache.py rename to modules/users/tests/admin/service/test_users_admin_summary_cache.py index c30b06451..8cffadc19 100644 --- a/modules/users/tests/test_users_admin_summary_cache.py +++ b/modules/users/tests/admin/service/test_users_admin_summary_cache.py @@ -10,13 +10,13 @@ from simple_module_db.callbacks import register_on_commit from simple_module_db.transaction import finalize_session, rollback_session from sqlalchemy import event, select +from users.accounts.db_adapter import UserDatabaseWithRoles +from users.accounts.manager import UserManager from users.admin import summary_cache from users.admin.service import UserService from users.contracts.schemas import UserCreate, UserListItem -from users.db_adapter import UserDatabaseWithRoles -from users.manager import UserManager +from users.core.roles_cache import refresh_roles_cache from users.models import Role, User, UserRole -from users.roles_cache import refresh_roles_cache def service(db, app): diff --git a/modules/users/tests/test_users_deletion_events.py b/modules/users/tests/admin/service/test_users_deletion_events.py similarity index 100% rename from modules/users/tests/test_users_deletion_events.py rename to modules/users/tests/admin/service/test_users_deletion_events.py diff --git a/modules/users/tests/test_service_admin.py b/modules/users/tests/admin/service/test_users_service_admin.py similarity index 91% rename from modules/users/tests/test_service_admin.py rename to modules/users/tests/admin/service/test_users_service_admin.py index 3e12cf7c6..de9766d76 100644 --- a/modules/users/tests/test_service_admin.py +++ b/modules/users/tests/admin/service/test_users_service_admin.py @@ -6,23 +6,7 @@ from datetime import UTC, datetime import pytest - - -def _build_service(session, users_app_empty): - """Build a UserService directly (bypass FastAPI Depends).""" - from users.admin.service import UserService - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager - from users.models import User - - user_db = UserDatabaseWithRoles(session, User) - manager = UserManager( - user_db, - users_app_empty.state.users.mailer, - users_app_empty.state.users.settings, - ) - return UserService(session, manager) - +from _users_admin_support import _build_service # --------------------------------------------------------------------------- # Filter tests @@ -69,7 +53,7 @@ async def test_list_users_status_disabled_filter(users_app_empty): async def test_list_users_role_filter(users_app_empty): """list_users(role_name='admin') returns only users with that role.""" from fastapi_users.password import PasswordHelper - from users.constants import ADMIN_ROLE_ID + from users.core.constants import ADMIN_ROLE_ID from users.models import User, UserRole async with users_app_empty.state.sm.db.session_factory() as session: @@ -225,7 +209,7 @@ async def test_mark_verified_sets_flag_and_is_idempotent(users_app_empty): @pytest.mark.anyio async def test_mark_verified_unknown_raises(users_app_empty): """mark_verified raises UserNotFoundError for an unknown user_id.""" - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app_empty.state.sm.db.session_factory() as session: svc = _build_service(session, users_app_empty) diff --git a/modules/users/tests/test_service_admin_crud.py b/modules/users/tests/admin/service/test_users_service_admin_crud.py similarity index 94% rename from modules/users/tests/test_service_admin_crud.py rename to modules/users/tests/admin/service/test_users_service_admin_crud.py index 0360d3daa..4b8c30289 100644 --- a/modules/users/tests/test_service_admin_crud.py +++ b/modules/users/tests/admin/service/test_users_service_admin_crud.py @@ -5,7 +5,7 @@ import uuid import pytest -from test_service_admin import _build_service +from _users_admin_support import _build_service # --------------------------------------------------------------------------- # create_user @@ -78,7 +78,7 @@ async def test_create_user_duplicate_email_rejected(users_app): @pytest.mark.anyio async def test_update_details_changes_email_and_name(users_app): - from test_api_admin import _make_user + from _users_admin_support import _make_user async with users_app.state.sm.db.session_factory() as session: user = await _make_user(session, email="old@example.com") @@ -90,8 +90,8 @@ async def test_update_details_changes_email_and_name(users_app): @pytest.mark.anyio async def test_update_details_duplicate_email_rejected(users_app): - from test_api_admin import _make_user - from users.exceptions import EmailAlreadyExistsError + from _users_admin_support import _make_user + from users.core.exceptions import EmailAlreadyExistsError async with users_app.state.sm.db.session_factory() as session: await _make_user(session, email="a@example.com") @@ -103,7 +103,7 @@ async def test_update_details_duplicate_email_rejected(users_app): @pytest.mark.anyio async def test_update_details_same_email_is_allowed(users_app): - from test_api_admin import _make_user + from _users_admin_support import _make_user async with users_app.state.sm.db.session_factory() as session: user = await _make_user(session, email="keep@example.com") @@ -122,8 +122,8 @@ async def test_update_details_same_email_is_allowed(users_app): async def test_delete_user_removes_user_and_all_child_rows(users_app): from datetime import UTC, datetime, timedelta + from _users_admin_support import _make_user from sqlalchemy import select - from test_api_admin import _make_user from users.models import ( OAuthAccount, RefreshToken, @@ -168,7 +168,7 @@ async def test_delete_user_removes_user_and_all_child_rows(users_app): @pytest.mark.anyio async def test_delete_user_nonexistent_raises(users_app): - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app.state.sm.db.session_factory() as session: svc = _build_service(session, users_app) diff --git a/modules/users/tests/test_user_service.py b/modules/users/tests/admin/service/test_users_user_service.py similarity index 85% rename from modules/users/tests/test_user_service.py rename to modules/users/tests/admin/service/test_users_user_service.py index bd2e5dfe6..5932fa2d3 100644 --- a/modules/users/tests/test_user_service.py +++ b/modules/users/tests/admin/service/test_users_user_service.py @@ -9,9 +9,9 @@ def _build_service(session, users_app): """Build a UserService directly (bypass FastAPI Depends).""" + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager from users.admin.service import UserService - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager from users.models import User user_db = UserDatabaseWithRoles(session, User) @@ -25,7 +25,7 @@ def _build_service(session, users_app): @pytest.mark.anyio async def test_disable_unknown_user_raises_user_not_found(users_app): - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app.state.sm.db.session_factory() as session: service = _build_service(session, users_app) @@ -35,7 +35,7 @@ async def test_disable_unknown_user_raises_user_not_found(users_app): @pytest.mark.anyio async def test_enable_unknown_user_raises_user_not_found(users_app): - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app.state.sm.db.session_factory() as session: service = _build_service(session, users_app) @@ -45,7 +45,7 @@ async def test_enable_unknown_user_raises_user_not_found(users_app): @pytest.mark.anyio async def test_set_roles_unknown_user_raises_user_not_found(users_app): - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app.state.sm.db.session_factory() as session: service = _build_service(session, users_app) @@ -55,7 +55,7 @@ async def test_set_roles_unknown_user_raises_user_not_found(users_app): @pytest.mark.anyio async def test_generate_reset_link_unknown_user_raises_user_not_found(users_app): - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app.state.sm.db.session_factory() as session: service = _build_service(session, users_app) @@ -65,7 +65,7 @@ async def test_generate_reset_link_unknown_user_raises_user_not_found(users_app) @pytest.mark.anyio async def test_get_list_item_unknown_user_raises_user_not_found(users_app): - from users.exceptions import UserNotFoundError + from users.core.exceptions import UserNotFoundError async with users_app.state.sm.db.session_factory() as session: service = _build_service(session, users_app) @@ -77,9 +77,9 @@ async def test_get_list_item_unknown_user_raises_user_not_found(users_app): async def test_to_list_item_includes_created_at(users_app): """`UserListItem` carries `created_at` sourced from AuditMixin.""" from fastapi_users.password import PasswordHelper + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager from users.admin.service import UserService - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager from users.models import User async with users_app.state.sm.db.session_factory() as session: diff --git a/modules/users/tests/test_user_state.py b/modules/users/tests/admin/user_state/test_users_user_state.py similarity index 100% rename from modules/users/tests/test_user_state.py rename to modules/users/tests/admin/user_state/test_users_user_state.py diff --git a/modules/users/tests/test_views.py b/modules/users/tests/admin/views/test_users_views.py similarity index 98% rename from modules/users/tests/test_views.py rename to modules/users/tests/admin/views/test_users_views.py index 1e2cd78b7..193d512dc 100644 --- a/modules/users/tests/test_views.py +++ b/modules/users/tests/admin/views/test_users_views.py @@ -87,7 +87,7 @@ async def test_dev_accounts_resolved_via_dotenv_fallback( still appear — otherwise the admin gets seeded by the boot-time hook but the dev-quick-login UX silently breaks. """ - from users import bootstrap as bootstrap_module + from users.core import bootstrap as bootstrap_module monkeypatch.setattr(users_app.state.sm.settings, "environment", "development") monkeypatch.setattr( @@ -131,7 +131,7 @@ async def test_login_redirect_url_falls_back_when_setting_is_blanked(self, anon_ Assigning the attribute here would bypass pydantic and test nothing, so this goes through the real path. """ - from users.settings import UsersSettings + from users.core.settings import UsersSettings app = anon_client._transport.app app.state.users.settings = UsersSettings(login_redirect_url="") diff --git a/modules/users/tests/test_views_admin.py b/modules/users/tests/admin/views/test_users_views_admin.py similarity index 97% rename from modules/users/tests/test_views_admin.py rename to modules/users/tests/admin/views/test_users_views_admin.py index bb5466b17..e460237ed 100644 --- a/modules/users/tests/test_views_admin.py +++ b/modules/users/tests/admin/views/test_users_views_admin.py @@ -12,7 +12,7 @@ class TestAdminIndexFilters: @pytest.mark.anyio async def test_status_filter_in_view(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="on-view@x.com") u = await _make_user(users_db, email="off-view@x.com") @@ -65,7 +65,7 @@ async def test_page_past_the_end_clamps_to_last_page(self, admin_client, users_d return the last page's rows, not an empty list with a nonzero total — the same bug class background_tasks and file_storage fix for their own admin listings.""" - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="clamp-a@x.com") await _make_user(users_db, email="clamp-b@x.com") @@ -85,7 +85,7 @@ async def test_pagination_prop_echoes_clamped_values(self, admin_client, users_d """The pagination prop must carry the values the query actually ran with — ?page=0 renders page-1 rows and must not be labelled page 0, and an oversized per_page is bounded to what the service fetched.""" - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="clamp-c@x.com") @@ -119,7 +119,7 @@ class TestHasPermissionsModuleFlag: async def test_flag_true_when_permissions_installed(self, admin_client, users_app, users_db): import dataclasses - from test_api_admin import _make_user + from _users_admin_support import _make_user user = await _make_user(users_db, email="flagtest-true@example.com") @@ -145,7 +145,7 @@ async def test_flag_true_when_permissions_installed(self, admin_client, users_ap async def test_flag_false_when_not_installed(self, admin_client, users_app, users_db): import dataclasses - from test_api_admin import _make_user + from _users_admin_support import _make_user user = await _make_user(users_db, email="flagtest-false@example.com") diff --git a/modules/users/tests/test_api_auth.py b/modules/users/tests/auth_local/api/test_users_api_auth.py similarity index 98% rename from modules/users/tests/test_api_auth.py rename to modules/users/tests/auth_local/api/test_users_api_auth.py index 76fd6195e..e6c7190ee 100644 --- a/modules/users/tests/test_api_auth.py +++ b/modules/users/tests/auth_local/api/test_users_api_auth.py @@ -128,8 +128,8 @@ async def test_valid_invite_verifies_and_logs_in(self, anon_client, users_db, us """Full flow: create unverified user, mint token, accept-invite, get cookie.""" from unittest.mock import AsyncMock, MagicMock - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager from users.models import User # Create unverified user diff --git a/modules/users/tests/test_users_browser_auth_boundary.py b/modules/users/tests/auth_local/browser/test_users_browser_auth_boundary.py similarity index 100% rename from modules/users/tests/test_users_browser_auth_boundary.py rename to modules/users/tests/auth_local/browser/test_users_browser_auth_boundary.py diff --git a/modules/users/tests/test_users_browser_fetch_metadata.py b/modules/users/tests/auth_local/browser/test_users_browser_fetch_metadata.py similarity index 100% rename from modules/users/tests/test_users_browser_fetch_metadata.py rename to modules/users/tests/auth_local/browser/test_users_browser_fetch_metadata.py diff --git a/modules/users/tests/test_users_browser_session_logout.py b/modules/users/tests/auth_local/browser/test_users_browser_session_logout.py similarity index 97% rename from modules/users/tests/test_users_browser_session_logout.py rename to modules/users/tests/auth_local/browser/test_users_browser_session_logout.py index 425d8e790..ccb88d79f 100644 --- a/modules/users/tests/test_users_browser_session_logout.py +++ b/modules/users/tests/auth_local/browser/test_users_browser_session_logout.py @@ -11,15 +11,15 @@ from auth.core.constants import SESSION_ID_KEY from simple_module_core.invalidation import InvalidationBus from sqlalchemy import select -from users.auth_local.browser_sessions import revoke_browser_session -from users.constants import SESSION_USER_ID_KEY -from users.models import RevokedSession -from users.session_revocation import CHANNEL, subscribe -from users.session_version_cache import ( +from users.accounts.session_revocation import CHANNEL, subscribe +from users.accounts.session_version_cache import ( clear_session_version_cache, read_session_version, store_session_version, ) +from users.auth_local.browser_sessions import revoke_browser_session +from users.core.constants import SESSION_USER_ID_KEY +from users.models import RevokedSession CREDENTIALS = {"username": "admin@example.com", "password": "AdminPass1!"} diff --git a/modules/users/tests/test_users_login_session_rotation.py b/modules/users/tests/auth_local/browser/test_users_login_session_rotation.py similarity index 97% rename from modules/users/tests/test_users_login_session_rotation.py rename to modules/users/tests/auth_local/browser/test_users_login_session_rotation.py index b3a8c02f9..17406ab31 100644 --- a/modules/users/tests/test_users_login_session_rotation.py +++ b/modules/users/tests/auth_local/browser/test_users_login_session_rotation.py @@ -60,8 +60,8 @@ async def test_sign_in_rotates_session_and_page_token(users_app, anon_client, me assert response.status_code == 303, response.text assert response.headers["location"] == "/admin/users/?page=2" else: - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager from users.models import User await seed_password_user(users_app, "invite@example.com", is_verified=False) diff --git a/modules/users/tests/lockout/test_users_admin_unlock.py b/modules/users/tests/auth_local/lockout/test_users_admin_unlock.py similarity index 100% rename from modules/users/tests/lockout/test_users_admin_unlock.py rename to modules/users/tests/auth_local/lockout/test_users_admin_unlock.py diff --git a/modules/users/tests/lockout/test_users_lockout_checkpoint.py b/modules/users/tests/auth_local/lockout/test_users_lockout_checkpoint.py similarity index 100% rename from modules/users/tests/lockout/test_users_lockout_checkpoint.py rename to modules/users/tests/auth_local/lockout/test_users_lockout_checkpoint.py diff --git a/modules/users/tests/lockout/test_users_lockout_columns.py b/modules/users/tests/auth_local/lockout/test_users_lockout_columns.py similarity index 100% rename from modules/users/tests/lockout/test_users_lockout_columns.py rename to modules/users/tests/auth_local/lockout/test_users_lockout_columns.py diff --git a/modules/users/tests/lockout/test_users_lockout_enumeration.py b/modules/users/tests/auth_local/lockout/test_users_lockout_enumeration.py similarity index 98% rename from modules/users/tests/lockout/test_users_lockout_enumeration.py rename to modules/users/tests/auth_local/lockout/test_users_lockout_enumeration.py index 2ac1b6d7b..b1d8e45b6 100644 --- a/modules/users/tests/lockout/test_users_lockout_enumeration.py +++ b/modules/users/tests/auth_local/lockout/test_users_lockout_enumeration.py @@ -10,8 +10,8 @@ import pytest from _users_app_builders import ALICE, _build_users_app, _login, _make_standard_user, user_row from simple_module_core.rate_limit import InProcessWindowStore +from users.accounts.manager import UserManager from users.auth_local import lockout -from users.manager import UserManager GHOST = "ghost@example.com" T0 = datetime(2026, 10, 19, 12, 0, tzinfo=UTC) diff --git a/modules/users/tests/lockout/test_users_lockout_service.py b/modules/users/tests/auth_local/lockout/test_users_lockout_service.py similarity index 98% rename from modules/users/tests/lockout/test_users_lockout_service.py rename to modules/users/tests/auth_local/lockout/test_users_lockout_service.py index 5d87c3aed..c2e3396f7 100644 --- a/modules/users/tests/lockout/test_users_lockout_service.py +++ b/modules/users/tests/auth_local/lockout/test_users_lockout_service.py @@ -15,9 +15,9 @@ record_failure, record_success, ) -from users.exceptions import LoginLockedError +from users.core.exceptions import LoginLockedError +from users.core.settings import UsersSettings from users.models import User -from users.settings import UsersSettings T0 = datetime(2026, 10, 19, 12, 0, tzinfo=UTC) diff --git a/modules/users/tests/lockout/test_users_login_lockout.py b/modules/users/tests/auth_local/lockout/test_users_login_lockout.py similarity index 100% rename from modules/users/tests/lockout/test_users_login_lockout.py rename to modules/users/tests/auth_local/lockout/test_users_login_lockout.py diff --git a/modules/users/tests/test_rate_limit.py b/modules/users/tests/auth_local/rate_limit/test_users_rate_limit.py similarity index 100% rename from modules/users/tests/test_rate_limit.py rename to modules/users/tests/auth_local/rate_limit/test_users_rate_limit.py diff --git a/modules/users/tests/test_users_require_verification.py b/modules/users/tests/auth_local/registration/test_users_require_verification.py similarity index 100% rename from modules/users/tests/test_users_require_verification.py rename to modules/users/tests/auth_local/registration/test_users_require_verification.py diff --git a/modules/users/tests/test_users_auth_regressions.py b/modules/users/tests/auth_local/regressions/test_users_auth_regressions.py similarity index 96% rename from modules/users/tests/test_users_auth_regressions.py rename to modules/users/tests/auth_local/regressions/test_users_auth_regressions.py index 53eda35fc..ce3a41815 100644 --- a/modules/users/tests/test_users_auth_regressions.py +++ b/modules/users/tests/auth_local/regressions/test_users_auth_regressions.py @@ -12,6 +12,10 @@ from simple_module_db import init_db from sqlalchemy import func, select from starlette.requests import Request +from users.accounts.db_adapter import UserDatabaseWithRoles +from users.accounts.manager import UserManager +from users.accounts.provider import UsersAuthProvider, clear_session_version_cache +from users.accounts.token_strategy import resolve_bearer from users.auth_local import self_account from users.auth_local.token_api import ( RefreshRequest, @@ -21,12 +25,8 @@ token_refresh, ) from users.contracts.schemas import SelfPasswordChange, UserCreate -from users.db_adapter import UserDatabaseWithRoles -from users.manager import UserManager +from users.core.settings import UsersSettings from users.models import OAuthAccount, RefreshToken, User, UserAccessToken -from users.provider import UsersAuthProvider, clear_session_version_cache -from users.settings import UsersSettings -from users.token_strategy import resolve_bearer @pytest.fixture diff --git a/modules/users/tests/test_users_me_credentials.py b/modules/users/tests/auth_local/self_account/test_users_me_credentials.py similarity index 100% rename from modules/users/tests/test_users_me_credentials.py rename to modules/users/tests/auth_local/self_account/test_users_me_credentials.py diff --git a/modules/users/tests/test_users_password_change_keeps_api.py b/modules/users/tests/auth_local/self_account/test_users_password_change_keeps_api.py similarity index 100% rename from modules/users/tests/test_users_password_change_keeps_api.py rename to modules/users/tests/auth_local/self_account/test_users_password_change_keeps_api.py diff --git a/modules/users/tests/test_profile_password.py b/modules/users/tests/auth_local/self_account/test_users_profile_password.py similarity index 100% rename from modules/users/tests/test_profile_password.py rename to modules/users/tests/auth_local/self_account/test_users_profile_password.py diff --git a/modules/users/tests/test_token_api.py b/modules/users/tests/auth_local/token_api/test_users_token_api.py similarity index 100% rename from modules/users/tests/test_token_api.py rename to modules/users/tests/auth_local/token_api/test_users_token_api.py diff --git a/modules/users/tests/test_auth_screens_props.py b/modules/users/tests/auth_local/views/test_users_auth_screens_props.py similarity index 100% rename from modules/users/tests/test_auth_screens_props.py rename to modules/users/tests/auth_local/views/test_users_auth_screens_props.py diff --git a/modules/users/tests/test_users_login_deep_link.py b/modules/users/tests/auth_local/views/test_users_login_deep_link.py similarity index 99% rename from modules/users/tests/test_users_login_deep_link.py rename to modules/users/tests/auth_local/views/test_users_login_deep_link.py index 2cfc6abae..d46b25b1e 100644 --- a/modules/users/tests/test_users_login_deep_link.py +++ b/modules/users/tests/auth_local/views/test_users_login_deep_link.py @@ -83,7 +83,7 @@ class TestEveryProviderConsumesTheStashedTarget: def _source(relative: str) -> str: from pathlib import Path - root = Path(__file__).resolve().parents[3] + root = Path(__file__).resolve().parents[5] return (root / relative).read_text(encoding="utf-8") @pytest.mark.parametrize( diff --git a/modules/users/tests/conftest.py b/modules/users/tests/conftest.py index f9473649b..797ad4d8c 100644 --- a/modules/users/tests/conftest.py +++ b/modules/users/tests/conftest.py @@ -38,10 +38,10 @@ def _isolate_users_env(monkeypatch): values, so this is belt-and-braces: it keeps old shell exports from muddying any ``SM_ENVIRONMENT`` checks or from being misread during developer spelunking. Also stubs out - ``users.bootstrap._read_dotenv_bootstrap_vars`` so the developer's local + ``users.core.bootstrap._read_dotenv_bootstrap_vars`` so the developer's local ``.env`` can't seed bootstrap creds into tests that exercise the resolver. """ - from users import bootstrap as bootstrap_module + from users.core import bootstrap as bootstrap_module for key in list(os.environ): if key.startswith("SM_USERS_"): diff --git a/modules/users/tests/test_users_update_schema.py b/modules/users/tests/contracts/schemas/test_users_update_schema.py similarity index 100% rename from modules/users/tests/test_users_update_schema.py rename to modules/users/tests/contracts/schemas/test_users_update_schema.py diff --git a/modules/users/tests/test_users_administrator_definition.py b/modules/users/tests/core/administrators/test_users_administrator_definition.py similarity index 96% rename from modules/users/tests/test_users_administrator_definition.py rename to modules/users/tests/core/administrators/test_users_administrator_definition.py index f8571331f..1b9515b8e 100644 --- a/modules/users/tests/test_users_administrator_definition.py +++ b/modules/users/tests/core/administrators/test_users_administrator_definition.py @@ -6,11 +6,11 @@ from simple_module_test.setup_wizard import wizard_client, wizard_headers from sqlalchemy import select from users.admin.service import UserService -from users.administrators import count_active_administrators -from users.bootstrap import ensure_admin_role -from users.exceptions import AdminAccountSafetyError +from users.core.administrators import count_active_administrators +from users.core.bootstrap import ensure_admin_role +from users.core.exceptions import AdminAccountSafetyError +from users.core.setup import STEP_ADMINISTRATOR, has_local_account from users.models import User, UserRole -from users.setup import STEP_ADMINISTRATOR, has_local_account async def test_role_administrator_keeps_setup_complete_after_superuser_disabled(setup_pending_app): diff --git a/modules/users/tests/test_users_audit_redaction.py b/modules/users/tests/core/audit/test_users_audit_redaction.py similarity index 100% rename from modules/users/tests/test_users_audit_redaction.py rename to modules/users/tests/core/audit/test_users_audit_redaction.py diff --git a/modules/users/tests/test_bootstrap.py b/modules/users/tests/core/bootstrap/test_users_bootstrap.py similarity index 97% rename from modules/users/tests/test_bootstrap.py rename to modules/users/tests/core/bootstrap/test_users_bootstrap.py index d69280623..e8b4417cf 100644 --- a/modules/users/tests/test_bootstrap.py +++ b/modules/users/tests/core/bootstrap/test_users_bootstrap.py @@ -1,4 +1,4 @@ -"""Tests for users.bootstrap — create_admin() and bootstrap_admin_from_env().""" +"""Tests for users.core.bootstrap — create_admin() and bootstrap_admin_from_env().""" from __future__ import annotations @@ -8,14 +8,14 @@ from fastapi_users.password import PasswordHelper from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from users.bootstrap import ( +from users.core.bootstrap import ( CreateAdminResult, bootstrap_admin_from_env, create_admin, ) -from users.constants import ADMIN_ROLE_ID +from users.core.constants import ADMIN_ROLE_ID +from users.core.settings import UsersSettings from users.models import Role, User, UserRole -from users.settings import UsersSettings # --------------------------------------------------------------------------- # Helpers diff --git a/modules/users/tests/test_bootstrap_resolution.py b/modules/users/tests/core/bootstrap/test_users_bootstrap_resolution.py similarity index 93% rename from modules/users/tests/test_bootstrap_resolution.py rename to modules/users/tests/core/bootstrap/test_users_bootstrap_resolution.py index 5376b7f09..e4401af89 100644 --- a/modules/users/tests/test_bootstrap_resolution.py +++ b/modules/users/tests/core/bootstrap/test_users_bootstrap_resolution.py @@ -9,9 +9,9 @@ from __future__ import annotations import pytest -from users import bootstrap as bootstrap_module -from users.bootstrap import BOOTSTRAP_ENV_KEYS, resolve_bootstrap_credentials -from users.settings import UsersSettings +from users.core import bootstrap as bootstrap_module +from users.core.bootstrap import BOOTSTRAP_ENV_KEYS, resolve_bootstrap_credentials +from users.core.settings import UsersSettings def _bare_users_settings(**overrides: str) -> UsersSettings: diff --git a/modules/users/tests/test_users_register_role_durable.py b/modules/users/tests/core/bootstrap/test_users_register_role_durable.py similarity index 94% rename from modules/users/tests/test_users_register_role_durable.py rename to modules/users/tests/core/bootstrap/test_users_register_role_durable.py index 39f9fd559..b66e022e2 100644 --- a/modules/users/tests/test_users_register_role_durable.py +++ b/modules/users/tests/core/bootstrap/test_users_register_role_durable.py @@ -23,8 +23,8 @@ def settings(settings: Settings) -> Settings: async def test_role_is_kept_when_the_verification_email_fails(app, client): - from users.bootstrap import ensure_user_role - from users.constants import USER_ROLE_NAME + from users.core.bootstrap import ensure_user_role + from users.core.constants import USER_ROLE_NAME from users.models import Role, User, UserRole async with app.state.sm.db.session_factory() as session: diff --git a/modules/users/tests/test_cli.py b/modules/users/tests/core/cli/test_users_cli.py similarity index 98% rename from modules/users/tests/test_cli.py rename to modules/users/tests/core/cli/test_users_cli.py index 6a34a26e9..603bab10b 100644 --- a/modules/users/tests/test_cli.py +++ b/modules/users/tests/core/cli/test_users_cli.py @@ -1,6 +1,6 @@ """Tests for the smpy users CLI (users.cli). -Strategy: monkeypatch ``users.bootstrap.create_admin`` so tests do not need +Strategy: monkeypatch ``users.core.bootstrap.create_admin`` so tests do not need a real database. This avoids the complexity of standing up a schema-stamped SQLite engine inside the same process as the CLI runner and keeps the tests fast and reliable. diff --git a/modules/users/tests/test_constants.py b/modules/users/tests/core/constants/test_users_constants.py similarity index 69% rename from modules/users/tests/test_constants.py rename to modules/users/tests/core/constants/test_users_constants.py index aa7254713..5c26bdd87 100644 --- a/modules/users/tests/test_constants.py +++ b/modules/users/tests/core/constants/test_users_constants.py @@ -5,26 +5,26 @@ class TestConstants: def test_admin_role_id_is_stable(self): - from users.constants import ADMIN_ROLE_ID + from users.core.constants import ADMIN_ROLE_ID assert str(ADMIN_ROLE_ID) == "00000000-0000-0000-0000-000000000001" def test_user_role_id_is_stable(self): - from users.constants import USER_ROLE_ID + from users.core.constants import USER_ROLE_ID assert str(USER_ROLE_ID) == "00000000-0000-0000-0000-000000000002" def test_admin_uuid_hex(self): - from users.constants import ADMIN_ROLE_ID + from users.core.constants import ADMIN_ROLE_ID assert ADMIN_ROLE_ID.hex == "00000000000000000000000000000001" def test_user_uuid_hex(self): - from users.constants import USER_ROLE_ID + from users.core.constants import USER_ROLE_ID assert USER_ROLE_ID.hex == "00000000000000000000000000000002" def test_ids_differ(self): - from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + from users.core.constants import ADMIN_ROLE_ID, USER_ROLE_ID assert ADMIN_ROLE_ID != USER_ROLE_ID diff --git a/modules/users/tests/test_users_seed_role_migration.py b/modules/users/tests/core/constants/test_users_seed_role_migration.py similarity index 94% rename from modules/users/tests/test_users_seed_role_migration.py rename to modules/users/tests/core/constants/test_users_seed_role_migration.py index a1369fe4d..b3b8e7779 100644 --- a/modules/users/tests/test_users_seed_role_migration.py +++ b/modules/users/tests/core/constants/test_users_seed_role_migration.py @@ -14,11 +14,11 @@ import sqlalchemy as sa from alembic import command from alembic.config import Config -from users.constants import USER_ROLE_DESCRIPTION, USER_ROLE_ID, USER_ROLE_NAME +from users.core.constants import USER_ROLE_DESCRIPTION, USER_ROLE_ID, USER_ROLE_NAME BEFORE = "e878882924e7" REVISION = "b5018b617142" -SCRIPTS = Path(__file__).resolve().parents[3] / "host" / "migrations" +SCRIPTS = Path(__file__).resolve().parents[5] / "host" / "migrations" pytestmark = pytest.mark.filterwarnings("ignore:Skipped unsupported reflection") diff --git a/modules/users/tests/test_users_directory.py b/modules/users/tests/core/directory/test_users_directory.py similarity index 98% rename from modules/users/tests/test_users_directory.py rename to modules/users/tests/core/directory/test_users_directory.py index ae9687499..c43693e28 100644 --- a/modules/users/tests/test_users_directory.py +++ b/modules/users/tests/core/directory/test_users_directory.py @@ -7,7 +7,7 @@ from simple_module_core.user_directory import GET_MANY_CHUNK, get_user_directory from sqlalchemy.ext.asyncio import AsyncSession -from users.directory import UsersDirectory +from users.core.directory import UsersDirectory from users.models import Role, User, UserRole _D = UsersDirectory() @@ -123,7 +123,7 @@ async def test_users_module_installs_its_directory(self, app): assert isinstance(get_user_directory(app), UsersDirectory) async def test_audit_label_resolver_delegates(self, db_session: AsyncSession): - from users.audit import resolve_user_labels + from users.core.audit import resolve_user_labels user = await _user(db_session, "label@example.com") assert await resolve_user_labels(db_session, [str(user.id)]) == { diff --git a/modules/users/tests/test_users_domain_errors.py b/modules/users/tests/core/errors/test_users_domain_errors.py similarity index 91% rename from modules/users/tests/test_users_domain_errors.py rename to modules/users/tests/core/errors/test_users_domain_errors.py index c8f741e79..8552bd8fe 100644 --- a/modules/users/tests/test_users_domain_errors.py +++ b/modules/users/tests/core/errors/test_users_domain_errors.py @@ -4,7 +4,7 @@ from fastapi import HTTPException from users.auth_local.lockout import too_many_attempts -from users.exceptions import UserNotFoundError +from users.core.exceptions import UserNotFoundError def test_lockout_is_a_domain_error(): @@ -14,7 +14,7 @@ def test_lockout_is_a_domain_error(): def test_user_errors_share_a_module_base(): - from users.exceptions import UsersError + from users.core.exceptions import UsersError assert isinstance(UserNotFoundError(uuid.uuid4()), UsersError) diff --git a/modules/users/tests/test_users_data_compatibility.py b/modules/users/tests/core/module/test_users_data_compatibility.py similarity index 100% rename from modules/users/tests/test_users_data_compatibility.py rename to modules/users/tests/core/module/test_users_data_compatibility.py diff --git a/modules/users/tests/test_users_password_offload.py b/modules/users/tests/core/password_hashing/test_users_password_offload.py similarity index 97% rename from modules/users/tests/test_users_password_offload.py rename to modules/users/tests/core/password_hashing/test_users_password_offload.py index 1f3b46882..8b7a41b0f 100644 --- a/modules/users/tests/test_users_password_offload.py +++ b/modules/users/tests/core/password_hashing/test_users_password_offload.py @@ -8,10 +8,10 @@ import pytest from fastapi_users import exceptions +from users.accounts.db_adapter import UserDatabaseWithRoles +from users.accounts.manager import UserManager from users.admin.service import UserService from users.contracts.schemas import UserCreate -from users.db_adapter import UserDatabaseWithRoles -from users.manager import UserManager from users.models import User diff --git a/modules/users/tests/test_settings.py b/modules/users/tests/core/settings/test_users_settings.py similarity index 83% rename from modules/users/tests/test_settings.py rename to modules/users/tests/core/settings/test_users_settings.py index 01ff3859a..b0c1bd074 100644 --- a/modules/users/tests/test_settings.py +++ b/modules/users/tests/core/settings/test_users_settings.py @@ -8,50 +8,50 @@ class TestUsersSettingsDefaults: def test_allow_signup_default_false(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert s.allow_signup is False def test_require_verification_default_true(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert s.require_verification is True def test_cookie_name_default(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert s.cookie_name == "sm_auth" def test_dev_token_secrets(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert "dev" in s.reset_password_token_secret assert "dev" in s.verification_token_secret def test_cookie_samesite_default(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert s.cookie_samesite == "lax" def test_cookie_samesite_is_case_insensitive(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings assert UsersSettings(cookie_samesite="Strict").cookie_samesite == "strict" def test_cookie_samesite_rejects_unknown_values(self): from pydantic import ValidationError - from users.settings import UsersSettings + from users.core.settings import UsersSettings with pytest.raises(ValidationError): UsersSettings(cookie_samesite="sideways") def test_bootstrap_email_default_empty(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert s.bootstrap_email == "" @@ -65,7 +65,7 @@ async def test_allow_signup_override_from_db(self, db_session): from settings.core.service import SettingService from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings - from users.settings import UsersSettings + from users.core.settings import UsersSettings store = SettingsStore(SettingService(db_session)) await store.set_override("users", "allow_signup", "true", "bool") @@ -78,7 +78,7 @@ async def test_base_url_override_from_db(self, db_session): from settings.core.service import SettingService from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings - from users.settings import UsersSettings + from users.core.settings import UsersSettings store = SettingsStore(SettingService(db_session)) await store.set_override("users", "base_url", "https://example.com", "string") @@ -89,7 +89,7 @@ async def test_base_url_override_from_db(self, db_session): def test_env_vars_are_ignored(self, monkeypatch): """Setting SM_USERS_* must not affect UsersSettings() after the migration.""" monkeypatch.setenv("SM_USERS_ALLOW_SIGNUP", "true") - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings() assert s.allow_signup is False @@ -98,19 +98,19 @@ def test_env_vars_are_ignored(self, monkeypatch): class TestTokenSecretProductionGuard: def test_placeholder_secrets_ok_in_development(self, monkeypatch): monkeypatch.setenv("SM_ENVIRONMENT", "development") - from users.settings import UsersSettings + from users.core.settings import UsersSettings UsersSettings() # must not raise def test_placeholder_secrets_ok_in_testing(self, monkeypatch): monkeypatch.setenv("SM_ENVIRONMENT", "testing") - from users.settings import UsersSettings + from users.core.settings import UsersSettings UsersSettings() # must not raise def test_placeholder_reset_secret_rejected_in_production(self, monkeypatch): monkeypatch.setenv("SM_ENVIRONMENT", "production") - from users.settings import UsersSettings + from users.core.settings import UsersSettings with pytest.raises(ValidationError, match="RESET_PASSWORD_TOKEN_SECRET"): UsersSettings( @@ -119,7 +119,7 @@ def test_placeholder_reset_secret_rejected_in_production(self, monkeypatch): def test_placeholder_verify_secret_rejected_in_production(self, monkeypatch): monkeypatch.setenv("SM_ENVIRONMENT", "production") - from users.settings import UsersSettings + from users.core.settings import UsersSettings with pytest.raises(ValidationError, match="VERIFICATION_TOKEN_SECRET"): UsersSettings( @@ -128,7 +128,7 @@ def test_placeholder_verify_secret_rejected_in_production(self, monkeypatch): def test_real_secrets_accepted_in_production(self, monkeypatch): monkeypatch.setenv("SM_ENVIRONMENT", "production") - from users.settings import UsersSettings + from users.core.settings import UsersSettings s = UsersSettings( reset_password_token_secret="real-reset-secret", @@ -150,16 +150,16 @@ class TestLoginRedirectUrl: """ def test_blank_falls_back_to_the_default(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings assert UsersSettings(login_redirect_url="").login_redirect_url == "/dashboard/" def test_whitespace_only_falls_back_to_the_default(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings assert UsersSettings(login_redirect_url=" ").login_redirect_url == "/dashboard/" def test_a_real_value_is_left_alone(self): - from users.settings import UsersSettings + from users.core.settings import UsersSettings assert UsersSettings(login_redirect_url="/home/").login_redirect_url == "/home/" diff --git a/modules/users/tests/test_users_setup_lock.py b/modules/users/tests/core/setup/test_users_setup_lock.py similarity index 95% rename from modules/users/tests/test_users_setup_lock.py rename to modules/users/tests/core/setup/test_users_setup_lock.py index b9020ed86..016d477ea 100644 --- a/modules/users/tests/test_users_setup_lock.py +++ b/modules/users/tests/core/setup/test_users_setup_lock.py @@ -14,8 +14,8 @@ import sqlalchemy as sa from simple_module_db import init_db from sqlalchemy.exc import OperationalError +from users.core.setup_action import _lock_admin_creation from users.models import User -from users.setup_action import _lock_admin_creation pytestmark = pytest.mark.anyio @@ -51,7 +51,7 @@ async def test_postgres_advisory_lock_is_held_for_the_transaction() -> None: async with state.session_factory() as first, state.session_factory() as second: await _lock_admin_creation(first) probe = sa.text("SELECT pg_try_advisory_xact_lock(:key)") - from users.setup_action import _PG_LOCK_KEY + from users.core.setup_action import _PG_LOCK_KEY assert await second.scalar(probe, {"key": _PG_LOCK_KEY}) is False await second.rollback() diff --git a/modules/users/tests/test_users_setup_wizard.py b/modules/users/tests/core/setup/test_users_setup_wizard.py similarity index 96% rename from modules/users/tests/test_users_setup_wizard.py rename to modules/users/tests/core/setup/test_users_setup_wizard.py index f18cf2a75..74d28a559 100644 --- a/modules/users/tests/test_users_setup_wizard.py +++ b/modules/users/tests/core/setup/test_users_setup_wizard.py @@ -19,9 +19,9 @@ from fastapi import HTTPException from simple_module_test.setup_wizard import post_step, wizard_client, wizard_headers from sqlalchemy import func, select +from users.core.setup import STEP_ADMINISTRATOR +from users.core.setup_action import create_first_administrator from users.models import User -from users.setup import STEP_ADMINISTRATOR -from users.setup_action import create_first_administrator pytestmark = pytest.mark.anyio @@ -141,7 +141,7 @@ async def test_concurrent_submissions_create_one_admin(setup_pending_app) -> Non async def test_existing_non_admin_address_is_refused(setup_pending_app) -> None: """An account created after the wizard opened closes it; nothing is granted.""" - from users.bootstrap import create_standard_user + from users.core.bootstrap import create_standard_user async with wizard_client(setup_pending_app) as client: headers = await wizard_headers(client) @@ -194,7 +194,7 @@ async def test_first_administrator_refreshes_the_roles_cache(setup_pending_app) Migrations seed only ``user``; ``create_admin`` adds ``admin``. A cache warmed before that kept offering only ``user`` on the admin user screens. """ - from users.roles_cache import get_roles_cache, refresh_roles_cache + from users.core.roles_cache import get_roles_cache, refresh_roles_cache await refresh_roles_cache(setup_pending_app) # what startup does assert "admin" not in {role.name for role in await get_roles_cache(setup_pending_app)} @@ -209,7 +209,7 @@ async def test_first_administrator_refreshes_the_roles_cache(setup_pending_app) async def test_roles_changed_empties_every_subscribed_cache(setup_pending_app) -> None: """Another worker's cache is emptied through the bus, then refills lazily.""" from simple_module_core.invalidation import InvalidationBus - from users import roles_cache + from users.core import roles_cache await roles_cache.refresh_roles_cache(setup_pending_app) assert setup_pending_app.state.users.roles_cache @@ -222,7 +222,7 @@ async def test_roles_changed_empties_every_subscribed_cache(setup_pending_app) - async def test_roles_cache_expires_without_an_invalidation(setup_pending_app, monkeypatch) -> None: """The bus only forgets and may not deliver, so the cache keeps a TTL floor.""" - from users import roles_cache + from users.core import roles_cache await roles_cache.refresh_roles_cache(setup_pending_app) setup_pending_app.state.users.roles_cache = [roles_cache.RoleSummary(id="x", name="stale")] diff --git a/modules/users/tests/test_login_redirect_root_mount.py b/modules/users/tests/core/startup/test_users_login_redirect_root_mount.py similarity index 95% rename from modules/users/tests/test_login_redirect_root_mount.py rename to modules/users/tests/core/startup/test_users_login_redirect_root_mount.py index 9cbefa8d2..0a97281e8 100644 --- a/modules/users/tests/test_login_redirect_root_mount.py +++ b/modules/users/tests/core/startup/test_users_login_redirect_root_mount.py @@ -7,7 +7,7 @@ def _fallback_url(app, prefixes: dict[str, str]) -> str: """Run the login fallback against a Dashboard-less module set (#435).""" - from users.startup import apply_login_redirect_fallback + from users.core.startup import apply_login_redirect_fallback sm = app.state.sm saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes) diff --git a/modules/users/tests/test_users_startup_role.py b/modules/users/tests/core/startup/test_users_startup_role.py similarity index 98% rename from modules/users/tests/test_users_startup_role.py rename to modules/users/tests/core/startup/test_users_startup_role.py index 41cf8d12e..a16dd10fc 100644 --- a/modules/users/tests/test_users_startup_role.py +++ b/modules/users/tests/core/startup/test_users_startup_role.py @@ -10,7 +10,7 @@ from simple_module_hosting.app_builder import create_app from simple_module_hosting.settings import Settings from sqlalchemy import select -from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID +from users.core.constants import ADMIN_ROLE_ID, USER_ROLE_ID from users.models import Role, User, UserRole from users.module import UsersModule diff --git a/modules/users/tests/demo/test_users_demo_account.py b/modules/users/tests/demo/test_users_demo_account.py index 2129b10af..cb3c95e92 100644 --- a/modules/users/tests/demo/test_users_demo_account.py +++ b/modules/users/tests/demo/test_users_demo_account.py @@ -11,7 +11,8 @@ from _demo_support import DEMO_ADMIN_EMAIL, DEMO_USER_EMAIL from _users_app_builders import user_row from sqlalchemy import select -from users.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME +from users.core.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME +from users.core.settings import UsersSettings from users.demo import ( DemoAccount, ensure_demo_users, @@ -20,7 +21,6 @@ resolve_demo_accounts, ) from users.models import Role, User, UserRole -from users.settings import UsersSettings def _settings(**overrides) -> UsersSettings: diff --git a/modules/users/tests/demo/test_users_demo_guard.py b/modules/users/tests/demo/test_users_demo_guard.py index 6a1304a32..678c31eac 100644 --- a/modules/users/tests/demo/test_users_demo_guard.py +++ b/modules/users/tests/demo/test_users_demo_guard.py @@ -7,7 +7,7 @@ from __future__ import annotations import httpx -from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME +from users.core.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME from users.demo import SESSION_DEMO_KEY from users.demo.guard import DEMO_READ_ONLY_DETAIL diff --git a/modules/users/tests/demo/test_users_demo_login.py b/modules/users/tests/demo/test_users_demo_login.py index 872d2bf05..3e7d86730 100644 --- a/modules/users/tests/demo/test_users_demo_login.py +++ b/modules/users/tests/demo/test_users_demo_login.py @@ -9,7 +9,7 @@ from __future__ import annotations from _demo_support import DEMO_ADMIN_EMAIL, DEMO_USER_EMAIL -from users.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME +from users.core.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME from users.demo import ensure_demo_users from users.models import User diff --git a/modules/users/tests/invites/test_users_admin_resend_invite.py b/modules/users/tests/invites/test_users_admin_resend_invite.py index 73a1fc9cb..8a809c756 100644 --- a/modules/users/tests/invites/test_users_admin_resend_invite.py +++ b/modules/users/tests/invites/test_users_admin_resend_invite.py @@ -114,7 +114,7 @@ async def test_resend_hands_back_a_link_when_mail_cannot_be_delivered(self, admi @pytest.mark.anyio async def test_resend_refuses_for_an_already_verified_account(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user user = await _make_user(users_db, email="settled@example.com", verified=True) resp = await admin_client.post(f"/api/users/admin/{user.id}/resend-invite") @@ -124,7 +124,7 @@ async def test_resend_refuses_for_an_already_verified_account(self, admin_client @pytest.mark.anyio async def test_resend_refuses_for_a_self_signup(self, admin_client, users_db): """Nobody invited them, so there is no invitation to send again.""" - from test_api_admin import _make_user + from _users_admin_support import _make_user user = await _make_user(users_db, email="walkedin@example.com", verified=False) resp = await admin_client.post(f"/api/users/admin/{user.id}/resend-invite") @@ -134,7 +134,7 @@ async def test_resend_refuses_for_a_self_signup(self, admin_client, users_db): @pytest.mark.anyio async def test_a_refused_resend_leaves_the_state_alone(self, admin_client, users_db): """Refusing must not stamp ``invited_at`` and turn a self-signup into an invite.""" - from test_api_admin import _make_user + from _users_admin_support import _make_user user = await _make_user(users_db, email="untouched@example.com", verified=False) await admin_client.post(f"/api/users/admin/{user.id}/resend-invite") diff --git a/modules/users/tests/invites/test_users_invite_flow.py b/modules/users/tests/invites/test_users_invite_flow.py index d902aa212..145ec80c5 100644 --- a/modules/users/tests/invites/test_users_invite_flow.py +++ b/modules/users/tests/invites/test_users_invite_flow.py @@ -11,7 +11,7 @@ import pytest from fastapi_users.jwt import generate_jwt from sqlalchemy import select -from users.manager import UserManager +from users.accounts.manager import UserManager from users.models import User diff --git a/modules/users/tests/invites/test_users_list_invites.py b/modules/users/tests/invites/test_users_list_invites.py index 288434a1d..593338661 100644 --- a/modules/users/tests/invites/test_users_list_invites.py +++ b/modules/users/tests/invites/test_users_list_invites.py @@ -24,7 +24,7 @@ async def _rows_by_email(client, query: str = "") -> dict[str, dict]: class TestUserState: @pytest.mark.anyio async def test_invited_and_unverified_are_distinct(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="selfsignup@example.com", verified=False) invited = await admin_client.post( @@ -40,7 +40,7 @@ async def test_invited_and_unverified_are_distinct(self, admin_client, users_db) @pytest.mark.anyio async def test_disabled_beats_every_other_state(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user user = await _make_user(users_db, email="gone@example.com", verified=False) resp = await admin_client.patch(f"/api/users/admin/{user.id}/disable") @@ -63,7 +63,7 @@ async def test_invited_row_carries_both_timestamps(self, admin_client): @pytest.mark.anyio async def test_self_signup_has_no_invite_timestamps(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="walkin@example.com", verified=False) row = (await _rows_by_email(admin_client))["walkin@example.com"] @@ -76,7 +76,7 @@ class TestStatusFilter: @pytest.mark.anyio async def test_invited_filter_excludes_self_signups(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="walkin2@example.com", verified=False) await admin_client.post( @@ -88,7 +88,7 @@ async def test_invited_filter_excludes_self_signups(self, admin_client, users_db @pytest.mark.anyio async def test_unverified_filter_excludes_invitees(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="walkin3@example.com", verified=False) await admin_client.post( @@ -100,7 +100,7 @@ async def test_unverified_filter_excludes_invitees(self, admin_client, users_db) @pytest.mark.anyio async def test_active_means_active_and_verified(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="walkin4@example.com", verified=False) rows = await _rows_by_email(admin_client, "?status=active") @@ -111,7 +111,7 @@ async def test_active_means_active_and_verified(self, admin_client, users_db): class TestPendingInvitesAggregate: @pytest.mark.anyio async def test_pending_counts_invites_not_self_signups(self, admin_client, users_db): - from test_api_admin import _make_user + from _users_admin_support import _make_user await _make_user(users_db, email="walkin5@example.com", verified=False) await admin_client.post( diff --git a/modules/users/tests/test_mailer.py b/modules/users/tests/mailer/templated/test_users_mailer.py similarity index 100% rename from modules/users/tests/test_mailer.py rename to modules/users/tests/mailer/templated/test_users_mailer.py diff --git a/modules/users/tests/test_users_mailer_core_sender.py b/modules/users/tests/mailer/templated/test_users_mailer_core_sender.py similarity index 100% rename from modules/users/tests/test_users_mailer_core_sender.py rename to modules/users/tests/mailer/templated/test_users_mailer_core_sender.py diff --git a/modules/users/tests/test_users_mailer_templates.py b/modules/users/tests/mailer/templated/test_users_mailer_templates.py similarity index 98% rename from modules/users/tests/test_users_mailer_templates.py rename to modules/users/tests/mailer/templated/test_users_mailer_templates.py index 6e31dd8cc..43b702b5d 100644 --- a/modules/users/tests/test_users_mailer_templates.py +++ b/modules/users/tests/mailer/templated/test_users_mailer_templates.py @@ -9,10 +9,10 @@ from jinja2 import TemplateNotFound, TemplateSyntaxError from jinja2.exceptions import SecurityError from simple_module_core.settings_hydrate import build_settings +from users.core.settings import UsersSettings +from users.core.startup import _build_mailer from users.mailer import build_mailer from users.mailer.templated import TemplatedMailer -from users.settings import UsersSettings -from users.startup import _build_mailer @pytest.mark.parametrize( diff --git a/modules/users/tests/test_users_browser_session_migration.py b/modules/users/tests/models/migrations/test_users_browser_session_migration.py similarity index 96% rename from modules/users/tests/test_users_browser_session_migration.py rename to modules/users/tests/models/migrations/test_users_browser_session_migration.py index d87aace46..f36598441 100644 --- a/modules/users/tests/test_users_browser_session_migration.py +++ b/modules/users/tests/models/migrations/test_users_browser_session_migration.py @@ -10,7 +10,7 @@ def test_browser_session_revision_roundtrip(monkeypatch): path = ( - Path(__file__).resolve().parents[3] + Path(__file__).resolve().parents[5] / "host/migrations/versions/e805a1b2c3d4_users_browser_sessions.py" ) spec = importlib.util.spec_from_file_location("browser_session_revision", path) diff --git a/modules/users/tests/test_users_token_hash_migration.py b/modules/users/tests/models/migrations/test_users_token_hash_migration.py similarity index 97% rename from modules/users/tests/test_users_token_hash_migration.py rename to modules/users/tests/models/migrations/test_users_token_hash_migration.py index 62e295569..38f629f35 100644 --- a/modules/users/tests/test_users_token_hash_migration.py +++ b/modules/users/tests/models/migrations/test_users_token_hash_migration.py @@ -10,7 +10,7 @@ def test_users_hash_migration_invalidates_tokens_in_both_directions(): - path = Path(__file__).resolve().parents[3] / "host/migrations/versions" + path = Path(__file__).resolve().parents[5] / "host/migrations/versions" spec = importlib.util.spec_from_file_location( "hash_migration", path / "a626b1c8d023_users_hash_bearer_tokens_at_rest.py" ) diff --git a/modules/users/tests/test_role_model.py b/modules/users/tests/models/roles/test_users_role_model.py similarity index 100% rename from modules/users/tests/test_role_model.py rename to modules/users/tests/models/roles/test_users_role_model.py diff --git a/modules/users/tests/test_user_role_model.py b/modules/users/tests/models/roles/test_users_user_role_model.py similarity index 96% rename from modules/users/tests/test_user_role_model.py rename to modules/users/tests/models/roles/test_users_user_role_model.py index fde427042..4c9b28018 100644 --- a/modules/users/tests/test_user_role_model.py +++ b/modules/users/tests/models/roles/test_users_user_role_model.py @@ -51,7 +51,7 @@ async def test_user_role_composite_pk(db_session): # UserRole declares none, so a single add_all can emit the association # INSERT before the rows it references. That is an FK violation on Postgres # and — since PRAGMA foreign_keys=ON — on SQLite too. Production code - # (``users.bootstrap``, ``users.admin.service``) already flushes first. + # (``users.core.bootstrap``, ``users.admin.service``) already flushes first. db_session.add_all([user, role]) await db_session.flush() db_session.add(link) @@ -91,7 +91,7 @@ async def test_fk_cascade_delete_user_removes_user_role(db_session): # UserRole declares none, so a single add_all can emit the association # INSERT before the rows it references. That is an FK violation on Postgres # and — since PRAGMA foreign_keys=ON — on SQLite too. Production code - # (``users.bootstrap``, ``users.admin.service``) already flushes first. + # (``users.core.bootstrap``, ``users.admin.service``) already flushes first. db_session.add_all([user, role]) await db_session.flush() db_session.add(link) diff --git a/modules/users/tests/test_access_token_model.py b/modules/users/tests/models/tokens/test_users_access_token_model.py similarity index 100% rename from modules/users/tests/test_access_token_model.py rename to modules/users/tests/models/tokens/test_users_access_token_model.py diff --git a/modules/users/tests/test_user_model.py b/modules/users/tests/models/user/test_users_user_model.py similarity index 100% rename from modules/users/tests/test_user_model.py rename to modules/users/tests/models/user/test_users_user_model.py diff --git a/modules/users/tests/test_external_users.py b/modules/users/tests/oauth/external/test_users_external_users.py similarity index 98% rename from modules/users/tests/test_external_users.py rename to modules/users/tests/oauth/external/test_users_external_users.py index 17f20bf6e..a69e6c18b 100644 --- a/modules/users/tests/test_external_users.py +++ b/modules/users/tests/oauth/external/test_users_external_users.py @@ -120,8 +120,8 @@ async def test_admin_reset_link_rejected_for_external_user(admin_client, users_d @pytest.mark.anyio async def test_forgot_password_is_noop_for_external_user(users_app): - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager from users.models import OAuthAccount async with users_app.state.sm.db.session_factory() as session: diff --git a/modules/users/tests/test_oauth.py b/modules/users/tests/oauth/flow/test_users_oauth.py similarity index 97% rename from modules/users/tests/test_oauth.py rename to modules/users/tests/oauth/flow/test_users_oauth.py index 46272ed45..c1c569831 100644 --- a/modules/users/tests/test_oauth.py +++ b/modules/users/tests/oauth/flow/test_users_oauth.py @@ -22,9 +22,9 @@ import pytest from fastapi_users.password import PasswordHelper from sqlalchemy import select +from users.core.settings import UsersSettings from users.models import OAuthAccount, User from users.oauth import build_client_map, build_clients -from users.settings import UsersSettings _pw = PasswordHelper() @@ -156,8 +156,8 @@ async def test_oauth_account_round_trip_and_cascade(users_db): async def _build_user_manager(app): """Construct a UserManager bound to the test app's DB session.""" - from users.db_adapter import UserDatabaseWithRoles - from users.manager import UserManager + from users.accounts.db_adapter import UserDatabaseWithRoles + from users.accounts.manager import UserManager from users.models import OAuthAccount, User session = app.state.sm.db.session_factory() @@ -223,7 +223,7 @@ async def test_oauth_callback_links_to_existing_email(users_app, users_db): def test_users_state_defaults_empty_oauth_clients(): - from users.state import UsersState + from users.core.state import UsersState state = UsersState(settings=UsersSettings()) assert state.oauth_clients == {} diff --git a/modules/users/tests/test_users_oauth_email_claim.py b/modules/users/tests/oauth/flow/test_users_oauth_email_claim.py similarity index 100% rename from modules/users/tests/test_users_oauth_email_claim.py rename to modules/users/tests/oauth/flow/test_users_oauth_email_claim.py diff --git a/modules/users/tests/test_users_oauth_error_responses.py b/modules/users/tests/oauth/flow/test_users_oauth_error_responses.py similarity index 97% rename from modules/users/tests/test_users_oauth_error_responses.py rename to modules/users/tests/oauth/flow/test_users_oauth_error_responses.py index 8422c851a..af8a1c609 100644 --- a/modules/users/tests/test_users_oauth_error_responses.py +++ b/modules/users/tests/oauth/flow/test_users_oauth_error_responses.py @@ -9,9 +9,9 @@ from fastapi_users.exceptions import UserNotExists from httpx_oauth.exceptions import GetProfileError from starlette.middleware.sessions import SessionMiddleware -from users.deps import auth_backend, get_user_manager from users.oauth.api import register_oauth_routes from users.oauth.providers import OAuthProvider +from users.web.deps import auth_backend, get_user_manager @pytest.mark.anyio @@ -92,7 +92,7 @@ async def strategy_dep(): async def test_login_view_filters_oauth_error_codes(code): from starlette.requests import Request from users.auth_local.views import login_page - from users.settings import UsersSettings + from users.core.settings import UsersSettings app = FastAPI() app.state.users = SimpleNamespace(settings=UsersSettings(), oauth_providers=[]) diff --git a/modules/users/tests/test_users_oauth_login_errors.py b/modules/users/tests/oauth/flow/test_users_oauth_login_errors.py similarity index 100% rename from modules/users/tests/test_users_oauth_login_errors.py rename to modules/users/tests/oauth/flow/test_users_oauth_login_errors.py diff --git a/modules/users/tests/test_users_oauth_provider_identity.py b/modules/users/tests/oauth/flow/test_users_oauth_provider_identity.py similarity index 100% rename from modules/users/tests/test_users_oauth_provider_identity.py rename to modules/users/tests/oauth/flow/test_users_oauth_provider_identity.py diff --git a/modules/users/tests/test_oauth_routes.py b/modules/users/tests/oauth/flow/test_users_oauth_routes.py similarity index 100% rename from modules/users/tests/test_oauth_routes.py rename to modules/users/tests/oauth/flow/test_users_oauth_routes.py diff --git a/modules/users/tests/test_users_oauth_signup_gate.py b/modules/users/tests/oauth/flow/test_users_oauth_signup_gate.py similarity index 100% rename from modules/users/tests/test_users_oauth_signup_gate.py rename to modules/users/tests/oauth/flow/test_users_oauth_signup_gate.py diff --git a/modules/users/tests/test_users_oauth_unverified_link.py b/modules/users/tests/oauth/flow/test_users_oauth_unverified_link.py similarity index 100% rename from modules/users/tests/test_users_oauth_unverified_link.py rename to modules/users/tests/oauth/flow/test_users_oauth_unverified_link.py diff --git a/modules/users/tests/test_negative_authz.py b/modules/users/tests/web/authz/test_users_negative_authz.py similarity index 100% rename from modules/users/tests/test_negative_authz.py rename to modules/users/tests/web/authz/test_users_negative_authz.py diff --git a/modules/users/tests/test_users_deps.py b/modules/users/tests/web/deps/test_users_deps.py similarity index 71% rename from modules/users/tests/test_users_deps.py rename to modules/users/tests/web/deps/test_users_deps.py index 14ad71d0c..0cd5f0f1c 100644 --- a/modules/users/tests/test_users_deps.py +++ b/modules/users/tests/web/deps/test_users_deps.py @@ -7,56 +7,56 @@ def test_auth_backend_import(): - from users.deps import auth_backend + from users.web.deps import auth_backend assert isinstance(auth_backend, AuthenticationBackend) def test_auth_backend_name(): - from users.deps import auth_backend + from users.web.deps import auth_backend assert auth_backend.name == "cookie" def test_fastapi_users_import(): - from users.deps import fastapi_users + from users.web.deps import fastapi_users assert isinstance(fastapi_users, FastAPIUsers) def test_current_active_user_is_callable(): - from users.deps import current_active_user + from users.web.deps import current_active_user assert callable(current_active_user) def test_current_superuser_is_callable(): - from users.deps import current_superuser + from users.web.deps import current_superuser assert callable(current_superuser) def test_get_user_manager_is_callable(): - from users.deps import get_user_manager + from users.web.deps import get_user_manager assert callable(get_user_manager) def test_get_user_db_is_callable(): - from users.deps import get_user_db + from users.web.deps import get_user_db assert callable(get_user_db) def test_get_access_token_db_is_callable(): - from users.deps import get_access_token_db + from users.web.deps import get_access_token_db assert callable(get_access_token_db) def test_all_exports_present(): - """Verify all listed __all__ members are importable from users.deps.""" - import users.deps as deps + """Verify all listed __all__ members are importable from users.web.deps.""" + import users.web.deps as deps for name in deps.__all__: assert hasattr(deps, name), f"Missing export: {name}" diff --git a/modules/users/tests/test_users_middleware.py b/modules/users/tests/web/middleware/test_users_middleware.py similarity index 100% rename from modules/users/tests/test_users_middleware.py rename to modules/users/tests/web/middleware/test_users_middleware.py diff --git a/modules/users/tests/test_users_middleware_public_paths.py b/modules/users/tests/web/middleware/test_users_middleware_public_paths.py similarity index 100% rename from modules/users/tests/test_users_middleware_public_paths.py rename to modules/users/tests/web/middleware/test_users_middleware_public_paths.py diff --git a/modules/users/tests/test_users_middleware_resolvers.py b/modules/users/tests/web/middleware/test_users_middleware_resolvers.py similarity index 100% rename from modules/users/tests/test_users_middleware_resolvers.py rename to modules/users/tests/web/middleware/test_users_middleware_resolvers.py diff --git a/modules/users/tests/test_users_shared_props.py b/modules/users/tests/web/shared_props/test_users_shared_props.py similarity index 99% rename from modules/users/tests/test_users_shared_props.py rename to modules/users/tests/web/shared_props/test_users_shared_props.py index 918e04a67..1dfe3bfb9 100644 --- a/modules/users/tests/test_users_shared_props.py +++ b/modules/users/tests/web/shared_props/test_users_shared_props.py @@ -17,7 +17,7 @@ import pytest from users.demo import SESSION_DEMO_KEY -from users.shared_props import users_shared_props +from users.web.shared_props import users_shared_props def _request(users_state: object, session: dict | None = None) -> SimpleNamespace: diff --git a/modules/users/users/accounts/__init__.py b/modules/users/users/accounts/__init__.py new file mode 100644 index 000000000..373efc223 --- /dev/null +++ b/modules/users/users/accounts/__init__.py @@ -0,0 +1,6 @@ +"""Local accounts and their credentials. + +The fastapi-users backend and DB adapter, the user manager and registration +hooks, the auth provider, bearer token storage and strategy, and credential, +reset and session revocation with the session-version cache. +""" diff --git a/modules/users/users/backend.py b/modules/users/users/accounts/backend.py similarity index 95% rename from modules/users/users/backend.py rename to modules/users/users/accounts/backend.py index 2b91a60f0..7c69058e0 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/accounts/backend.py @@ -19,12 +19,12 @@ from fastapi_users.authentication import AuthenticationBackend, CookieTransport from fastapi_users.authentication.strategy.db import AccessTokenDatabase -from users.db_adapter import get_access_token_db +from users.accounts.db_adapter import get_access_token_db +from users.accounts.token_strategy import ExpiringDatabaseStrategy from users.models import UserAccessToken -from users.token_strategy import ExpiringDatabaseStrategy if TYPE_CHECKING: - from users.settings import UsersSettings + from users.core.settings import UsersSettings _TOKEN_LIFETIME_SECONDS = 60 * 60 * 24 * 30 # 30 days """Oldest access-token row the query will even look at — the ceiling, not the norm. @@ -37,7 +37,7 @@ What bounds an individual credential is its own ``UserAccessToken.expires_at``, stamped at mint time from whatever that sign-in actually asked for and enforced -by :class:`~users.token_strategy.ExpiringDatabaseStrategy`. The cookie's +by :class:`~users.accounts.token_strategy.ExpiringDatabaseStrategy`. The cookie's ``Max-Age`` is browser-enforced only, so it cannot be the bound: a cookie lifted off disk is replayed without one. Revocation depends on neither window — a ``User.session_version`` bump strands sessions and tokens alike, the latter by diff --git a/modules/users/users/credentials.py b/modules/users/users/accounts/credentials.py similarity index 92% rename from modules/users/users/credentials.py rename to modules/users/users/accounts/credentials.py index b88379b9d..050d6f31b 100644 --- a/modules/users/users/credentials.py +++ b/modules/users/users/accounts/credentials.py @@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col +from users.accounts.session_revocation import bump_session_version, publish_revocation +from users.accounts.session_version_cache import forget_session_version from users.models import RefreshToken, User, UserAccessToken -from users.session_revocation import bump_session_version, publish_revocation -from users.session_version_cache import forget_session_version async def revoke_all_credentials(db: AsyncSession, user: User, app: FastAPI | None) -> None: diff --git a/modules/users/users/db_adapter.py b/modules/users/users/accounts/db_adapter.py similarity index 97% rename from modules/users/users/db_adapter.py rename to modules/users/users/accounts/db_adapter.py index b9e9939dd..89f739298 100644 --- a/modules/users/users/db_adapter.py +++ b/modules/users/users/accounts/db_adapter.py @@ -16,9 +16,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import noload, selectinload +from users.accounts.token_storage import prune_tokens +from users.core.settings import DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS from users.models import OAuthAccount, User, UserAccessToken -from users.settings import DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS -from users.token_storage import prune_tokens class UserDatabaseWithRoles(SQLAlchemyUserDatabase): diff --git a/modules/users/users/manager.py b/modules/users/users/accounts/manager.py similarity index 97% rename from modules/users/users/manager.py rename to modules/users/users/accounts/manager.py index 84afa6c73..1c99ebd33 100644 --- a/modules/users/users/manager.py +++ b/modules/users/users/accounts/manager.py @@ -16,23 +16,23 @@ from simple_module_db.callbacks import register_on_commit from simple_module_hosting.session import sign_in_preserved_keys, stamp_session_expiry -from users.constants import ( +from users.accounts.db_adapter import UserDatabaseWithRoles, get_user_db +from users.accounts.registration import attach_default_role +from users.accounts.reset_revocation import ResetRevocationMixin +from users.contracts.events import UserRegistered +from users.core.constants import ( LOGIN_SESSION_PRESERVED_KEYS, OAUTH_REGISTRATION_REQUEST_FLAG, SESSION_USER_ID_KEY, SESSION_VERSION_KEY, ) -from users.contracts.events import UserRegistered +from users.core.exceptions import ExternalUserNoPasswordError from users.core.password_hashing import PASSWORD_HELPER, PasswordHashingMixin -from users.db_adapter import UserDatabaseWithRoles, get_user_db -from users.exceptions import ExternalUserNoPasswordError from users.mailer import Mailer from users.models import User -from users.registration import attach_default_role -from users.reset_revocation import ResetRevocationMixin if TYPE_CHECKING: - from users.settings import UsersSettings + from users.core.settings import UsersSettings logger = logging.getLogger(__name__) diff --git a/modules/users/users/provider.py b/modules/users/users/accounts/provider.py similarity index 96% rename from modules/users/users/provider.py rename to modules/users/users/accounts/provider.py index ebbba6dd9..57eeb293d 100644 --- a/modules/users/users/provider.py +++ b/modules/users/users/accounts/provider.py @@ -27,11 +27,9 @@ from sqlmodel import col from starlette.requests import Request -from users.constants import SESSION_DEMO_KEY, SESSION_USER_ID_KEY, SESSION_VERSION_KEY - # Re-exported: the cache lives in its own module (see its docstring), but -# ``users.provider`` is where callers reach for it. -from users.session_version_cache import ( # noqa: F401 +# ``users.accounts.provider`` is where callers reach for it. +from users.accounts.session_version_cache import ( # noqa: F401 SESSION_VERSION_TTL_SECONDS, clear_session_version_cache, forget_session_version, @@ -39,10 +37,11 @@ read_session_version, store_session_version, ) +from users.core.constants import SESSION_DEMO_KEY, SESSION_USER_ID_KEY, SESSION_VERSION_KEY logger = logging.getLogger(__name__) -# Not in ``users.constants``: nothing outside this file writes it, and the +# Not in ``users.core.constants``: nothing outside this file writes it, and the # shape it holds is this provider's own caching detail. _SESSION_USER_CTX_KEY = "user_ctx" @@ -244,12 +243,12 @@ def is_bearer_request(self, request: Request | None) -> bool: async def _resolve_bearer(self, scope, token: str) -> UserContext | None: """Resolve an ``Authorization: Bearer`` token to its user. - Delegates to :func:`users.token_strategy.resolve_bearer`, which is where + Delegates to :func:`users.accounts.token_strategy.resolve_bearer`, which is where ``ExpiringDatabaseStrategy`` also lives — the two readers of ``users_access_token`` apply the same deadline and ``session_version`` rules, and keeping them in one file is what stops them drifting apart. """ - from users.token_strategy import resolve_bearer + from users.accounts.token_strategy import resolve_bearer return await resolve_bearer(scope, token) diff --git a/modules/users/users/registration.py b/modules/users/users/accounts/registration.py similarity index 97% rename from modules/users/users/registration.py rename to modules/users/users/accounts/registration.py index 62299027f..491340281 100644 --- a/modules/users/users/registration.py +++ b/modules/users/users/accounts/registration.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from users.constants import USER_ROLE_NAME +from users.core.constants import USER_ROLE_NAME from users.models import Role, User, UserRole logger = logging.getLogger(__name__) diff --git a/modules/users/users/reset_revocation.py b/modules/users/users/accounts/reset_revocation.py similarity index 96% rename from modules/users/users/reset_revocation.py rename to modules/users/users/accounts/reset_revocation.py index b39c49af1..0b473972c 100644 --- a/modules/users/users/reset_revocation.py +++ b/modules/users/users/accounts/reset_revocation.py @@ -7,7 +7,7 @@ from fastapi import Request -from users.credentials import announce_revocation, revoke_credential_rows +from users.accounts.credentials import announce_revocation, revoke_credential_rows from users.models import User logger = logging.getLogger(__name__) diff --git a/modules/users/users/session_revocation.py b/modules/users/users/accounts/session_revocation.py similarity index 97% rename from modules/users/users/session_revocation.py rename to modules/users/users/accounts/session_revocation.py index 90d31f43a..f5f3be178 100644 --- a/modules/users/users/session_revocation.py +++ b/modules/users/users/accounts/session_revocation.py @@ -22,8 +22,8 @@ from simple_module_core.app_state import get_services +from users.accounts.session_version_cache import clear_session_version_cache, forget_session_version from users.models import User -from users.session_version_cache import clear_session_version_cache, forget_session_version if TYPE_CHECKING: from fastapi import FastAPI diff --git a/modules/users/users/session_version_cache.py b/modules/users/users/accounts/session_version_cache.py similarity index 95% rename from modules/users/users/session_version_cache.py rename to modules/users/users/accounts/session_version_cache.py index b63819b03..91f980a83 100644 --- a/modules/users/users/session_version_cache.py +++ b/modules/users/users/accounts/session_version_cache.py @@ -1,9 +1,9 @@ """In-process cache of ``User.session_version``, the revocation counter. -Split out of :mod:`users.provider` so that file keeps one job — resolving a +Split out of :mod:`users.accounts.provider` so that file keeps one job — resolving a request to a principal — while the caching trade-off (a bounded window in which one worker has not yet seen another worker's revocation) is stated in -one place and testable on its own. Re-exported from ``users.provider``, which +one place and testable on its own. Re-exported from ``users.accounts.provider``, which is where callers reach for it. """ @@ -30,7 +30,7 @@ cost of the cache is a bounded staleness window: a revocation performed in *another* worker process takes up to this long to be seen here — unless a cross-process invalidation transport is installed, in which case -``users.session_revocation`` drops the entry in every worker at once and this +``users.accounts.session_revocation`` drops the entry in every worker at once and this TTL only bounds a dropped message. The process that performed the revocation sees it immediately either way, so the browser that pressed "Sign out everywhere" is never told it worked while still being let in. @@ -147,7 +147,7 @@ def configure_session_version_cache(ttl_seconds: int) -> None: ``0`` disables caching — every entry expires the moment it is written, so the revocation check goes back to one indexed read per request. That remains the only setting that admits *no* window at all, but it is no longer the only - answer to one: ``users.session_revocation`` publishes each bump on the + answer to one: ``users.accounts.session_revocation`` publishes each bump on the framework's ``InvalidationBus``, so an install whose ``background_tasks`` module has a reachable Redis sees a revocation in every worker within a round trip and can keep the cache. This TTL is then the bound on a *dropped* diff --git a/modules/users/users/token_storage.py b/modules/users/users/accounts/token_storage.py similarity index 97% rename from modules/users/users/token_storage.py rename to modules/users/users/accounts/token_storage.py index 817e4bed7..98f465fc8 100644 --- a/modules/users/users/token_storage.py +++ b/modules/users/users/accounts/token_storage.py @@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col +from users.core.settings import DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS from users.models import RefreshToken, UserAccessToken -from users.settings import DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS _PRUNE_BATCH_SIZE = 100 diff --git a/modules/users/users/token_strategy.py b/modules/users/users/accounts/token_strategy.py similarity index 98% rename from modules/users/users/token_strategy.py rename to modules/users/users/accounts/token_strategy.py index ecd5a38fe..3ef4ce682 100644 --- a/modules/users/users/token_strategy.py +++ b/modules/users/users/accounts/token_strategy.py @@ -29,8 +29,8 @@ from fastapi_users.manager import BaseUserManager from sqlmodel import col +from users.accounts.token_storage import hash_token from users.models import UserAccessToken -from users.token_storage import hash_token logger = logging.getLogger(__name__) @@ -132,7 +132,7 @@ async def resolve_bearer(scope, token: str) -> UserContext | None: from sqlalchemy import select from sqlalchemy.orm import noload, selectinload - from users.backend import _TOKEN_LIFETIME_SECONDS + from users.accounts.backend import _TOKEN_LIFETIME_SECONDS from users.models import User session_factory = scope["app"].state.sm.db.session_factory diff --git a/modules/users/users/admin/api.py b/modules/users/users/admin/api.py index 335a697e9..27a3526fa 100644 --- a/modules/users/users/admin/api.py +++ b/modules/users/users/admin/api.py @@ -18,7 +18,6 @@ from users.admin.events import publish_admin_event from users.admin.links import admin_link_base from users.admin.service import UserService -from users.constants import PERM_USERS_MANAGE, sanitize_list_filters from users.contracts.events import ( RoleAssigned, UserCreated, @@ -34,14 +33,15 @@ UserInvite, UserListItem, ) -from users.deps import get_event_bus, get_mailer, get_user_service -from users.exceptions import ( +from users.core.constants import PERM_USERS_MANAGE, sanitize_list_filters +from users.core.exceptions import ( EmailAlreadyExistsError, ExternalUserNoPasswordError, UserNotFoundError, ) from users.invites.bulk import bulk_router from users.invites.resend import resend_router +from users.web.deps import get_event_bus, get_mailer, get_user_service _DUPLICATE_EMAIL = "A user with this email already exists." diff --git a/modules/users/users/admin/components/useUserRowActions.ts b/modules/users/users/admin/components/useUserRowActions.ts index acee87670..131aab666 100644 --- a/modules/users/users/admin/components/useUserRowActions.ts +++ b/modules/users/users/admin/components/useUserRowActions.ts @@ -4,7 +4,7 @@ import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '../../pages/api'; -import { adminFailureMessage } from '../../pages/failureMessage'; +import { adminFailureMessage } from '../../ui/utils/failureMessage'; /** * The per-row actions on the users table — resend, copy a reset link, and diff --git a/modules/users/users/admin/links.py b/modules/users/users/admin/links.py index 5f1c94f19..9677b28fe 100644 --- a/modules/users/users/admin/links.py +++ b/modules/users/users/admin/links.py @@ -21,7 +21,7 @@ from simple_module_core.app_state import get_host_services from starlette.requests import Request -from users.settings import UsersSettings +from users.core.settings import UsersSettings _DEFAULT_BASE_URL = UsersSettings.model_fields["base_url"].default diff --git a/modules/users/users/admin/queries.py b/modules/users/users/admin/queries.py index 580661f9d..19e9d7e35 100644 --- a/modules/users/users/admin/queries.py +++ b/modules/users/users/admin/queries.py @@ -16,14 +16,14 @@ from sqlalchemy.orm import selectinload from sqlmodel import col +from users.accounts.manager import UserManager +from users.accounts.session_revocation import bump_session_version, publish_revocation +from users.accounts.session_version_cache import forget_session_version from users.admin.summary_cache import get_role_summary, get_state_counts from users.admin.user_state import STATUS_CONDITIONS, user_state from users.contracts.schemas import RoleListItem, UserListItem -from users.exceptions import EmailAlreadyExistsError, UserNotFoundError -from users.manager import UserManager +from users.core.exceptions import EmailAlreadyExistsError, UserNotFoundError from users.models import Role, User, UserRole -from users.session_revocation import bump_session_version, publish_revocation -from users.session_version_cache import forget_session_version def _live_lock(locked_until: datetime | None) -> datetime | None: diff --git a/modules/users/users/admin/safety.py b/modules/users/users/admin/safety.py index f8f5e0cf9..ca0b93058 100644 --- a/modules/users/users/admin/safety.py +++ b/modules/users/users/admin/safety.py @@ -15,11 +15,11 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from users.accounts.manager import UserManager from users.admin.queries import _UserServiceBase -from users.administrators import count_active_administrators -from users.constants import ADMIN_ROLE_NAME -from users.exceptions import AdminAccountSafetyError, AdminPermissionError -from users.manager import UserManager +from users.core.administrators import count_active_administrators +from users.core.constants import ADMIN_ROLE_NAME +from users.core.exceptions import AdminAccountSafetyError, AdminPermissionError from users.models import Role, User diff --git a/modules/users/users/admin/service.py b/modules/users/users/admin/service.py index 4237ea77a..ce18efd4a 100644 --- a/modules/users/users/admin/service.py +++ b/modules/users/users/admin/service.py @@ -12,9 +12,9 @@ from users.admin.safety import _UserAdminSafety from users.admin.summary_cache import publish_after_commit, publish_now -from users.constants import ADMIN_ROLE_NAME from users.contracts.schemas import UserCreate -from users.exceptions import ( +from users.core.constants import ADMIN_ROLE_NAME +from users.core.exceptions import ( EmailAlreadyExistsError, ExternalUserNoPasswordError, NotPendingInviteError, diff --git a/modules/users/users/admin/summary_cache.py b/modules/users/users/admin/summary_cache.py index 078dc5507..888426243 100644 --- a/modules/users/users/admin/summary_cache.py +++ b/modules/users/users/admin/summary_cache.py @@ -139,7 +139,7 @@ async def publish() -> None: def subscribe(bus: InvalidationBus, app: FastAPI) -> None: - from users.roles_cache import CHANNEL as ROLES_CHANNEL + from users.core.roles_cache import CHANNEL as ROLES_CHANNEL bus.subscribe(CHANNEL, lambda _: _cache(app).forget()) bus.subscribe(ROLES_CHANNEL, lambda _: _cache(app).forget()) diff --git a/modules/users/users/admin/views.py b/modules/users/users/admin/views.py index 25d111a61..688144f1d 100644 --- a/modules/users/users/admin/views.py +++ b/modules/users/users/admin/views.py @@ -17,11 +17,11 @@ from users.admin.recent_activity import recent_activity_for from users.admin.service import UserService -from users.constants import PERM_USERS_MANAGE, sanitize_list_filters -from users.deps import get_user_service -from users.exceptions import UserNotFoundError +from users.core.constants import PERM_USERS_MANAGE, sanitize_list_filters +from users.core.exceptions import UserNotFoundError +from users.core.roles_cache import get_roles_cache from users.mailer import mailer_delivers -from users.roles_cache import get_roles_cache +from users.web.deps import get_user_service router = APIRouter() diff --git a/modules/users/users/auth_local/api.py b/modules/users/users/auth_local/api.py index f1aeac67d..980751046 100644 --- a/modules/users/users/auth_local/api.py +++ b/modules/users/users/auth_local/api.py @@ -23,6 +23,9 @@ from simple_module_hosting.token_guard import Authenticated from sqlalchemy.ext.asyncio import AsyncSession +from users.accounts.backend import cookie_login_response +from users.accounts.db_adapter import get_access_token_db +from users.accounts.manager import UserManager from users.auth_local.browser_guard import require_browser_csrf from users.auth_local.browser_sessions import end_browser_session from users.auth_local.lockout import ( @@ -34,8 +37,6 @@ ) from users.auth_local.rate_limit import enforce_auth_throughput_limit from users.auth_local.self_account import router as self_account_router -from users.backend import cookie_login_response -from users.constants import SESSION_USER_ID_KEY from users.contracts.schemas import ( AcceptInviteRequest, LoginRequest, @@ -43,14 +44,13 @@ UserRead, UserUpdate, ) -from users.db_adapter import get_access_token_db -from users.deps import ( +from users.core.constants import SESSION_USER_ID_KEY +from users.invites.accept import complete_invite +from users.web.deps import ( auth_backend, get_current_local_user, get_user_manager, ) -from users.invites.accept import complete_invite -from users.manager import UserManager logger = logging.getLogger(__name__) router = APIRouter() diff --git a/modules/users/users/auth_local/browser_sessions.py b/modules/users/users/auth_local/browser_sessions.py index c13e97d80..0054db071 100644 --- a/modules/users/users/auth_local/browser_sessions.py +++ b/modules/users/users/auth_local/browser_sessions.py @@ -13,10 +13,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from users.constants import SESSION_USER_ID_KEY +from users.accounts.session_revocation import publish_revocation +from users.accounts.token_storage import revoke_access_token +from users.core.constants import SESSION_USER_ID_KEY from users.models import RevokedSession -from users.session_revocation import publish_revocation -from users.token_storage import revoke_access_token def _insert(dialect: str) -> Any: diff --git a/modules/users/users/auth_local/components/PasswordCard.tsx b/modules/users/users/auth_local/components/PasswordCard.tsx index 8761b4fc5..6f4a627cb 100644 --- a/modules/users/users/auth_local/components/PasswordCard.tsx +++ b/modules/users/users/auth_local/components/PasswordCard.tsx @@ -9,7 +9,7 @@ import { apiResult } from '@simple-module-py/ui/lib/api'; import { useId, useState } from 'react'; import { toast } from 'sonner'; import { api } from '../../pages/api'; -import { failureMessage } from '../../pages/failureMessage'; +import { failureMessage } from '../../ui/utils/failureMessage'; /** * Change your own password. diff --git a/modules/users/users/auth_local/components/ProfileDetailsCard.tsx b/modules/users/users/auth_local/components/ProfileDetailsCard.tsx index dfa1a6ec0..9f70ebd8f 100644 --- a/modules/users/users/auth_local/components/ProfileDetailsCard.tsx +++ b/modules/users/users/auth_local/components/ProfileDetailsCard.tsx @@ -9,7 +9,7 @@ import { initials } from '@simple-module-py/ui/lib/initials'; import { useId, useState } from 'react'; import { toast } from 'sonner'; import { api } from '../../pages/api'; -import { failureMessage } from '../../pages/failureMessage'; +import { failureMessage } from '../../ui/utils/failureMessage'; import type { ProfileUser } from './profile-user'; /** diff --git a/modules/users/users/auth_local/components/SessionsCard.tsx b/modules/users/users/auth_local/components/SessionsCard.tsx index 58e288289..5f68401e1 100644 --- a/modules/users/users/auth_local/components/SessionsCard.tsx +++ b/modules/users/users/auth_local/components/SessionsCard.tsx @@ -9,7 +9,7 @@ import { LogOut } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { api } from '../../pages/api'; -import { failureMessage } from '../../pages/failureMessage'; +import { failureMessage } from '../../ui/utils/failureMessage'; interface Props { lastLoginAt: string | null; diff --git a/modules/users/users/auth_local/lockout/__init__.py b/modules/users/users/auth_local/lockout/__init__.py index 5d0368f0c..44402659b 100644 --- a/modules/users/users/auth_local/lockout/__init__.py +++ b/modules/users/users/auth_local/lockout/__init__.py @@ -41,7 +41,7 @@ from users.auth_local.lockout.attempt import Admission, LoginAttempt from users.auth_local.rate_limit import client_ip -from users.exceptions import LOGIN_LOCKED_DETAIL, LoginLockedError +from users.core.exceptions import LOGIN_LOCKED_DETAIL, LoginLockedError from users.models import User if TYPE_CHECKING: diff --git a/modules/users/users/auth_local/lockout/attempt.py b/modules/users/users/auth_local/lockout/attempt.py index 6f0c38e4d..68b8f0f6a 100644 --- a/modules/users/users/auth_local/lockout/attempt.py +++ b/modules/users/users/auth_local/lockout/attempt.py @@ -11,7 +11,7 @@ from simple_module_core.rate_limit import RateSpec, WindowStore if TYPE_CHECKING: - from users.settings import UsersSettings + from users.core.settings import UsersSettings @dataclass(frozen=True, slots=True) diff --git a/modules/users/users/auth_local/self_account.py b/modules/users/users/auth_local/self_account.py index 68da3b3bb..3d2117a35 100644 --- a/modules/users/users/auth_local/self_account.py +++ b/modules/users/users/auth_local/self_account.py @@ -19,14 +19,14 @@ from simple_module_hosting.session import SESSION_REMEMBER_KEY from simple_module_hosting.token_guard import Authenticated +from users.accounts.backend import cookie_login_response +from users.accounts.credentials import revoke_all_credentials +from users.accounts.db_adapter import get_access_token_db +from users.accounts.manager import UserManager from users.auth_local.rate_limit import enforce_auth_throughput_limit -from users.backend import cookie_login_response -from users.constants import SESSION_VERSION_KEY from users.contracts.schemas import SelfPasswordChange -from users.credentials import revoke_all_credentials -from users.db_adapter import get_access_token_db -from users.deps import fastapi_users, get_user_manager -from users.manager import UserManager +from users.core.constants import SESSION_VERSION_KEY +from users.web.deps import fastapi_users, get_user_manager router = APIRouter() diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index a23a37c1d..4cd10b770 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -19,6 +19,7 @@ from sqlmodel import SQLModel, col from starlette.responses import JSONResponse +from users.accounts.token_storage import hash_token, prune_tokens from users.auth_local.lockout import ( admit, login_attempt, @@ -30,7 +31,6 @@ from users.demo.guard import DEMO_READ_ONLY_DETAIL, is_read_only_demo from users.models import User from users.models.refresh_token import RefreshToken -from users.token_storage import hash_token, prune_tokens # Pre-computed bcrypt hash used when a login attempt targets a non-existent # user. Running verify against this takes the same time as a real check, diff --git a/modules/users/users/auth_local/views.py b/modules/users/users/auth_local/views.py index 53de991dc..28744bc6d 100644 --- a/modules/users/users/auth_local/views.py +++ b/modules/users/users/auth_local/views.py @@ -13,15 +13,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import RedirectResponse +from users.accounts.manager import UserManager, get_user_manager from users.auth_local.browser_guard import require_browser_origin from users.auth_local.browser_sessions import end_browser_session from users.auth_local.token_preview import decode_verify_token, preview_reset -from users.bootstrap import resolve_bootstrap_credentials from users.contracts.schemas import UserRead +from users.core.bootstrap import resolve_bootstrap_credentials from users.demo import resolve_demo_accounts from users.invites.preview import preview_invite from users.mailer import mailer_delivers -from users.manager import UserManager, get_user_manager from users.models import User from users.oauth.errors import OAUTH_ERROR_CODES @@ -86,7 +86,7 @@ async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse: # refused save. # # Not ``demo`` — that name belongs to the shared prop describing - # the *current* session (``users.shared_props``). Page props win + # the *current* session (``users.web.shared_props``). Page props win # the merge, so reusing it would have this key quietly shadow the # banner's on this one page. "demo_signin": { diff --git a/modules/users/users/bootstrap.py b/modules/users/users/bootstrap.py index 4ec9201da..5bb2de75f 100644 --- a/modules/users/users/bootstrap.py +++ b/modules/users/users/bootstrap.py @@ -1,276 +1,11 @@ -"""Shared admin-creation logic used by the CLI and env-var auto-bootstrap.""" +"""Moved to :mod:`users.core.bootstrap`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging -import os -from dataclasses import dataclass +import importlib +import sys -from fastapi import FastAPI -from fastapi_users.password import PasswordHelper -from simple_module_core.dotenv import parse_dotenv -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import col +from simple_module_core.deprecation import deprecated_module -from users.constants import ( - ADMIN_ROLE_DESCRIPTION, - ADMIN_ROLE_ID, - ADMIN_ROLE_NAME, - USER_ROLE_DESCRIPTION, - USER_ROLE_ID, - USER_ROLE_NAME, -) -from users.models import Role, User, UserRole -from users.settings import UsersSettings - -# Maps a UsersSettings attribute to the env var that seeds it on first boot. -# Shared between the bootstrap function and the test fixture that isolates it. -BOOTSTRAP_ENV_KEYS: dict[str, str] = { - "bootstrap_email": "SM_USERS_BOOTSTRAP_EMAIL", - "bootstrap_password": "SM_USERS_BOOTSTRAP_PASSWORD", - "bootstrap_user_email": "SM_USERS_BOOTSTRAP_USER_EMAIL", - "bootstrap_user_password": "SM_USERS_BOOTSTRAP_USER_PASSWORD", -} - -logger = logging.getLogger("users.bootstrap") - -_EVT_CREATED = "users.bootstrap.created" -_EVT_UPDATED = "users.bootstrap.updated" -_EVT_NOOP = "users.bootstrap.noop" -_EVT_USER_NOOP = "users.bootstrap.user_noop" -_EVT_USER_CREATED = "users.bootstrap.user_created" -_EVT_FAILED = "users.bootstrap.failed" - - -@dataclass -class CreateAdminResult: - user: User - created: bool # False when admin already existed and we just ensured the role - - -async def create_admin( - db: AsyncSession, - *, - email: str, - password: str, - full_name: str | None = None, - force: bool = False, -) -> CreateAdminResult: - """Create or update an admin user. Idempotent by default. - - - If no user with this email exists: create one with is_active=True, - is_verified=True, is_superuser=True; hash the password; ensure the - 'admin' Role row exists (create from ADMIN_ROLE_ID if missing); - insert UserRole. - - If the user exists and force=True: update the password and ensure the - admin role is attached. - - If the user exists and force=False: return created=False without - changing the password. - """ - existing = ( - await db.execute(select(User).where(func.lower(User.email) == email.lower())) - ).scalar_one_or_none() - - admin_role = await ensure_admin_role(db) - - hasher = PasswordHelper() - if existing is None: - user = User( - email=email.lower(), - hashed_password=hasher.hash(password), - is_active=True, - is_verified=True, - is_superuser=True, - full_name=full_name, - ) - db.add(user) - await db.flush() - db.add(UserRole(user_id=user.id, role_id=admin_role.id)) - await db.commit() - await db.refresh(user) - logger.info(_EVT_CREATED, extra={"email": email, "id": str(user.id)}) - return CreateAdminResult(user=user, created=True) - - if force: - existing.hashed_password = hasher.hash(password) - existing.is_active = True - existing.is_verified = True - existing.is_superuser = True - if full_name is not None: - existing.full_name = full_name - existing_link = ( - await db.execute( - select(UserRole).where( - col(UserRole.user_id) == existing.id, - col(UserRole.role_id) == admin_role.id, - ) - ) - ).scalar_one_or_none() - if existing_link is None: - db.add(UserRole(user_id=existing.id, role_id=admin_role.id)) - await db.commit() - logger.info(_EVT_UPDATED, extra={"email": email, "id": str(existing.id)}) - return CreateAdminResult(user=existing, created=False) - - logger.info(_EVT_NOOP, extra={"email": email, "id": str(existing.id)}) - return CreateAdminResult(user=existing, created=False) - - -async def ensure_admin_role(db: AsyncSession) -> Role: - """Return the administrator role, preserving an existing name or seed ID.""" - # Look up by name first (works on both Postgres and SQLite regardless of UUID - # storage format), then fall back to id-based lookup in case name was changed. - admin_role = ( - await db.execute(select(Role).where(col(Role.name) == ADMIN_ROLE_NAME)) - ).scalar_one_or_none() - if admin_role is None: - admin_role = ( - await db.execute(select(Role).where(col(Role.id) == ADMIN_ROLE_ID)) - ).scalar_one_or_none() - if admin_role is None: - # Schema-only installs do not run the host role seed migration. - admin_role = Role( - id=ADMIN_ROLE_ID, name=ADMIN_ROLE_NAME, description=ADMIN_ROLE_DESCRIPTION - ) - db.add(admin_role) - await db.flush() - - return admin_role - - -async def ensure_user_role(db: AsyncSession) -> Role: - """The standard ``user`` Role row, created (and flushed) if it is missing. - - Startup ensures it even in scaffolded hosts whose schema baseline omits - the host's data migration ``b5018b617142``. Bootstrap callers can also use - this before startup has run. - """ - user_role = ( - await db.execute(select(Role).where(col(Role.name) == USER_ROLE_NAME)) - ).scalar_one_or_none() - if user_role is None: - user_role = ( - await db.execute(select(Role).where(col(Role.id) == USER_ROLE_ID)) - ).scalar_one_or_none() - if user_role is None: - user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION) - db.add(user_role) - await db.flush() - return user_role - - -async def create_standard_user( - db: AsyncSession, - *, - email: str, - password: str, - full_name: str | None = None, -) -> CreateAdminResult: - """Create a non-admin user with the 'user' role. Idempotent (noop if exists). - - Unlike ``create_admin`` this is not meant to be called from the CLI — it's - used by the env-var bootstrap to seed a second account for dev/testing. - """ - existing = ( - await db.execute(select(User).where(func.lower(User.email) == email.lower())) - ).scalar_one_or_none() - if existing is not None: - logger.info(_EVT_USER_NOOP, extra={"email": email, "id": str(existing.id)}) - return CreateAdminResult(user=existing, created=False) - - user_role = await ensure_user_role(db) - - user = User( - email=email.lower(), - hashed_password=PasswordHelper().hash(password), - is_active=True, - is_verified=True, - is_superuser=False, - full_name=full_name, - ) - db.add(user) - await db.flush() - db.add(UserRole(user_id=user.id, role_id=user_role.id)) - await db.commit() - await db.refresh(user) - logger.info(_EVT_USER_CREATED, extra={"email": email, "id": str(user.id)}) - return CreateAdminResult(user=user, created=True) - - -async def _user_table_is_empty(db: AsyncSession) -> bool: - count = (await db.execute(select(func.count()).select_from(User))).scalar_one() - return count == 0 - - -def _read_dotenv_bootstrap_vars() -> dict[str, str]: - """Return SM_USERS_BOOTSTRAP_* entries from ``.env`` (ignore everything else). - - ``UsersSettings`` deliberately doesn't use ``env_file`` — runtime fields - come from the DB, and pulling the whole ``.env`` in would re-expose every - SMTP/cookie/token secret as an env knob. So we re-read ``.env`` just for - the four documented seed keys. - """ - wanted = set(BOOTSTRAP_ENV_KEYS.values()) - return {k: v for k, v in parse_dotenv().items() if k in wanted} - - -def resolve_bootstrap_credentials(settings: UsersSettings) -> dict[str, str]: - """Resolve the four bootstrap fields with the same precedence everywhere. - - Order: ``UsersSettings`` (test overrides) → ``os.environ`` (docker/systemd) - → ``.env`` file (documented dev path). Used by both the boot-time admin - seeder and the dev-only login-page quick-fill so the two stay in lockstep. - """ - dotenv_vars = _read_dotenv_bootstrap_vars() - return { - attr: getattr(settings, attr) or os.environ.get(env_key) or dotenv_vars.get(env_key, "") - for attr, env_key in BOOTSTRAP_ENV_KEYS.items() - } - - -async def bootstrap_admin_from_env(app: FastAPI) -> None: - """On-startup hook: create admin from env vars iff users table is empty. - - Resolves each of the four bootstrap fields via - :func:`resolve_bootstrap_credentials`. If the admin email or password is - still blank, returns silently — same if the users table already has rows - (so restarts don't try to re-bootstrap). - - Optionally also creates a non-admin user from - ``SM_USERS_BOOTSTRAP_USER_EMAIL`` + ``SM_USERS_BOOTSTRAP_USER_PASSWORD`` — - useful in dev for testing non-admin flows alongside the admin account. - """ - settings: UsersSettings = app.state.users.settings - resolved = resolve_bootstrap_credentials(settings) - email = resolved["bootstrap_email"] - password = resolved["bootstrap_password"] - user_email = resolved["bootstrap_user_email"] - user_password = resolved["bootstrap_user_password"] - - if not email or not password: - return - - session_factory = app.state.sm.db.session_factory - async with session_factory() as session: - if not await _user_table_is_empty(session): - logger.debug("users.bootstrap.skipped (users table non-empty)") - return - - try: - result = await create_admin(session, email=email, password=password) - if result.created and os.environ.get("SM_USERS_BOOTSTRAP_PASSWORD_GENERATED") == "1": - logger.warning( - "first-boot admin created; store this password securely: %s / %s", - email, - password, - ) - if user_email and user_password: - await create_standard_user( - session, - email=user_email, - password=user_password, - ) - except Exception: - logger.exception(_EVT_FAILED) - raise +deprecated_module("users.bootstrap", remove_in="0.0.46", use="users.core.bootstrap") +sys.modules[__name__] = importlib.import_module("users.core.bootstrap") diff --git a/modules/users/users/cli.py b/modules/users/users/cli.py index 385643a93..09579da9d 100644 --- a/modules/users/users/cli.py +++ b/modules/users/users/cli.py @@ -17,7 +17,7 @@ from simple_module_hosting.settings import Settings from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from users.bootstrap import create_admin +from users.core.bootstrap import create_admin app = typer.Typer(help="Users module administration.", no_args_is_help=True) diff --git a/modules/users/users/constants.py b/modules/users/users/constants.py index f1fb5181b..a7400ef95 100644 --- a/modules/users/users/constants.py +++ b/modules/users/users/constants.py @@ -1,93 +1,11 @@ -"""Stable identifiers used by both the seed migration and tests.""" +"""Moved to :mod:`users.core.constants`; this alias goes in 0.0.46 (module layout).""" -import uuid +from __future__ import annotations -from simple_module_core.redirect_safety import SESSION_NEXT_KEY +import importlib +import sys -ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") -USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002") +from simple_module_core.deprecation import deprecated_module -# Role name strings -ADMIN_ROLE_NAME = "admin" -USER_ROLE_NAME = "user" - -# Role descriptions -ADMIN_ROLE_DESCRIPTION = "Administrator" -USER_ROLE_DESCRIPTION = "Standard user" - -# Auth email template filenames (HTML siblings are optional). -MAIL_TEMPLATE_VERIFY = "verify_email.txt" -MAIL_TEMPLATE_RESET = "reset_password.txt" -MAIL_TEMPLATE_INVITE = "invite.txt" - -# Permission identifiers -PERM_USERS_MANAGE = "users.manage" -PERM_USERS_SELF_PROFILE = "users.self.profile" - -# Only the pending redirect belongs to both the anonymous and signed-in session. -# Locale is a separate cookie; cached principal, OAuth and CSRF state must go. -LOGIN_SESSION_PRESERVED_KEYS = (SESSION_NEXT_KEY,) - -# Session keys -SESSION_USER_ID_KEY = "user_id" -# Revocation stamp copied out of ``User.session_version`` at login. A session -# whose stamp has fallen behind the account's was minted before the owner -# pressed "Sign out everywhere", so the auth provider refuses it. Absent means -# 0, which is what every session predating the column carries. -SESSION_VERSION_KEY = "session_version" -# Stamped by the demo sign-in endpoint so ``DemoReadOnlyMiddleware`` can -# recognise a shared demo session without a database read. Lives here rather -# than in ``users.demo`` because ``manager``/``provider`` have to clear it on -# every *other* sign-in and on every session teardown, and neither of those -# should have to import the seeding module to do it. Re-exported from -# ``users.demo``, which is where the feature's own code reads it from. -SESSION_DEMO_KEY = "is_demo" - -# request.state flag set by the OAuth callback before find-or-create so the -# manager's on_after_register hook can mark *newly provisioned* OAuth users as -# external (null password). on_after_register fires only for new users, so the -# flag is ignored when an OAuth login merely links to an existing account. -OAUTH_REGISTRATION_REQUEST_FLAG = "users_oauth_registration" - -# Admin list-endpoint allowed filter/sort values -# "active" here means active *and* verified: the table shows unverified and -# invited as their own states, so folding them back into "active" would make -# the filter disagree with the pill beside every row it returned. -ALLOWED_STATUS = frozenset({"active", "unverified", "invited", "disabled"}) -ALLOWED_VERIFIED = frozenset({"yes", "no"}) -ALLOWED_SORT = frozenset({"email", "last_login_at", "created_at"}) -ALLOWED_ORDER = frozenset({"asc", "desc"}) - -LOGIN_PAGE_PATH = "/users/login" - -# Anonymous-allowed prefixes (login, registration, reset, token endpoints), -# registered in ``UsersModule.register_public_routes``. -PUBLIC_PATH_PREFIXES = ( - "/users/login", - "/users/register", - "/users/forgot-password", - "/users/reset-password", - "/users/verify", - "/users/invite/accept", - "/api/users/auth/", - "/api/users/register", -) - - -def sanitize_list_filters( - status: str | None, - verified: str | None, - sort: str, - order: str, -) -> tuple[str | None, str | None, str, str]: - """Coerce unknown filter values to None/defaults.""" - return ( - status if status in ALLOWED_STATUS else None, - verified if verified in ALLOWED_VERIFIED else None, - sort if sort in ALLOWED_SORT else "email", - order if order in ALLOWED_ORDER else "asc", - ) - - -UPGRADE_HASHED_BEARER_TOKENS = "users.hashed_bearer_tokens_v1" -UPGRADE_UNIQUE_EMAILS = "users.unique_email_v1" +deprecated_module("users.constants", remove_in="0.0.46", use="users.core.constants") +sys.modules[__name__] = importlib.import_module("users.core.constants") diff --git a/modules/users/users/core/__init__.py b/modules/users/users/core/__init__.py index 38e57919f..66f1206cd 100644 --- a/modules/users/users/core/__init__.py +++ b/modules/users/users/core/__init__.py @@ -1 +1,7 @@ -"""Users domain core: password hashing boundaries.""" +"""What every users feature builds on. + +Constants, settings, the app.state container, errors, the administrator +definition, admin bootstrap, startup wiring, data-compatibility checks, the +user directory, audit naming, the roles cache, the first-run setup step and +password hashing. +""" diff --git a/modules/users/users/administrators.py b/modules/users/users/core/administrators.py similarity index 96% rename from modules/users/users/administrators.py rename to modules/users/users/core/administrators.py index 19bb0e541..0f3002802 100644 --- a/modules/users/users/administrators.py +++ b/modules/users/users/core/administrators.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from users.constants import ADMIN_ROLE_NAME +from users.core.constants import ADMIN_ROLE_NAME from users.models import Role, User, UserRole diff --git a/modules/users/users/audit.py b/modules/users/users/core/audit.py similarity index 95% rename from modules/users/users/audit.py rename to modules/users/users/core/audit.py index 3fa226986..57ac13097 100644 --- a/modules/users/users/audit.py +++ b/modules/users/users/core/audit.py @@ -16,8 +16,8 @@ from simple_module_core.audit_links import AuditLink from simple_module_core.user_directory import resolve_user_labels as _labels_via -from users.constants import PERM_USERS_MANAGE -from users.directory import UsersDirectory +from users.core.constants import PERM_USERS_MANAGE +from users.core.directory import UsersDirectory from users.models import User _LABEL = "User" diff --git a/modules/users/users/core/bootstrap.py b/modules/users/users/core/bootstrap.py new file mode 100644 index 000000000..6b3945bc8 --- /dev/null +++ b/modules/users/users/core/bootstrap.py @@ -0,0 +1,276 @@ +"""Shared admin-creation logic used by the CLI and env-var auto-bootstrap.""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass + +from fastapi import FastAPI +from fastapi_users.password import PasswordHelper +from simple_module_core.dotenv import parse_dotenv +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from users.core.constants import ( + ADMIN_ROLE_DESCRIPTION, + ADMIN_ROLE_ID, + ADMIN_ROLE_NAME, + USER_ROLE_DESCRIPTION, + USER_ROLE_ID, + USER_ROLE_NAME, +) +from users.core.settings import UsersSettings +from users.models import Role, User, UserRole + +# Maps a UsersSettings attribute to the env var that seeds it on first boot. +# Shared between the bootstrap function and the test fixture that isolates it. +BOOTSTRAP_ENV_KEYS: dict[str, str] = { + "bootstrap_email": "SM_USERS_BOOTSTRAP_EMAIL", + "bootstrap_password": "SM_USERS_BOOTSTRAP_PASSWORD", + "bootstrap_user_email": "SM_USERS_BOOTSTRAP_USER_EMAIL", + "bootstrap_user_password": "SM_USERS_BOOTSTRAP_USER_PASSWORD", +} + +logger = logging.getLogger("users.bootstrap") + +_EVT_CREATED = "users.bootstrap.created" +_EVT_UPDATED = "users.bootstrap.updated" +_EVT_NOOP = "users.bootstrap.noop" +_EVT_USER_NOOP = "users.bootstrap.user_noop" +_EVT_USER_CREATED = "users.bootstrap.user_created" +_EVT_FAILED = "users.bootstrap.failed" + + +@dataclass +class CreateAdminResult: + user: User + created: bool # False when admin already existed and we just ensured the role + + +async def create_admin( + db: AsyncSession, + *, + email: str, + password: str, + full_name: str | None = None, + force: bool = False, +) -> CreateAdminResult: + """Create or update an admin user. Idempotent by default. + + - If no user with this email exists: create one with is_active=True, + is_verified=True, is_superuser=True; hash the password; ensure the + 'admin' Role row exists (create from ADMIN_ROLE_ID if missing); + insert UserRole. + - If the user exists and force=True: update the password and ensure the + admin role is attached. + - If the user exists and force=False: return created=False without + changing the password. + """ + existing = ( + await db.execute(select(User).where(func.lower(User.email) == email.lower())) + ).scalar_one_or_none() + + admin_role = await ensure_admin_role(db) + + hasher = PasswordHelper() + if existing is None: + user = User( + email=email.lower(), + hashed_password=hasher.hash(password), + is_active=True, + is_verified=True, + is_superuser=True, + full_name=full_name, + ) + db.add(user) + await db.flush() + db.add(UserRole(user_id=user.id, role_id=admin_role.id)) + await db.commit() + await db.refresh(user) + logger.info(_EVT_CREATED, extra={"email": email, "id": str(user.id)}) + return CreateAdminResult(user=user, created=True) + + if force: + existing.hashed_password = hasher.hash(password) + existing.is_active = True + existing.is_verified = True + existing.is_superuser = True + if full_name is not None: + existing.full_name = full_name + existing_link = ( + await db.execute( + select(UserRole).where( + col(UserRole.user_id) == existing.id, + col(UserRole.role_id) == admin_role.id, + ) + ) + ).scalar_one_or_none() + if existing_link is None: + db.add(UserRole(user_id=existing.id, role_id=admin_role.id)) + await db.commit() + logger.info(_EVT_UPDATED, extra={"email": email, "id": str(existing.id)}) + return CreateAdminResult(user=existing, created=False) + + logger.info(_EVT_NOOP, extra={"email": email, "id": str(existing.id)}) + return CreateAdminResult(user=existing, created=False) + + +async def ensure_admin_role(db: AsyncSession) -> Role: + """Return the administrator role, preserving an existing name or seed ID.""" + # Look up by name first (works on both Postgres and SQLite regardless of UUID + # storage format), then fall back to id-based lookup in case name was changed. + admin_role = ( + await db.execute(select(Role).where(col(Role.name) == ADMIN_ROLE_NAME)) + ).scalar_one_or_none() + if admin_role is None: + admin_role = ( + await db.execute(select(Role).where(col(Role.id) == ADMIN_ROLE_ID)) + ).scalar_one_or_none() + if admin_role is None: + # Schema-only installs do not run the host role seed migration. + admin_role = Role( + id=ADMIN_ROLE_ID, name=ADMIN_ROLE_NAME, description=ADMIN_ROLE_DESCRIPTION + ) + db.add(admin_role) + await db.flush() + + return admin_role + + +async def ensure_user_role(db: AsyncSession) -> Role: + """The standard ``user`` Role row, created (and flushed) if it is missing. + + Startup ensures it even in scaffolded hosts whose schema baseline omits + the host's data migration ``b5018b617142``. Bootstrap callers can also use + this before startup has run. + """ + user_role = ( + await db.execute(select(Role).where(col(Role.name) == USER_ROLE_NAME)) + ).scalar_one_or_none() + if user_role is None: + user_role = ( + await db.execute(select(Role).where(col(Role.id) == USER_ROLE_ID)) + ).scalar_one_or_none() + if user_role is None: + user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION) + db.add(user_role) + await db.flush() + return user_role + + +async def create_standard_user( + db: AsyncSession, + *, + email: str, + password: str, + full_name: str | None = None, +) -> CreateAdminResult: + """Create a non-admin user with the 'user' role. Idempotent (noop if exists). + + Unlike ``create_admin`` this is not meant to be called from the CLI — it's + used by the env-var bootstrap to seed a second account for dev/testing. + """ + existing = ( + await db.execute(select(User).where(func.lower(User.email) == email.lower())) + ).scalar_one_or_none() + if existing is not None: + logger.info(_EVT_USER_NOOP, extra={"email": email, "id": str(existing.id)}) + return CreateAdminResult(user=existing, created=False) + + user_role = await ensure_user_role(db) + + user = User( + email=email.lower(), + hashed_password=PasswordHelper().hash(password), + is_active=True, + is_verified=True, + is_superuser=False, + full_name=full_name, + ) + db.add(user) + await db.flush() + db.add(UserRole(user_id=user.id, role_id=user_role.id)) + await db.commit() + await db.refresh(user) + logger.info(_EVT_USER_CREATED, extra={"email": email, "id": str(user.id)}) + return CreateAdminResult(user=user, created=True) + + +async def _user_table_is_empty(db: AsyncSession) -> bool: + count = (await db.execute(select(func.count()).select_from(User))).scalar_one() + return count == 0 + + +def _read_dotenv_bootstrap_vars() -> dict[str, str]: + """Return SM_USERS_BOOTSTRAP_* entries from ``.env`` (ignore everything else). + + ``UsersSettings`` deliberately doesn't use ``env_file`` — runtime fields + come from the DB, and pulling the whole ``.env`` in would re-expose every + SMTP/cookie/token secret as an env knob. So we re-read ``.env`` just for + the four documented seed keys. + """ + wanted = set(BOOTSTRAP_ENV_KEYS.values()) + return {k: v for k, v in parse_dotenv().items() if k in wanted} + + +def resolve_bootstrap_credentials(settings: UsersSettings) -> dict[str, str]: + """Resolve the four bootstrap fields with the same precedence everywhere. + + Order: ``UsersSettings`` (test overrides) → ``os.environ`` (docker/systemd) + → ``.env`` file (documented dev path). Used by both the boot-time admin + seeder and the dev-only login-page quick-fill so the two stay in lockstep. + """ + dotenv_vars = _read_dotenv_bootstrap_vars() + return { + attr: getattr(settings, attr) or os.environ.get(env_key) or dotenv_vars.get(env_key, "") + for attr, env_key in BOOTSTRAP_ENV_KEYS.items() + } + + +async def bootstrap_admin_from_env(app: FastAPI) -> None: + """On-startup hook: create admin from env vars iff users table is empty. + + Resolves each of the four bootstrap fields via + :func:`resolve_bootstrap_credentials`. If the admin email or password is + still blank, returns silently — same if the users table already has rows + (so restarts don't try to re-bootstrap). + + Optionally also creates a non-admin user from + ``SM_USERS_BOOTSTRAP_USER_EMAIL`` + ``SM_USERS_BOOTSTRAP_USER_PASSWORD`` — + useful in dev for testing non-admin flows alongside the admin account. + """ + settings: UsersSettings = app.state.users.settings + resolved = resolve_bootstrap_credentials(settings) + email = resolved["bootstrap_email"] + password = resolved["bootstrap_password"] + user_email = resolved["bootstrap_user_email"] + user_password = resolved["bootstrap_user_password"] + + if not email or not password: + return + + session_factory = app.state.sm.db.session_factory + async with session_factory() as session: + if not await _user_table_is_empty(session): + logger.debug("users.bootstrap.skipped (users table non-empty)") + return + + try: + result = await create_admin(session, email=email, password=password) + if result.created and os.environ.get("SM_USERS_BOOTSTRAP_PASSWORD_GENERATED") == "1": + logger.warning( + "first-boot admin created; store this password securely: %s / %s", + email, + password, + ) + if user_email and user_password: + await create_standard_user( + session, + email=user_email, + password=user_password, + ) + except Exception: + logger.exception(_EVT_FAILED) + raise diff --git a/modules/users/users/core/constants.py b/modules/users/users/core/constants.py new file mode 100644 index 000000000..f1fb5181b --- /dev/null +++ b/modules/users/users/core/constants.py @@ -0,0 +1,93 @@ +"""Stable identifiers used by both the seed migration and tests.""" + +import uuid + +from simple_module_core.redirect_safety import SESSION_NEXT_KEY + +ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") +USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002") + +# Role name strings +ADMIN_ROLE_NAME = "admin" +USER_ROLE_NAME = "user" + +# Role descriptions +ADMIN_ROLE_DESCRIPTION = "Administrator" +USER_ROLE_DESCRIPTION = "Standard user" + +# Auth email template filenames (HTML siblings are optional). +MAIL_TEMPLATE_VERIFY = "verify_email.txt" +MAIL_TEMPLATE_RESET = "reset_password.txt" +MAIL_TEMPLATE_INVITE = "invite.txt" + +# Permission identifiers +PERM_USERS_MANAGE = "users.manage" +PERM_USERS_SELF_PROFILE = "users.self.profile" + +# Only the pending redirect belongs to both the anonymous and signed-in session. +# Locale is a separate cookie; cached principal, OAuth and CSRF state must go. +LOGIN_SESSION_PRESERVED_KEYS = (SESSION_NEXT_KEY,) + +# Session keys +SESSION_USER_ID_KEY = "user_id" +# Revocation stamp copied out of ``User.session_version`` at login. A session +# whose stamp has fallen behind the account's was minted before the owner +# pressed "Sign out everywhere", so the auth provider refuses it. Absent means +# 0, which is what every session predating the column carries. +SESSION_VERSION_KEY = "session_version" +# Stamped by the demo sign-in endpoint so ``DemoReadOnlyMiddleware`` can +# recognise a shared demo session without a database read. Lives here rather +# than in ``users.demo`` because ``manager``/``provider`` have to clear it on +# every *other* sign-in and on every session teardown, and neither of those +# should have to import the seeding module to do it. Re-exported from +# ``users.demo``, which is where the feature's own code reads it from. +SESSION_DEMO_KEY = "is_demo" + +# request.state flag set by the OAuth callback before find-or-create so the +# manager's on_after_register hook can mark *newly provisioned* OAuth users as +# external (null password). on_after_register fires only for new users, so the +# flag is ignored when an OAuth login merely links to an existing account. +OAUTH_REGISTRATION_REQUEST_FLAG = "users_oauth_registration" + +# Admin list-endpoint allowed filter/sort values +# "active" here means active *and* verified: the table shows unverified and +# invited as their own states, so folding them back into "active" would make +# the filter disagree with the pill beside every row it returned. +ALLOWED_STATUS = frozenset({"active", "unverified", "invited", "disabled"}) +ALLOWED_VERIFIED = frozenset({"yes", "no"}) +ALLOWED_SORT = frozenset({"email", "last_login_at", "created_at"}) +ALLOWED_ORDER = frozenset({"asc", "desc"}) + +LOGIN_PAGE_PATH = "/users/login" + +# Anonymous-allowed prefixes (login, registration, reset, token endpoints), +# registered in ``UsersModule.register_public_routes``. +PUBLIC_PATH_PREFIXES = ( + "/users/login", + "/users/register", + "/users/forgot-password", + "/users/reset-password", + "/users/verify", + "/users/invite/accept", + "/api/users/auth/", + "/api/users/register", +) + + +def sanitize_list_filters( + status: str | None, + verified: str | None, + sort: str, + order: str, +) -> tuple[str | None, str | None, str, str]: + """Coerce unknown filter values to None/defaults.""" + return ( + status if status in ALLOWED_STATUS else None, + verified if verified in ALLOWED_VERIFIED else None, + sort if sort in ALLOWED_SORT else "email", + order if order in ALLOWED_ORDER else "asc", + ) + + +UPGRADE_HASHED_BEARER_TOKENS = "users.hashed_bearer_tokens_v1" +UPGRADE_UNIQUE_EMAILS = "users.unique_email_v1" diff --git a/modules/users/users/data_compatibility.py b/modules/users/users/core/data_compatibility.py similarity index 98% rename from modules/users/users/data_compatibility.py rename to modules/users/users/core/data_compatibility.py index 20703ba3f..173f1f073 100644 --- a/modules/users/users/data_compatibility.py +++ b/modules/users/users/core/data_compatibility.py @@ -4,7 +4,7 @@ from sqlalchemy import String, column, func, inspect, or_, select, table from sqlalchemy.ext.asyncio import AsyncSession -from users.constants import UPGRADE_HASHED_BEARER_TOKENS, UPGRADE_UNIQUE_EMAILS +from users.core.constants import UPGRADE_HASHED_BEARER_TOKENS, UPGRADE_UNIQUE_EMAILS from users.models import RefreshToken, UserAccessToken _SHOWN_CONFLICTS = 20 diff --git a/modules/users/users/core/directory.py b/modules/users/users/core/directory.py new file mode 100644 index 000000000..2b15df5f1 --- /dev/null +++ b/modules/users/users/core/directory.py @@ -0,0 +1,119 @@ +"""The users module's :class:`~simple_module_core.user_directory.UserDirectory`. + +Other modules read user records through ``get_user_directory(app)`` rather than +importing ``users.models`` (GH #501), so the same audit log, dashboard and +permissions screens work whether ``users`` or ``keycloak`` holds the accounts. +This is the implementation over ``users_user``; it is installed in +``UsersModule.register_settings``. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Collection +from datetime import UTC, datetime, timedelta +from typing import Any + +from simple_module_core.user_directory import ( + DirectoryStats, + DirectoryUser, + chunked, + parse_user_ids, +) +from simple_module_db.user_directory_sql import name_or_email_contains, to_directory_user +from sqlalchemy import String, cast, func, select + +from users.models import Role, User, UserRole + +# Columns only: loading ``User`` itself would also selectin-load its OAuth +# accounts, a second query per lookup that nothing here reads. +_COLUMNS = ( + User.id, + User.email, + User.full_name, + User.is_active, + User.disabled_at, + User.is_verified, +) + + +def _is_usable(row: Any) -> bool: + # A disabled account is still ``is_active`` in fastapi-users' sense; + # to every other module it is simply not usable. + return bool(row.is_active) and row.disabled_at is None + + +class UsersDirectory: + """Directory over the local ``users_user`` table.""" + + #: Audit entries record ``type(obj).__name__``; the users module registers + #: its audit link under the same class name. + entity_type: str | None = User.__name__ + + async def get_many( + self, db: Any, ids: Collection[str], *, with_roles: bool = False + ) -> dict[str, DirectoryUser]: + parsed = parse_user_ids(ids) + found: dict[str, DirectoryUser] = {} + for chunk in chunked(list(parsed)): + rows = (await db.execute(select(*_COLUMNS).where(User.id.in_(chunk)))).all() + roles = await self._roles_for(db, chunk) if with_roles else {} + for row in rows: + user_roles = tuple(sorted(roles.get(row.id, ()))) if with_roles else None + for key in parsed[row.id]: + found[key] = to_directory_user( + row, + key, + roles=user_roles, + is_active=_is_usable(row), + email_verified=bool(row.is_verified), + ) + return found + + async def _roles_for( + self, db: Any, user_ids: Collection[uuid.UUID] + ) -> dict[uuid.UUID, list[str]]: + rows = await db.execute( + select(UserRole.user_id, Role.name) + .join(Role, Role.id == UserRole.role_id) + .where(UserRole.user_id.in_(list(user_ids))) + ) + roles: dict[uuid.UUID, list[str]] = {} + for user_id, name in rows.all(): + roles.setdefault(user_id, []).append(name) + return roles + + async def search(self, db: Any, term: str | None, *, limit: int) -> list[DirectoryUser]: + stmt = select(*_COLUMNS).order_by(User.email).limit(limit) + if term and term.strip(): + stmt = stmt.where(name_or_email_contains(User.full_name, User.email, term.strip())) + rows = (await db.execute(stmt)).all() + return [to_directory_user(row, str(row.id), is_active=_is_usable(row)) for row in rows] + + def match_ids(self, term: str) -> Any | None: + # ``CAST`` renders the canonical lowercase hyphenated form on both + # dialects (Postgres from ``uuid``, SQLite from GUID's ``CHAR(36)``), + # which is the form the audit trail and every other text id stores. + return select(cast(User.id, String)).where( + name_or_email_contains(User.full_name, User.email, term) + ) + + async def stats(self, db: Any, *, active_window: timedelta) -> DirectoryStats: + now = datetime.now(UTC) + # Calendar month, not a rolling 30 days: the tile reads "+6 this month". + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + # One scan for all three figures rather than a query each. + row = ( + await db.execute( + select( + func.count(), + func.count().filter(User.last_login_at >= now - active_window), + func.count().filter(User.created_at >= month_start), + ).select_from(User) + ) + ).one() + total, active, created = row + return DirectoryStats(total=total, active_since=active, created_this_month=created) + + +__all__ = ["UsersDirectory"] diff --git a/modules/users/users/errors.py b/modules/users/users/core/errors.py similarity index 91% rename from modules/users/users/errors.py rename to modules/users/users/core/errors.py index ee349472f..0a7f7ca3c 100644 --- a/modules/users/users/errors.py +++ b/modules/users/users/core/errors.py @@ -3,7 +3,7 @@ from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse -from users.exceptions import AdminAccountSafetyError, AdminPermissionError, LoginLockedError +from users.core.exceptions import AdminAccountSafetyError, AdminPermissionError, LoginLockedError async def _admin_permission(request: Request, exc: Exception) -> JSONResponse: diff --git a/modules/users/users/exceptions.py b/modules/users/users/core/exceptions.py similarity index 100% rename from modules/users/users/exceptions.py rename to modules/users/users/core/exceptions.py diff --git a/modules/users/users/roles_cache.py b/modules/users/users/core/roles_cache.py similarity index 100% rename from modules/users/users/roles_cache.py rename to modules/users/users/core/roles_cache.py diff --git a/modules/users/users/core/settings.py b/modules/users/users/core/settings.py new file mode 100644 index 000000000..36695abdd --- /dev/null +++ b/modules/users/users/core/settings.py @@ -0,0 +1,261 @@ +"""Users module settings — DB-backed via ``register_module_settings``. + +Most fields hydrate from the DB after boot, with pydantic defaults filling in +until then. Runtime changes go through ``settings.module_settings.reload.apply_changes_and_reload``. + +The two token-secret fields read ``SM_USERS_*`` at import time as a bootstrap +path: a fresh production deploy needs to clear the validator below before any +DB-backed settings can be seeded, otherwise the two paths deadlock. +""" + +from __future__ import annotations + +import logging +import os +from typing import Literal + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import SettingsConfigDict +from simple_module_core.dotenv import env_str +from simple_module_core.environments import NON_PROD_ENVIRONMENTS +from simple_module_core.redirect_safety import non_empty_redirect +from simple_module_core.settings_base import DbBackedSettings +from simple_module_core.stored_settings import field_error + +from users.accounts.session_version_cache import SESSION_VERSION_TTL_SECONDS + +logger = logging.getLogger("users.settings") + +_PLACEHOLDER_RESET_SECRET = "dev-reset-token-secret-change-me" +_PLACEHOLDER_VERIFY_SECRET = "dev-verify-token-secret-change-me" + +DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS = 90 + +DEFAULT_LOGIN_REDIRECT_URL = "/dashboard/" + +# Groups the demo fields together in the module-settings editor. +DEMO_SETTINGS_GROUP = "Demo account" + + +class UsersSettings(DbBackedSettings): + """Local user management configuration.""" + + # ``DbBackedSettings`` (not ``BaseSettings``) so the DB is genuinely the + # only source: omitting ``env_prefix`` would leave pydantic-settings + # reading each field from its bare name — GH #283. + model_config = SettingsConfigDict(extra="ignore") + + # Self-service signup + allow_signup: bool = False + require_verification: bool = True + + # Where the login page sends a successful sign-in. Sites without the + # bundled ``dashboard`` module (``smpy new --preset minimal``) override + # this to wherever their post-login landing lives. + login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL + + @field_validator("login_redirect_url") + @classmethod + def _non_empty_redirect(cls, value: str) -> str: + """Blank is never a usable navigation target. + + Covers this module's consumers — the login view (which hands it to + Inertia, where ``router.visit("")`` silently reloads the current page) + and the generic OAuth callback (which puts it in a ``Location`` + header). Keycloak has its own settings class with its own copy of this + field and normalises it the same way. + """ + return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) + + # Token secrets — MUST be set in production. Dev default is a deterministic + # placeholder that's obvious in logs so it can't be mistaken for a real key. + # Resolved at module-import time so ``info.default`` stays serializable + # for the settings admin UI. + reset_password_token_secret: str = env_str( + "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", + _PLACEHOLDER_RESET_SECRET, + ) + verification_token_secret: str = env_str( + "SM_USERS_VERIFICATION_TOKEN_SECRET", + _PLACEHOLDER_VERIFY_SECRET, + ) + reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour + verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days + + # Bearer token (mobile / API clients) + bearer_token_lifetime_seconds: int = 60 * 15 # 15 minutes + refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30 # 30 days + + refresh_token_family_max_age_days: int = Field(DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS, ge=1) + + # Cookie (fastapi-users AuthenticationBackend) + cookie_name: str = "sm_auth" + cookie_max_age_seconds: int = 60 * 60 * 24 * 14 # 14 days + # "Keep me signed in for N days" on the sign-in card. The page reads the + # number out of this rather than spelling it in the copy, so an operator + # who shortens the window does not leave the checkbox lying. + remember_me_max_age_seconds: int = 60 * 60 * 24 * 30 # 30 days + # How long a worker reuses a read of ``User.session_version`` before going + # back to the DB. The cost of the cache is a window in which *another* + # worker's revocation has not been seen here; 0 disables it and pays one + # indexed read per request instead. See ``users.accounts.session_version_cache``. + session_version_cache_ttl_seconds: int = SESSION_VERSION_TTL_SECONDS + + cookie_secure: bool = True # flipped False in dev by the module at startup + cookie_samesite: Literal["lax", "strict", "none"] = "lax" + + @field_validator("cookie_samesite", mode="before") + @classmethod + def _lowercase_samesite(cls, value: object) -> object: + """``"Strict"`` worked when this was a plain ``str`` (Starlette lowercases it).""" + return value.lower() if isinstance(value, str) else value + + # Mail transport (SMTP, console) lives in the ``mail`` module. + base_url: str = "http://localhost:8000" + mail_template_dirs: list[str] = Field( + default_factory=list, + description=( + "Absolute directories searched, in order, for verify_email / reset_password / " + "invite templates (.txt, optional .html) before the bundled ones. " + "Relative or missing paths are ignored." + ), + json_schema_extra={"requires_restart": True}, + ) + + # Login lockout (GH #530, ``users.auth_local.lockout``). Failures within the + # window: ``login_rate_limit_failures`` from one IP for one username + # throttle that source until the window ends; ``login_lockout_account_failures`` + # from anywhere lock the account for ``login_rate_limit_cooldown_seconds``. + # 10 rather than 5 for the account: anyone who knows an address could + # otherwise lock its owner out from any network. + login_rate_limit_failures: int = 5 + login_rate_limit_window_seconds: int = 300 + login_rate_limit_cooldown_seconds: int = 900 + login_lockout_account_failures: int = Field(10, ge=1) + + # Rate limit (auth side-effects: forgot-password, register, accept-invite, + # request-verify-token). Counts every attempt per IP per window. + auth_rate_limit_attempts: int = 10 + auth_rate_limit_window_seconds: int = 300 + + # ── Demo accounts ─────────────────────────────────────────────────── + # For public showcase instances: the sign-in card grows one button per + # configured account, and a click signs the visitor straight in. Two + # accounts rather than one because the two halves of the app look nothing + # alike — the admin surface is what the framework is *for*, and a + # visitor who only ever sees it never meets the app an end user uses. + # + # No password is ever sent to the browser, which is what separates this + # from the dev quick-fill buttons: those paste real credentials into the + # form and stay development-only for exactly that reason. + demo_mode: bool = Field(default=False, json_schema_extra={"group": DEMO_SETTINGS_GROUP}) + + # Blank either email to offer only the other account. Blank both and + # ``demo_mode`` has nothing to turn on. + demo_admin_email: str = Field( + default="demo-admin@example.com", json_schema_extra={"group": DEMO_SETTINGS_GROUP} + ) + demo_user_email: str = Field( + default="demo-user@example.com", json_schema_extra={"group": DEMO_SETTINGS_GROUP} + ) + + # Blank means "no password anyone can type": the account is seeded with a + # random one and is reachable only through its button. Set one only if you + # also intend to publish the credentials (for an API demo, say). + demo_admin_password: str = Field(default="", json_schema_extra={"group": DEMO_SETTINGS_GROUP}) + demo_user_password: str = Field(default="", json_schema_extra={"group": DEMO_SETTINGS_GROUP}) + + # Refuse every unsafe HTTP method from either demo session. On by default: + # a demo account is a published credential, so the safe posture is the one + # you get without reading the docs. + demo_read_only: bool = Field(default=True, json_schema_extra={"group": DEMO_SETTINGS_GROUP}) + + # Bootstrap (env-var auto-create users on first boot) + bootstrap_email: str = "" + bootstrap_password: str = "" + # Optional second seed user with the "user" role — handy in dev for + # testing non-admin flows without logging out/in repeatedly. + bootstrap_user_email: str = "" + bootstrap_user_password: str = "" + + # OAuth / OIDC providers — configured via the admin settings UI + # (/settings/modules → Users). Credentials live in the DB-backed settings + # store and hydrate after boot; secret fields are masked in the UI (the + # same treatment the SMTP password gets). Provider changes apply live via + # the SettingsReloaded event — no restart (see users/module.py). + oauth_google_client_id: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) + oauth_google_client_secret: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) + oauth_github_client_id: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) + oauth_github_client_secret: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) + # Generic OIDC — any provider that exposes a discovery URL + # (Keycloak, Authentik, Auth0, Zitadel, ...). + oauth_oidc_client_id: str = Field(default="", json_schema_extra={"group": "OIDC"}) + oauth_oidc_client_secret: str = Field(default="", json_schema_extra={"group": "OIDC"}) + oauth_oidc_discovery_url: str = Field(default="", json_schema_extra={"group": "OIDC"}) + oauth_oidc_display_name: str = Field(default="OIDC", json_schema_extra={"group": "OIDC"}) + # Microsoft Entra ID / Microsoft accounts. tenant: "common" (any work/school + # or personal account), "organizations" (work/school only), or a tenant GUID + # to restrict sign-in to a single Entra tenant. + oauth_microsoft_client_id: str = Field( + default="", json_schema_extra={"group": "Microsoft OAuth"} + ) + oauth_microsoft_client_secret: str = Field( + default="", json_schema_extra={"group": "Microsoft OAuth"} + ) + oauth_microsoft_tenant: str = Field( + default="common", json_schema_extra={"group": "Microsoft OAuth"} + ) + + @model_validator(mode="after") + def _warn_on_writable_admin_demo(self) -> UsersSettings: + """A writable admin demo is a public superuser — say so, loudly. + + Not an error: an operator who resets the demo database on a timer has + a legitimate reason to want it, and refusing would make a settings + edit in the admin UI fail with no way to opt in. But the combination + hands anyone who finds the URL the settings editor, the user table and + maintenance mode, so it does not get to happen quietly. + """ + if self.demo_mode and self.demo_admin_email.strip() and not self.demo_read_only: + logger.warning( + "users.demo.writable_admin — demo_mode is on with demo_admin_email=%r and " + "demo_read_only=False: anyone who can reach the sign-in page gets a " + "writable administrator session. Set demo_read_only=True unless this " + "instance's database is disposable.", + self.demo_admin_email, + ) + return self + + @model_validator(mode="after") + def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings: + """Fail boot if the reset/verify token secrets are still placeholders. + + Both are HMAC keys for fastapi-users JWTs. A well-known default lets + an attacker mint password-reset or email-verification tokens for any + user. The environment is read from ``SM_ENVIRONMENT`` (host setting) + so this check has no runtime coupling to the hosting package. + """ + env = os.environ.get("SM_ENVIRONMENT", "development") + if env in NON_PROD_ENVIRONMENTS: + return self + bad = [] + if self.reset_password_token_secret == _PLACEHOLDER_RESET_SECRET: + bad.append(("reset_password_token_secret", "RESET_PASSWORD_TOKEN_SECRET")) + if self.verification_token_secret == _PLACEHOLDER_VERIFY_SECRET: + bad.append(("verification_token_secret", "VERIFICATION_TOKEN_SECRET")) + if bad: + names = ", ".join(label for _, label in bad) + # Located on the first placeholder field. Dropping a stored + # placeholder falls back to the same placeholder, so lenient boot + # still refuses: the check cannot be sidestepped. + raise field_error( + type(self), + bad[0][0], + "placeholder_token_secret", + f"users.{names} must be set to non-default value(s) when " + f"SM_ENVIRONMENT={env!r}. Generate with " + "`python -c 'import secrets; print(secrets.token_urlsafe(48))'`.", + value="", + related=["reset_password_token_secret", "verification_token_secret"], + ) + return self diff --git a/modules/users/users/setup.py b/modules/users/users/core/setup.py similarity index 93% rename from modules/users/users/setup.py rename to modules/users/users/core/setup.py index 12ffcff11..6322ba5ee 100644 --- a/modules/users/users/setup.py +++ b/modules/users/users/core/setup.py @@ -36,10 +36,10 @@ def _admin_action() -> SetupAction: - """The wizard form that completes the step — see ``users.setup_action``.""" - # Imported lazily: ``users.setup_action`` imports this module for + """The wizard form that completes the step — see ``users.core.setup_action``.""" + # Imported lazily: ``users.core.setup_action`` imports this module for # ``session_has_local_account``, so a top-level import would be circular. - from users.setup_action import create_first_administrator + from users.core.setup_action import create_first_administrator return SetupAction( handler=create_first_administrator, diff --git a/modules/users/users/setup_action.py b/modules/users/users/core/setup_action.py similarity index 96% rename from modules/users/users/setup_action.py rename to modules/users/users/core/setup_action.py index e4a10cd99..5a25322ec 100644 --- a/modules/users/users/setup_action.py +++ b/modules/users/users/core/setup_action.py @@ -21,11 +21,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import SQLModel -from users.bootstrap import create_admin -from users.manager import UserManager +from users.accounts.manager import UserManager +from users.core.bootstrap import create_admin +from users.core.roles_cache import publish_roles_changed +from users.core.setup import session_has_local_account from users.models import User -from users.roles_cache import publish_roles_changed -from users.setup import session_has_local_account logger = logging.getLogger(__name__) diff --git a/modules/users/users/startup.py b/modules/users/users/core/startup.py similarity index 90% rename from modules/users/users/startup.py rename to modules/users/users/core/startup.py index dc6bb45fe..300170b09 100644 --- a/modules/users/users/startup.py +++ b/modules/users/users/core/startup.py @@ -28,8 +28,8 @@ if TYPE_CHECKING: from fastapi import FastAPI + from users.core.settings import UsersSettings from users.module import UsersModule - from users.settings import UsersSettings __all__ = [ "apply_login_redirect_fallback", @@ -44,14 +44,14 @@ async def run_startup(module: UsersModule, app: FastAPI) -> None: """Populate ``app.state.users`` and apply settings-derived configuration.""" + from users.accounts.backend import reconfigure_cookie_transport + from users.accounts.session_version_cache import configure_session_version_cache from users.admin.summary_cache import register_writes - from users.backend import reconfigure_cookie_transport - from users.bootstrap import bootstrap_admin_from_env, ensure_user_role + from users.core.bootstrap import bootstrap_admin_from_env, ensure_user_role + from users.core.roles_cache import refresh_roles_cache + from users.core.settings import DEFAULT_LOGIN_REDIRECT_URL from users.demo import ensure_demo_users - from users.deps import auth_backend - from users.roles_cache import refresh_roles_cache - from users.session_version_cache import configure_session_version_cache - from users.settings import DEFAULT_LOGIN_REDIRECT_URL + from users.web.deps import auth_backend register_writes(app) state = app.state.users @@ -65,7 +65,7 @@ async def run_startup(module: UsersModule, app: FastAPI) -> None: reconfigure_cookie_transport(auth_backend, s) # The revocation cache's staleness window is an operator choice: the default # trades one indexed read per request for a bounded window in which another - # worker's revocation is not yet seen here. ``users.session_revocation`` + # worker's revocation is not yet seen here. ``users.accounts.session_revocation`` # closes that window wherever an invalidation transport is installed, so # this is the bound on a dropped message rather than on every revocation. configure_session_version_cache(s.session_version_cache_ttl_seconds) diff --git a/modules/users/users/core/state.py b/modules/users/users/core/state.py new file mode 100644 index 000000000..ddc48de8c --- /dev/null +++ b/modules/users/users/core/state.py @@ -0,0 +1,43 @@ +"""Module-scoped state container for the users module. + +Stored as ``app.state.users`` by :meth:`UsersModule.register_settings` (for +fields available at that phase) and populated the rest of the way during +:meth:`UsersModule.on_startup` (for fields that depend on the DB or other +framework services). + +Not frozen — ``on_startup`` needs to set fields that aren't available at +``register_settings`` time. Convention: set once during boot, treat as +read-only after. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import uuid + + from users.auth_local.rate_limit import ThroughputLimiter + from users.core.roles_cache import RoleSummary + from users.core.settings import UsersSettings + from users.mailer import Mailer + from users.oauth.providers import OAuthProvider + + +@dataclass +class UsersState: + """Users-module singletons. Single slot at ``app.state.users``.""" + + settings: UsersSettings + mailer: Mailer | None = None + auth_throughput_limiter: ThroughputLimiter | None = None + roles_cache: list[RoleSummary] = field(default_factory=list) + #: ``time.monotonic()`` of the last load; the TTL floor in ``users.core.roles_cache``. + roles_cache_loaded_at: float = 0.0 + oauth_providers: list[dict[str, str]] = field(default_factory=list) + oauth_clients: dict[str, OAuthProvider] = field(default_factory=dict) + # Ids of the seeded demo accounts, empty when demo mode is off. Cached at + # boot (and on every settings reload) so ``DemoReadOnlyMiddleware`` can + # recognise a demo session without a database read per request. + demo_user_ids: tuple[uuid.UUID, ...] = () diff --git a/modules/users/users/demo/__init__.py b/modules/users/users/demo/__init__.py index e2168c6b1..523223424 100644 --- a/modules/users/users/demo/__init__.py +++ b/modules/users/users/demo/__init__.py @@ -33,7 +33,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from users.constants import ( +from users.core.constants import ( ADMIN_ROLE_DESCRIPTION, ADMIN_ROLE_ID, ADMIN_ROLE_NAME, @@ -42,15 +42,15 @@ USER_ROLE_ID, USER_ROLE_NAME, ) +from users.core.settings import UsersSettings from users.models import Role, User, UserRole -from users.settings import UsersSettings logger = logging.getLogger("users.demo") # ``SESSION_DEMO_KEY`` is stamped on the session by the demo sign-in endpoint; # the guard also matches on the user ids, so it is not the only line of defence # — it is what keeps the hot path off the database. It is defined in -# ``users.constants`` because ``manager`` and ``provider`` have to clear it on +# ``users.core.constants`` because ``manager`` and ``provider`` have to clear it on # every other sign-in and on session teardown, and neither may import this # module. Re-exported here, which is where the feature's own code reads it. __all__ = [ @@ -124,7 +124,7 @@ def resolve_demo_account(settings: UsersSettings | None, role: str) -> DemoAccou async def _role_row(db: AsyncSession, name: str) -> Role: """The Role row for ``name``, created from its seed constants if absent. - Same shape as ``users.bootstrap``: the seed migration normally inserts + Same shape as ``users.core.bootstrap``: the seed migration normally inserts both, so this only fires for tests built with ``create_all`` and for databases downgraded past the seed revision. """ diff --git a/modules/users/users/demo/api.py b/modules/users/users/demo/api.py index 3a0fd4a81..132dc8a53 100644 --- a/modules/users/users/demo/api.py +++ b/modules/users/users/demo/api.py @@ -21,11 +21,11 @@ from fastapi_users import exceptions as fu_exceptions from simple_module_core.redirect_safety import SESSION_NEXT_KEY +from users.accounts.manager import UserManager from users.auth_local.rate_limit import enforce_auth_throughput_limit -from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME +from users.core.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME from users.demo import SESSION_DEMO_KEY, resolve_demo_account -from users.deps import auth_backend, get_user_manager -from users.manager import UserManager +from users.web.deps import auth_backend, get_user_manager logger = logging.getLogger("users.demo") diff --git a/modules/users/users/demo/guard.py b/modules/users/users/demo/guard.py index 23123a132..7ca0378e4 100644 --- a/modules/users/users/demo/guard.py +++ b/modules/users/users/demo/guard.py @@ -31,7 +31,7 @@ from starlette.responses import JSONResponse, Response from starlette.types import ASGIApp, Receive, Scope, Send -from users.constants import SESSION_USER_ID_KEY +from users.core.constants import SESSION_USER_ID_KEY from users.demo import SESSION_DEMO_KEY if TYPE_CHECKING: diff --git a/modules/users/users/deps.py b/modules/users/users/deps.py index a8a2b7c50..517c7638b 100644 --- a/modules/users/users/deps.py +++ b/modules/users/users/deps.py @@ -1,104 +1,11 @@ -"""Public dependencies and the FastAPIUsers instance. - -Cookie transport and auth backend are constructed at import time with -dev-safe defaults (cookie_secure=False, dev cookie name). UsersModule.on_startup -overrides the cookie params from the real UsersSettings once app.state is -populated — CookieTransport's cookie fields are mutable on the instance. - -The singleton is here rather than in register_routes because -``current_active_user`` and ``current_superuser`` are decorator-factories -that must be resolvable at import time for route-handler signatures. -""" +"""Moved to :mod:`users.web.deps`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import uuid -from typing import TYPE_CHECKING - -from auth.web.deps import CurrentUser -from fastapi import Depends, HTTPException, Request -from fastapi_users import FastAPIUsers -from fastapi_users import exceptions as fu_exceptions -from simple_module_core.events import EventBus -from simple_module_db.deps import get_db -from sqlalchemy.ext.asyncio import AsyncSession - -from users.backend import build_auth_backend, build_cookie_transport -from users.db_adapter import ( - UserDatabaseWithRoles, - get_access_token_db, - get_user_db, -) -from users.manager import UserManager, get_user_manager -from users.models import User - -if TYPE_CHECKING: - from users.admin.service import UserService - -# Dev-safe singleton — UsersModule patches cookie params at startup. -_cookie_transport = build_cookie_transport( - cookie_name="sm_auth", - cookie_max_age_seconds=60 * 60 * 24 * 14, - cookie_secure=False, # host flips True in production via register_routes - cookie_samesite="lax", -) -auth_backend = build_auth_backend(_cookie_transport) - -fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) - -current_active_user = fastapi_users.current_user(active=True) -current_superuser = fastapi_users.current_user(active=True, superuser=True) - - -async def get_current_local_user( - principal: CurrentUser, - user_manager: UserManager = Depends(get_user_manager), -) -> User: - """Load the local account authenticated by the session or bearer provider.""" - try: - user = await user_manager.get(user_manager.parse_id(principal.id)) - except (fu_exceptions.UserNotExists, fu_exceptions.InvalidID): - raise HTTPException(status_code=401, detail="Not authenticated") from None - if not user.is_active or user.disabled_at is not None: - raise HTTPException(status_code=401, detail="Not authenticated") - return user - - -async def get_mailer(request: Request): - """Return the mailer from app.state.users (built in UsersModule.on_startup).""" - return request.app.state.users.mailer - - -async def get_event_bus(request: Request) -> EventBus: - """Return the event bus from app.state.sm.""" - return request.app.state.sm.event_bus - - -async def get_user_service( - request: Request, - db: AsyncSession = Depends(get_db), - user_manager: UserManager = Depends(get_user_manager), -) -> UserService: - from users.admin.service import UserService - - actor = getattr(request.state, "user", None) - if actor is None: - raise HTTPException(status_code=401, detail="Not authenticated") - return UserService(db, user_manager, request.app, actor=actor, request=request) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = [ - "UserDatabaseWithRoles", - "UserManager", - "auth_backend", - "current_active_user", - "current_superuser", - "fastapi_users", - "get_access_token_db", - "get_current_local_user", - "get_event_bus", - "get_mailer", - "get_user_db", - "get_user_manager", - "get_user_service", -] +deprecated_module("users.deps", remove_in="0.0.46", use="users.web.deps") +sys.modules[__name__] = importlib.import_module("users.web.deps") diff --git a/modules/users/users/directory.py b/modules/users/users/directory.py index 2b15df5f1..5ed4bd92a 100644 --- a/modules/users/users/directory.py +++ b/modules/users/users/directory.py @@ -1,119 +1,11 @@ -"""The users module's :class:`~simple_module_core.user_directory.UserDirectory`. - -Other modules read user records through ``get_user_directory(app)`` rather than -importing ``users.models`` (GH #501), so the same audit log, dashboard and -permissions screens work whether ``users`` or ``keycloak`` holds the accounts. -This is the implementation over ``users_user``; it is installed in -``UsersModule.register_settings``. -""" +"""Moved to :mod:`users.core.directory`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import uuid -from collections.abc import Collection -from datetime import UTC, datetime, timedelta -from typing import Any - -from simple_module_core.user_directory import ( - DirectoryStats, - DirectoryUser, - chunked, - parse_user_ids, -) -from simple_module_db.user_directory_sql import name_or_email_contains, to_directory_user -from sqlalchemy import String, cast, func, select - -from users.models import Role, User, UserRole - -# Columns only: loading ``User`` itself would also selectin-load its OAuth -# accounts, a second query per lookup that nothing here reads. -_COLUMNS = ( - User.id, - User.email, - User.full_name, - User.is_active, - User.disabled_at, - User.is_verified, -) - - -def _is_usable(row: Any) -> bool: - # A disabled account is still ``is_active`` in fastapi-users' sense; - # to every other module it is simply not usable. - return bool(row.is_active) and row.disabled_at is None - - -class UsersDirectory: - """Directory over the local ``users_user`` table.""" - - #: Audit entries record ``type(obj).__name__``; the users module registers - #: its audit link under the same class name. - entity_type: str | None = User.__name__ - - async def get_many( - self, db: Any, ids: Collection[str], *, with_roles: bool = False - ) -> dict[str, DirectoryUser]: - parsed = parse_user_ids(ids) - found: dict[str, DirectoryUser] = {} - for chunk in chunked(list(parsed)): - rows = (await db.execute(select(*_COLUMNS).where(User.id.in_(chunk)))).all() - roles = await self._roles_for(db, chunk) if with_roles else {} - for row in rows: - user_roles = tuple(sorted(roles.get(row.id, ()))) if with_roles else None - for key in parsed[row.id]: - found[key] = to_directory_user( - row, - key, - roles=user_roles, - is_active=_is_usable(row), - email_verified=bool(row.is_verified), - ) - return found - - async def _roles_for( - self, db: Any, user_ids: Collection[uuid.UUID] - ) -> dict[uuid.UUID, list[str]]: - rows = await db.execute( - select(UserRole.user_id, Role.name) - .join(Role, Role.id == UserRole.role_id) - .where(UserRole.user_id.in_(list(user_ids))) - ) - roles: dict[uuid.UUID, list[str]] = {} - for user_id, name in rows.all(): - roles.setdefault(user_id, []).append(name) - return roles - - async def search(self, db: Any, term: str | None, *, limit: int) -> list[DirectoryUser]: - stmt = select(*_COLUMNS).order_by(User.email).limit(limit) - if term and term.strip(): - stmt = stmt.where(name_or_email_contains(User.full_name, User.email, term.strip())) - rows = (await db.execute(stmt)).all() - return [to_directory_user(row, str(row.id), is_active=_is_usable(row)) for row in rows] - - def match_ids(self, term: str) -> Any | None: - # ``CAST`` renders the canonical lowercase hyphenated form on both - # dialects (Postgres from ``uuid``, SQLite from GUID's ``CHAR(36)``), - # which is the form the audit trail and every other text id stores. - return select(cast(User.id, String)).where( - name_or_email_contains(User.full_name, User.email, term) - ) - - async def stats(self, db: Any, *, active_window: timedelta) -> DirectoryStats: - now = datetime.now(UTC) - # Calendar month, not a rolling 30 days: the tile reads "+6 this month". - month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - # One scan for all three figures rather than a query each. - row = ( - await db.execute( - select( - func.count(), - func.count().filter(User.last_login_at >= now - active_window), - func.count().filter(User.created_at >= month_start), - ).select_from(User) - ) - ).one() - total, active, created = row - return DirectoryStats(total=total, active_since=active, created_this_month=created) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["UsersDirectory"] +deprecated_module("users.directory", remove_in="0.0.46", use="users.core.directory") +sys.modules[__name__] = importlib.import_module("users.core.directory") diff --git a/modules/users/users/invites/accept.py b/modules/users/users/invites/accept.py index 29284c129..9045d1d39 100644 --- a/modules/users/users/invites/accept.py +++ b/modules/users/users/invites/accept.py @@ -9,8 +9,8 @@ from fastapi_users import exceptions from fastapi_users.jwt import decode_jwt +from users.accounts.manager import UserManager from users.contracts.schemas import AcceptInviteRequest -from users.manager import UserManager from users.models import User diff --git a/modules/users/users/invites/bulk.py b/modules/users/users/invites/bulk.py index 8f47c25ca..6f2fde1e3 100644 --- a/modules/users/users/invites/bulk.py +++ b/modules/users/users/invites/bulk.py @@ -16,16 +16,16 @@ from simple_module_core.events import EventBus from simple_module_db import independent_session +from users.accounts.db_adapter import UserDatabaseWithRoles +from users.accounts.manager import UserManager from users.admin.links import admin_link_base from users.admin.safety import check_request_role_assignment from users.admin.service import UserService from users.contracts.events import UserInvited from users.contracts.schemas import BulkInviteResponse, BulkInviteResult, UserBulkInvite -from users.db_adapter import UserDatabaseWithRoles -from users.deps import get_event_bus, get_mailer from users.mailer import mailer_delivers -from users.manager import UserManager from users.models import OAuthAccount, User +from users.web.deps import get_event_bus, get_mailer logger = logging.getLogger(__name__) diff --git a/modules/users/users/invites/resend.py b/modules/users/users/invites/resend.py index 834332aa9..b23359c8e 100644 --- a/modules/users/users/invites/resend.py +++ b/modules/users/users/invites/resend.py @@ -21,8 +21,7 @@ from users.admin.service import UserService from users.contracts.schemas import BulkInviteResult -from users.deps import get_mailer, get_user_service -from users.exceptions import NotPendingInviteError, UserNotFoundError +from users.core.exceptions import NotPendingInviteError, UserNotFoundError from users.invites.bulk import ( MAILER_FAILURE_DETAIL, STATUS_LINK, @@ -30,6 +29,7 @@ invite_link, ) from users.mailer import mailer_delivers +from users.web.deps import get_mailer, get_user_service logger = logging.getLogger(__name__) diff --git a/modules/users/users/mailer/__init__.py b/modules/users/users/mailer/__init__.py index 47be1bcca..e4f915d36 100644 --- a/modules/users/users/mailer/__init__.py +++ b/modules/users/users/mailer/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Protocol, runtime_checkable -from users.settings import UsersSettings +from users.core.settings import UsersSettings #: Returns the live application name (e.g. from the branding module) so emails #: are branded with the deployment's name rather than the framework default. diff --git a/modules/users/users/mailer/templated.py b/modules/users/users/mailer/templated.py index 262527cfa..6b155aaf7 100644 --- a/modules/users/users/mailer/templated.py +++ b/modules/users/users/mailer/templated.py @@ -17,7 +17,7 @@ import jinja2.sandbox from simple_module_core.mail import MailMessage, current_mail_sender -from users.constants import MAIL_TEMPLATE_INVITE, MAIL_TEMPLATE_RESET, MAIL_TEMPLATE_VERIFY +from users.core.constants import MAIL_TEMPLATE_INVITE, MAIL_TEMPLATE_RESET, MAIL_TEMPLATE_VERIFY if TYPE_CHECKING: from users.mailer import AppNameProvider diff --git a/modules/users/users/middleware.py b/modules/users/users/middleware.py index 99152ff14..96373ad86 100644 --- a/modules/users/users/middleware.py +++ b/modules/users/users/middleware.py @@ -1,10 +1,11 @@ -"""Backwards-compatibility re-export. +"""Moved to :mod:`auth.web.middleware`; this alias goes in 0.0.46 (module layout).""" -The canonical AuthMiddleware now lives in ``auth.web.middleware``. This shim -exists only to avoid breaking imports in downstream apps that referenced -``users.middleware.AuthMiddleware`` directly. -""" +from __future__ import annotations -from auth.web.middleware import AuthMiddleware +import importlib +import sys -__all__ = ["AuthMiddleware"] +from simple_module_core.deprecation import deprecated_module + +deprecated_module("users.middleware", remove_in="0.0.46", use="auth.web.middleware") +sys.modules[__name__] = importlib.import_module("auth.web.middleware") diff --git a/modules/users/users/module.py b/modules/users/users/module.py index d5cbdcff5..e72472386 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -15,7 +15,7 @@ from simple_module_core.public_routes import PublicRouteRegistry from simple_module_core.setup_steps import SetupRegistry -from users.constants import ( +from users.core.constants import ( ADMIN_ROLE_NAME, PERM_USERS_MANAGE, PERM_USERS_SELF_PROFILE, @@ -60,12 +60,12 @@ class UsersModule(ModuleBase): _is_auth_provider = True def register_settings(self, app: FastAPI) -> None: - from users.settings import UsersSettings - from users.state import UsersState + from users.core.settings import UsersSettings + from users.core.state import UsersState register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s)) - from users.provider import UsersAuthProvider + from users.accounts.provider import UsersAuthProvider app.state.auth.auth_provider = UsersAuthProvider() @@ -73,7 +73,7 @@ def register_settings(self, app: FastAPI) -> None: # ``users.models`` (GH #501). from simple_module_core.user_directory import set_user_directory - from users.directory import UsersDirectory + from users.core.directory import UsersDirectory set_user_directory(app, UsersDirectory()) @@ -82,7 +82,7 @@ def register_settings(self, app: FastAPI) -> None: # layer) so it is set before the first request is served. from simple_module_hosting.shared_props import register_inertia_shared_provider - from users.shared_props import users_shared_props + from users.web.shared_props import users_shared_props register_inertia_shared_provider(app, users_shared_props) @@ -125,9 +125,9 @@ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None: believed compromised leaves the *other* workers admitting the old sessions for the rest of their TTL (GH #318). """ - from users import roles_cache + from users.accounts.session_revocation import subscribe from users.admin import summary_cache - from users.session_revocation import subscribe + from users.core import roles_cache subscribe(bus) # The admin user screens' cached role list: the setup wizard can add @@ -136,7 +136,7 @@ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None: summary_cache.subscribe(bus, app) def register_exception_handlers(self, app: FastAPI) -> None: - from users.errors import install_exception_handlers + from users.core.errors import install_exception_handlers install_exception_handlers(app) @@ -160,16 +160,16 @@ def register_permissions(self, registry: PermissionRegistry) -> None: registry.map_role(USER_ROLE_NAME, [PERM_USERS_SELF_PROFILE]) def register_setup_steps(self, registry: SetupRegistry) -> None: - """Gate the app until a local account exists — see ``users.setup``.""" - from users.setup import build_admin_step + """Gate the app until a local account exists — see ``users.core.setup``.""" + from users.core.setup import build_admin_step registry.add(build_admin_step()) def register_audit_links(self, registry: AuditLinkRegistry) -> None: - # Built in users.audit: the link now carries a batch resolver that + # Built in users.core.audit: the link now carries a batch resolver that # names each row (full_name, or the email while an invite is # outstanding), which is a query and does not belong in a hook file. - from users.audit import build_user_audit_link + from users.core.audit import build_user_audit_link registry.register(build_user_audit_link(_URL_USERS_ADMIN)) @@ -223,8 +223,8 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None from users.auth_local.views import router as auth_views from users.contracts.schemas import UserCreate, UserRead from users.demo.api import router as demo_router - from users.deps import fastapi_users from users.oauth.api import register_oauth_routes + from users.web.deps import fastapi_users api_router.include_router(auth_local_api.router) api_router.include_router(demo_router, dependencies=[Depends(require_browser_csrf)]) @@ -268,17 +268,17 @@ def register_admin_routes(self, admin_router: APIRouter) -> None: admin_router.include_router(admin_views) async def check_data_compatibility(self, db: Any) -> list[DataCompatibilityIssue]: - from users.data_compatibility import check_data_compatibility + from users.core.data_compatibility import check_data_compatibility return await check_data_compatibility(db) async def on_startup(self, app: FastAPI) -> None: """Build the mailer, limiters, OAuth clients and the seeded admin. - The body lives in ``users.startup``: every step reads a DB-hydrated + The body lives in ``users.core.startup``: every step reads a DB-hydrated setting, so it must run after the lifespan's hydration, and keeping it out of this file keeps that boundary visible. """ - from users.startup import run_startup + from users.core.startup import run_startup await run_startup(self, app) diff --git a/modules/users/users/oauth/api.py b/modules/users/users/oauth/api.py index ecb3b2db2..dd8436049 100644 --- a/modules/users/users/oauth/api.py +++ b/modules/users/users/oauth/api.py @@ -28,12 +28,12 @@ from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none from starlette.responses import RedirectResponse -from users.constants import OAUTH_REGISTRATION_REQUEST_FLAG -from users.deps import auth_backend, get_user_manager +from users.core.constants import OAUTH_REGISTRATION_REQUEST_FLAG from users.oauth.errors import refuse_login +from users.web.deps import auth_backend, get_user_manager if TYPE_CHECKING: - from users.manager import UserManager + from users.accounts.manager import UserManager from users.models import User from users.oauth.providers import OAuthProvider diff --git a/modules/users/users/oauth/errors.py b/modules/users/users/oauth/errors.py index 2af40988a..492718b8b 100644 --- a/modules/users/users/oauth/errors.py +++ b/modules/users/users/oauth/errors.py @@ -3,7 +3,7 @@ from fastapi import HTTPException, Request from starlette.responses import RedirectResponse -from users.constants import LOGIN_PAGE_PATH +from users.core.constants import LOGIN_PAGE_PATH OAUTH_ERROR_CODES = frozenset({"account_not_verified", "signup_disabled", "signin_failed"}) diff --git a/modules/users/users/oauth/providers.py b/modules/users/users/oauth/providers.py index cc80ba6ad..43de1e7c6 100644 --- a/modules/users/users/oauth/providers.py +++ b/modules/users/users/oauth/providers.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from httpx_oauth.oauth2 import BaseOAuth2 - from users.settings import UsersSettings + from users.core.settings import UsersSettings logger = logging.getLogger(__name__) diff --git a/modules/users/users/pages/Login.tsx b/modules/users/users/pages/Login.tsx index 7fff10c7a..85c7dc794 100644 --- a/modules/users/users/pages/Login.tsx +++ b/modules/users/users/pages/Login.tsx @@ -8,8 +8,8 @@ import { useState } from 'react'; import { type DemoAccount, DemoSignIn } from '../auth_local/components/DemoSignIn'; import { LoginForm, type OAuthProvider } from '../auth_local/components/LoginForm'; import { WaitingOnYou } from '../auth_local/components/WaitingOnYou'; +import { signIn } from '../ui/utils/signIn'; import { api } from './api'; -import { signIn } from './signIn'; interface DevAccount { label: string; diff --git a/modules/users/users/pages/ResetPassword.tsx b/modules/users/users/pages/ResetPassword.tsx index db6efaa3f..36b155045 100644 --- a/modules/users/users/pages/ResetPassword.tsx +++ b/modules/users/users/pages/ResetPassword.tsx @@ -10,8 +10,8 @@ import { useState } from 'react'; import { AuthStateCard } from '../auth_local/components/AuthStateCard'; import { PasswordFields } from '../auth_local/components/PasswordFields'; import { resetPasswordReason } from '../auth_local/resetPasswordError'; +import { signIn } from '../ui/utils/signIn'; import { api } from './api'; -import { signIn } from './signIn'; const FORGOT_PATH = '/users/forgot-password'; diff --git a/modules/users/users/pages/Users/components/DangerZone.tsx b/modules/users/users/pages/Users/components/DangerZone.tsx index 4a7945361..b5f7f0348 100644 --- a/modules/users/users/pages/Users/components/DangerZone.tsx +++ b/modules/users/users/pages/Users/components/DangerZone.tsx @@ -7,8 +7,8 @@ import { apiResult } from '@simple-module-py/ui/lib/api'; import { Trash2, TriangleAlert } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { adminFailureMessage } from '../../../ui/utils/failureMessage'; import { api } from '../../api'; -import { adminFailureMessage } from '../../failureMessage'; interface Props { userId: string; diff --git a/modules/users/users/pages/Users/components/useEditUserForm.ts b/modules/users/users/pages/Users/components/useEditUserForm.ts index 828c8c90b..005a283c1 100644 --- a/modules/users/users/pages/Users/components/useEditUserForm.ts +++ b/modules/users/users/pages/Users/components/useEditUserForm.ts @@ -4,8 +4,8 @@ import { useLeaveGuard } from '@simple-module-py/ui/hooks/useLeaveGuard'; import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { adminFailureMessage } from '../../../ui/utils/failureMessage'; import { api } from '../../api'; -import { adminFailureMessage } from '../../failureMessage'; export interface EditableUser { id: string; diff --git a/modules/users/users/pages/Users/components/useUserActions.ts b/modules/users/users/pages/Users/components/useUserActions.ts index c4730ca33..afbd301a9 100644 --- a/modules/users/users/pages/Users/components/useUserActions.ts +++ b/modules/users/users/pages/Users/components/useUserActions.ts @@ -2,8 +2,8 @@ import { keys, useT } from '@simple-module-py/i18n'; import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { toast } from 'sonner'; +import { adminFailureMessage } from '../../../ui/utils/failureMessage'; import { api } from '../../api'; -import { adminFailureMessage } from '../../failureMessage'; /** * The immediate account actions on the edit page — disable/enable, mark diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index aef6eec1a..b24e95e9c 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -1,261 +1,11 @@ -"""Users module settings — DB-backed via ``register_module_settings``. - -Most fields hydrate from the DB after boot, with pydantic defaults filling in -until then. Runtime changes go through ``settings.module_settings.reload.apply_changes_and_reload``. - -The two token-secret fields read ``SM_USERS_*`` at import time as a bootstrap -path: a fresh production deploy needs to clear the validator below before any -DB-backed settings can be seeded, otherwise the two paths deadlock. -""" +"""Moved to :mod:`users.core.settings`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging -import os -from typing import Literal - -from pydantic import Field, field_validator, model_validator -from pydantic_settings import SettingsConfigDict -from simple_module_core.dotenv import env_str -from simple_module_core.environments import NON_PROD_ENVIRONMENTS -from simple_module_core.redirect_safety import non_empty_redirect -from simple_module_core.settings_base import DbBackedSettings -from simple_module_core.stored_settings import field_error - -from users.session_version_cache import SESSION_VERSION_TTL_SECONDS - -logger = logging.getLogger("users.settings") - -_PLACEHOLDER_RESET_SECRET = "dev-reset-token-secret-change-me" -_PLACEHOLDER_VERIFY_SECRET = "dev-verify-token-secret-change-me" - -DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS = 90 - -DEFAULT_LOGIN_REDIRECT_URL = "/dashboard/" - -# Groups the demo fields together in the module-settings editor. -DEMO_SETTINGS_GROUP = "Demo account" - - -class UsersSettings(DbBackedSettings): - """Local user management configuration.""" - - # ``DbBackedSettings`` (not ``BaseSettings``) so the DB is genuinely the - # only source: omitting ``env_prefix`` would leave pydantic-settings - # reading each field from its bare name — GH #283. - model_config = SettingsConfigDict(extra="ignore") - - # Self-service signup - allow_signup: bool = False - require_verification: bool = True - - # Where the login page sends a successful sign-in. Sites without the - # bundled ``dashboard`` module (``smpy new --preset minimal``) override - # this to wherever their post-login landing lives. - login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL - - @field_validator("login_redirect_url") - @classmethod - def _non_empty_redirect(cls, value: str) -> str: - """Blank is never a usable navigation target. - - Covers this module's consumers — the login view (which hands it to - Inertia, where ``router.visit("")`` silently reloads the current page) - and the generic OAuth callback (which puts it in a ``Location`` - header). Keycloak has its own settings class with its own copy of this - field and normalises it the same way. - """ - return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) - - # Token secrets — MUST be set in production. Dev default is a deterministic - # placeholder that's obvious in logs so it can't be mistaken for a real key. - # Resolved at module-import time so ``info.default`` stays serializable - # for the settings admin UI. - reset_password_token_secret: str = env_str( - "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", - _PLACEHOLDER_RESET_SECRET, - ) - verification_token_secret: str = env_str( - "SM_USERS_VERIFICATION_TOKEN_SECRET", - _PLACEHOLDER_VERIFY_SECRET, - ) - reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour - verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days - - # Bearer token (mobile / API clients) - bearer_token_lifetime_seconds: int = 60 * 15 # 15 minutes - refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30 # 30 days - - refresh_token_family_max_age_days: int = Field(DEFAULT_REFRESH_TOKEN_FAMILY_MAX_AGE_DAYS, ge=1) - - # Cookie (fastapi-users AuthenticationBackend) - cookie_name: str = "sm_auth" - cookie_max_age_seconds: int = 60 * 60 * 24 * 14 # 14 days - # "Keep me signed in for N days" on the sign-in card. The page reads the - # number out of this rather than spelling it in the copy, so an operator - # who shortens the window does not leave the checkbox lying. - remember_me_max_age_seconds: int = 60 * 60 * 24 * 30 # 30 days - # How long a worker reuses a read of ``User.session_version`` before going - # back to the DB. The cost of the cache is a window in which *another* - # worker's revocation has not been seen here; 0 disables it and pays one - # indexed read per request instead. See ``users.session_version_cache``. - session_version_cache_ttl_seconds: int = SESSION_VERSION_TTL_SECONDS - - cookie_secure: bool = True # flipped False in dev by the module at startup - cookie_samesite: Literal["lax", "strict", "none"] = "lax" - - @field_validator("cookie_samesite", mode="before") - @classmethod - def _lowercase_samesite(cls, value: object) -> object: - """``"Strict"`` worked when this was a plain ``str`` (Starlette lowercases it).""" - return value.lower() if isinstance(value, str) else value - - # Mail transport (SMTP, console) lives in the ``mail`` module. - base_url: str = "http://localhost:8000" - mail_template_dirs: list[str] = Field( - default_factory=list, - description=( - "Absolute directories searched, in order, for verify_email / reset_password / " - "invite templates (.txt, optional .html) before the bundled ones. " - "Relative or missing paths are ignored." - ), - json_schema_extra={"requires_restart": True}, - ) - - # Login lockout (GH #530, ``users.auth_local.lockout``). Failures within the - # window: ``login_rate_limit_failures`` from one IP for one username - # throttle that source until the window ends; ``login_lockout_account_failures`` - # from anywhere lock the account for ``login_rate_limit_cooldown_seconds``. - # 10 rather than 5 for the account: anyone who knows an address could - # otherwise lock its owner out from any network. - login_rate_limit_failures: int = 5 - login_rate_limit_window_seconds: int = 300 - login_rate_limit_cooldown_seconds: int = 900 - login_lockout_account_failures: int = Field(10, ge=1) - - # Rate limit (auth side-effects: forgot-password, register, accept-invite, - # request-verify-token). Counts every attempt per IP per window. - auth_rate_limit_attempts: int = 10 - auth_rate_limit_window_seconds: int = 300 - - # ── Demo accounts ─────────────────────────────────────────────────── - # For public showcase instances: the sign-in card grows one button per - # configured account, and a click signs the visitor straight in. Two - # accounts rather than one because the two halves of the app look nothing - # alike — the admin surface is what the framework is *for*, and a - # visitor who only ever sees it never meets the app an end user uses. - # - # No password is ever sent to the browser, which is what separates this - # from the dev quick-fill buttons: those paste real credentials into the - # form and stay development-only for exactly that reason. - demo_mode: bool = Field(default=False, json_schema_extra={"group": DEMO_SETTINGS_GROUP}) - - # Blank either email to offer only the other account. Blank both and - # ``demo_mode`` has nothing to turn on. - demo_admin_email: str = Field( - default="demo-admin@example.com", json_schema_extra={"group": DEMO_SETTINGS_GROUP} - ) - demo_user_email: str = Field( - default="demo-user@example.com", json_schema_extra={"group": DEMO_SETTINGS_GROUP} - ) - - # Blank means "no password anyone can type": the account is seeded with a - # random one and is reachable only through its button. Set one only if you - # also intend to publish the credentials (for an API demo, say). - demo_admin_password: str = Field(default="", json_schema_extra={"group": DEMO_SETTINGS_GROUP}) - demo_user_password: str = Field(default="", json_schema_extra={"group": DEMO_SETTINGS_GROUP}) - - # Refuse every unsafe HTTP method from either demo session. On by default: - # a demo account is a published credential, so the safe posture is the one - # you get without reading the docs. - demo_read_only: bool = Field(default=True, json_schema_extra={"group": DEMO_SETTINGS_GROUP}) - - # Bootstrap (env-var auto-create users on first boot) - bootstrap_email: str = "" - bootstrap_password: str = "" - # Optional second seed user with the "user" role — handy in dev for - # testing non-admin flows without logging out/in repeatedly. - bootstrap_user_email: str = "" - bootstrap_user_password: str = "" - - # OAuth / OIDC providers — configured via the admin settings UI - # (/settings/modules → Users). Credentials live in the DB-backed settings - # store and hydrate after boot; secret fields are masked in the UI (the - # same treatment the SMTP password gets). Provider changes apply live via - # the SettingsReloaded event — no restart (see users/module.py). - oauth_google_client_id: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) - oauth_google_client_secret: str = Field(default="", json_schema_extra={"group": "Google OAuth"}) - oauth_github_client_id: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) - oauth_github_client_secret: str = Field(default="", json_schema_extra={"group": "GitHub OAuth"}) - # Generic OIDC — any provider that exposes a discovery URL - # (Keycloak, Authentik, Auth0, Zitadel, ...). - oauth_oidc_client_id: str = Field(default="", json_schema_extra={"group": "OIDC"}) - oauth_oidc_client_secret: str = Field(default="", json_schema_extra={"group": "OIDC"}) - oauth_oidc_discovery_url: str = Field(default="", json_schema_extra={"group": "OIDC"}) - oauth_oidc_display_name: str = Field(default="OIDC", json_schema_extra={"group": "OIDC"}) - # Microsoft Entra ID / Microsoft accounts. tenant: "common" (any work/school - # or personal account), "organizations" (work/school only), or a tenant GUID - # to restrict sign-in to a single Entra tenant. - oauth_microsoft_client_id: str = Field( - default="", json_schema_extra={"group": "Microsoft OAuth"} - ) - oauth_microsoft_client_secret: str = Field( - default="", json_schema_extra={"group": "Microsoft OAuth"} - ) - oauth_microsoft_tenant: str = Field( - default="common", json_schema_extra={"group": "Microsoft OAuth"} - ) - - @model_validator(mode="after") - def _warn_on_writable_admin_demo(self) -> UsersSettings: - """A writable admin demo is a public superuser — say so, loudly. - - Not an error: an operator who resets the demo database on a timer has - a legitimate reason to want it, and refusing would make a settings - edit in the admin UI fail with no way to opt in. But the combination - hands anyone who finds the URL the settings editor, the user table and - maintenance mode, so it does not get to happen quietly. - """ - if self.demo_mode and self.demo_admin_email.strip() and not self.demo_read_only: - logger.warning( - "users.demo.writable_admin — demo_mode is on with demo_admin_email=%r and " - "demo_read_only=False: anyone who can reach the sign-in page gets a " - "writable administrator session. Set demo_read_only=True unless this " - "instance's database is disposable.", - self.demo_admin_email, - ) - return self +import importlib +import sys - @model_validator(mode="after") - def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings: - """Fail boot if the reset/verify token secrets are still placeholders. +from simple_module_core.deprecation import deprecated_module - Both are HMAC keys for fastapi-users JWTs. A well-known default lets - an attacker mint password-reset or email-verification tokens for any - user. The environment is read from ``SM_ENVIRONMENT`` (host setting) - so this check has no runtime coupling to the hosting package. - """ - env = os.environ.get("SM_ENVIRONMENT", "development") - if env in NON_PROD_ENVIRONMENTS: - return self - bad = [] - if self.reset_password_token_secret == _PLACEHOLDER_RESET_SECRET: - bad.append(("reset_password_token_secret", "RESET_PASSWORD_TOKEN_SECRET")) - if self.verification_token_secret == _PLACEHOLDER_VERIFY_SECRET: - bad.append(("verification_token_secret", "VERIFICATION_TOKEN_SECRET")) - if bad: - names = ", ".join(label for _, label in bad) - # Located on the first placeholder field. Dropping a stored - # placeholder falls back to the same placeholder, so lenient boot - # still refuses: the check cannot be sidestepped. - raise field_error( - type(self), - bad[0][0], - "placeholder_token_secret", - f"users.{names} must be set to non-default value(s) when " - f"SM_ENVIRONMENT={env!r}. Generate with " - "`python -c 'import secrets; print(secrets.token_urlsafe(48))'`.", - value="", - related=["reset_password_token_secret", "verification_token_secret"], - ) - return self +deprecated_module("users.settings", remove_in="0.0.46", use="users.core.settings") +sys.modules[__name__] = importlib.import_module("users.core.settings") diff --git a/modules/users/users/state.py b/modules/users/users/state.py index 3c0e1eecf..6a6d2c40f 100644 --- a/modules/users/users/state.py +++ b/modules/users/users/state.py @@ -1,43 +1,11 @@ -"""Module-scoped state container for the users module. - -Stored as ``app.state.users`` by :meth:`UsersModule.register_settings` (for -fields available at that phase) and populated the rest of the way during -:meth:`UsersModule.on_startup` (for fields that depend on the DB or other -framework services). - -Not frozen — ``on_startup`` needs to set fields that aren't available at -``register_settings`` time. Convention: set once during boot, treat as -read-only after. -""" +"""Moved to :mod:`users.core.state`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import uuid - - from users.auth_local.rate_limit import ThroughputLimiter - from users.mailer import Mailer - from users.oauth.providers import OAuthProvider - from users.roles_cache import RoleSummary - from users.settings import UsersSettings - +import importlib +import sys -@dataclass -class UsersState: - """Users-module singletons. Single slot at ``app.state.users``.""" +from simple_module_core.deprecation import deprecated_module - settings: UsersSettings - mailer: Mailer | None = None - auth_throughput_limiter: ThroughputLimiter | None = None - roles_cache: list[RoleSummary] = field(default_factory=list) - #: ``time.monotonic()`` of the last load; the TTL floor in ``users.roles_cache``. - roles_cache_loaded_at: float = 0.0 - oauth_providers: list[dict[str, str]] = field(default_factory=list) - oauth_clients: dict[str, OAuthProvider] = field(default_factory=dict) - # Ids of the seeded demo accounts, empty when demo mode is off. Cached at - # boot (and on every settings reload) so ``DemoReadOnlyMiddleware`` can - # recognise a demo session without a database read per request. - demo_user_ids: tuple[uuid.UUID, ...] = () +deprecated_module("users.state", remove_in="0.0.46", use="users.core.state") +sys.modules[__name__] = importlib.import_module("users.core.state") diff --git a/modules/users/users/pages/failureMessage.ts b/modules/users/users/ui/utils/failureMessage.ts similarity index 100% rename from modules/users/users/pages/failureMessage.ts rename to modules/users/users/ui/utils/failureMessage.ts diff --git a/modules/users/users/pages/signIn.ts b/modules/users/users/ui/utils/signIn.ts similarity index 94% rename from modules/users/users/pages/signIn.ts rename to modules/users/users/ui/utils/signIn.ts index 66690f6f4..e93e3a5b0 100644 --- a/modules/users/users/pages/signIn.ts +++ b/modules/users/users/ui/utils/signIn.ts @@ -1,5 +1,5 @@ import { type ApiResult, apiResult } from '@simple-module-py/ui/lib/api'; -import { api } from './api'; +import { api } from '../../pages/api'; /** * Password sign-in. The route takes a form-encoded body (the OAuth2 password diff --git a/modules/users/users/web/__init__.py b/modules/users/users/web/__init__.py new file mode 100644 index 000000000..8f4cf7f76 --- /dev/null +++ b/modules/users/users/web/__init__.py @@ -0,0 +1,5 @@ +"""The HTTP side. + +The public dependencies and FastAPIUsers instance, and the users shared Inertia +props. +""" diff --git a/modules/users/users/web/deps.py b/modules/users/users/web/deps.py new file mode 100644 index 000000000..d00a1bacb --- /dev/null +++ b/modules/users/users/web/deps.py @@ -0,0 +1,104 @@ +"""Public dependencies and the FastAPIUsers instance. + +Cookie transport and auth backend are constructed at import time with +dev-safe defaults (cookie_secure=False, dev cookie name). UsersModule.on_startup +overrides the cookie params from the real UsersSettings once app.state is +populated — CookieTransport's cookie fields are mutable on the instance. + +The singleton is here rather than in register_routes because +``current_active_user`` and ``current_superuser`` are decorator-factories +that must be resolvable at import time for route-handler signatures. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from auth.web.deps import CurrentUser +from fastapi import Depends, HTTPException, Request +from fastapi_users import FastAPIUsers +from fastapi_users import exceptions as fu_exceptions +from simple_module_core.events import EventBus +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from users.accounts.backend import build_auth_backend, build_cookie_transport +from users.accounts.db_adapter import ( + UserDatabaseWithRoles, + get_access_token_db, + get_user_db, +) +from users.accounts.manager import UserManager, get_user_manager +from users.models import User + +if TYPE_CHECKING: + from users.admin.service import UserService + +# Dev-safe singleton — UsersModule patches cookie params at startup. +_cookie_transport = build_cookie_transport( + cookie_name="sm_auth", + cookie_max_age_seconds=60 * 60 * 24 * 14, + cookie_secure=False, # host flips True in production via register_routes + cookie_samesite="lax", +) +auth_backend = build_auth_backend(_cookie_transport) + +fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) + +current_active_user = fastapi_users.current_user(active=True) +current_superuser = fastapi_users.current_user(active=True, superuser=True) + + +async def get_current_local_user( + principal: CurrentUser, + user_manager: UserManager = Depends(get_user_manager), +) -> User: + """Load the local account authenticated by the session or bearer provider.""" + try: + user = await user_manager.get(user_manager.parse_id(principal.id)) + except (fu_exceptions.UserNotExists, fu_exceptions.InvalidID): + raise HTTPException(status_code=401, detail="Not authenticated") from None + if not user.is_active or user.disabled_at is not None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +async def get_mailer(request: Request): + """Return the mailer from app.state.users (built in UsersModule.on_startup).""" + return request.app.state.users.mailer + + +async def get_event_bus(request: Request) -> EventBus: + """Return the event bus from app.state.sm.""" + return request.app.state.sm.event_bus + + +async def get_user_service( + request: Request, + db: AsyncSession = Depends(get_db), + user_manager: UserManager = Depends(get_user_manager), +) -> UserService: + from users.admin.service import UserService + + actor = getattr(request.state, "user", None) + if actor is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return UserService(db, user_manager, request.app, actor=actor, request=request) + + +__all__ = [ + "UserDatabaseWithRoles", + "UserManager", + "auth_backend", + "current_active_user", + "current_superuser", + "fastapi_users", + "get_access_token_db", + "get_current_local_user", + "get_event_bus", + "get_mailer", + "get_user_db", + "get_user_manager", + "get_user_service", +] diff --git a/modules/users/users/shared_props.py b/modules/users/users/web/shared_props.py similarity index 100% rename from modules/users/users/shared_props.py rename to modules/users/users/web/shared_props.py diff --git a/scripts/loadtest_seed.py b/scripts/loadtest_seed.py index 3b8a7c919..8886d1424 100644 --- a/scripts/loadtest_seed.py +++ b/scripts/loadtest_seed.py @@ -18,7 +18,7 @@ from simple_module_db.session import init_db from simple_module_hosting import merge_host_settings from simple_module_test import forge_session_cookie -from users.bootstrap import create_admin +from users.core.bootstrap import create_admin LOAD_EMAIL = "loadtest@example.com" LOAD_PASSWORD = "loadtest-password-x!1" diff --git a/skills/simple-module-conventions/SKILL.md b/skills/simple-module-conventions/SKILL.md index 19ae9706c..b0c2e951b 100644 --- a/skills/simple-module-conventions/SKILL.md +++ b/skills/simple-module-conventions/SKILL.md @@ -49,8 +49,8 @@ from simple_module_core.module_settings import register_module_settings class UsersModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: - from users.settings import UsersSettings # a DbBackedSettings subclass - from users.state import UsersState + from users.core.settings import UsersSettings # a DbBackedSettings subclass + from users.core.state import UsersState register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s)) ``` diff --git a/skills/simple-module-testing/SKILL.md b/skills/simple-module-testing/SKILL.md index 03ef1f99f..7997a8922 100644 --- a/skills/simple-module-testing/SKILL.md +++ b/skills/simple-module-testing/SKILL.md @@ -20,7 +20,7 @@ Root `conftest.py` provides app-level fixtures that **every** test directory inh | `db_session` | `AsyncSession` against in-memory SQLite with **all** module tables created and `alembic_version` stamped at head. The stamp matters: without it the boot-time `check_migrations` raises SM010 and the `app` fixture can't start. | | `app` | A live FastAPI app — `create_app(settings)` plus lifespan started/stopped. Tables are pre-created the same way as `db_session`. | | `client` | Unauthenticated `httpx.AsyncClient` against `app` via `ASGITransport`. | -| `authenticated_client` | Same client with a forged session cookie carrying a seeded admin (`admin@test` / `test-password`). The seed runs `users.bootstrap.create_admin`, so the users tables must be in scope. | +| `authenticated_client` | Same client with a forged session cookie carrying a seeded admin (`admin@test` / `test-password`). The seed runs `users.core.bootstrap.create_admin`, so the users tables must be in scope. | ## Standard test patterns diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 36bc13329..04c9bee66 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -26,7 +26,7 @@ async def _seed_user_with_roles(app, email: str, role_names: list[str]): If a named role doesn't exist yet, creates it with a deterministic UUID. """ - from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + from users.core.constants import ADMIN_ROLE_ID, USER_ROLE_ID from users.models import Role, User, UserRole _role_ids: dict[str, object] = { diff --git a/tests/integration/test_pluggable_auth.py b/tests/integration/test_pluggable_auth.py index 18e728570..c23d8a593 100644 --- a/tests/integration/test_pluggable_auth.py +++ b/tests/integration/test_pluggable_auth.py @@ -40,7 +40,7 @@ class StubModule(ModuleBase): def test_auth_provider_protocol_satisfied_by_users(): - from users.provider import UsersAuthProvider + from users.accounts.provider import UsersAuthProvider assert isinstance(UsersAuthProvider(), AuthProvider)