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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.
`MenuRegistry.add_provider(fn)` (from `register_menu_items`) contributes per-request menu items evaluated in `InertiaLayoutDataMiddleware` after auth/tenant resolution, and `PermissionRegistry.add_source(name, provider)` (from `register_permissions`) contributes runtime-defined permissions from a sync in-memory cache, refreshed with `invalidate_source(name)`; see [docs/framework/permissions.md](docs/framework/permissions.md).

**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request:
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → Setup → Maintenance → CommitBeforeResponse → app`. `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (`_inertia_url.py`), so no scheme travels in the payload to disagree with the document's — the cross-scheme `pushState` `SecurityError` of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. `Setup` runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance.
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → BodyLimit → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup → Maintenance → CommitBeforeResponse → app`. `BodyLimit` refuses an oversized request body (`HostSettings.max_request_body_bytes`, default 10 MiB; per-path overrides via `register_body_limits`) on `Content-Length` or by counting streamed chunks, before GZip and every module see it; `RateLimit` sits *after* auth and Locale/layout data so it can tell anonymous from signed-in and render a translated 429, and by default throttles only anonymous requests to routes the public-route registry exempts (`rate_limit_public`, per client IP, Redis via `SM_REDIS_URL` else per-worker). See [docs/framework/request-guards.md](docs/framework/request-guards.md). `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (`_inertia_url.py`), so no scheme travels in the payload to disagree with the document's — the cross-scheme `pushState` `SecurityError` of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. `Setup` runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance.

**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.

Expand Down
15 changes: 15 additions & 0 deletions docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ register_event_handlers
register_invalidations
register_health_checks
register_public_routes
register_body_limits
register_csp_sources
register_design_packs
register_audit_links
Expand Down Expand Up @@ -147,6 +148,19 @@ async def _check_db(self) -> HealthCheckResult: ...

Each check returns a `HealthCheckResult(status=HealthStatus.HEALTHY | DEGRADED | UNHEALTHY, detail=...)`. The `/health/ready` endpoint runs all checks concurrently and reports the worst status (a raising check counts as `UNHEALTHY`).

## `register_body_limits(registry)`

Raise (or lower) the request-body ceiling for specific routes — the host
refuses any body over `max_request_body_bytes` (10 MiB by default) with a 413
before the route runs. A multipart upload endpoint declares what it needs:

```python
def register_body_limits(self, registry) -> None:
registry.add_exact("/api/media/upload", 200 * 1024 * 1024, methods={"POST"})
```

See [request-guards.md](request-guards.md).

## `register_csp_sources(registry)`

Whitelist external origins your frontend loads assets from — a font CDN, a
Expand Down Expand Up @@ -305,6 +319,7 @@ class OrdersModule(ModuleBase):
def register_event_handlers(self, bus, app=None): ...
def register_health_checks(self, registry): ...
def register_public_routes(self, registry): ...
def register_body_limits(self, registry): ...
def register_csp_sources(self, registry): ...
def register_exception_handlers(self, app): ...
def register_middleware(self, app): ...
Expand Down
8 changes: 8 additions & 0 deletions docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The actual `add_middleware` call order (in `install_middleware`) is the
# Added first → executed last (closest to the app)
app.add_middleware(CommitBeforeResponseMiddleware)
app.add_middleware(MaintenanceMiddleware)
app.add_middleware(RateLimitMiddleware, ...)
app.add_middleware(InertiaCacheMiddleware)
app.add_middleware(InertiaLayoutDataMiddleware, ...)
app.add_middleware(LocaleMiddleware, ...)
Expand All @@ -24,6 +25,7 @@ for module in discovered_modules:
app.add_middleware(SessionMiddleware, secret_key=...)
app.add_middleware(SecurityHeadersMiddleware, ...)
app.add_middleware(GZipMiddleware, minimum_size=...)
app.add_middleware(BodyLimitMiddleware, ...)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CorrelationIdMiddleware)

Expand All @@ -41,6 +43,8 @@ CorrelationId
↓
RequestLogging
↓
BodyLimit (413 on an oversized body)
↓
GZip
↓
SecurityHeaders
Expand All @@ -57,6 +61,8 @@ InertiaLayoutData
↓
InertiaCache
↓
RateLimit (429 for anonymous public-route traffic)
↓
Maintenance
↓
CommitBeforeResponse
Expand All @@ -72,6 +78,8 @@ The last three are ordered relative to each other for reasons worth stating, bec
- **`Maintenance` runs *after* `InertiaLayoutData`, `Locale` and auth.** It needs the shared props to render with a layout instead of bare, the locale to answer in the right language, and the resolved user to know whether the caller is an admin who should pass through.
- **`CommitBeforeResponse` is innermost.** It hooks the `send` channel, so being added first makes its wrapper the first to see the response — which is what lets the commit land before any byte is written.

The two request guards, `BodyLimit` and `RateLimit`, are documented in [request-guards.md](request-guards.md): `BodyLimit` is early so a huge body never reaches GZip or any module, `RateLimit` is late because it needs the auth result and the shared props its error page renders with.

## What each built-in does

### `ProxyHeadersMiddleware` *(opt-in)*
Expand Down
13 changes: 13 additions & 0 deletions docs/framework/public-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ verb matches.
`registry.add(route_or_pattern, *, methods=, kind=)` is the general form;
pass a prebuilt `PublicRoute` or a string.

## Rate limiting anonymous callers

Every anonymous request that matches a public rule is rate-limited per client
IP by the host (`rate_limit_public`, `120/minute` by default). A rule can carry
its own budget, or opt out:

```python
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}, rate="600/minute")
registry.add_prefix("/api/gis/stac", rate="off")
```

See [request-guards.md](request-guards.md).

## Why method-awareness matters

`/api/gis/datasets/{id}/` carries both reads and mutations:
Expand Down
143 changes: 143 additions & 0 deletions docs/framework/request-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Request guards: body size and rate limiting

Two pipeline-level guards protect the process before a route does any work.
Both are raw ASGI middleware in `simple_module_hosting`, configured from
`HostSettings` (env, then DB, then default) and extended by modules through a
registration hook.

| Guard | Answers | Default | Module hook |
|---|---|---|---|
| Body size (`BodyLimitMiddleware`) | `413` | 10 MiB | `register_body_limits` |
| Rate limit (`RateLimitMiddleware`) | `429` + `Retry-After` | `120/minute` per IP, anonymous public routes only | `rate=` on a public rule |

Both settings are read when the middleware stack is built, so editing them in
the admin UI needs a restart (the pre-app settings read picks the DB value up
on the next boot; an env var still wins).

## Body-size guard

A module's own payload check runs only after the framework has read the whole
body into memory, so it protects the database, not the process. The guard
refuses first:

- **`Content-Length` over the ceiling** is refused before anything reads the
body.
- **No `Content-Length` (chunked)**, or one that lies, is caught by counting the
bytes the app pulls through `receive` and aborting once the ceiling is
crossed. A client cannot bypass the guard by streaming.

It sits right inside `RequestLogging` (so a 413 is logged with its correlation
id) and outside `GZip` and every module middleware.

### Response shape

Negotiated like every other error (`_wants_json`): API callers (`/api/*`, or an
explicit JSON `Accept`) get `413 {"detail": "...", "max_bytes": N}` immediately.
A browser request is not refused at the outer layer, because the error page needs
the session, locale and shared props the inner layers set up; instead the body
read raises `HTTPException(413)` and the app's normal handler renders the
Inertia error page. A route that never reads its body is therefore not
interrupted on that path.

### Settings

| Field | Env | Default |
|---|---|---|
| `max_request_body_bytes` | `SM_MAX_REQUEST_BODY_BYTES` | `10485760` (10 MiB); `0` disables the guard |

### Per-path overrides: `register_body_limits`

```python
def register_body_limits(self, registry: BodyLimitRegistry) -> None:
# fixed ceiling
registry.add_exact("/api/media/upload", 200 * 1024 * 1024, methods={"POST"})
# a limit that is itself a runtime setting: pass a callable taking the app
registry.add_prefix("/api/x/import", lambda app: app.state.x.settings.max_bytes)
```

Helpers: `add` (any match kind), `add_prefix`, `add_exact`, `add_regex`. The
first matching rule in registration order wins and *replaces* the global
ceiling, so it can lower it as well as raise it; `0` means unlimited for that
route. `file_storage` uses this for its upload endpoint: its ceiling is the
`max_file_size_bytes` setting plus 1 MiB of multipart framing.

## Rate limiter

Keyed on the client IP as the ASGI scope reports it, so `SM_TRUSTED_PROXY`
(`ProxyHeaders`, outermost) is honoured. It runs **after** the auth middleware
and after `Locale`/`InertiaLayoutData`, so it can tell anonymous from signed-in
and render a translated error page.

Policy:

1. A request is limited by `rate_limit_public` only if the auth middleware
judged it anonymous-allowed **and** nobody is signed in. `AuthMiddleware`
records that decision as `scope["state"]["auth_public"]` (framework defaults,
the public-route registry and the provider's legacy public paths), and the
limiter reads the flag, so the two cannot disagree, path variants included.
The key is a plain scope-state contract (no import, SM009-safe). With no auth
provider installed the registry match is used instead. `/health` and
`/static/` are never limited.
2. A matching public rule's own `rate=` replaces that default and gets its own
bucket per client.
3. `rate_limit_authenticated` (blank by default) opts signed-in traffic in.
4. Everything else, including routes outside the registry such as `/health` and
`/static/`, is untouched.

### Settings

| Field | Env | Default |
|---|---|---|
| `rate_limit_public` | `SM_RATE_LIMIT_PUBLIC` | `120/minute`; blank or `off` disables |
| `rate_limit_authenticated` | `SM_RATE_LIMIT_AUTHENTICATED` | blank (off) |
| `redis_url` | `SM_REDIS_URL` | unset |

Rates read `<count>/<period>` with `second`, `minute`, `hour` or `day`
(`5/10s` for a multiple). A malformed rate fails at boot rather than silently
disabling protection.

Without `SM_TRUSTED_PROXY`, every client behind a reverse proxy appears as the
proxy's address and shares one anonymous bucket. Set it (or raise
`rate_limit_public`) before putting the host behind a proxy.

### Per-rule override

```python
def register_public_routes(self, registry) -> None:
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}, rate="600/minute")
registry.add_prefix("/api/records/public/", rate="60/minute")
registry.add_prefix("/api/gis/stac", rate="off") # exempt this rule
```

### Storage

- **Redis** when `SM_REDIS_URL` is set (needs the `redis` package, which
`background_tasks` already installs): a fixed window, counted atomically with
one Lua `INCR` + `PEXPIRE`, shared by every worker. Short socket timeouts keep
a stalled Redis from hanging requests.
- **In process** otherwise: counters live in one worker, so with N workers the
effective limit is up to N times the configured rate.
- **Degrades, never latches.** If Redis errors (down, timeout), hits are counted
in per-worker counters instead (so the limit is weaker, not gone), a warning is
logged (at most once a minute), and Redis is probed again after 5 seconds, so
recovery is automatic. The script also re-arms a counter that lost its TTL, so
no key can block an IP permanently. The limiter never takes requests down.

### Response shape

`429` with a `Retry-After` header (seconds until the window resets). API callers
get `{"detail": "...", "retry_after": N}`; browsers get the Inertia error page.

### Not covered

`users/auth_local/rate_limit.py` (`LoginRateLimiter`, `ThroughputLimiter`) stays
as it is: they are synchronous, per-key lockout and per-endpoint budget
primitives used from FastAPI dependencies, a different shape from this
middleware's async per-IP window.

## Where it lives

- `simple_module_core.body_limits.BodyLimitRegistry`, `simple_module_core.rate_limit`
(rate parsing, in-process store), `PublicRoute.rate`
- `simple_module_hosting._body_limit`, `_rate_limit`, `_request_guard_responses`
- Wiring: `_phase_helpers.install_middleware`; registry on `app.state.body_limits`
3 changes: 2 additions & 1 deletion docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ Prefix is always `SM_`. These are the pre-DB knobs read by `simple_module_hostin
| `SM_LOG_FORMAT` | `json` | `json` (structured) or `text`. |
| `SM_MODULES_ENABLED` | unset (all enabled) | Comma-separated allow-list to disable modules without uninstalling them. |
| `SM_AUTH_PUBLIC_PATHS` | `[]` | JSON array of anonymous-access path prefixes — a host-level escape hatch. Modules should prefer the `register_public_routes` hook. |
| `SM_REDIS_URL` | unset | Optional. Shared store for the rate limiter (and the background-tasks broker). Unset means per-worker in-process counters. See [request guards](../framework/request-guards.md). |

Multi-tenancy (`multi_tenant`, `tenant_header`) and i18n (`i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name`) are **DB-backed host settings** now, not env vars — edit them under `host` at `/admin/settings/`. (`smpy new --tenancy` still writes `SM_MULTI_TENANT=true` into `.env.example` as a scaffold convenience, and tests can override these.)
Request guards (`max_request_body_bytes`, `rate_limit_public`, `rate_limit_authenticated`) are DB-backed host settings read at boot; changing them needs a restart. Multi-tenancy (`multi_tenant`, `tenant_header`) and i18n (`i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name`) are **DB-backed host settings** now, not env vars — edit them under `host` at `/admin/settings/`. (`smpy new --tenancy` still writes `SM_MULTI_TENANT=true` into `.env.example` as a scaffold convenience, and tests can override these.)

## Database bootstrap knobs

Expand Down
2 changes: 2 additions & 0 deletions framework/core/simple_module_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""

from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelResolver
from simple_module_core.body_limits import BodyLimitRegistry
from simple_module_core.csp import CspSourceError, CspSourceRegistry
from simple_module_core.design_packs import DesignPack, DesignPackRegistry
from simple_module_core.diagnostics import (
Expand Down Expand Up @@ -56,6 +57,7 @@
"TENANT_ROLE_PREFIX",
"AuditLink",
"AuditLinkRegistry",
"BodyLimitRegistry",
"CircularDependencyError",
"CspSourceError",
"CspSourceRegistry",
Expand Down
Loading
Loading