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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.

**Database**: per-module `Base` via `create_module_base("<name>")`. On SQLite `init_db` enables WAL, an explicit `busy_timeout`, and **`foreign_keys=ON`** — so both sides of an FK must use the same column type (`sa.Uuid` and fastapi-users' `GUID` are identical on Postgres but not on SQLite), and parents must be flushed before children. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout.

Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass the read filter with `stmt.execution_options(include_deleted=True)`; purge by deleting an already-trashed row, or with `hard_delete(session, obj)`), `MultiTenantMixin`, `VersionedMixin`. The soft-delete/tenant filters cover every statement shape, not only selects that name the entity — joins, ORM subqueries, `select(func.count()).select_from(Model)`, and Core statements over `Model.__table__` (GH #332). **Tenancy fails closed**: with `multi_tenant` on, a query or insert on a `MultiTenantMixin` model with no `current_tenant_id` raises `TenantIsolationError` instead of reading every tenant; cross-tenant code says so with `all_tenants()` / `execution_options(all_tenants=True)`, and jobs/CLI act for one tenant with `tenant_context(id)`. Unique keys on such tables must include `tenant_id` (`SM024`). The `tenants` module owns organisations, memberships and `app.state.tenant_resolver`; tenant-level routes act on the *active* tenant, never a tenant id from the URL. See [docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md). The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. DML executed through the session (`session.execute(update(Model)...)`) counts as a write; a raw `text("UPDATE ...")` does not, and needs `mark_written(session)`. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins.
Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass the read filter with `stmt.execution_options(include_deleted=True)`; purge by deleting an already-trashed row, or with `hard_delete(session, obj)`), `MultiTenantMixin`, `VersionedMixin`. The soft-delete/tenant filters cover every statement shape, not only selects that name the entity — joins, ORM subqueries, `select(func.count()).select_from(Model)`, and Core statements over `Model.__table__` (GH #332). **Tenancy fails closed**: with `multi_tenant` on, a query or insert on a `MultiTenantMixin` model with no `current_tenant_id` raises `TenantIsolationError` instead of reading every tenant; cross-tenant code says so with `all_tenants()` / `execution_options(all_tenants=True)`, and jobs/CLI act for one tenant with `tenant_context(id)`. With `multi_tenant` off and no tenant bound, inserts are stamped `DEFAULT_TENANT_ID` (`"default"`, from `simple_module_db`) and reads stay unfiltered; adoption migrations backfill with the same constant. Tenant roles reach the principal as `tenant:<role>` — map them with `tenant_role(TenantRole.MEMBER)` from `simple_module_core.tenancy`, never by importing `tenants`; tests use the `tenant_client(role)` fixture. Unique keys on such tables must include `tenant_id` (`SM024`). The `tenants` module owns organisations, memberships and `app.state.tenant_resolver`; tenant-level routes act on the *active* tenant, never a tenant id from the URL. See [docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md). The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. DML executed through the session (`session.execute(update(Model)...)`) counts as a write; a raw `text("UPDATE ...")` does not, and needs `mark_written(session)`. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins.

**Migrations** live in `host/migrations/versions/` — not in module packages. `host/alembic/env.py` calls `build_module_metadata()` + `make_include_object()` so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set `branch_labels = ("<module_name>",)` to enable per-module `downgrade <module>@base`.

Expand Down Expand Up @@ -110,7 +110,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b

## Diagnostic codes

Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.<module>` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn), `SM022` `@theme`/`@custom-variant`/`@utility` in a module's `styles.css`, where `layer(components)` makes them inert (warn), `SM023` an unlayered rule in a module's `theme.css`, which outranks every Tailwind utility (warn). `SM024` a unique key on a `MultiTenantMixin` table that omits `tenant_id` (warn). In production, errors fail boot.
Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.<module>` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn), `SM022` `@theme`/`@custom-variant`/`@utility` in a module's `styles.css`, where `layer(components)` makes them inert (warn), `SM023` an unlayered rule in a module's `theme.css`, which outranks every Tailwind utility (warn). `SM024` a unique key on a `MultiTenantMixin` table that omits `tenant_id` (warn). `SM025` `multi_tenant` is on but no module registered `app.state.tenant_resolver` (warn — checked at boot after module registration, in every environment, not by the `make doctor` CLI). In production, errors fail boot.

## Tests & fixtures

Expand Down
104 changes: 96 additions & 8 deletions docs/framework/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ tenant a request acts for.
|---|---|---|---|
| `SELECT` | filtered to the tenant | `MissingTenantError` | unfiltered |
| ORM `update()` / `delete()` | filtered to the tenant; `update().values(tenant_id=…)` raises | `MissingTenantError` | unfiltered |
| `session.add` + flush | `tenant_id` filled in; a different explicit value raises | `MissingTenantError` unless `tenant_id` is set explicitly | DB `NOT NULL` error unless set |
| ORM `insert(Model)` (bulk / `.values()`) | `tenant_id` filled in; a different explicit value raises | `MissingTenantError` unless every row sets `tenant_id` | DB `NOT NULL` error unless set |
| `session.add` + flush | `tenant_id` filled in; a different explicit value raises | `MissingTenantError` unless `tenant_id` is set explicitly | `tenant_id` filled in with the install's fallback tenant unless set |
| ORM `insert(Model)` (bulk / `.values()`) | `tenant_id` filled in; a different explicit value raises | `MissingTenantError` unless every row sets `tenant_id` | `tenant_id` filled in with the install's fallback tenant unless set |
| Flushing a change to, or a delete of, a loaded object | only if it belongs to the bound tenant | `MissingTenantError` | allowed |
| Changing `tenant_id` | raises | raises | raises (only an `all_tenants()` block may move a row) |

Expand Down Expand Up @@ -103,12 +103,54 @@ the call, sync or async (#364).

## Single-tenant hosts

A host with `multi_tenant` off can still install modules whose tables use the
mixin: set `default_tenant` (a `HostSettings` field, e.g. `main`) and every
request, and every background task with no tenant on its message, acts as
that tenant (#359). CLI commands and scripts use
`tenant_context(settings.default_tenant)`. It is ignored when `multi_tenant`
is on — a multi-tenant install never falls back to a shared tenant.
A host with `multi_tenant` off can install modules whose tables use the mixin
with no configuration at all. With no tenant bound, an insert is stamped with
`DEFAULT_TENANT_ID` (`"default"`, exported by `simple_module_db`) — inside an
`all_tenants()` block too — and reads stay unfiltered, so the install behaves
as one tenant that owns every row (#380). A module adopting the mixin backfills
its existing rows with the same constant in its migration:

```python
from simple_module_db import DEFAULT_TENANT_ID

op.add_column("files_file", sa.Column("tenant_id", sa.String(50), nullable=True))
op.execute(sa.text("UPDATE files_file SET tenant_id = :t").bindparams(t=DEFAULT_TENANT_ID))
op.alter_column("files_file", "tenant_id", nullable=False)
```

Unbound reads are deliberately *not* narrowed to `DEFAULT_TENANT_ID`: on a
single-tenant install every row is the install's, whatever `tenant_id` it
carries — rows written under `default_tenant`, or while `multi_tenant` was
briefly on, must not vanish. Strict mode never uses the constant; it raises.

To give the single tenant a name of your choosing instead, set
`default_tenant` (a `HostSettings` field, e.g. `main`): every request, and
every background task with no tenant on its message, then *binds* that tenant
(#359), and inserts are stamped with it rather than the constant. The host
also publishes it as the install's fallback (`DatabaseState.default_tenant_id`),
so writes with nothing bound — a CLI command, an `all_tenants()` block — land
in `main` too instead of in `DEFAULT_TENANT_ID`, where the install's own
(scoped) requests would never see them. CLI commands and scripts should still
prefer `tenant_context(settings.default_tenant)`. It is ignored when
`multi_tenant` is on — a multi-tenant install never falls back to a shared
tenant.

The fallback only fills a *missing* `tenant_id`, and only when nothing is
bound: a statement run with `execution_options(all_tenants=True)` inside a
request keeps the request's tenant on the rows it inserts.

Reads under `default_tenant` are scoped to it, so an existing install that
adopts the setting must re-stamp the rows it already wrote under
`DEFAULT_TENANT_ID` (and any it wrote while `multi_tenant` was briefly on),
in a migration or a one-off script, before switching it on:

```python
op.execute(
sa.text("UPDATE files_file SET tenant_id = :new WHERE tenant_id = :old").bindparams(
new="main", old=DEFAULT_TENANT_ID
)
)
```

## Background jobs

Expand Down Expand Up @@ -136,6 +178,52 @@ for anonymous visitors, on public routes), the tenant header (members only),
then the session's choice validated against a membership (#363). Without one it falls back to the principal's
`tenant_id` claim, and for **anonymous** requests only, the configured
`tenant_header`. An authenticated user can never pick a tenant by header.
Most auth providers set no `tenant_id` claim, so `multi_tenant` with no
resolver fails every tenant-scoped query closed; the boot reports that as
`SM025`.

## Tenant roles

A membership role — `owner`, `admin` or `member` — reaches the request
principal as `tenant:<role>`, for the active tenant only, so a tenant `admin`
is never the platform `admin`. The vocabulary lives in core, so a module maps
these onto its own permissions without depending on `tenants`:

```python
from simple_module_core.tenancy import TenantRole, tenant_role


def register_permissions(self, registry):
registry.map_role(tenant_role(TenantRole.MEMBER), ["files.view", "files.upload"])
registry.map_role(tenant_role(TenantRole.ADMIN), ["files.manage"])
```

`tenant_role()` rejects a name outside `TenantRole`, so a typo fails at boot
rather than granting nothing. `TENANT_ROLE_PREFIX` and `is_tenant_role()` are
there for code that inspects a principal's roles. A role maps only what it is
given — map `owner` and `admin` too if they should hold a member's
permissions.

## Testing

The `simple_module_test` plugin ships `tenant_client` (needs the `users` and
`tenants` modules): a factory yielding a client signed in as a fresh user with
`role` in a new tenant — or in `tenant_id=` — with that tenant active.

```python
async def test_isolation(tenant_client):
async with tenant_client() as a, tenant_client("member") as b:
await a.client.post("/api/things", json={"name": "x"})
assert (await b.client.get("/api/things")).json() == []


async def test_same_tenant(tenant_client):
async with (
tenant_client("owner") as (owner, tenant_id, _),
tenant_client("member", tenant_id=tenant_id) as (member, _, member_id),
):
...
```

## Unique keys

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/diagnostic-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The framework runs a set of static checks over installed modules at app boot. Th
| `SM022` | WARNING | A module's `styles.css` contains a top-level `@theme`, `@custom-variant` or `@utility` block. That file is imported into `layer(components)`, where those at-rules are inert. | Move the block to the module's `theme.css`, which is imported unlayered so its tokens actually register. |
| `SM023` | WARNING | A module's `theme.css` contains an unlayered plain rule (anything but an at-rule or a `:root`-style selector). Unlayered CSS outranks every Tailwind utility. | Move the rule to the module's `styles.css`, which is imported into `layer(components)` so utilities still win. |
| `SM024` | WARNING | A unique column, constraint or index on a `MultiTenantMixin` table does not include `tenant_id`, so the first tenant to claim a value locks every other tenant out of it. | Make the key per tenant: add `tenant_id` to it (`Index(..., "tenant_id", "slug", unique=True)`). |
| `SM025` | WARNING | `multi_tenant` is on but no module registered `app.state.tenant_resolver`, so only the principal's `tenant_id` claim can bind a tenant — with most auth providers every tenant-scoped query then fails closed. Checked at boot (it needs the built app), in every environment, not by `make doctor`. | Install the `tenants` module, or register your own `async (Request) -> str \| None` resolver on `app.state.tenant_resolver`. |

`SM022`/`SM023` are the two halves of the same invariant: a module's optional [`theme.css` is imported unlayered and `styles.css` into `layer(components)`](/module-authoring#styling), and CSS put in the wrong one silently does nothing (or silently wins everything).

Expand All @@ -41,7 +42,7 @@ The framework runs a set of static checks over installed modules at app boot. Th
| Context | What runs |
|---|---|
| App boot in development | Full structural/page/i18n suite, results logged to stderr. ERRORS abort boot. |
| App boot in non-development | Strict module discovery (raises on SM001-class failures) + the migration check (SM010). The page/locale static suite is dev-only. |
| App boot in non-development | Strict module discovery (raises on SM001-class failures) + the migration check (SM010) + SM025 (logged as a warning). The page/locale static suite is dev-only. |

Sample dev-mode output:

Expand Down
5 changes: 3 additions & 2 deletions framework/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.

This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM021`) used by `make doctor`.
This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM025`) used by `make doctor`.

## Install

Expand All @@ -17,7 +17,8 @@ You usually don't install this directly — it's pulled in by `simple_module_hos
- `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
- `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
- `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
- Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `SM021`.
- Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `SM025`.
- Tenant-role vocabulary (`simple_module_core.tenancy`) — `TenantRole`, `TENANT_ROLE_PREFIX`, `tenant_role()`, so any module can map `tenant:<role>` onto its permissions without depending on `tenants`.
- Tiny event-bus (`pyee`) for decoupled module-to-module communication.

## Usage
Expand Down
Loading
Loading