From 2ed90262852d9fd8e25d523697fb53e9a48e7fdb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 01:48:40 +0100 Subject: [PATCH 1/2] refactor(tenants): follow the module layout (moves only) constants/settings/services/errors, the tenant service with its membership, owner-guard and deletion helpers, and user cleanup -> core/; resolver, host resolver and token tenant -> resolution/; invitations and the invitation mail with its template -> invitations/; deps, shared props and endpoints -> web/; components and hooks -> ui/. Tests mirror the areas with test_tenants_* basenames. tenants.{constants,resolver,service,deps,errors} stay as deprecated sys.modules aliases (remove_in 0.0.46). Durable handler names, permission names and invalidation channels are unchanged. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 1 + docs/module-layout.md | 2 +- docs/modules/tenants.md | 4 +- .../simple_module_test/tenant_client.py | 4 +- host/tests/test_module_layout.py | 9 +- .../api_tokens/tests/_api_tokens_support.py | 2 +- modules/api_tokens/tests/conftest.py | 2 +- .../resolver/test_api_tokens_hardening.py | 6 +- .../resolver/test_api_tokens_resolver.py | 2 +- .../service/test_api_tokens_service_keys.py | 2 +- .../test_branding_tenant_branding_assets.py | 2 +- .../endpoints/test_branding_platform_files.py | 2 +- modules/file_storage/tests/conftest.py | 2 +- .../test_file_storage_tenant_deletion.py | 2 +- .../tests/web/endpoints/test_sharing_http.py | 4 +- modules/tenants/tenants/constants.py | 86 +----- modules/tenants/tenants/contracts/schemas.py | 2 +- modules/tenants/tenants/core/__init__.py | 5 + .../tenants/tenants/{ => core}/_deletion.py | 0 .../tenants/{ => core}/_membership_ops.py | 10 +- .../tenants/{ => core}/_owner_guard.py | 2 +- modules/tenants/tenants/core/constants.py | 85 ++++++ modules/tenants/tenants/core/errors.py | 70 +++++ modules/tenants/tenants/core/service.py | 220 ++++++++++++++ .../tenants/tenants/{ => core}/services.py | 2 +- .../tenants/tenants/{ => core}/settings.py | 0 .../tenants/{ => core}/user_cleanup.py | 4 +- modules/tenants/tenants/deps.py | 90 +----- modules/tenants/tenants/errors.py | 71 +---- .../tenants/tenants/invitations/__init__.py | 4 + .../mail.py} | 2 +- .../service.py} | 8 +- .../templates/invitation.txt | 0 modules/tenants/tenants/models.py | 2 +- modules/tenants/tenants/module.py | 30 +- .../tenants/pages/AcceptInvitation.tsx | 2 +- modules/tenants/tenants/pages/AdminBrowse.tsx | 2 +- modules/tenants/tenants/pages/Index.tsx | 4 +- modules/tenants/tenants/pages/Members.tsx | 6 +- modules/tenants/tenants/pages/Settings.tsx | 2 +- .../tenants/tenants/resolution/__init__.py | 4 + .../tenants/{ => resolution}/host_resolver.py | 2 +- .../tenants/tenants/resolution/resolver.py | 276 +++++++++++++++++ .../tenants/{ => resolution}/token_tenant.py | 4 +- modules/tenants/tenants/resolver.py | 277 +----------------- modules/tenants/tenants/service.py | 216 +------------- .../{ => ui}/components/AdminTenantsTable.tsx | 2 +- .../{ => ui}/components/CreateOrgForm.tsx | 2 +- .../{ => ui}/components/InvitationsList.tsx | 2 +- .../{ => ui}/components/InviteForm.tsx | 2 +- .../{ => ui}/components/MembersTable.tsx | 2 +- .../{ => ui}/components/MembershipList.tsx | 2 +- .../components/TenantImageControl.tsx | 0 .../{ => ui}/components/TenantSettingRow.tsx | 0 .../tenants/{ => ui}/hooks/useTenantErrors.ts | 0 modules/tenants/tenants/web/__init__.py | 4 + modules/tenants/tenants/web/deps.py | 89 ++++++ .../tenants/{ => web}/endpoints/__init__.py | 0 .../tenants/{ => web}/endpoints/admin.py | 9 +- .../tenants/{ => web}/endpoints/api.py | 24 +- .../tenants/{ => web}/endpoints/views.py | 6 +- .../tenants/tenants/{ => web}/shared_props.py | 2 +- .../tests-js/TenantSettingRow.test.tsx | 2 +- .../tests-js/tenants_api_client.test.tsx | 4 +- .../tests-js/test_tenants_InviteForm.test.tsx | 2 +- ...elpers.py => _tenants_deletion_support.py} | 0 modules/tenants/tests/conftest.py | 2 +- .../fixtures/test_tenants_client_fixture.py} | 2 +- .../module/test_tenants_boot.py} | 2 +- .../module}/test_tenants_usage_contract.py | 4 +- .../service}/test_tenants_audit_redaction.py | 0 .../service/test_tenants_deleted_event.py} | 10 +- .../service}/test_tenants_durable_deletion.py | 4 +- .../service}/test_tenants_inline_deletion.py | 2 +- .../test_tenants_membership_changes.py} | 0 .../service/test_tenants_owner_race.py} | 8 +- .../service/test_tenants_role_permissions.py} | 2 +- .../test_tenants_sqlite_admission_race.py | 8 +- .../test_tenants_teardown_integration.py | 2 +- .../mail}/test_tenants_invitation_mail.py | 10 +- .../test_tenants_invitation_verification.py | 0 .../service/test_tenants_invitations.py} | 0 .../test_tenants_negative_host_cache.py | 6 +- .../hosts/test_tenants_subdomains.py} | 2 +- ...test_tenants_subdomains_forwarded_host.py} | 2 +- .../test_tenants_cache_singleflight.py | 4 +- .../resolver/test_tenants_isolation_e2e.py} | 0 .../resolver/test_tenants_resolver_source.py} | 2 +- .../{ => web/endpoints}/test_tenants_api.py | 0 .../endpoints/test_tenants_hardening.py} | 4 +- .../test_tenants_members_pagination.py | 2 +- .../test_tenants_optional_settings.py | 0 .../endpoints/test_tenants_settings_page.py} | 0 .../test_tenants_shared_props.py | 6 +- .../users/tests/test_users_deletion_events.py | 4 +- 95 files changed, 926 insertions(+), 850 deletions(-) create mode 100644 modules/tenants/tenants/core/__init__.py rename modules/tenants/tenants/{ => core}/_deletion.py (100%) rename modules/tenants/tenants/{ => core}/_membership_ops.py (95%) rename modules/tenants/tenants/{ => core}/_owner_guard.py (98%) create mode 100644 modules/tenants/tenants/core/constants.py create mode 100644 modules/tenants/tenants/core/errors.py create mode 100644 modules/tenants/tenants/core/service.py rename modules/tenants/tenants/{ => core}/services.py (93%) rename modules/tenants/tenants/{ => core}/settings.py (100%) rename modules/tenants/tenants/{ => core}/user_cleanup.py (92%) create mode 100644 modules/tenants/tenants/invitations/__init__.py rename modules/tenants/tenants/{invitation_mail.py => invitations/mail.py} (98%) rename modules/tenants/tenants/{invitations.py => invitations/service.py} (96%) rename modules/tenants/tenants/{ => invitations}/templates/invitation.txt (100%) create mode 100644 modules/tenants/tenants/resolution/__init__.py rename modules/tenants/tenants/{ => resolution}/host_resolver.py (98%) create mode 100644 modules/tenants/tenants/resolution/resolver.py rename modules/tenants/tenants/{ => resolution}/token_tenant.py (95%) rename modules/tenants/tenants/{ => ui}/components/AdminTenantsTable.tsx (99%) rename modules/tenants/tenants/{ => ui}/components/CreateOrgForm.tsx (98%) rename modules/tenants/tenants/{ => ui}/components/InvitationsList.tsx (98%) rename modules/tenants/tenants/{ => ui}/components/InviteForm.tsx (99%) rename modules/tenants/tenants/{ => ui}/components/MembersTable.tsx (99%) rename modules/tenants/tenants/{ => ui}/components/MembershipList.tsx (98%) rename modules/tenants/tenants/{ => ui}/components/TenantImageControl.tsx (100%) rename modules/tenants/tenants/{ => ui}/components/TenantSettingRow.tsx (100%) rename modules/tenants/tenants/{ => ui}/hooks/useTenantErrors.ts (100%) create mode 100644 modules/tenants/tenants/web/__init__.py create mode 100644 modules/tenants/tenants/web/deps.py rename modules/tenants/tenants/{ => web}/endpoints/__init__.py (100%) rename modules/tenants/tenants/{ => web}/endpoints/admin.py (93%) rename modules/tenants/tenants/{ => web}/endpoints/api.py (97%) rename modules/tenants/tenants/{ => web}/endpoints/views.py (97%) rename modules/tenants/tenants/{ => web}/shared_props.py (94%) rename modules/tenants/tests/{tenants_deletion_helpers.py => _tenants_deletion_support.py} (100%) rename modules/tenants/tests/{test_tenant_client_fixture.py => core/fixtures/test_tenants_client_fixture.py} (95%) rename modules/tenants/tests/{test_boot.py => core/module/test_tenants_boot.py} (76%) rename modules/tenants/tests/{ => core/module}/test_tenants_usage_contract.py (98%) rename modules/tenants/tests/{ => core/service}/test_tenants_audit_redaction.py (100%) rename modules/tenants/tests/{test_tenant_deleted_event.py => core/service/test_tenants_deleted_event.py} (95%) rename modules/tenants/tests/{ => core/service}/test_tenants_durable_deletion.py (95%) rename modules/tenants/tests/{ => core/service}/test_tenants_inline_deletion.py (98%) rename modules/tenants/tests/{test_membership_changes.py => core/service/test_tenants_membership_changes.py} (100%) rename modules/tenants/tests/{test_owner_race.py => core/service/test_tenants_owner_race.py} (96%) rename modules/tenants/tests/{test_tenant_role_permissions.py => core/service/test_tenants_role_permissions.py} (98%) rename modules/tenants/tests/{ => core/service}/test_tenants_sqlite_admission_race.py (94%) rename modules/tenants/tests/{ => core/service}/test_tenants_teardown_integration.py (99%) rename modules/tenants/tests/{ => invitations/mail}/test_tenants_invitation_mail.py (96%) rename modules/tenants/tests/{ => invitations/service}/test_tenants_invitation_verification.py (100%) rename modules/tenants/tests/{test_invitations.py => invitations/service/test_tenants_invitations.py} (100%) rename modules/tenants/tests/{ => resolution/hosts}/test_tenants_negative_host_cache.py (97%) rename modules/tenants/tests/{test_subdomains.py => resolution/hosts/test_tenants_subdomains.py} (98%) rename modules/tenants/tests/{test_subdomains_forwarded_host.py => resolution/hosts/test_tenants_subdomains_forwarded_host.py} (97%) rename modules/tenants/tests/{ => resolution/resolver}/test_tenants_cache_singleflight.py (97%) rename modules/tenants/tests/{test_isolation_e2e.py => resolution/resolver/test_tenants_isolation_e2e.py} (100%) rename modules/tenants/tests/{test_resolver_source.py => resolution/resolver/test_tenants_resolver_source.py} (98%) rename modules/tenants/tests/{ => web/endpoints}/test_tenants_api.py (100%) rename modules/tenants/tests/{test_hardening.py => web/endpoints/test_tenants_hardening.py} (98%) rename modules/tenants/tests/{ => web/endpoints}/test_tenants_members_pagination.py (98%) rename modules/tenants/tests/{ => web/endpoints}/test_tenants_optional_settings.py (100%) rename modules/tenants/tests/{test_settings_page.py => web/endpoints/test_tenants_settings_page.py} (100%) rename modules/tenants/tests/{ => web/shared_props}/test_tenants_shared_props.py (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index aac62aa2c..1efb82241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ All notable changes to this project are documented in this file. The format is b - `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. - `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain. - `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46. +- `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged. - Coalesce concurrent tenant membership and permission grant cache misses. In-flight invalidation retries against fresh authorization data; failures and cancellation release waiting readers. Cache unknown tenant subdomains for five seconds and drop the new slug's host lookups (on a new `tenants.hosts` invalidation channel, so no user id is read as a slug) and the owner's memberships after committed tenant creation; other users' caches are not cleared. Services built outside request dependencies pass `invalidate_hosts=make_host_invalidator(app)` beside `invalidate=make_invalidator(app)`. Retries after concurrent invalidation are bounded to three reads (2026-11-05 local performance review). diff --git a/docs/module-layout.md b/docs/module-layout.md index 899ceb599..a0cc293a8 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -227,5 +227,5 @@ when a later module PR moves files. | settings | 31 | 43 | flat | | sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | -| tenants | 19 | 23 | flat | +| tenants | 3 (+5 shims) | 0 | follows this layout | | users | 31 | 97 | flat | diff --git a/docs/modules/tenants.md b/docs/modules/tenants.md index 87d81e723..c8f5b8d51 100644 --- a/docs/modules/tenants.md +++ b/docs/modules/tenants.md @@ -181,8 +181,8 @@ drops the owner's memberships and the new slug's host lookups; other users' caches stay warm. Status changes and deletion publish `None` on the membership channel. Construct services with both invalidators outside request dependencies: `TenantService(db, invalidate=make_invalidator(app), -invalidate_hosts=make_host_invalidator(app))` (from `tenants.resolver` and -`tenants.host_resolver`). Direct SQL writes must publish `tenants.membership` +invalidate_hosts=make_host_invalidator(app))` (from `tenants.resolution.resolver` and +`tenants.resolution.host_resolver`). Direct SQL writes must publish `tenants.membership` with `key=None` after commit or wait for TTL expiry. ## Models diff --git a/framework/testing/simple_module_test/tenant_client.py b/framework/testing/simple_module_test/tenant_client.py index 97fd12bf4..857f99255 100644 --- a/framework/testing/simple_module_test/tenant_client.py +++ b/framework/testing/simple_module_test/tenant_client.py @@ -69,7 +69,7 @@ async def create_user(app: Any, email: str) -> str: async def _join(app: Any, user_id: str, email: str, role: TenantRole, tenant_id: str | None) -> str: from tenants.models import Membership, Tenant - from tenants.resolver import forget + from tenants.resolution.resolver import forget async with app.state.sm.db.session_factory() as session: if tenant_id is None: @@ -104,7 +104,7 @@ def tenant_client(app: Any) -> Callable[..., AbstractAsyncContextManager[TenantC ``tenant_id`` when given — and yields a :class:`TenantClient` whose session has that tenant active. """ - from tenants.constants import SESSION_ACTIVE_TENANT + from tenants.core.constants import SESSION_ACTIVE_TENANT @asynccontextmanager async def factory( diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 42600701d..11eed561e 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -41,6 +41,7 @@ "mail": frozenset({"delivery"}), "oidc": frozenset({"identity"}), "permissions": frozenset({"grants"}), + "tenants": frozenset({"invitations", "resolution"}), } # Frozen inventory, not a computed baseline: moves must remove their old entries. @@ -78,14 +79,6 @@ reload.py service.py services.py settings.py store.py tenant_scope.py tenant_view.py """.split() ), - "tenants": frozenset( - """ - _deletion.py _membership_ops.py _owner_guard.py components constants.py deps.py - endpoints errors.py hooks host_resolver.py invitation_mail.py invitations.py - resolver.py service.py services.py settings.py shared_props.py templates - token_tenant.py user_cleanup.py - """.split() - ), "users": frozenset( """ admin administrators.py audit.py auth_local backend.py bootstrap.py constants.py diff --git a/modules/api_tokens/tests/_api_tokens_support.py b/modules/api_tokens/tests/_api_tokens_support.py index 48e2a746f..28b13628b 100644 --- a/modules/api_tokens/tests/_api_tokens_support.py +++ b/modules/api_tokens/tests/_api_tokens_support.py @@ -27,7 +27,7 @@ class Actor: async def join_tenant(app: Any, user_id: str, email: str, tenant_id: str | None = None) -> str: """Make *user_id* an owner of a new tenant (or of *tenant_id*); return its id.""" from tenants.models import Membership, Tenant - from tenants.resolver import forget + from tenants.resolution.resolver import forget async with app.state.sm.db.session_factory() as session: if tenant_id is None: diff --git a/modules/api_tokens/tests/conftest.py b/modules/api_tokens/tests/conftest.py index 8a0539913..93793686a 100644 --- a/modules/api_tokens/tests/conftest.py +++ b/modules/api_tokens/tests/conftest.py @@ -20,7 +20,7 @@ def actor(app) -> Callable[..., AbstractAsyncContextManager[Actor]]: The session carries a known CSRF token (``CSRF``) so mutations can echo it. """ - from tenants.constants import SESSION_ACTIVE_TENANT + from tenants.core.constants import SESSION_ACTIVE_TENANT @asynccontextmanager async def factory( diff --git a/modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py index db4754211..8d80bd49b 100644 --- a/modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py +++ b/modules/api_tokens/tests/credentials/resolver/test_api_tokens_hardening.py @@ -59,7 +59,7 @@ async def test_pat_is_pinned_to_its_tenant_whatever_the_header_says(app, actor, async def _remove_membership(app: Any, user_id: str, tenant_id: str) -> None: from sqlalchemy import delete from tenants.models import Membership - from tenants.resolver import forget + from tenants.resolution.resolver import forget async with app.state.sm.db.session_factory() as session: await session.execute( @@ -83,9 +83,9 @@ async def test_pat_stops_working_once_the_owner_leaves_its_tenant(app, actor, be async def test_pat_stops_working_while_its_tenant_is_suspended(app, actor, bearer): from sqlalchemy import update - from tenants.constants import TenantStatus + from tenants.core.constants import TenantStatus from tenants.models import Tenant - from tenants.resolver import forget + from tenants.resolution.resolver import forget async with actor(admin=True) as a: token = (await create_pat(a.client, ["feature_flags.view"]))["token"] 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 57d31ba99..c9ee44718 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 @@ -157,7 +157,7 @@ async def spy(scope, token): async def test_personal_token_tenant_answer_varies_on_authorization(app, actor): """The tenant chosen for a PAT depended on its Authorization header, not a cookie.""" from api_tokens.credentials.resolver import resolve_api_token - from tenants.resolver import resolve_tenant + from tenants.resolution.resolver import resolve_tenant async with actor(admin=True) as a: token = (await create_pat(a.client, ["feature_flags.view"]))["token"] diff --git a/modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py b/modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py index 94a1552a7..4c1daa52a 100644 --- a/modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py +++ b/modules/api_tokens/tests/credentials/service/test_api_tokens_service_keys.py @@ -57,7 +57,7 @@ async def test_only_managers_create_service_keys(actor): async def test_service_principal_is_pinned_to_its_tenant(app, actor): from api_tokens.credentials.resolver import resolve_api_token - from tenants.resolver import resolve_tenant + from tenants.resolution.resolver import resolve_tenant async with actor(admin=True) as a: resp = await _create_key(a.client, roles=["user"], scopes=["feature_flags.view"]) diff --git a/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py b/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py index 9f6cb7d6d..db0ec84b4 100644 --- a/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py +++ b/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py @@ -18,8 +18,8 @@ from settings.service import SettingService from simple_module_db import all_tenants from sqlalchemy import select -from tenants.host_resolver import forget_hosts from tenants.models import Tenant +from tenants.resolution.host_resolver import forget_hosts _PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 TENANT_LOGO = "/api/branding/tenant/logo" diff --git a/modules/branding/tests/web/endpoints/test_branding_platform_files.py b/modules/branding/tests/web/endpoints/test_branding_platform_files.py index d716cdaa5..c38dc9fb7 100644 --- a/modules/branding/tests/web/endpoints/test_branding_platform_files.py +++ b/modules/branding/tests/web/endpoints/test_branding_platform_files.py @@ -38,7 +38,7 @@ async def _rows(app) -> list[StoredFile]: async def _give_admin_a_tenant(app) -> str: from simple_module_test.fixtures import SETUP_ADMIN_EMAIL from tenants.models import Membership, Tenant - from tenants.resolver import forget + from tenants.resolution.resolver import forget from users.models import User async with app.state.sm.db.session_factory() as session: diff --git a/modules/file_storage/tests/conftest.py b/modules/file_storage/tests/conftest.py index fe39fd01a..b561727a9 100644 --- a/modules/file_storage/tests/conftest.py +++ b/modules/file_storage/tests/conftest.py @@ -67,7 +67,7 @@ async def admin_tenant_id(app) -> str: from simple_module_test.fixtures import SETUP_ADMIN_EMAIL from sqlalchemy import select from tenants.models import Membership, Tenant - from tenants.resolver import forget + from tenants.resolution.resolver import forget from users.models import User async with app.state.sm.db.session_factory() as session: diff --git a/modules/file_storage/tests/test_file_storage_tenant_deletion.py b/modules/file_storage/tests/test_file_storage_tenant_deletion.py index d54549495..487ee820d 100644 --- a/modules/file_storage/tests/test_file_storage_tenant_deletion.py +++ b/modules/file_storage/tests/test_file_storage_tenant_deletion.py @@ -129,8 +129,8 @@ async def test_inline_cleanup_defers_objects_until_commit( db_session, tmp_path, monkeypatch, rollback ): from simple_module_db.transaction import finalize_session, rollback_session + from tenants.core.service import TenantService from tenants.models import Tenant - from tenants.service import TenantService bus = EventBus() backend = FilesystemBackend(tmp_path) diff --git a/modules/sharing/tests/web/endpoints/test_sharing_http.py b/modules/sharing/tests/web/endpoints/test_sharing_http.py index 055b07215..c09cb493f 100644 --- a/modules/sharing/tests/web/endpoints/test_sharing_http.py +++ b/modules/sharing/tests/web/endpoints/test_sharing_http.py @@ -70,9 +70,9 @@ async def test_grant_refusals(note_app, tenant_client): async def test_admin_page_lists_tenant_shares(note_app, tenant_client): - from tenants.constants import SESSION_ACTIVE_TENANT + from tenants.core.constants import SESSION_ACTIVE_TENANT from tenants.models import Membership - from tenants.resolver import forget + from tenants.resolution.resolver import forget from users.bootstrap import create_admin async with tenant_client(TenantRole.OWNER) as owner: diff --git a/modules/tenants/tenants/constants.py b/modules/tenants/tenants/constants.py index 84758e9ef..84502abff 100644 --- a/modules/tenants/tenants/constants.py +++ b/modules/tenants/tenants/constants.py @@ -1,85 +1,11 @@ -"""Tenants module constants.""" +"""Moved to :mod:`tenants.core.constants`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from enum import StrEnum +import importlib +import sys -# The role vocabulary lives in core so other modules can map ``tenant:`` -# onto their permissions without depending on this module (#380). -from simple_module_core.tenancy import TENANT_ROLE_PREFIX, tenant_role -from simple_module_core.tenancy import TenantRole as MembershipRole +from simple_module_core.deprecation import deprecated_module -MODULE_PACKAGE = "tenants" -DISPLAY_NAME = "Tenants" - -# Names of the modules this one depends on (``ModuleMeta.depends_on``). -_MODULE_AUTH = "Auth" -_MODULE_SETTINGS = "Settings" - -# Session key holding the tenant the signed-in user last switched to. Only a -# preference: the resolver re-validates it against a membership every request. -SESSION_ACTIVE_TENANT = "sm_active_tenant" - -# Key: a user id, or ``None`` for every user (which also drops every host lookup). -INVALIDATION_CHANNEL = "tenants.membership" -# Key: a tenant slug, or ``None`` for every slug. Kept apart from the membership -# channel so no user id (an OIDC subject is any string) can be read as a slug. -HOSTS_INVALIDATION_CHANNEL = "tenants.hosts" - -# Entitlement keys this module enforces (see contracts.entitlements). -ENTITLEMENT_SEATS = "tenants.seats" -ERROR_ADMISSION_CONFLICT = "admission_conflict" - -MAX_NAME_LEN = 200 -MAX_SLUG_LEN = 50 -MAX_EMAIL_LEN = 320 -TENANT_ID_LEN = 32 - - -class TenantStatus(StrEnum): - ACTIVE = "active" - SUSPENDED = "suspended" - - -MANAGER_ROLES = frozenset({MembershipRole.OWNER, MembershipRole.ADMIN}) - -# Permissions. ``tenants.*`` within the active tenant; ``tenants.platform.*`` -# spans tenants and is granted to no tenant role — only the platform admin -# (wildcard) or an explicit role grant. -PERM_MEMBERS_VIEW = "tenants.members.view" -PERM_MEMBERS_MANAGE = "tenants.members.manage" -# Owned and mapped by ``settings`` (#382); named here for the page guard and -# the sidebar entry only. -PERM_TENANT_SETTINGS = "settings.tenant.edit" -PERM_PLATFORM_VIEW = "tenants.platform.view" -PERM_PLATFORM_MANAGE = "tenants.platform.manage" - -ROLE_PERMISSIONS: dict[MembershipRole, list[str]] = { - # Tenant settings are ``settings.tenant.edit``, which the settings module - # owns and maps onto owner + admin itself (#382). - MembershipRole.OWNER: [PERM_MEMBERS_VIEW, PERM_MEMBERS_MANAGE], - MembershipRole.ADMIN: [PERM_MEMBERS_VIEW, PERM_MEMBERS_MANAGE], - MembershipRole.MEMBER: [PERM_MEMBERS_VIEW], -} - -PAGE_INDEX = "Tenants/Index" -PAGE_MEMBERS = "Tenants/Members" -PAGE_SETTINGS = "Tenants/Settings" -PAGE_ACCEPT = "Tenants/AcceptInvitation" -PAGE_ADMIN = "Tenants/AdminBrowse" - -__all__ = ["TENANT_ROLE_PREFIX", "MembershipRole", "tenant_role"] - -INVITATION_MAIL_HANDLER = "tenants.send_invitation_email" -# Durable handler name (outbox key); pinned to the original import-path default. -USER_CLEANUP_HANDLER = "tenants.user_cleanup.purge_user_memberships" -INVITATION_MAIL_SUBJECT = "You're invited to join {tenant_name}" -INVITATION_TEMPLATE = "templates/invitation.txt" -MAIL_URL_SCHEMES = frozenset({"http", "https"}) -MAIL_RELATIVE_WARNING = ( - "Invitation email skipped: configure tenants.public_base_url for absolute links" -) -MAIL_FAILURE_WARNING = "Invitation email failed after commit" -MAIL_NO_TRANSPORT_ERROR = ( - "No mail transport in this process: add Mail to SM_MODULES_ENABLED for the outbox worker" -) +deprecated_module("tenants.constants", remove_in="0.0.46", use="tenants.core.constants") +sys.modules[__name__] = importlib.import_module("tenants.core.constants") diff --git a/modules/tenants/tenants/contracts/schemas.py b/modules/tenants/tenants/contracts/schemas.py index 76aa9450b..75f2e1d03 100644 --- a/modules/tenants/tenants/contracts/schemas.py +++ b/modules/tenants/tenants/contracts/schemas.py @@ -11,7 +11,7 @@ from simple_module_db.pagination import Page from sqlmodel import Field, SQLModel -from tenants.constants import MAX_EMAIL_LEN, MAX_NAME_LEN, MembershipRole +from tenants.core.constants import MAX_EMAIL_LEN, MAX_NAME_LEN, MembershipRole _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$") diff --git a/modules/tenants/tenants/core/__init__.py b/modules/tenants/tenants/core/__init__.py new file mode 100644 index 000000000..1804408c4 --- /dev/null +++ b/modules/tenants/tenants/core/__init__.py @@ -0,0 +1,5 @@ +"""What every tenants feature builds on. + +Constants, settings, errors, the app.state container, the tenant service with +its membership, owner-guard and deletion helpers, and deleted-user cleanup. +""" diff --git a/modules/tenants/tenants/_deletion.py b/modules/tenants/tenants/core/_deletion.py similarity index 100% rename from modules/tenants/tenants/_deletion.py rename to modules/tenants/tenants/core/_deletion.py diff --git a/modules/tenants/tenants/_membership_ops.py b/modules/tenants/tenants/core/_membership_ops.py similarity index 95% rename from modules/tenants/tenants/_membership_ops.py rename to modules/tenants/tenants/core/_membership_ops.py index e3c338f5d..47c474d48 100644 --- a/modules/tenants/tenants/_membership_ops.py +++ b/modules/tenants/tenants/core/_membership_ops.py @@ -1,4 +1,4 @@ -"""Membership operations of :class:`tenants.service.TenantService`. +"""Membership operations of :class:`tenants.core.service.TenantService`. Split out of ``service`` by responsibility: tenant lifecycle stays there, the member roster, seats and role changes live here as a mixin. @@ -16,11 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from tenants._owner_guard import demote_owner, remove_owner -from tenants.constants import ENTITLEMENT_SEATS, MembershipRole from tenants.contracts.entitlements import EntitlementProvider, ensure_within_limit from tenants.contracts.events import MembershipAdded, MembershipRemoved -from tenants.errors import TenantError +from tenants.core._owner_guard import demote_owner, remove_owner +from tenants.core.constants import ENTITLEMENT_SEATS, MembershipRole +from tenants.core.errors import TenantError from tenants.models import Membership, Tenant @@ -75,7 +75,7 @@ async def seats_used(self, tenant_id: str, *, members: int | None = None) -> int Pass *members* when the caller already counted every membership of the tenant (``list_members(...).total``) to skip a second count. """ - from tenants.invitations import pending_invitation_count + from tenants.invitations.service import pending_invitation_count if members is None: members = await self.db.scalar( diff --git a/modules/tenants/tenants/_owner_guard.py b/modules/tenants/tenants/core/_owner_guard.py similarity index 98% rename from modules/tenants/tenants/_owner_guard.py rename to modules/tenants/tenants/core/_owner_guard.py index 3e1eca125..7a60d348d 100644 --- a/modules/tenants/tenants/_owner_guard.py +++ b/modules/tenants/tenants/core/_owner_guard.py @@ -21,7 +21,7 @@ from sqlalchemy.orm import aliased from sqlmodel import col -from tenants.constants import MembershipRole +from tenants.core.constants import MembershipRole from tenants.models import Membership diff --git a/modules/tenants/tenants/core/constants.py b/modules/tenants/tenants/core/constants.py new file mode 100644 index 000000000..984f2df0a --- /dev/null +++ b/modules/tenants/tenants/core/constants.py @@ -0,0 +1,85 @@ +"""Tenants module constants.""" + +from __future__ import annotations + +from enum import StrEnum + +# The role vocabulary lives in core so other modules can map ``tenant:`` +# onto their permissions without depending on this module (#380). +from simple_module_core.tenancy import TENANT_ROLE_PREFIX, tenant_role +from simple_module_core.tenancy import TenantRole as MembershipRole + +MODULE_PACKAGE = "tenants" +DISPLAY_NAME = "Tenants" + +# Names of the modules this one depends on (``ModuleMeta.depends_on``). +_MODULE_AUTH = "Auth" +_MODULE_SETTINGS = "Settings" + +# Session key holding the tenant the signed-in user last switched to. Only a +# preference: the resolver re-validates it against a membership every request. +SESSION_ACTIVE_TENANT = "sm_active_tenant" + +# Key: a user id, or ``None`` for every user (which also drops every host lookup). +INVALIDATION_CHANNEL = "tenants.membership" +# Key: a tenant slug, or ``None`` for every slug. Kept apart from the membership +# channel so no user id (an OIDC subject is any string) can be read as a slug. +HOSTS_INVALIDATION_CHANNEL = "tenants.hosts" + +# Entitlement keys this module enforces (see contracts.entitlements). +ENTITLEMENT_SEATS = "tenants.seats" +ERROR_ADMISSION_CONFLICT = "admission_conflict" + +MAX_NAME_LEN = 200 +MAX_SLUG_LEN = 50 +MAX_EMAIL_LEN = 320 +TENANT_ID_LEN = 32 + + +class TenantStatus(StrEnum): + ACTIVE = "active" + SUSPENDED = "suspended" + + +MANAGER_ROLES = frozenset({MembershipRole.OWNER, MembershipRole.ADMIN}) + +# Permissions. ``tenants.*`` within the active tenant; ``tenants.platform.*`` +# spans tenants and is granted to no tenant role — only the platform admin +# (wildcard) or an explicit role grant. +PERM_MEMBERS_VIEW = "tenants.members.view" +PERM_MEMBERS_MANAGE = "tenants.members.manage" +# Owned and mapped by ``settings`` (#382); named here for the page guard and +# the sidebar entry only. +PERM_TENANT_SETTINGS = "settings.tenant.edit" +PERM_PLATFORM_VIEW = "tenants.platform.view" +PERM_PLATFORM_MANAGE = "tenants.platform.manage" + +ROLE_PERMISSIONS: dict[MembershipRole, list[str]] = { + # Tenant settings are ``settings.tenant.edit``, which the settings module + # owns and maps onto owner + admin itself (#382). + MembershipRole.OWNER: [PERM_MEMBERS_VIEW, PERM_MEMBERS_MANAGE], + MembershipRole.ADMIN: [PERM_MEMBERS_VIEW, PERM_MEMBERS_MANAGE], + MembershipRole.MEMBER: [PERM_MEMBERS_VIEW], +} + +PAGE_INDEX = "Tenants/Index" +PAGE_MEMBERS = "Tenants/Members" +PAGE_SETTINGS = "Tenants/Settings" +PAGE_ACCEPT = "Tenants/AcceptInvitation" +PAGE_ADMIN = "Tenants/AdminBrowse" + +__all__ = ["TENANT_ROLE_PREFIX", "MembershipRole", "tenant_role"] + +INVITATION_MAIL_HANDLER = "tenants.send_invitation_email" +# Durable handler name (outbox key); pinned to the original import-path default. +USER_CLEANUP_HANDLER = "tenants.user_cleanup.purge_user_memberships" +INVITATION_MAIL_SUBJECT = "You're invited to join {tenant_name}" +INVITATION_TEMPLATE = "invitations/templates/invitation.txt" +MAIL_URL_SCHEMES = frozenset({"http", "https"}) +MAIL_RELATIVE_WARNING = ( + "Invitation email skipped: configure tenants.public_base_url for absolute links" +) +MAIL_FAILURE_WARNING = "Invitation email failed after commit" +MAIL_NO_TRANSPORT_ERROR = ( + "No mail transport in this process: add Mail to SM_MODULES_ENABLED for the outbox worker" +) diff --git a/modules/tenants/tenants/core/errors.py b/modules/tenants/tenants/core/errors.py new file mode 100644 index 000000000..286d28921 --- /dev/null +++ b/modules/tenants/tenants/core/errors.py @@ -0,0 +1,70 @@ +"""Tenants errors and their HTTP mapping.""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, RedirectResponse, Response +from simple_module_db import MissingTenantError, TenantIsolationError + +from tenants.contracts.entitlements import EntitlementExceededError + +logger = logging.getLogger(__name__) + +_INDEX_URL = "/tenants/" + + +class TenantError(Exception): + """A rejected tenant operation. ``code`` is a stable, translatable key.""" + + def __init__(self, code: str, *, status_code: int = 400) -> None: + super().__init__(code) + self.code = code + self.status_code = status_code + + +def _is_api(request: Request) -> bool: + return request.url.path.startswith("/api/") + + +async def _tenant_error(request: Request, exc: Exception) -> Response: + assert isinstance(exc, TenantError) + return JSONResponse({"detail": exc.code}, status_code=exc.status_code) + + +async def _entitlement_exceeded(request: Request, exc: Exception) -> Response: + assert isinstance(exc, EntitlementExceededError) + return JSONResponse( + {"detail": "plan_limit", "key": exc.key, "limit": exc.limit}, status_code=402 + ) + + +async def _isolation_error(request: Request, exc: Exception) -> Response: + """A tenant isolation rule fired during a request. + + Only a *missing* tenant for a signed-in user is a user state — no + organisation yet, or theirs is suspended — and gets the picker instead of + a 500. Anything else (a cross-tenant write, a tenant_id change) is a bug + or an attack: refused with 403 and logged as an error, never dressed up + as "pick an organisation". + """ + user = getattr(request.state, "user", None) + tenant_id = getattr(request.state, "tenant_id", None) + if not isinstance(exc, MissingTenantError) or tenant_id is not None or user is None: + logger.error( + "Tenant isolation violation on %s %s: %s", + request.method, + request.url.path, + exc, + ) + return JSONResponse({"detail": "tenant_isolation"}, status_code=403) + if _is_api(request) or request.method not in ("GET", "HEAD"): + return JSONResponse({"detail": "tenant_required"}, status_code=403) + return RedirectResponse(f"{_INDEX_URL}?reason=tenant_required", status_code=303) + + +def install_exception_handlers(app: FastAPI) -> None: + app.add_exception_handler(TenantError, _tenant_error) + app.add_exception_handler(EntitlementExceededError, _entitlement_exceeded) + app.add_exception_handler(TenantIsolationError, _isolation_error) diff --git a/modules/tenants/tenants/core/service.py b/modules/tenants/tenants/core/service.py new file mode 100644 index 000000000..1606bcf1c --- /dev/null +++ b/modules/tenants/tenants/core/service.py @@ -0,0 +1,220 @@ +"""Tenant and membership business logic. + +Membership operations filter explicitly on ``tenant_id``: these registry +tables are not ``MultiTenantMixin`` (see ``models``), so scoping is done here. + +Side effects (domain events, cache invalidation) run *after commit* through +``_after_commit``: a billing handler must never create a customer for a tenant +whose transaction rolled back. +""" + +from __future__ import annotations + +import logging +import re +import secrets +from collections.abc import Awaitable, Callable, Sequence +from typing import Any + +from simple_module_core.events import Event, EventBus +from simple_module_core.invalidation import InvalidationBus +from simple_module_core.tenant_events import TENANT_DELETED_CHANNEL +from simple_module_db import PLATFORM_TENANT_ID +from simple_module_db.admission import serialize_sqlite_writes +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from tenants.contracts.entitlements import ( + EntitlementProvider, + UnlimitedEntitlements, +) +from tenants.contracts.events import ( + MembershipAdded, + TenantCreated, + TenantDeleted, + TenantStatusChanged, +) +from tenants.contracts.schemas import TenantCreate +from tenants.core._deletion import delete_tenant_rows +from tenants.core._membership_ops import MembershipOpsMixin +from tenants.core.constants import ( + ERROR_ADMISSION_CONFLICT, + MAX_SLUG_LEN, + MembershipRole, + TenantStatus, +) +from tenants.core.errors import TenantError +from tenants.models import Membership, Tenant + +logger = logging.getLogger(__name__) + +Invalidate = Callable[[str | None], Awaitable[None]] + + +async def _no_invalidate(_key: str | None) -> None: + return None + + +def slugify(name: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug[:MAX_SLUG_LEN].strip("-") or "org" + + +class TenantService(MembershipOpsMixin): + def __init__( + self, + db: AsyncSession, + *, + bus: EventBus | None = None, + invalidate: Invalidate = _no_invalidate, + invalidate_hosts: Invalidate = _no_invalidate, + entitlements: EntitlementProvider | None = None, + invalidation_bus: InvalidationBus | None = None, + ) -> None: + self.db = db + self.bus = bus + self.invalidate = invalidate # membership channel: a user id, or None + self.invalidate_hosts = invalidate_hosts # hosts channel: a slug, or None + self.invalidation_bus = invalidation_bus + self.entitlements = entitlements or UnlimitedEntitlements() + self.pending: list[Callable[[], Awaitable[Any]]] = [] + + def _after_commit( + self, + *events: Event, + invalidate: Sequence[str | None] = (), + invalidate_hosts: Sequence[str | None] = (), + callback: Callable[[], Awaitable[None]] | None = None, + ) -> None: + async def run() -> None: + for key in invalidate: + await self.invalidate(key) + for slug in invalidate_hosts: + await self.invalidate_hosts(slug) + if self.bus is not None: + for event in events: + await self.bus.publish(event) + if callback is not None: # last: may wait on an SMTP server + await callback() + + on_commit = getattr(self.db, "on_commit", None) + if on_commit is not None: + on_commit(run) + else: # plain AsyncSession (scripts, tests): caller drains ``pending`` + self.pending.append(run) + + # ── tenants ───────────────────────────────────────────────── + + async def get(self, tenant_id: str) -> Tenant | None: + return await self.db.get(Tenant, tenant_id) + + async def _free_slug(self, wanted: str) -> str: + slug = wanted + # The reserved platform owner is never handed out, even as a slug. + while slug == PLATFORM_TENANT_ID or await self.db.scalar( + select(Tenant.id).where(Tenant.slug == slug) + ): + suffix = secrets.token_hex(2) + slug = f"{wanted[: MAX_SLUG_LEN - len(suffix) - 1]}-{suffix}" + return slug + + async def create_tenant( + self, data: TenantCreate, *, owner_user_id: str, owner_email: str | None = None + ) -> Tenant: + if data.slug: + if await self.db.scalar(select(Tenant.id).where(Tenant.slug == data.slug)): + raise TenantError("slug_taken", status_code=409) + slug = data.slug + else: + slug = await self._free_slug(slugify(data.name)) + tenant = Tenant(name=data.name, slug=slug) + self.db.add(tenant) + try: + # A concurrent create can claim the slug between the check above + # and this insert: a 409, not a 500. The request fails as a whole, + # so its transaction is rolled back — no savepoint needed. + await self.db.flush() + except IntegrityError as exc: + raise TenantError("slug_taken", status_code=409) from exc + owner = Membership( + tenant_id=tenant.id, user_id=owner_user_id, role=MembershipRole.OWNER, email=owner_email + ) + self.db.add(owner) + await self.db.flush() + self._after_commit( + TenantCreated(tenant.id, tenant.slug, tenant.name, owner_user_id), + MembershipAdded(tenant.id, owner_user_id, MembershipRole.OWNER), + invalidate=[owner_user_id], + invalidate_hosts=[tenant.slug], + ) + return tenant + + async def set_status(self, tenant_id: str, status: TenantStatus) -> Tenant: + """Suspend or reactivate — the call a billing module makes on dunning.""" + tenant = await self._require(tenant_id) + previous = tenant.status + if previous != status: + tenant.status = status + await self.db.flush() + # Every member's cached view of this tenant is now wrong. + self._after_commit(TenantStatusChanged(tenant_id, status, previous), invalidate=[None]) + return tenant + + async def delete_tenant(self, tenant_id: str) -> None: + tenant = await self.lock(tenant_id) + events = await delete_tenant_rows(self.db, tenant) + deleted = next(event for event in events if isinstance(event, TenantDeleted)) + if self.bus is not None: + await self.bus.publish_durable_or_inline(deleted, self.db) + else: + logger.warning( + "Tenant-owned data cleanup was skipped tenant=%s: no event bus", tenant_id + ) + + async def invalidate_deleted() -> None: + if self.invalidation_bus is not None: + await self.invalidation_bus.publish(TENANT_DELETED_CHANNEL, key=tenant_id) + + self._after_commit(*events, invalidate=[None], callback=invalidate_deleted) + + async def list_all(self, *, search: str = "", limit: int = 50, offset: int = 0) -> list[Tenant]: + stmt = select(Tenant).order_by(Tenant.created_at.desc()).limit(limit).offset(offset) + if search: + like = f"%{search.lower()}%" + stmt = stmt.where(func.lower(Tenant.name).like(like) | Tenant.slug.like(like)) + return list((await self.db.execute(stmt)).scalars().all()) + + async def member_counts(self, tenant_ids: Sequence[str]) -> dict[str, int]: + if not tenant_ids: + return {} + stmt = ( + select(Membership.tenant_id, func.count()) + .where(Membership.tenant_id.in_(tenant_ids)) + .group_by(Membership.tenant_id) + ) + return dict((await self.db.execute(stmt)).tuples().all()) + + async def lock(self, tenant_id: str) -> Tenant: + """Row-lock the tenant for the rest of the transaction. + + Serialises check-then-act rules per tenant. SQLite takes a real writer + lock before reading; FOR UPDATE alone compiles away on SQLite. + """ + await serialize_sqlite_writes( + self.db, + Tenant.__table__, + on_busy=TenantError(ERROR_ADMISSION_CONFLICT, status_code=409), + ) + stmt = select(Tenant).where(col(Tenant.id) == tenant_id).with_for_update() + tenant = (await self.db.execute(stmt)).scalar_one_or_none() + if tenant is None: + raise TenantError("not_found", status_code=404) + return tenant + + async def _require(self, tenant_id: str) -> Tenant: + tenant = await self.get(tenant_id) + if tenant is None: + raise TenantError("not_found", status_code=404) + return tenant diff --git a/modules/tenants/tenants/services.py b/modules/tenants/tenants/core/services.py similarity index 93% rename from modules/tenants/tenants/services.py rename to modules/tenants/tenants/core/services.py index 42ca76d43..9567a37ae 100644 --- a/modules/tenants/tenants/services.py +++ b/modules/tenants/tenants/core/services.py @@ -6,7 +6,7 @@ from tenants.contracts.entitlements import EntitlementProvider, UnlimitedEntitlements from tenants.contracts.usage import UnlimitedUsageMeter, UsageMeter -from tenants.settings import TenantsSettings +from tenants.core.settings import TenantsSettings @dataclass diff --git a/modules/tenants/tenants/settings.py b/modules/tenants/tenants/core/settings.py similarity index 100% rename from modules/tenants/tenants/settings.py rename to modules/tenants/tenants/core/settings.py diff --git a/modules/tenants/tenants/user_cleanup.py b/modules/tenants/tenants/core/user_cleanup.py similarity index 92% rename from modules/tenants/tenants/user_cleanup.py rename to modules/tenants/tenants/core/user_cleanup.py index 33bedf285..3650f14b8 100644 --- a/modules/tenants/tenants/user_cleanup.py +++ b/modules/tenants/tenants/core/user_cleanup.py @@ -8,9 +8,9 @@ from simple_module_db.callbacks import register_on_commit from sqlmodel import col, delete, select -from tenants.constants import MembershipRole, TenantStatus +from tenants.core.constants import MembershipRole, TenantStatus from tenants.models import Membership, Tenant -from tenants.resolver import forget +from tenants.resolution.resolver import forget if TYPE_CHECKING: from simple_module_core.user_events import UserDeleted diff --git a/modules/tenants/tenants/deps.py b/modules/tenants/tenants/deps.py index 83b53ac1a..9f21e62ce 100644 --- a/modules/tenants/tenants/deps.py +++ b/modules/tenants/tenants/deps.py @@ -1,89 +1,11 @@ -"""FastAPI dependencies for the Tenants module.""" +"""Moved to :mod:`tenants.web.deps`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from dataclasses import dataclass -from typing import Annotated +import importlib +import sys -from fastapi import Depends, Request -from simple_module_db.deps import get_db -from sqlalchemy.ext.asyncio import AsyncSession +from simple_module_core.deprecation import deprecated_module -from tenants.constants import MANAGER_ROLES -from tenants.errors import TenantError -from tenants.host_resolver import make_host_invalidator -from tenants.invitations import InvitationService -from tenants.resolver import make_invalidator -from tenants.service import TenantService - - -async def get_tenant_service(request: Request, db: AsyncSession = Depends(get_db)) -> TenantService: - app = request.app - return TenantService( - db, - bus=app.state.sm.event_bus, - invalidate=make_invalidator(app), - invalidate_hosts=make_host_invalidator(app), - invalidation_bus=app.state.sm.invalidation, - entitlements=app.state.tenants.entitlements, - ) - - -TenantServiceDep = Annotated[TenantService, Depends(get_tenant_service)] - - -async def get_invitation_service(request: Request, tenants: TenantServiceDep) -> InvitationService: - ttl = request.app.state.tenants.settings.invitation_ttl_hours - return InvitationService(tenants, ttl_hours=ttl) - - -InvitationServiceDep = Annotated[InvitationService, Depends(get_invitation_service)] - - -@dataclass(frozen=True) -class ActiveTenantContext: - tenant_id: str - role: str - user_id: str - - -def _current_user_id(request: Request) -> str: - user = getattr(request.state, "user", None) - if user is None: - raise TenantError("not_authenticated", status_code=401) - return str(user.id) - - -async def require_user_id(request: Request) -> str: - return _current_user_id(request) - - -async def require_active_tenant(request: Request) -> ActiveTenantContext: - """The tenant resolved for this request. Operations act on it — never on an - id from the URL — so a tenant-level permission cannot reach another tenant.""" - user_id = _current_user_id(request) - tenant_id = getattr(request.state, "tenant_id", None) - role = getattr(request.state, "tenant_role", None) - if tenant_id is None or role is None: - raise TenantError("tenant_required", status_code=403) - return ActiveTenantContext(tenant_id=tenant_id, role=role, user_id=user_id) - - -async def require_tenant_manager( - ctx: Annotated[ActiveTenantContext, Depends(require_active_tenant)], -) -> ActiveTenantContext: - """An owner or admin *of the active tenant*. - - Checked on top of the permission gate: a platform-wide grant of - ``tenants.members.manage`` (the wildcard admin, say) must not turn a plain - member of this tenant into its manager. Platform staff act through - ``/admin/tenants`` instead. - """ - if ctx.role not in MANAGER_ROLES: - raise TenantError("tenant_manager_required", status_code=403) - return ctx - - -UserIdDep = Annotated[str, Depends(require_user_id)] -ActiveTenantDep = Annotated[ActiveTenantContext, Depends(require_active_tenant)] -ManagerDep = Annotated[ActiveTenantContext, Depends(require_tenant_manager)] +deprecated_module("tenants.deps", remove_in="0.0.46", use="tenants.web.deps") +sys.modules[__name__] = importlib.import_module("tenants.web.deps") diff --git a/modules/tenants/tenants/errors.py b/modules/tenants/tenants/errors.py index 286d28921..b75050fb4 100644 --- a/modules/tenants/tenants/errors.py +++ b/modules/tenants/tenants/errors.py @@ -1,70 +1,11 @@ -"""Tenants errors and their HTTP mapping.""" +"""Moved to :mod:`tenants.core.errors`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging +import importlib +import sys -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, RedirectResponse, Response -from simple_module_db import MissingTenantError, TenantIsolationError +from simple_module_core.deprecation import deprecated_module -from tenants.contracts.entitlements import EntitlementExceededError - -logger = logging.getLogger(__name__) - -_INDEX_URL = "/tenants/" - - -class TenantError(Exception): - """A rejected tenant operation. ``code`` is a stable, translatable key.""" - - def __init__(self, code: str, *, status_code: int = 400) -> None: - super().__init__(code) - self.code = code - self.status_code = status_code - - -def _is_api(request: Request) -> bool: - return request.url.path.startswith("/api/") - - -async def _tenant_error(request: Request, exc: Exception) -> Response: - assert isinstance(exc, TenantError) - return JSONResponse({"detail": exc.code}, status_code=exc.status_code) - - -async def _entitlement_exceeded(request: Request, exc: Exception) -> Response: - assert isinstance(exc, EntitlementExceededError) - return JSONResponse( - {"detail": "plan_limit", "key": exc.key, "limit": exc.limit}, status_code=402 - ) - - -async def _isolation_error(request: Request, exc: Exception) -> Response: - """A tenant isolation rule fired during a request. - - Only a *missing* tenant for a signed-in user is a user state — no - organisation yet, or theirs is suspended — and gets the picker instead of - a 500. Anything else (a cross-tenant write, a tenant_id change) is a bug - or an attack: refused with 403 and logged as an error, never dressed up - as "pick an organisation". - """ - user = getattr(request.state, "user", None) - tenant_id = getattr(request.state, "tenant_id", None) - if not isinstance(exc, MissingTenantError) or tenant_id is not None or user is None: - logger.error( - "Tenant isolation violation on %s %s: %s", - request.method, - request.url.path, - exc, - ) - return JSONResponse({"detail": "tenant_isolation"}, status_code=403) - if _is_api(request) or request.method not in ("GET", "HEAD"): - return JSONResponse({"detail": "tenant_required"}, status_code=403) - return RedirectResponse(f"{_INDEX_URL}?reason=tenant_required", status_code=303) - - -def install_exception_handlers(app: FastAPI) -> None: - app.add_exception_handler(TenantError, _tenant_error) - app.add_exception_handler(EntitlementExceededError, _entitlement_exceeded) - app.add_exception_handler(TenantIsolationError, _isolation_error) +deprecated_module("tenants.errors", remove_in="0.0.46", use="tenants.core.errors") +sys.modules[__name__] = importlib.import_module("tenants.core.errors") diff --git a/modules/tenants/tenants/invitations/__init__.py b/modules/tenants/tenants/invitations/__init__.py new file mode 100644 index 000000000..8fbf2ce6d --- /dev/null +++ b/modules/tenants/tenants/invitations/__init__.py @@ -0,0 +1,4 @@ +"""Inviting people into an organisation. + +The invitation service and the invitation mail with its template. +""" diff --git a/modules/tenants/tenants/invitation_mail.py b/modules/tenants/tenants/invitations/mail.py similarity index 98% rename from modules/tenants/tenants/invitation_mail.py rename to modules/tenants/tenants/invitations/mail.py index 7b4546b4b..db5607798 100644 --- a/modules/tenants/tenants/invitation_mail.py +++ b/modules/tenants/tenants/invitations/mail.py @@ -16,8 +16,8 @@ resolve_mail_sender, ) -from tenants import constants as c from tenants.contracts.events import InvitationCreated +from tenants.core import constants as c logger = logging.getLogger(__name__) diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations/service.py similarity index 96% rename from modules/tenants/tenants/invitations.py rename to modules/tenants/tenants/invitations/service.py index d9b1e0083..aaa4c0079 100644 --- a/modules/tenants/tenants/invitations.py +++ b/modules/tenants/tenants/invitations/service.py @@ -18,13 +18,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from tenants.constants import MembershipRole, TenantStatus from tenants.contracts.events import InvitationCreated from tenants.contracts.schemas import InvitationCreate -from tenants.errors import TenantError -from tenants.invitation_mail import invitation_mail_wanted, send_invitation_after_commit +from tenants.core.constants import MembershipRole, TenantStatus +from tenants.core.errors import TenantError +from tenants.core.service import TenantService +from tenants.invitations.mail import invitation_mail_wanted, send_invitation_after_commit from tenants.models import Invitation, Tenant -from tenants.service import TenantService ACCEPT_PATH = "/tenants/invitations/accept" diff --git a/modules/tenants/tenants/templates/invitation.txt b/modules/tenants/tenants/invitations/templates/invitation.txt similarity index 100% rename from modules/tenants/tenants/templates/invitation.txt rename to modules/tenants/tenants/invitations/templates/invitation.txt diff --git a/modules/tenants/tenants/models.py b/modules/tenants/tenants/models.py index 6c2fe2484..ddf94efa0 100644 --- a/modules/tenants/tenants/models.py +++ b/modules/tenants/tenants/models.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import validates from sqlmodel import Field -from tenants.constants import ( +from tenants.core.constants import ( MAX_EMAIL_LEN, MAX_NAME_LEN, MAX_SLUG_LEN, diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py index 1c303913a..548ba8c24 100644 --- a/modules/tenants/tenants/module.py +++ b/modules/tenants/tenants/module.py @@ -18,7 +18,7 @@ from simple_module_core.module import ModuleBase, ModuleMeta from simple_module_core.module_settings import register_module_settings -from tenants import constants as c +from tenants.core import constants as c if TYPE_CHECKING: from collections.abc import Collection @@ -81,7 +81,7 @@ def _settings_available(self) -> bool: def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: from tenants.contracts.events import InvitationCreated - from tenants.invitation_mail import send_invitation_email + from tenants.invitations.mail import send_invitation_email bus.subscribe_durable( InvitationCreated, send_invitation_email, name=c.INVITATION_MAIL_HANDLER @@ -89,17 +89,17 @@ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> from simple_module_core.user_events import UserDeleted - from tenants.user_cleanup import purge_user_memberships + from tenants.core.user_cleanup import purge_user_memberships bus.subscribe_durable(UserDeleted, purge_user_memberships, name=c.USER_CLEANUP_HANDLER) def register_settings(self, app: FastAPI) -> None: from simple_module_hosting.shared_props import register_inertia_shared_provider - from tenants.resolver import resolve_tenant - from tenants.services import TenantsServices - from tenants.settings import TenantsSettings - from tenants.shared_props import tenant_shared_props + from tenants.core.services import TenantsServices + from tenants.core.settings import TenantsSettings + from tenants.resolution.resolver import resolve_tenant + from tenants.web.shared_props import tenant_shared_props self._app = app register_module_settings( @@ -114,22 +114,22 @@ def register_settings(self, app: FastAPI) -> None: register_inertia_shared_provider(app, tenant_shared_props) def register_exception_handlers(self, app: FastAPI) -> None: - from tenants.errors import install_exception_handlers + from tenants.core.errors import install_exception_handlers install_exception_handlers(app) def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None: - from tenants.host_resolver import subscribe_hosts - from tenants.resolver import subscribe + from tenants.resolution.host_resolver import subscribe_hosts + from tenants.resolution.resolver import subscribe subscribe(bus) subscribe_hosts(bus) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from tenants.endpoints.admin import api as admin_api - from tenants.endpoints.api import router as api - from tenants.endpoints.views import router as views - from tenants.endpoints.views import settings_router + from tenants.web.endpoints.admin import api as admin_api + from tenants.web.endpoints.api import router as api + from tenants.web.endpoints.views import router as views + from tenants.web.endpoints.views import settings_router # Admin first: "/admin" must not be captured by "/{tenant_id}/…". api_router.include_router(admin_api) @@ -139,7 +139,7 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None view_router.include_router(settings_router) def register_admin_routes(self, admin_router: APIRouter) -> None: - from tenants.endpoints.admin import views as admin_views + from tenants.web.endpoints.admin import views as admin_views admin_router.include_router(admin_views) diff --git a/modules/tenants/tenants/pages/AcceptInvitation.tsx b/modules/tenants/tenants/pages/AcceptInvitation.tsx index 6b682f9e8..2c68ec3f4 100644 --- a/modules/tenants/tenants/pages/AcceptInvitation.tsx +++ b/modules/tenants/tenants/pages/AcceptInvitation.tsx @@ -5,7 +5,7 @@ import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; import { apiResult } from '@simple-module-py/ui/lib/api'; import { TimerOff } from 'lucide-react'; import { useState } from 'react'; -import { useTenantErrors } from '../hooks/useTenantErrors'; +import { useTenantErrors } from '../ui/hooks/useTenantErrors'; import { api } from './api'; interface InvitationPreview { diff --git a/modules/tenants/tenants/pages/AdminBrowse.tsx b/modules/tenants/tenants/pages/AdminBrowse.tsx index 8b1fae552..cd76ce32b 100644 --- a/modules/tenants/tenants/pages/AdminBrowse.tsx +++ b/modules/tenants/tenants/pages/AdminBrowse.tsx @@ -8,7 +8,7 @@ import { Input } from '@simple-module-py/ui/components/ui/input'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import { Building2, Search } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; -import { type AdminTenant, AdminTenantsTable } from '../components/AdminTenantsTable'; +import { type AdminTenant, AdminTenantsTable } from '../ui/components/AdminTenantsTable'; interface Props { tenants: AdminTenant[]; diff --git a/modules/tenants/tenants/pages/Index.tsx b/modules/tenants/tenants/pages/Index.tsx index b7fe756c7..fa0f2ed8c 100644 --- a/modules/tenants/tenants/pages/Index.tsx +++ b/modules/tenants/tenants/pages/Index.tsx @@ -4,8 +4,8 @@ import { EmptyState } from '@simple-module-py/ui/components/EmptyState'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; import { Building2 } from 'lucide-react'; -import { CreateOrgForm } from '../components/CreateOrgForm'; -import { type Membership, MembershipList } from '../components/MembershipList'; +import { CreateOrgForm } from '../ui/components/CreateOrgForm'; +import { type Membership, MembershipList } from '../ui/components/MembershipList'; interface Props { memberships: Membership[]; diff --git a/modules/tenants/tenants/pages/Members.tsx b/modules/tenants/tenants/pages/Members.tsx index b9891d554..8e9010c09 100644 --- a/modules/tenants/tenants/pages/Members.tsx +++ b/modules/tenants/tenants/pages/Members.tsx @@ -4,9 +4,9 @@ import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { PaginationBar } from '@simple-module-py/ui/components/PaginationBar'; import { Card } from '@simple-module-py/ui/components/ui/card'; import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; -import { type Invitation, InvitationsList } from '../components/InvitationsList'; -import { InviteForm } from '../components/InviteForm'; -import { type Member, type MembershipRole, MembersTable } from '../components/MembersTable'; +import { type Invitation, InvitationsList } from '../ui/components/InvitationsList'; +import { InviteForm } from '../ui/components/InviteForm'; +import { type Member, type MembershipRole, MembersTable } from '../ui/components/MembersTable'; interface Tenant { id: string; diff --git a/modules/tenants/tenants/pages/Settings.tsx b/modules/tenants/tenants/pages/Settings.tsx index 6a7b12af2..f41635870 100644 --- a/modules/tenants/tenants/pages/Settings.tsx +++ b/modules/tenants/tenants/pages/Settings.tsx @@ -5,7 +5,7 @@ import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { Card } from '@simple-module-py/ui/components/ui/card'; import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; import { Settings2 } from 'lucide-react'; -import { type TenantSetting, TenantSettingRow } from '../components/TenantSettingRow'; +import { type TenantSetting, TenantSettingRow } from '../ui/components/TenantSettingRow'; interface Props { tenant: { id: string; name: string; slug: string }; diff --git a/modules/tenants/tenants/resolution/__init__.py b/modules/tenants/tenants/resolution/__init__.py new file mode 100644 index 000000000..ca78d3d5d --- /dev/null +++ b/modules/tenants/tenants/resolution/__init__.py @@ -0,0 +1,4 @@ +"""Which tenant a request acts for. + +Session and header resolution, subdomain hosts and API-token tenants. +""" diff --git a/modules/tenants/tenants/host_resolver.py b/modules/tenants/tenants/resolution/host_resolver.py similarity index 98% rename from modules/tenants/tenants/host_resolver.py rename to modules/tenants/tenants/resolution/host_resolver.py index a54404114..287264b61 100644 --- a/modules/tenants/tenants/host_resolver.py +++ b/modules/tenants/tenants/resolution/host_resolver.py @@ -30,7 +30,7 @@ from sqlalchemy import select from starlette.requests import Request -from tenants.constants import HOSTS_INVALIDATION_CHANNEL, TenantStatus +from tenants.core.constants import HOSTS_INVALIDATION_CHANNEL, TenantStatus from tenants.models import Tenant HOST_TTL_SECONDS = 60 diff --git a/modules/tenants/tenants/resolution/resolver.py b/modules/tenants/tenants/resolution/resolver.py new file mode 100644 index 000000000..f1d4da5d8 --- /dev/null +++ b/modules/tenants/tenants/resolution/resolver.py @@ -0,0 +1,276 @@ +"""Tenant resolution: which tenant does this request act for? + +Registered as ``app.state.tenant_resolver`` and called by the framework's +``TenantMiddleware``. The session only stores a *preference* +(``SESSION_ACTIVE_TENANT``); every request re-validates it against the user's +memberships, so removing a member or suspending a tenant takes effect on the +next request after the cache entry drops (immediately in-process, via +``InvalidationBus`` across workers, ``MEMBERSHIP_TTL_SECONDS`` at worst). + +The principal gains ``tenant:`` for the active tenant only; that is how +membership roles reach the permission registry without ever touching the +platform-wide roles. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any +from weakref import WeakKeyDictionary + +from auth.contracts.tokens import AUTH_KIND_PAT, AUTH_KIND_SERVICE, AUTH_KIND_SESSION +from cachetools import TTLCache +from fastapi import FastAPI +from simple_module_core.app_state import get_services +from simple_module_core.invalidation import Invalidation, InvalidationBus +from simple_module_core.singleflight import MISS, SingleFlight, fill +from simple_module_core.tenancy import is_tenant_role +from simple_module_db import is_valid_tenant_id +from simple_module_hosting.middleware import TenantResolution +from starlette.requests import Request + +from tenants.contracts.schemas import MyTenantView +from tenants.core.constants import ( + INVALIDATION_CHANNEL, + SESSION_ACTIVE_TENANT, + TENANT_ROLE_PREFIX, + TenantStatus, +) +from tenants.core.service import TenantService +from tenants.resolution.host_resolver import ( + forget_hosts, + is_public_route, + resolve_from_host, + subdomain_slug, + subdomains_enabled, +) +from tenants.resolution.token_tenant import pinned_token_tenant + +logger = logging.getLogger(__name__) + +MEMBERSHIP_TTL_SECONDS = 60 + +_MEMBERSHIPS: TTLCache[str, list[MyTenantView]] = TTLCache( + maxsize=10_000, ttl=MEMBERSHIP_TTL_SECONDS +) + + +_FLIGHTS: WeakKeyDictionary[FastAPI, SingleFlight] = WeakKeyDictionary() + + +# Bumped by every invalidation. A read that started before an invalidation +# must not store its (possibly stale) result afterwards: otherwise a member +# removed while their own request was mid-read is re-cached as a member for a +# whole TTL. +_epoch = 0 + + +def forget(key: str | None) -> None: + """Drop one user's memberships, or every user's (and every host lookup) on ``None``. + + Keys on this channel are always user ids; slug evictions travel on + ``HOSTS_INVALIDATION_CHANNEL`` (see ``host_resolver``). + """ + global _epoch + _epoch += 1 + if key is None: + _MEMBERSHIPS.clear() + forget_hosts() # a status change: cached subdomain lookups are stale too + else: + _MEMBERSHIPS.pop(key, None) + + +def _apply_invalidation(inv: Invalidation) -> None: + forget(inv.key) + + +def subscribe(bus: InvalidationBus) -> None: + bus.subscribe(INVALIDATION_CHANNEL, _apply_invalidation) + + +def make_invalidator(app: FastAPI): + async def invalidate(key: str | None) -> None: + forget(key) # this worker, now — the bus reaches the others + bus = getattr(get_services(app), "invalidation", None) + if bus is not None: + await bus.publish(INVALIDATION_CHANNEL, key=key) + + return invalidate + + +def cached_memberships(user_id: str) -> list[MyTenantView] | None: + return _MEMBERSHIPS.get(user_id) + + +async def memberships_for(app: FastAPI, user_id: str) -> list[MyTenantView]: + cached = _MEMBERSHIPS.get(user_id) + if cached is not None: + return cached + if (flight := _FLIGHTS.get(app)) is None: + flight = _FLIGHTS[app] = SingleFlight() + async with flight.hold(user_id): + + def store(views: list[MyTenantView]) -> None: + _MEMBERSHIPS[user_id] = views + + return await fill( + lambda: _read_memberships(app, user_id), + epoch=lambda: _epoch, + store=store, + cached=lambda: _MEMBERSHIPS.get(user_id, MISS), + ) + + +async def _read_memberships(app: FastAPI, user_id: str) -> list[MyTenantView]: + async with app.state.sm.db.session_factory() as db: + rows = await TenantService(db).list_for_user(user_id) + return [ + MyTenantView( + id=t.id, slug=t.slug, name=t.name, status=t.status, created_at=t.created_at, role=role + ) + for t, role in rows + ] + + +def pick_active(memberships: list[MyTenantView], preferred: str | None) -> MyTenantView | None: + """The preferred tenant if it is an active membership, else the first active one.""" + usable = [m for m in memberships if m.status == TenantStatus.ACTIVE] + for m in usable: + if m.id == preferred: + return m + return usable[0] if usable else None + + +def _with_tenant_role(user: Any, tenant_id: str, role: str) -> Any: + if not dataclasses.is_dataclass(user) or isinstance(user, type): + return user + roles = [r for r in getattr(user, "roles", []) if not is_tenant_role(r)] + changes: dict[str, Any] = {"roles": [*roles, f"{TENANT_ROLE_PREFIX}{role}"]} + if "tenant_id" in {f.name for f in dataclasses.fields(user)}: + changes["tenant_id"] = tenant_id + return dataclasses.replace(user, **changes) + + +async def resolve_tenant(request: Request) -> TenantResolution: + """``TenantResolver`` for the framework's ``TenantMiddleware``. + + Reports the source (``subdomain`` / ``header`` / ``session``) and the headers + the answer depended on, so the middleware can set ``Vary``. + """ + tenant_id, source, vary = await _resolve(request) + return TenantResolution(tenant_id, source if tenant_id is not None else None, vary) + + +async def _resolve(request: Request) -> tuple[str | None, str | None, tuple[str, ...]]: + request.state.tenant_role = None + request.state.tenant_suspended = False + request.state.suspended_tenant_name = None + user = getattr(request.state, "user", None) + # A signed-in answer depended on the credential (membership is checked on + # every branch), so a shared cache must key on it too; an anonymous one did + # not, and anonymous public pages stay cacheable (#418). + credential: tuple[str, ...] = ("Cookie",) if user is not None else () + kind = getattr(user, "auth_kind", AUTH_KIND_SESSION) + if kind != AUTH_KIND_SESSION: + # A bearer credential is carried by the Authorization header, not a cookie. + credential = ("Authorization",) + if kind in (AUTH_KIND_PAT, AUTH_KIND_SERVICE): + # An API token is pinned to the tenant it was created in: neither a + # header, a subdomain nor a session preference may move it elsewhere. + return await pinned_token_tenant(request, user), "token", credential + slug = subdomain_slug(request) + if slug is not None: + tenant_id = await _resolve_subdomain(request, user, slug) + return tenant_id, "subdomain", ("Host", *credential) + # No slug in the host is still an answer that depended on the host. + vary: tuple[str, ...] = ("Host",) if subdomains_enabled(request) else () + if user is None: + return None, None, vary + user_id = str(user.id) + memberships = await memberships_for(request.app, user_id) + + header_name = _header_name(request) + if header_name: + vary = (*vary, header_name) + vary = (*vary, *credential) + requested = request.headers.get(header_name) if header_name else None + if requested is not None: + # An explicit per-request choice (API clients). Never fall back to + # another tenant: a client that asked for X — or sent junk — must not + # act on Y. + if not is_valid_tenant_id(requested): + return None, None, vary + active = next( + (m for m in memberships if m.id == requested and m.status == TenantStatus.ACTIVE), + None, + ) + if active is None: + return None, None, vary + return _enter(request, user, active), "header", vary + + # From here the answer also depends on the stored preference in the + # session cookie, which ``vary`` already names. + session = request.scope.get("session") + preferred = session.get(SESSION_ACTIVE_TENANT) if session is not None else None + active = pick_active(memberships, preferred) + chosen = next((m for m in memberships if m.id == preferred), None) + chosen_suspended = chosen is not None and chosen.status == TenantStatus.SUSPENDED + if chosen_suspended: + # Falling back to another membership must not be silent: say which + # organisation was suspended, and keep the choice in the session so + # the notice stays until the user switches deliberately. + request.state.tenant_suspended = True + request.state.suspended_tenant_name = chosen.name + + if active is None: + request.state.tenant_suspended = any( + m.status == TenantStatus.SUSPENDED for m in memberships + ) + return None, None, vary + if session is not None and preferred != active.id and not chosen_suspended: + session[SESSION_ACTIVE_TENANT] = active.id + return _enter(request, user, active), "session", vary + + +async def _resolve_subdomain(request: Request, user: Any, slug: str) -> str | None: + tenant_id, active = await resolve_from_host(request, slug) + if not active or tenant_id is None: + return None + if user is not None: + memberships = await memberships_for(request.app, str(user.id)) + own = next((m for m in memberships if m.id == tenant_id), None) + if own is not None: + return _enter(request, user, own) + if not is_public_route(request): + return None + return tenant_id + + +def _header_name(request: Request) -> str: + settings = getattr(get_services(request.app), "settings", None) + return getattr(settings, "tenant_header", "") or "" + + +def _enter(request: Request, user: Any, active: MyTenantView) -> str: + request.state.tenant_role = active.role + request.state.user = _with_tenant_role(user, active.id, active.role) + return active.id + + +def switch_active(request: Request, tenant_id: str) -> None: + """Record the user's choice; ``resolve_tenant`` validates it next request.""" + request.session[SESSION_ACTIVE_TENANT] = tenant_id + + +__all__ = [ + "MEMBERSHIP_TTL_SECONDS", + "cached_memberships", + "forget", + "make_invalidator", + "memberships_for", + "pick_active", + "resolve_tenant", + "subscribe", + "switch_active", +] diff --git a/modules/tenants/tenants/token_tenant.py b/modules/tenants/tenants/resolution/token_tenant.py similarity index 95% rename from modules/tenants/tenants/token_tenant.py rename to modules/tenants/tenants/resolution/token_tenant.py index f51c7918c..629c227d8 100644 --- a/modules/tenants/tenants/token_tenant.py +++ b/modules/tenants/tenants/resolution/token_tenant.py @@ -22,7 +22,7 @@ from simple_module_db.listeners import current_user_id from starlette.requests import Request -from tenants.constants import TenantStatus +from tenants.core.constants import TenantStatus async def pinned_token_tenant(request: Request, user: Any) -> str | None: @@ -48,7 +48,7 @@ async def _service_tenant(request: Request, tenant_id: Any) -> str | None: async def _personal_tenant(request: Request, user: Any, tenant_id: Any) -> str | None: - from tenants.resolver import _enter, memberships_for + from tenants.resolution.resolver import _enter, memberships_for own = None if is_valid_tenant_id(tenant_id): diff --git a/modules/tenants/tenants/resolver.py b/modules/tenants/tenants/resolver.py index 06f9083b5..cc50f213e 100644 --- a/modules/tenants/tenants/resolver.py +++ b/modules/tenants/tenants/resolver.py @@ -1,276 +1,11 @@ -"""Tenant resolution: which tenant does this request act for? - -Registered as ``app.state.tenant_resolver`` and called by the framework's -``TenantMiddleware``. The session only stores a *preference* -(``SESSION_ACTIVE_TENANT``); every request re-validates it against the user's -memberships, so removing a member or suspending a tenant takes effect on the -next request after the cache entry drops (immediately in-process, via -``InvalidationBus`` across workers, ``MEMBERSHIP_TTL_SECONDS`` at worst). - -The principal gains ``tenant:`` for the active tenant only; that is how -membership roles reach the permission registry without ever touching the -platform-wide roles. -""" +"""Moved to :mod:`tenants.resolution.resolver`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import dataclasses -import logging -from typing import Any -from weakref import WeakKeyDictionary - -from auth.contracts.tokens import AUTH_KIND_PAT, AUTH_KIND_SERVICE, AUTH_KIND_SESSION -from cachetools import TTLCache -from fastapi import FastAPI -from simple_module_core.app_state import get_services -from simple_module_core.invalidation import Invalidation, InvalidationBus -from simple_module_core.singleflight import MISS, SingleFlight, fill -from simple_module_core.tenancy import is_tenant_role -from simple_module_db import is_valid_tenant_id -from simple_module_hosting.middleware import TenantResolution -from starlette.requests import Request - -from tenants.constants import ( - INVALIDATION_CHANNEL, - SESSION_ACTIVE_TENANT, - TENANT_ROLE_PREFIX, - TenantStatus, -) -from tenants.contracts.schemas import MyTenantView -from tenants.host_resolver import ( - forget_hosts, - is_public_route, - resolve_from_host, - subdomain_slug, - subdomains_enabled, -) -from tenants.service import TenantService -from tenants.token_tenant import pinned_token_tenant - -logger = logging.getLogger(__name__) - -MEMBERSHIP_TTL_SECONDS = 60 - -_MEMBERSHIPS: TTLCache[str, list[MyTenantView]] = TTLCache( - maxsize=10_000, ttl=MEMBERSHIP_TTL_SECONDS -) - - -_FLIGHTS: WeakKeyDictionary[FastAPI, SingleFlight] = WeakKeyDictionary() - - -# Bumped by every invalidation. A read that started before an invalidation -# must not store its (possibly stale) result afterwards: otherwise a member -# removed while their own request was mid-read is re-cached as a member for a -# whole TTL. -_epoch = 0 - - -def forget(key: str | None) -> None: - """Drop one user's memberships, or every user's (and every host lookup) on ``None``. - - Keys on this channel are always user ids; slug evictions travel on - ``HOSTS_INVALIDATION_CHANNEL`` (see ``host_resolver``). - """ - global _epoch - _epoch += 1 - if key is None: - _MEMBERSHIPS.clear() - forget_hosts() # a status change: cached subdomain lookups are stale too - else: - _MEMBERSHIPS.pop(key, None) - - -def _apply_invalidation(inv: Invalidation) -> None: - forget(inv.key) - - -def subscribe(bus: InvalidationBus) -> None: - bus.subscribe(INVALIDATION_CHANNEL, _apply_invalidation) - - -def make_invalidator(app: FastAPI): - async def invalidate(key: str | None) -> None: - forget(key) # this worker, now — the bus reaches the others - bus = getattr(get_services(app), "invalidation", None) - if bus is not None: - await bus.publish(INVALIDATION_CHANNEL, key=key) - - return invalidate - - -def cached_memberships(user_id: str) -> list[MyTenantView] | None: - return _MEMBERSHIPS.get(user_id) - - -async def memberships_for(app: FastAPI, user_id: str) -> list[MyTenantView]: - cached = _MEMBERSHIPS.get(user_id) - if cached is not None: - return cached - if (flight := _FLIGHTS.get(app)) is None: - flight = _FLIGHTS[app] = SingleFlight() - async with flight.hold(user_id): - - def store(views: list[MyTenantView]) -> None: - _MEMBERSHIPS[user_id] = views - - return await fill( - lambda: _read_memberships(app, user_id), - epoch=lambda: _epoch, - store=store, - cached=lambda: _MEMBERSHIPS.get(user_id, MISS), - ) - - -async def _read_memberships(app: FastAPI, user_id: str) -> list[MyTenantView]: - async with app.state.sm.db.session_factory() as db: - rows = await TenantService(db).list_for_user(user_id) - return [ - MyTenantView( - id=t.id, slug=t.slug, name=t.name, status=t.status, created_at=t.created_at, role=role - ) - for t, role in rows - ] - - -def pick_active(memberships: list[MyTenantView], preferred: str | None) -> MyTenantView | None: - """The preferred tenant if it is an active membership, else the first active one.""" - usable = [m for m in memberships if m.status == TenantStatus.ACTIVE] - for m in usable: - if m.id == preferred: - return m - return usable[0] if usable else None - - -def _with_tenant_role(user: Any, tenant_id: str, role: str) -> Any: - if not dataclasses.is_dataclass(user) or isinstance(user, type): - return user - roles = [r for r in getattr(user, "roles", []) if not is_tenant_role(r)] - changes: dict[str, Any] = {"roles": [*roles, f"{TENANT_ROLE_PREFIX}{role}"]} - if "tenant_id" in {f.name for f in dataclasses.fields(user)}: - changes["tenant_id"] = tenant_id - return dataclasses.replace(user, **changes) - - -async def resolve_tenant(request: Request) -> TenantResolution: - """``TenantResolver`` for the framework's ``TenantMiddleware``. - - Reports the source (``subdomain`` / ``header`` / ``session``) and the headers - the answer depended on, so the middleware can set ``Vary``. - """ - tenant_id, source, vary = await _resolve(request) - return TenantResolution(tenant_id, source if tenant_id is not None else None, vary) - - -async def _resolve(request: Request) -> tuple[str | None, str | None, tuple[str, ...]]: - request.state.tenant_role = None - request.state.tenant_suspended = False - request.state.suspended_tenant_name = None - user = getattr(request.state, "user", None) - # A signed-in answer depended on the credential (membership is checked on - # every branch), so a shared cache must key on it too; an anonymous one did - # not, and anonymous public pages stay cacheable (#418). - credential: tuple[str, ...] = ("Cookie",) if user is not None else () - kind = getattr(user, "auth_kind", AUTH_KIND_SESSION) - if kind != AUTH_KIND_SESSION: - # A bearer credential is carried by the Authorization header, not a cookie. - credential = ("Authorization",) - if kind in (AUTH_KIND_PAT, AUTH_KIND_SERVICE): - # An API token is pinned to the tenant it was created in: neither a - # header, a subdomain nor a session preference may move it elsewhere. - return await pinned_token_tenant(request, user), "token", credential - slug = subdomain_slug(request) - if slug is not None: - tenant_id = await _resolve_subdomain(request, user, slug) - return tenant_id, "subdomain", ("Host", *credential) - # No slug in the host is still an answer that depended on the host. - vary: tuple[str, ...] = ("Host",) if subdomains_enabled(request) else () - if user is None: - return None, None, vary - user_id = str(user.id) - memberships = await memberships_for(request.app, user_id) - - header_name = _header_name(request) - if header_name: - vary = (*vary, header_name) - vary = (*vary, *credential) - requested = request.headers.get(header_name) if header_name else None - if requested is not None: - # An explicit per-request choice (API clients). Never fall back to - # another tenant: a client that asked for X — or sent junk — must not - # act on Y. - if not is_valid_tenant_id(requested): - return None, None, vary - active = next( - (m for m in memberships if m.id == requested and m.status == TenantStatus.ACTIVE), - None, - ) - if active is None: - return None, None, vary - return _enter(request, user, active), "header", vary - - # From here the answer also depends on the stored preference in the - # session cookie, which ``vary`` already names. - session = request.scope.get("session") - preferred = session.get(SESSION_ACTIVE_TENANT) if session is not None else None - active = pick_active(memberships, preferred) - chosen = next((m for m in memberships if m.id == preferred), None) - chosen_suspended = chosen is not None and chosen.status == TenantStatus.SUSPENDED - if chosen_suspended: - # Falling back to another membership must not be silent: say which - # organisation was suspended, and keep the choice in the session so - # the notice stays until the user switches deliberately. - request.state.tenant_suspended = True - request.state.suspended_tenant_name = chosen.name - - if active is None: - request.state.tenant_suspended = any( - m.status == TenantStatus.SUSPENDED for m in memberships - ) - return None, None, vary - if session is not None and preferred != active.id and not chosen_suspended: - session[SESSION_ACTIVE_TENANT] = active.id - return _enter(request, user, active), "session", vary - - -async def _resolve_subdomain(request: Request, user: Any, slug: str) -> str | None: - tenant_id, active = await resolve_from_host(request, slug) - if not active or tenant_id is None: - return None - if user is not None: - memberships = await memberships_for(request.app, str(user.id)) - own = next((m for m in memberships if m.id == tenant_id), None) - if own is not None: - return _enter(request, user, own) - if not is_public_route(request): - return None - return tenant_id - - -def _header_name(request: Request) -> str: - settings = getattr(get_services(request.app), "settings", None) - return getattr(settings, "tenant_header", "") or "" - - -def _enter(request: Request, user: Any, active: MyTenantView) -> str: - request.state.tenant_role = active.role - request.state.user = _with_tenant_role(user, active.id, active.role) - return active.id - - -def switch_active(request: Request, tenant_id: str) -> None: - """Record the user's choice; ``resolve_tenant`` validates it next request.""" - request.session[SESSION_ACTIVE_TENANT] = tenant_id +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = [ - "MEMBERSHIP_TTL_SECONDS", - "cached_memberships", - "forget", - "make_invalidator", - "memberships_for", - "pick_active", - "resolve_tenant", - "subscribe", - "switch_active", -] +deprecated_module("tenants.resolver", remove_in="0.0.46", use="tenants.resolution.resolver") +sys.modules[__name__] = importlib.import_module("tenants.resolution.resolver") diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index 211703882..6dae1226f 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -1,215 +1,11 @@ -"""Tenant and membership business logic. - -Membership operations filter explicitly on ``tenant_id``: these registry -tables are not ``MultiTenantMixin`` (see ``models``), so scoping is done here. - -Side effects (domain events, cache invalidation) run *after commit* through -``_after_commit``: a billing handler must never create a customer for a tenant -whose transaction rolled back. -""" +"""Moved to :mod:`tenants.core.service`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging -import re -import secrets -from collections.abc import Awaitable, Callable, Sequence -from typing import Any - -from simple_module_core.events import Event, EventBus -from simple_module_core.invalidation import InvalidationBus -from simple_module_core.tenant_events import TENANT_DELETED_CHANNEL -from simple_module_db import PLATFORM_TENANT_ID -from simple_module_db.admission import serialize_sqlite_writes -from sqlalchemy import func, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import col - -from tenants._deletion import delete_tenant_rows -from tenants._membership_ops import MembershipOpsMixin -from tenants.constants import ERROR_ADMISSION_CONFLICT, MAX_SLUG_LEN, MembershipRole, TenantStatus -from tenants.contracts.entitlements import ( - EntitlementProvider, - UnlimitedEntitlements, -) -from tenants.contracts.events import ( - MembershipAdded, - TenantCreated, - TenantDeleted, - TenantStatusChanged, -) -from tenants.contracts.schemas import TenantCreate -from tenants.errors import TenantError -from tenants.models import Membership, Tenant - -logger = logging.getLogger(__name__) - -Invalidate = Callable[[str | None], Awaitable[None]] - - -async def _no_invalidate(_key: str | None) -> None: - return None - - -def slugify(name: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - return slug[:MAX_SLUG_LEN].strip("-") or "org" - - -class TenantService(MembershipOpsMixin): - def __init__( - self, - db: AsyncSession, - *, - bus: EventBus | None = None, - invalidate: Invalidate = _no_invalidate, - invalidate_hosts: Invalidate = _no_invalidate, - entitlements: EntitlementProvider | None = None, - invalidation_bus: InvalidationBus | None = None, - ) -> None: - self.db = db - self.bus = bus - self.invalidate = invalidate # membership channel: a user id, or None - self.invalidate_hosts = invalidate_hosts # hosts channel: a slug, or None - self.invalidation_bus = invalidation_bus - self.entitlements = entitlements or UnlimitedEntitlements() - self.pending: list[Callable[[], Awaitable[Any]]] = [] - - def _after_commit( - self, - *events: Event, - invalidate: Sequence[str | None] = (), - invalidate_hosts: Sequence[str | None] = (), - callback: Callable[[], Awaitable[None]] | None = None, - ) -> None: - async def run() -> None: - for key in invalidate: - await self.invalidate(key) - for slug in invalidate_hosts: - await self.invalidate_hosts(slug) - if self.bus is not None: - for event in events: - await self.bus.publish(event) - if callback is not None: # last: may wait on an SMTP server - await callback() - - on_commit = getattr(self.db, "on_commit", None) - if on_commit is not None: - on_commit(run) - else: # plain AsyncSession (scripts, tests): caller drains ``pending`` - self.pending.append(run) - - # ── tenants ───────────────────────────────────────────────── - - async def get(self, tenant_id: str) -> Tenant | None: - return await self.db.get(Tenant, tenant_id) - - async def _free_slug(self, wanted: str) -> str: - slug = wanted - # The reserved platform owner is never handed out, even as a slug. - while slug == PLATFORM_TENANT_ID or await self.db.scalar( - select(Tenant.id).where(Tenant.slug == slug) - ): - suffix = secrets.token_hex(2) - slug = f"{wanted[: MAX_SLUG_LEN - len(suffix) - 1]}-{suffix}" - return slug - - async def create_tenant( - self, data: TenantCreate, *, owner_user_id: str, owner_email: str | None = None - ) -> Tenant: - if data.slug: - if await self.db.scalar(select(Tenant.id).where(Tenant.slug == data.slug)): - raise TenantError("slug_taken", status_code=409) - slug = data.slug - else: - slug = await self._free_slug(slugify(data.name)) - tenant = Tenant(name=data.name, slug=slug) - self.db.add(tenant) - try: - # A concurrent create can claim the slug between the check above - # and this insert: a 409, not a 500. The request fails as a whole, - # so its transaction is rolled back — no savepoint needed. - await self.db.flush() - except IntegrityError as exc: - raise TenantError("slug_taken", status_code=409) from exc - owner = Membership( - tenant_id=tenant.id, user_id=owner_user_id, role=MembershipRole.OWNER, email=owner_email - ) - self.db.add(owner) - await self.db.flush() - self._after_commit( - TenantCreated(tenant.id, tenant.slug, tenant.name, owner_user_id), - MembershipAdded(tenant.id, owner_user_id, MembershipRole.OWNER), - invalidate=[owner_user_id], - invalidate_hosts=[tenant.slug], - ) - return tenant - - async def set_status(self, tenant_id: str, status: TenantStatus) -> Tenant: - """Suspend or reactivate — the call a billing module makes on dunning.""" - tenant = await self._require(tenant_id) - previous = tenant.status - if previous != status: - tenant.status = status - await self.db.flush() - # Every member's cached view of this tenant is now wrong. - self._after_commit(TenantStatusChanged(tenant_id, status, previous), invalidate=[None]) - return tenant - - async def delete_tenant(self, tenant_id: str) -> None: - tenant = await self.lock(tenant_id) - events = await delete_tenant_rows(self.db, tenant) - deleted = next(event for event in events if isinstance(event, TenantDeleted)) - if self.bus is not None: - await self.bus.publish_durable_or_inline(deleted, self.db) - else: - logger.warning( - "Tenant-owned data cleanup was skipped tenant=%s: no event bus", tenant_id - ) - - async def invalidate_deleted() -> None: - if self.invalidation_bus is not None: - await self.invalidation_bus.publish(TENANT_DELETED_CHANNEL, key=tenant_id) - - self._after_commit(*events, invalidate=[None], callback=invalidate_deleted) - - async def list_all(self, *, search: str = "", limit: int = 50, offset: int = 0) -> list[Tenant]: - stmt = select(Tenant).order_by(Tenant.created_at.desc()).limit(limit).offset(offset) - if search: - like = f"%{search.lower()}%" - stmt = stmt.where(func.lower(Tenant.name).like(like) | Tenant.slug.like(like)) - return list((await self.db.execute(stmt)).scalars().all()) - - async def member_counts(self, tenant_ids: Sequence[str]) -> dict[str, int]: - if not tenant_ids: - return {} - stmt = ( - select(Membership.tenant_id, func.count()) - .where(Membership.tenant_id.in_(tenant_ids)) - .group_by(Membership.tenant_id) - ) - return dict((await self.db.execute(stmt)).tuples().all()) - - async def lock(self, tenant_id: str) -> Tenant: - """Row-lock the tenant for the rest of the transaction. +import importlib +import sys - Serialises check-then-act rules per tenant. SQLite takes a real writer - lock before reading; FOR UPDATE alone compiles away on SQLite. - """ - await serialize_sqlite_writes( - self.db, - Tenant.__table__, - on_busy=TenantError(ERROR_ADMISSION_CONFLICT, status_code=409), - ) - stmt = select(Tenant).where(col(Tenant.id) == tenant_id).with_for_update() - tenant = (await self.db.execute(stmt)).scalar_one_or_none() - if tenant is None: - raise TenantError("not_found", status_code=404) - return tenant +from simple_module_core.deprecation import deprecated_module - async def _require(self, tenant_id: str) -> Tenant: - tenant = await self.get(tenant_id) - if tenant is None: - raise TenantError("not_found", status_code=404) - return tenant +deprecated_module("tenants.service", remove_in="0.0.46", use="tenants.core.service") +sys.modules[__name__] = importlib.import_module("tenants.core.service") diff --git a/modules/tenants/tenants/components/AdminTenantsTable.tsx b/modules/tenants/tenants/ui/components/AdminTenantsTable.tsx similarity index 99% rename from modules/tenants/tenants/components/AdminTenantsTable.tsx rename to modules/tenants/tenants/ui/components/AdminTenantsTable.tsx index cc4227fd2..f1e962051 100644 --- a/modules/tenants/tenants/components/AdminTenantsTable.tsx +++ b/modules/tenants/tenants/ui/components/AdminTenantsTable.tsx @@ -12,8 +12,8 @@ import { import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; import { useTenantErrors } from '../hooks/useTenantErrors'; -import { api } from '../pages/api'; export interface AdminTenant { id: string; diff --git a/modules/tenants/tenants/components/CreateOrgForm.tsx b/modules/tenants/tenants/ui/components/CreateOrgForm.tsx similarity index 98% rename from modules/tenants/tenants/components/CreateOrgForm.tsx rename to modules/tenants/tenants/ui/components/CreateOrgForm.tsx index cae884f49..24ddc1bef 100644 --- a/modules/tenants/tenants/components/CreateOrgForm.tsx +++ b/modules/tenants/tenants/ui/components/CreateOrgForm.tsx @@ -6,8 +6,8 @@ import { Label } from '@simple-module-py/ui/components/ui/label'; import { apiResult } from '@simple-module-py/ui/lib/api'; import type React from 'react'; import { useState } from 'react'; +import { api } from '../../pages/api'; import { useTenantErrors } from '../hooks/useTenantErrors'; -import { api } from '../pages/api'; interface Props { /** Called after the API confirms creation, so the page can reload its props. */ diff --git a/modules/tenants/tenants/components/InvitationsList.tsx b/modules/tenants/tenants/ui/components/InvitationsList.tsx similarity index 98% rename from modules/tenants/tenants/components/InvitationsList.tsx rename to modules/tenants/tenants/ui/components/InvitationsList.tsx index 5821c1eb4..2f5c99b69 100644 --- a/modules/tenants/tenants/components/InvitationsList.tsx +++ b/modules/tenants/tenants/ui/components/InvitationsList.tsx @@ -5,8 +5,8 @@ import { apiResult } from '@simple-module-py/ui/lib/api'; import { X } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; import { useTenantErrors } from '../hooks/useTenantErrors'; -import { api } from '../pages/api'; export interface Invitation { id: number; diff --git a/modules/tenants/tenants/components/InviteForm.tsx b/modules/tenants/tenants/ui/components/InviteForm.tsx similarity index 99% rename from modules/tenants/tenants/components/InviteForm.tsx rename to modules/tenants/tenants/ui/components/InviteForm.tsx index 463273f98..2f6665958 100644 --- a/modules/tenants/tenants/components/InviteForm.tsx +++ b/modules/tenants/tenants/ui/components/InviteForm.tsx @@ -9,8 +9,8 @@ import { Copy } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; import { useTenantErrors } from '../hooks/useTenantErrors'; -import { api } from '../pages/api'; interface Props { onInvited: () => void; diff --git a/modules/tenants/tenants/components/MembersTable.tsx b/modules/tenants/tenants/ui/components/MembersTable.tsx similarity index 99% rename from modules/tenants/tenants/components/MembersTable.tsx rename to modules/tenants/tenants/ui/components/MembersTable.tsx index 35a5cdab5..ce503364f 100644 --- a/modules/tenants/tenants/components/MembersTable.tsx +++ b/modules/tenants/tenants/ui/components/MembersTable.tsx @@ -14,8 +14,8 @@ import { apiResult } from '@simple-module-py/ui/lib/api'; import { UserMinus } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; import { useTenantErrors } from '../hooks/useTenantErrors'; -import { api } from '../pages/api'; export type MembershipRole = 'owner' | 'admin' | 'member'; diff --git a/modules/tenants/tenants/components/MembershipList.tsx b/modules/tenants/tenants/ui/components/MembershipList.tsx similarity index 98% rename from modules/tenants/tenants/components/MembershipList.tsx rename to modules/tenants/tenants/ui/components/MembershipList.tsx index 60144f036..de9462117 100644 --- a/modules/tenants/tenants/components/MembershipList.tsx +++ b/modules/tenants/tenants/ui/components/MembershipList.tsx @@ -6,8 +6,8 @@ import { apiResult } from '@simple-module-py/ui/lib/api'; import { Building2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; import { useTenantErrors } from '../hooks/useTenantErrors'; -import { api } from '../pages/api'; export interface Membership { id: string; diff --git a/modules/tenants/tenants/components/TenantImageControl.tsx b/modules/tenants/tenants/ui/components/TenantImageControl.tsx similarity index 100% rename from modules/tenants/tenants/components/TenantImageControl.tsx rename to modules/tenants/tenants/ui/components/TenantImageControl.tsx diff --git a/modules/tenants/tenants/components/TenantSettingRow.tsx b/modules/tenants/tenants/ui/components/TenantSettingRow.tsx similarity index 100% rename from modules/tenants/tenants/components/TenantSettingRow.tsx rename to modules/tenants/tenants/ui/components/TenantSettingRow.tsx diff --git a/modules/tenants/tenants/hooks/useTenantErrors.ts b/modules/tenants/tenants/ui/hooks/useTenantErrors.ts similarity index 100% rename from modules/tenants/tenants/hooks/useTenantErrors.ts rename to modules/tenants/tenants/ui/hooks/useTenantErrors.ts diff --git a/modules/tenants/tenants/web/__init__.py b/modules/tenants/tenants/web/__init__.py new file mode 100644 index 000000000..1b5e93ba2 --- /dev/null +++ b/modules/tenants/tenants/web/__init__.py @@ -0,0 +1,4 @@ +"""The HTTP side. + +Request deps, the tenant shared prop and the REST, admin and Inertia endpoints. +""" diff --git a/modules/tenants/tenants/web/deps.py b/modules/tenants/tenants/web/deps.py new file mode 100644 index 000000000..c37364b47 --- /dev/null +++ b/modules/tenants/tenants/web/deps.py @@ -0,0 +1,89 @@ +"""FastAPI dependencies for the Tenants module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from tenants.core.constants import MANAGER_ROLES +from tenants.core.errors import TenantError +from tenants.core.service import TenantService +from tenants.invitations.service import InvitationService +from tenants.resolution.host_resolver import make_host_invalidator +from tenants.resolution.resolver import make_invalidator + + +async def get_tenant_service(request: Request, db: AsyncSession = Depends(get_db)) -> TenantService: + app = request.app + return TenantService( + db, + bus=app.state.sm.event_bus, + invalidate=make_invalidator(app), + invalidate_hosts=make_host_invalidator(app), + invalidation_bus=app.state.sm.invalidation, + entitlements=app.state.tenants.entitlements, + ) + + +TenantServiceDep = Annotated[TenantService, Depends(get_tenant_service)] + + +async def get_invitation_service(request: Request, tenants: TenantServiceDep) -> InvitationService: + ttl = request.app.state.tenants.settings.invitation_ttl_hours + return InvitationService(tenants, ttl_hours=ttl) + + +InvitationServiceDep = Annotated[InvitationService, Depends(get_invitation_service)] + + +@dataclass(frozen=True) +class ActiveTenantContext: + tenant_id: str + role: str + user_id: str + + +def _current_user_id(request: Request) -> str: + user = getattr(request.state, "user", None) + if user is None: + raise TenantError("not_authenticated", status_code=401) + return str(user.id) + + +async def require_user_id(request: Request) -> str: + return _current_user_id(request) + + +async def require_active_tenant(request: Request) -> ActiveTenantContext: + """The tenant resolved for this request. Operations act on it — never on an + id from the URL — so a tenant-level permission cannot reach another tenant.""" + user_id = _current_user_id(request) + tenant_id = getattr(request.state, "tenant_id", None) + role = getattr(request.state, "tenant_role", None) + if tenant_id is None or role is None: + raise TenantError("tenant_required", status_code=403) + return ActiveTenantContext(tenant_id=tenant_id, role=role, user_id=user_id) + + +async def require_tenant_manager( + ctx: Annotated[ActiveTenantContext, Depends(require_active_tenant)], +) -> ActiveTenantContext: + """An owner or admin *of the active tenant*. + + Checked on top of the permission gate: a platform-wide grant of + ``tenants.members.manage`` (the wildcard admin, say) must not turn a plain + member of this tenant into its manager. Platform staff act through + ``/admin/tenants`` instead. + """ + if ctx.role not in MANAGER_ROLES: + raise TenantError("tenant_manager_required", status_code=403) + return ctx + + +UserIdDep = Annotated[str, Depends(require_user_id)] +ActiveTenantDep = Annotated[ActiveTenantContext, Depends(require_active_tenant)] +ManagerDep = Annotated[ActiveTenantContext, Depends(require_tenant_manager)] diff --git a/modules/tenants/tenants/endpoints/__init__.py b/modules/tenants/tenants/web/endpoints/__init__.py similarity index 100% rename from modules/tenants/tenants/endpoints/__init__.py rename to modules/tenants/tenants/web/endpoints/__init__.py diff --git a/modules/tenants/tenants/endpoints/admin.py b/modules/tenants/tenants/web/endpoints/admin.py similarity index 93% rename from modules/tenants/tenants/endpoints/admin.py rename to modules/tenants/tenants/web/endpoints/admin.py index 18caa3374..1900a8ba1 100644 --- a/modules/tenants/tenants/endpoints/admin.py +++ b/modules/tenants/tenants/web/endpoints/admin.py @@ -13,9 +13,14 @@ from simple_module_hosting.permissions import RequiresPermission from simple_module_inertia import InertiaResponse -from tenants.constants import PAGE_ADMIN, PERM_PLATFORM_MANAGE, PERM_PLATFORM_VIEW, TenantStatus from tenants.contracts.schemas import TenantView -from tenants.deps import TenantServiceDep +from tenants.core.constants import ( + PAGE_ADMIN, + PERM_PLATFORM_MANAGE, + PERM_PLATFORM_VIEW, + TenantStatus, +) +from tenants.web.deps import TenantServiceDep views = APIRouter() api = APIRouter(prefix="/admin") diff --git a/modules/tenants/tenants/endpoints/api.py b/modules/tenants/tenants/web/endpoints/api.py similarity index 97% rename from modules/tenants/tenants/endpoints/api.py rename to modules/tenants/tenants/web/endpoints/api.py index 275d396a9..df1006b2f 100644 --- a/modules/tenants/tenants/endpoints/api.py +++ b/modules/tenants/tenants/web/endpoints/api.py @@ -18,13 +18,6 @@ from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for from simple_module_hosting.token_guard import Authenticated -from tenants.constants import ( - PERM_MEMBERS_MANAGE, - PERM_MEMBERS_VIEW, - PERM_PLATFORM_MANAGE, - MembershipRole, - TenantStatus, -) from tenants.contracts.schemas import ( InvitationCreate, InvitationIssued, @@ -35,17 +28,24 @@ RoleChange, TenantCreate, ) -from tenants.deps import ( +from tenants.core.constants import ( + PERM_MEMBERS_MANAGE, + PERM_MEMBERS_VIEW, + PERM_PLATFORM_MANAGE, + MembershipRole, + TenantStatus, +) +from tenants.core.errors import TenantError +from tenants.invitations.mail import invitation_emailed +from tenants.models import Membership +from tenants.resolution.resolver import switch_active +from tenants.web.deps import ( ActiveTenantDep, InvitationServiceDep, ManagerDep, TenantServiceDep, UserIdDep, ) -from tenants.errors import TenantError -from tenants.invitation_mail import invitation_emailed -from tenants.models import Membership -from tenants.resolver import switch_active router = APIRouter() diff --git a/modules/tenants/tenants/endpoints/views.py b/modules/tenants/tenants/web/endpoints/views.py similarity index 97% rename from modules/tenants/tenants/endpoints/views.py rename to modules/tenants/tenants/web/endpoints/views.py index f85188acc..704f067d8 100644 --- a/modules/tenants/tenants/endpoints/views.py +++ b/modules/tenants/tenants/web/endpoints/views.py @@ -14,7 +14,7 @@ from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for from simple_module_inertia import InertiaResponse -from tenants.constants import ( +from tenants.core.constants import ( ENTITLEMENT_SEATS, MANAGER_ROLES, PAGE_ACCEPT, @@ -26,8 +26,8 @@ PERM_PLATFORM_MANAGE, PERM_TENANT_SETTINGS, ) -from tenants.deps import InvitationServiceDep, TenantServiceDep, UserIdDep -from tenants.resolver import memberships_for +from tenants.resolution.resolver import memberships_for +from tenants.web.deps import InvitationServiceDep, TenantServiceDep, UserIdDep router = APIRouter() settings_router = APIRouter() diff --git a/modules/tenants/tenants/shared_props.py b/modules/tenants/tenants/web/shared_props.py similarity index 94% rename from modules/tenants/tenants/shared_props.py rename to modules/tenants/tenants/web/shared_props.py index 2c0cd1dc7..74b8b9052 100644 --- a/modules/tenants/tenants/shared_props.py +++ b/modules/tenants/tenants/web/shared_props.py @@ -10,7 +10,7 @@ from starlette.requests import Request -from tenants.resolver import cached_memberships +from tenants.resolution.resolver import cached_memberships def tenant_shared_props(request: Request) -> dict[str, Any]: diff --git a/modules/tenants/tests-js/TenantSettingRow.test.tsx b/modules/tenants/tests-js/TenantSettingRow.test.tsx index ba3ff8660..885f58067 100644 --- a/modules/tenants/tests-js/TenantSettingRow.test.tsx +++ b/modules/tenants/tests-js/TenantSettingRow.test.tsx @@ -8,7 +8,7 @@ import { configureI18n } from '@simple-module-py/i18n'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { type TenantSetting, TenantSettingRow } from '../tenants/components/TenantSettingRow'; +import { type TenantSetting, TenantSettingRow } from '../tenants/ui/components/TenantSettingRow'; configureI18n({ locale: 'en', diff --git a/modules/tenants/tests-js/tenants_api_client.test.tsx b/modules/tenants/tests-js/tenants_api_client.test.tsx index 4bdb59962..072f9f375 100644 --- a/modules/tenants/tests-js/tenants_api_client.test.tsx +++ b/modules/tenants/tests-js/tenants_api_client.test.tsx @@ -10,8 +10,8 @@ import { failure } from '@simple-module-py/ui/lib/api-result'; import { cleanup, render, renderHook, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { MembershipList } from '../tenants/components/MembershipList'; -import { useTenantErrors } from '../tenants/hooks/useTenantErrors'; +import { MembershipList } from '../tenants/ui/components/MembershipList'; +import { useTenantErrors } from '../tenants/ui/hooks/useTenantErrors'; configureI18n({ locale: 'en', diff --git a/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx b/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx index 15a52a609..a9d51f58c 100644 --- a/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx +++ b/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx @@ -3,7 +3,7 @@ import { configureI18n, keys } from '@simple-module-py/i18n'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; -import { InviteForm } from '../tenants/components/InviteForm'; +import { InviteForm } from '../tenants/ui/components/InviteForm'; configureI18n({ locale: 'en', diff --git a/modules/tenants/tests/tenants_deletion_helpers.py b/modules/tenants/tests/_tenants_deletion_support.py similarity index 100% rename from modules/tenants/tests/tenants_deletion_helpers.py rename to modules/tenants/tests/_tenants_deletion_support.py diff --git a/modules/tenants/tests/conftest.py b/modules/tenants/tests/conftest.py index 9c02a5ab0..bf13e6d85 100644 --- a/modules/tenants/tests/conftest.py +++ b/modules/tenants/tests/conftest.py @@ -13,7 +13,7 @@ import httpx import pytest from simple_module_test.tenant_client import create_user, session_client -from tenants.resolver import forget +from tenants.resolution.resolver import forget @pytest.fixture(autouse=True) diff --git a/modules/tenants/tests/test_tenant_client_fixture.py b/modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py similarity index 95% rename from modules/tenants/tests/test_tenant_client_fixture.py rename to modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py index 1d30fed15..71b1727be 100644 --- a/modules/tenants/tests/test_tenant_client_fixture.py +++ b/modules/tenants/tests/core/fixtures/test_tenants_client_fixture.py @@ -26,7 +26,7 @@ async def test_second_member_joins_an_existing_tenant(tenant_client): async def test_tenant_roles_reach_the_permission_registry(app): - from tenants.constants import ROLE_PERMISSIONS, TENANT_ROLE_PREFIX, MembershipRole + from tenants.core.constants import ROLE_PERMISSIONS, TENANT_ROLE_PREFIX, MembershipRole assert MembershipRole is TenantRole assert TENANT_ROLE_PREFIX == "tenant:" diff --git a/modules/tenants/tests/test_boot.py b/modules/tenants/tests/core/module/test_tenants_boot.py similarity index 76% rename from modules/tenants/tests/test_boot.py rename to modules/tenants/tests/core/module/test_tenants_boot.py index 8430fd72a..a7ce3805f 100644 --- a/modules/tenants/tests/test_boot.py +++ b/modules/tenants/tests/core/module/test_tenants_boot.py @@ -2,7 +2,7 @@ async def test_module_registers_resolver(app): - from tenants.resolver import resolve_tenant + from tenants.resolution.resolver import resolve_tenant assert app.state.tenant_resolver is resolve_tenant assert app.state.tenants.entitlements is not None diff --git a/modules/tenants/tests/test_tenants_usage_contract.py b/modules/tenants/tests/core/module/test_tenants_usage_contract.py similarity index 98% rename from modules/tenants/tests/test_tenants_usage_contract.py rename to modules/tenants/tests/core/module/test_tenants_usage_contract.py index 66f673f5d..75aab4853 100644 --- a/modules/tenants/tests/test_tenants_usage_contract.py +++ b/modules/tenants/tests/core/module/test_tenants_usage_contract.py @@ -15,9 +15,9 @@ UsageSummaryRow, get_usage_meter, ) +from tenants.core.services import TenantsServices +from tenants.core.settings import TenantsSettings from tenants.module import TenantsModule -from tenants.services import TenantsServices -from tenants.settings import TenantsSettings class FakeUsageMeter: diff --git a/modules/tenants/tests/test_tenants_audit_redaction.py b/modules/tenants/tests/core/service/test_tenants_audit_redaction.py similarity index 100% rename from modules/tenants/tests/test_tenants_audit_redaction.py rename to modules/tenants/tests/core/service/test_tenants_audit_redaction.py diff --git a/modules/tenants/tests/test_tenant_deleted_event.py b/modules/tenants/tests/core/service/test_tenants_deleted_event.py similarity index 95% rename from modules/tenants/tests/test_tenant_deleted_event.py rename to modules/tenants/tests/core/service/test_tenants_deleted_event.py index 66d3e5a7e..1c908fdd7 100644 --- a/modules/tenants/tests/test_tenant_deleted_event.py +++ b/modules/tenants/tests/core/service/test_tenants_deleted_event.py @@ -3,16 +3,16 @@ from __future__ import annotations import pytest +from _tenants_deletion_support import deletion_bus from simple_module_core.events import EventBus from simple_module_db.transaction import finalize_session, rollback_session from sqlalchemy import func, select -from tenants import resolver -from tenants.constants import MembershipRole from tenants.contracts.events import MembershipRemoved, TenantDeleted from tenants.contracts.schemas import TenantCreate +from tenants.core.constants import MembershipRole +from tenants.core.service import TenantService from tenants.models import Membership, Tenant -from tenants.service import TenantService -from tenants_deletion_helpers import deletion_bus +from tenants.resolution import resolver async def _tenant_with_owner(db_session, bus: EventBus) -> tuple[TenantService, Tenant]: @@ -131,7 +131,7 @@ async def handler(event: MembershipRemoved) -> None: async def test_delete_invalidates_tenant_channel_after_commit(db_session, app, rollback): from simple_module_core.tenant_events import TENANT_DELETED_CHANNEL from starlette.requests import Request - from tenants.deps import get_tenant_service + from tenants.web.deps import get_tenant_service bus = app.state.sm.invalidation seen = [] diff --git a/modules/tenants/tests/test_tenants_durable_deletion.py b/modules/tenants/tests/core/service/test_tenants_durable_deletion.py similarity index 95% rename from modules/tenants/tests/test_tenants_durable_deletion.py rename to modules/tenants/tests/core/service/test_tenants_durable_deletion.py index 9af42bff8..7a55bfa4d 100644 --- a/modules/tenants/tests/test_tenants_durable_deletion.py +++ b/modules/tenants/tests/core/service/test_tenants_durable_deletion.py @@ -1,6 +1,7 @@ """Deleting the tenant and enqueueing its teardown are one transaction.""" import pytest +from _tenants_deletion_support import deletion_bus from background_tasks.models import OutboxMessage from simple_module_core.durable_events import event_key from simple_module_core.events import EventBus @@ -8,9 +9,8 @@ from simple_module_db.transaction import finalize_session, rollback_session from sqlalchemy import select from tenants.contracts.events import TenantDeleted as ProviderTenantDeleted +from tenants.core.service import TenantService from tenants.models import Tenant -from tenants.service import TenantService -from tenants_deletion_helpers import deletion_bus async def _cleanup(event, ctx): diff --git a/modules/tenants/tests/test_tenants_inline_deletion.py b/modules/tenants/tests/core/service/test_tenants_inline_deletion.py similarity index 98% rename from modules/tenants/tests/test_tenants_inline_deletion.py rename to modules/tenants/tests/core/service/test_tenants_inline_deletion.py index b12b3d5d3..a0e24bbb1 100644 --- a/modules/tenants/tests/test_tenants_inline_deletion.py +++ b/modules/tenants/tests/core/service/test_tenants_inline_deletion.py @@ -15,8 +15,8 @@ from simple_module_db.tenant_cleanup import register_tenant_cleanup from simple_module_db.transaction import finalize_session, rollback_session from sqlmodel import select +from tenants.core.service import TenantService from tenants.models import Tenant -from tenants.service import TenantService @pytest.mark.parametrize("rollback", [False, True]) diff --git a/modules/tenants/tests/test_membership_changes.py b/modules/tenants/tests/core/service/test_tenants_membership_changes.py similarity index 100% rename from modules/tenants/tests/test_membership_changes.py rename to modules/tenants/tests/core/service/test_tenants_membership_changes.py diff --git a/modules/tenants/tests/test_owner_race.py b/modules/tenants/tests/core/service/test_tenants_owner_race.py similarity index 96% rename from modules/tenants/tests/test_owner_race.py rename to modules/tenants/tests/core/service/test_tenants_owner_race.py index 86707f239..5a7d0e05b 100644 --- a/modules/tenants/tests/test_owner_race.py +++ b/modules/tenants/tests/core/service/test_tenants_owner_race.py @@ -22,12 +22,12 @@ is_sqlite, reset_schema, ) -from tenants.constants import MembershipRole from tenants.contracts.schemas import InvitationCreate, TenantCreate -from tenants.errors import TenantError -from tenants.invitations import InvitationService +from tenants.core.constants import MembershipRole +from tenants.core.errors import TenantError +from tenants.core.service import TenantService +from tenants.invitations.service import InvitationService from tenants.models import Base -from tenants.service import TenantService def _backends() -> list[str]: diff --git a/modules/tenants/tests/test_tenant_role_permissions.py b/modules/tenants/tests/core/service/test_tenants_role_permissions.py similarity index 98% rename from modules/tenants/tests/test_tenant_role_permissions.py rename to modules/tenants/tests/core/service/test_tenants_role_permissions.py index 3e5c41c74..41c328be1 100644 --- a/modules/tenants/tests/test_tenant_role_permissions.py +++ b/modules/tenants/tests/core/service/test_tenants_role_permissions.py @@ -12,7 +12,7 @@ from simple_module_core.permissions import ADMIN_ROLE, WILDCARD from simple_module_core.tenancy import TENANT_ROLE_PREFIX, TenantRole, is_tenant_role, tenant_role from simple_module_hosting.permissions import resolved_permissions_for -from tenants.constants import ROLE_PERMISSIONS +from tenants.core.constants import ROLE_PERMISSIONS from users.models import Role diff --git a/modules/tenants/tests/test_tenants_sqlite_admission_race.py b/modules/tenants/tests/core/service/test_tenants_sqlite_admission_race.py similarity index 94% rename from modules/tenants/tests/test_tenants_sqlite_admission_race.py rename to modules/tenants/tests/core/service/test_tenants_sqlite_admission_race.py index e2713dc87..fed97d23a 100644 --- a/modules/tenants/tests/test_tenants_sqlite_admission_race.py +++ b/modules/tenants/tests/core/service/test_tenants_sqlite_admission_race.py @@ -7,13 +7,13 @@ from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db from sqlalchemy import event, func, select, text -from tenants.constants import MembershipRole from tenants.contracts.entitlements import EntitlementExceededError from tenants.contracts.schemas import InvitationCreate, TenantCreate -from tenants.errors import TenantError -from tenants.invitations import InvitationService +from tenants.core.constants import MembershipRole +from tenants.core.errors import TenantError +from tenants.core.service import TenantService +from tenants.invitations.service import InvitationService from tenants.models import Base, Invitation, Membership -from tenants.service import TenantService class TwoSeats: diff --git a/modules/tenants/tests/test_tenants_teardown_integration.py b/modules/tenants/tests/core/service/test_tenants_teardown_integration.py similarity index 99% rename from modules/tenants/tests/test_tenants_teardown_integration.py rename to modules/tenants/tests/core/service/test_tenants_teardown_integration.py index 17c66de86..f42dd76f5 100644 --- a/modules/tenants/tests/test_tenants_teardown_integration.py +++ b/modules/tenants/tests/core/service/test_tenants_teardown_integration.py @@ -15,7 +15,7 @@ from simple_module_db import all_tenants, tenant_context from simple_module_db.stored_settings import SCOPE_TENANT, Setting from sqlmodel import select -from tenants.service import TenantService +from tenants.core.service import TenantService @pytest.fixture(autouse=True, params=[False, True], ids=["default-order", "sharing-after-users"]) diff --git a/modules/tenants/tests/test_tenants_invitation_mail.py b/modules/tenants/tests/invitations/mail/test_tenants_invitation_mail.py similarity index 96% rename from modules/tenants/tests/test_tenants_invitation_mail.py rename to modules/tenants/tests/invitations/mail/test_tenants_invitation_mail.py index a0a526c48..3bb98a91f 100644 --- a/modules/tenants/tests/test_tenants_invitation_mail.py +++ b/modules/tenants/tests/invitations/mail/test_tenants_invitation_mail.py @@ -8,10 +8,10 @@ from simple_module_core.mail import ConsoleMailSender, install_mail_sender from tenants.contracts.events import InvitationCreated from tenants.contracts.schemas import InvitationCreate -from tenants.invitations import InvitationService +from tenants.core.service import TenantService +from tenants.invitations.service import InvitationService from tenants.models import Invitation from tenants.module import TenantsModule -from tenants.service import TenantService @pytest.fixture @@ -107,7 +107,7 @@ async def enqueue(self, event, handler_names, db): async def test_tenants_durable_relative_link_skipped(db_session, captured_mail, caplog): - from tenants.invitation_mail import send_invitation_email + from tenants.invitations.mail import send_invitation_email event = InvitationCreated("tenant", "Acme", "friend@x.io", "member", "/accept?token=secret") with caplog.at_level(logging.WARNING): @@ -117,7 +117,7 @@ async def test_tenants_durable_relative_link_skipped(db_session, captured_mail, async def test_tenants_durable_failure_propagates(db_session, captured_mail, monkeypatch): - from tenants.invitation_mail import send_invitation_email + from tenants.invitations.mail import send_invitation_email async def fail(message): raise RuntimeError("transport failed") @@ -170,7 +170,7 @@ async def test_tenants_relative_link_is_not_queued(db_session, captured_mail): async def test_tenants_worker_without_transport_retries(db_session): """A worker started without Mail must fail the delivery, not mark it sent.""" from simple_module_core.mail import install_mail_resolver - from tenants.invitation_mail import send_invitation_email + from tenants.invitations.mail import send_invitation_email install_mail_sender(None) install_mail_resolver(None) diff --git a/modules/tenants/tests/test_tenants_invitation_verification.py b/modules/tenants/tests/invitations/service/test_tenants_invitation_verification.py similarity index 100% rename from modules/tenants/tests/test_tenants_invitation_verification.py rename to modules/tenants/tests/invitations/service/test_tenants_invitation_verification.py diff --git a/modules/tenants/tests/test_invitations.py b/modules/tenants/tests/invitations/service/test_tenants_invitations.py similarity index 100% rename from modules/tenants/tests/test_invitations.py rename to modules/tenants/tests/invitations/service/test_tenants_invitations.py diff --git a/modules/tenants/tests/test_tenants_negative_host_cache.py b/modules/tenants/tests/resolution/hosts/test_tenants_negative_host_cache.py similarity index 97% rename from modules/tenants/tests/test_tenants_negative_host_cache.py rename to modules/tenants/tests/resolution/hosts/test_tenants_negative_host_cache.py index e33820c3b..f42fba480 100644 --- a/modules/tenants/tests/test_tenants_negative_host_cache.py +++ b/modules/tenants/tests/resolution/hosts/test_tenants_negative_host_cache.py @@ -9,10 +9,10 @@ from simple_module_core.invalidation import Invalidation from simple_module_core.singleflight import MAX_FILL_ATTEMPTS from simple_module_db.transaction import finalize_session -from tenants import host_resolver, resolver -from tenants.constants import HOSTS_INVALIDATION_CHANNEL, INVALIDATION_CHANNEL from tenants.contracts.schemas import TenantCreate -from tenants.service import TenantService +from tenants.core.constants import HOSTS_INVALIDATION_CHANNEL, INVALIDATION_CHANNEL +from tenants.core.service import TenantService +from tenants.resolution import host_resolver, resolver def _fake_app(db) -> FastAPI: diff --git a/modules/tenants/tests/test_subdomains.py b/modules/tenants/tests/resolution/hosts/test_tenants_subdomains.py similarity index 98% rename from modules/tenants/tests/test_subdomains.py rename to modules/tenants/tests/resolution/hosts/test_tenants_subdomains.py index 9c764feaa..8d336220c 100644 --- a/modules/tenants/tests/test_subdomains.py +++ b/modules/tenants/tests/resolution/hosts/test_tenants_subdomains.py @@ -9,7 +9,7 @@ from simple_module_db.deps import get_db from sqlalchemy import select from sqlmodel import Field -from tenants.host_resolver import forget_hosts +from tenants.resolution.host_resolver import forget_hosts _Base = create_module_base("subdom") diff --git a/modules/tenants/tests/test_subdomains_forwarded_host.py b/modules/tenants/tests/resolution/hosts/test_tenants_subdomains_forwarded_host.py similarity index 97% rename from modules/tenants/tests/test_subdomains_forwarded_host.py rename to modules/tenants/tests/resolution/hosts/test_tenants_subdomains_forwarded_host.py index 3a60d665a..7402a2e22 100644 --- a/modules/tenants/tests/test_subdomains_forwarded_host.py +++ b/modules/tenants/tests/resolution/hosts/test_tenants_subdomains_forwarded_host.py @@ -9,7 +9,7 @@ from simple_module_hosting._forwarded_host import ForwardedHostMiddleware from sqlalchemy import select from sqlmodel import Field -from tenants.host_resolver import forget_hosts +from tenants.resolution.host_resolver import forget_hosts _Base = create_module_base("fwdhost") _PROXY_IP = "10.9.8.7" diff --git a/modules/tenants/tests/test_tenants_cache_singleflight.py b/modules/tenants/tests/resolution/resolver/test_tenants_cache_singleflight.py similarity index 97% rename from modules/tenants/tests/test_tenants_cache_singleflight.py rename to modules/tenants/tests/resolution/resolver/test_tenants_cache_singleflight.py index 853134fcc..b7ee898df 100644 --- a/modules/tenants/tests/test_tenants_cache_singleflight.py +++ b/modules/tenants/tests/resolution/resolver/test_tenants_cache_singleflight.py @@ -8,8 +8,8 @@ from cachetools import TTLCache from fastapi import FastAPI from simple_module_core.singleflight import MAX_FILL_ATTEMPTS -from tenants import resolver -from tenants.service import TenantService +from tenants.core.service import TenantService +from tenants.resolution import resolver @pytest.mark.parametrize("invalidate", [None, "same", "all"]) diff --git a/modules/tenants/tests/test_isolation_e2e.py b/modules/tenants/tests/resolution/resolver/test_tenants_isolation_e2e.py similarity index 100% rename from modules/tenants/tests/test_isolation_e2e.py rename to modules/tenants/tests/resolution/resolver/test_tenants_isolation_e2e.py diff --git a/modules/tenants/tests/test_resolver_source.py b/modules/tenants/tests/resolution/resolver/test_tenants_resolver_source.py similarity index 98% rename from modules/tenants/tests/test_resolver_source.py rename to modules/tenants/tests/resolution/resolver/test_tenants_resolver_source.py index 87ccda4b2..077649aca 100644 --- a/modules/tenants/tests/test_resolver_source.py +++ b/modules/tenants/tests/resolution/resolver/test_tenants_resolver_source.py @@ -6,7 +6,7 @@ import pytest from fastapi import Request from simple_module_test import forge_session_cookie -from tenants.host_resolver import forget_hosts +from tenants.resolution.host_resolver import forget_hosts @pytest.fixture diff --git a/modules/tenants/tests/test_tenants_api.py b/modules/tenants/tests/web/endpoints/test_tenants_api.py similarity index 100% rename from modules/tenants/tests/test_tenants_api.py rename to modules/tenants/tests/web/endpoints/test_tenants_api.py diff --git a/modules/tenants/tests/test_hardening.py b/modules/tenants/tests/web/endpoints/test_tenants_hardening.py similarity index 98% rename from modules/tenants/tests/test_hardening.py rename to modules/tenants/tests/web/endpoints/test_tenants_hardening.py index 7cb385a1b..9103cc393 100644 --- a/modules/tenants/tests/test_hardening.py +++ b/modules/tenants/tests/web/endpoints/test_tenants_hardening.py @@ -5,8 +5,8 @@ import asyncio from simple_module_test.fixtures import SETUP_ADMIN_EMAIL -from tenants import resolver -from tenants.service import TenantService +from tenants.core.service import TenantService +from tenants.resolution import resolver async def _org(client, name: str) -> dict: diff --git a/modules/tenants/tests/test_tenants_members_pagination.py b/modules/tenants/tests/web/endpoints/test_tenants_members_pagination.py similarity index 98% rename from modules/tenants/tests/test_tenants_members_pagination.py rename to modules/tenants/tests/web/endpoints/test_tenants_members_pagination.py index 3da58e6a6..4dfe66fc1 100644 --- a/modules/tenants/tests/test_tenants_members_pagination.py +++ b/modules/tenants/tests/web/endpoints/test_tenants_members_pagination.py @@ -5,8 +5,8 @@ import pytest from fastapi import FastAPI from sqlalchemy import delete, select -from tenants.endpoints.api import router from tenants.models import Membership, Tenant +from tenants.web.endpoints.api import router async def test_members_api_and_page_paginate_and_clamp(app, tenant_client): diff --git a/modules/tenants/tests/test_tenants_optional_settings.py b/modules/tenants/tests/web/endpoints/test_tenants_optional_settings.py similarity index 100% rename from modules/tenants/tests/test_tenants_optional_settings.py rename to modules/tenants/tests/web/endpoints/test_tenants_optional_settings.py diff --git a/modules/tenants/tests/test_settings_page.py b/modules/tenants/tests/web/endpoints/test_tenants_settings_page.py similarity index 100% rename from modules/tenants/tests/test_settings_page.py rename to modules/tenants/tests/web/endpoints/test_tenants_settings_page.py diff --git a/modules/tenants/tests/test_tenants_shared_props.py b/modules/tenants/tests/web/shared_props/test_tenants_shared_props.py similarity index 95% rename from modules/tenants/tests/test_tenants_shared_props.py rename to modules/tenants/tests/web/shared_props/test_tenants_shared_props.py index dbbe3c2c1..06c825201 100644 --- a/modules/tenants/tests/test_tenants_shared_props.py +++ b/modules/tenants/tests/web/shared_props/test_tenants_shared_props.py @@ -7,10 +7,10 @@ from typing import Any, cast from starlette.requests import Request -from tenants import resolver -from tenants.constants import TenantStatus from tenants.contracts.schemas import MyTenantView -from tenants.shared_props import tenant_shared_props +from tenants.core.constants import TenantStatus +from tenants.resolution import resolver +from tenants.web.shared_props import tenant_shared_props CREATED = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) diff --git a/modules/users/tests/test_users_deletion_events.py b/modules/users/tests/test_users_deletion_events.py index c306c3ef3..a5731c5d3 100644 --- a/modules/users/tests/test_users_deletion_events.py +++ b/modules/users/tests/test_users_deletion_events.py @@ -16,10 +16,10 @@ from simple_module_db.transaction import finalize_session, rollback_session from sqlmodel import select from starlette.requests import Request -from tenants.constants import MembershipRole, TenantStatus +from tenants.core.constants import MembershipRole, TenantStatus +from tenants.core.service import TenantService from tenants.models import Membership, Tenant from tenants.module import TenantsModule -from tenants.service import TenantService from users.admin.api import admin_delete_user, admin_disable_user, admin_set_roles from users.admin.service import UserService from users.contracts.events import RoleAssigned, UserDeleted, UserDisabled From 9d02e876d981b39a39b741ef1e09d55c2d1e932e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 02:05:28 +0100 Subject: [PATCH 2/2] fix(tenants): keep tenants.host_resolver importing until 0.0.46 docs/modules/tenants.md documented importing make_host_invalidator from it. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 2 +- docs/module-layout.md | 2 +- modules/tenants/tenants/host_resolver.py | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 modules/tenants/tenants/host_resolver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1efb82241..fc755b1f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,7 @@ All notable changes to this project are documented in this file. The format is b - `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. - `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain. - `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46. -- `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged. +- `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.host_resolver`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged. - Coalesce concurrent tenant membership and permission grant cache misses. In-flight invalidation retries against fresh authorization data; failures and cancellation release waiting readers. Cache unknown tenant subdomains for five seconds and drop the new slug's host lookups (on a new `tenants.hosts` invalidation channel, so no user id is read as a slug) and the owner's memberships after committed tenant creation; other users' caches are not cleared. Services built outside request dependencies pass `invalidate_hosts=make_host_invalidator(app)` beside `invalidate=make_invalidator(app)`. Retries after concurrent invalidation are bounded to three reads (2026-11-05 local performance review). diff --git a/docs/module-layout.md b/docs/module-layout.md index a0cc293a8..11a06140d 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -227,5 +227,5 @@ when a later module PR moves files. | settings | 31 | 43 | flat | | sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | -| tenants | 3 (+5 shims) | 0 | follows this layout | +| tenants | 3 (+6 shims) | 0 | follows this layout | | users | 31 | 97 | flat | diff --git a/modules/tenants/tenants/host_resolver.py b/modules/tenants/tenants/host_resolver.py new file mode 100644 index 000000000..5fcd37344 --- /dev/null +++ b/modules/tenants/tenants/host_resolver.py @@ -0,0 +1,13 @@ +"""Moved to :mod:`tenants.resolution.host_resolver`; this alias goes in 0.0.46 (module layout).""" + +from __future__ import annotations + +import importlib +import sys + +from simple_module_core.deprecation import deprecated_module + +deprecated_module( + "tenants.host_resolver", remove_in="0.0.46", use="tenants.resolution.host_resolver" +) +sys.modules[__name__] = importlib.import_module("tenants.resolution.host_resolver")