Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())
```
Expand Down
4 changes: 2 additions & 2 deletions docs/framework/invalidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -53,7 +53,7 @@ Channel naming is `<module>.<cache>`. `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

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/user-directory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
2 changes: 1 addition & 1 deletion docs/frontend/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
6 changes: 3 additions & 3 deletions docs/modules/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/testing/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion framework/cli/tests/test_cli_docker_persistence_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion framework/cli/tests/test_cli_prepare_data_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
2 changes: 1 addition & 1 deletion framework/cli/tests/test_scaffolding_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
2 changes: 1 addition & 1 deletion framework/core/simple_module_core/invalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion framework/testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion framework/testing/simple_module_test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
10 changes: 5 additions & 5 deletions framework/testing/simple_module_test/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion framework/testing/simple_module_test/tenant_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion host/tests/test_inactive_provider_public_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading