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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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).
Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (+6 shims) | 0 | follows this layout |
| users | 31 | 97 | flat |
4 changes: 2 additions & 2 deletions docs/modules/tenants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions framework/testing/simple_module_test/tenant_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 1 addition & 8 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion modules/api_tokens/tests/_api_tokens_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion modules/api_tokens/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion modules/file_storage/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions modules/sharing/tests/web/endpoints/test_sharing_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
86 changes: 6 additions & 80 deletions modules/tenants/tenants/constants.py
Original file line number Diff line number Diff line change
@@ -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:<role>``
# 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")
2 changes: 1 addition & 1 deletion modules/tenants/tenants/contracts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])?$")

Expand Down
5 changes: 5 additions & 0 deletions modules/tenants/tenants/core/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading