Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
105a44e
feat(hosting): groundwork for batch 1 - request hooks mixin, CORS and…
antosubash Oct 21, 2026
05b70c8
feat(hosting): per-route rate limits - register_rate_limits rules and…
antosubash Oct 21, 2026
501c235
feat(hosting): honour allowlisted X-Forwarded-Host from a trusted pro…
antosubash Oct 21, 2026
3ae2eca
Merge branch 'slice/b1-b-fwdhost' into feat/hosting-batch-1
antosubash Oct 21, 2026
34ba396
feat(hosting): configurable CORS with per-path policies; public files…
antosubash Oct 21, 2026
a3aebfc
fix(hosting): trust the rightmost X-Forwarded-Host; tenant rate-limit…
antosubash Oct 21, 2026
ac71a22
Merge branch 'slice/b1-a-cors' into feat/hosting-batch-1
antosubash Oct 21, 2026
6a0e1e6
feat(hosting): OpenAPI security schemes, Authenticated() gate, SM031 …
antosubash Oct 21, 2026
083ce0f
feat(db): pool, connect, statement and idle-in-transaction timeouts f…
antosubash Oct 21, 2026
5fe8aa2
Merge branch 'slice/b1-d-dbtimeouts' into feat/hosting-batch-1
antosubash Oct 21, 2026
2644945
Merge branch 'slice/b1-e-openapi' into feat/hosting-batch-1
antosubash Oct 21, 2026
21b24d5
fix(db): pass outbox worker engine options explicitly; drop the batch…
antosubash Oct 21, 2026
f37c006
refactor(optimize): dedupe API prefix and rate-limit key constants, s…
antosubash Oct 21, 2026
9d3a620
fix(hosting): separate public-tier and rule rate buckets; RateLimit d…
antosubash Oct 21, 2026
7f12093
fix: method-scoped rules win ties; keep URL options/connect_timeout; …
antosubash Oct 21, 2026
0d16c30
fix: address code review findings (round 1, pass 1)
antosubash Oct 21, 2026
c7b3c7a
Merge remote-tracking branch 'origin/main' into feat/hosting-batch-1
antosubash Oct 21, 2026
281130e
Merge remote-tracking branch 'origin/main' into feat/hosting-batch-1
antosubash Oct 21, 2026
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ All notable changes to this project are documented in this file. The format is b
- `host/client_app` no longer re-declares `@simple-module-py/ui`'s runtime dependencies, nor `@base-ui/react`, `@radix-ui/react-slot`, `autoprefixer` or `postcss` (nothing loaded them) (#521).

### Added
- Per-route rate limits (#529): modules declare `RateLimitRule(rate, key=...)` in the new
`register_rate_limits(registry)` hook, and handlers use the
`RateLimit("10/minute", key="user")` dependency. Keys: `user`, `ip`, `user_or_ip`,
`tenant`; both apply to anonymous and signed-in requests and answer `429` with
`Retry-After`.
- `forwarded_host_allowlist` host setting (#528): behind a trusted proxy (`SM_TRUSTED_PROXY`),
`X-Forwarded-Host` replaces the request host (and `scope["server"]`) when it matches an exact host or
`*.example.com` entry, so `url_for` and subdomain tenancy see the public host. Empty (default) ignores
the header; tenant subdomains need `*.<subdomain_base>` in the list.
- Configurable CORS (#527). `cors_allow_origins` (plus `cors_allow_credentials`,
`cors_allow_methods`, `cors_allow_headers`, `cors_expose_headers`, `cors_max_age`;
restart-required) registers a policy on `/api/`; modules add policies with the new
`register_cors_policies` hook. The dispatcher runs before `BodyLimit`, so preflights
never reach auth and a 413 keeps its CORS headers. `file_storage` serves public
files cross-origin (Range-capable) unless the new `public_files_cors` setting is off.
- PostgreSQL pool, connect, statement and idle-in-transaction timeout settings for web and worker engines, with worker pool and statement overrides (#531).
- The OpenAPI export declares `bearerAuth` and `sessionCookie` security schemes and a
`security` entry per operation (`[]` for public routes), so generated clients know where
credentials go (#534). New `Authenticated()` marker dependency
(`simple_module_hosting.token_guard`) for routes any signed-in user may call, and diagnostic
`SM031` (WARNING, dev boot) for `/api` routes with no gate and no public match. Marked
`Authenticated()`: `GET/POST /api/tenants/`, `POST /api/tenants/{tenant_id}/switch`,
`DELETE /api/tenants/current/membership`, `POST /api/tenants/invitations/accept`,
`GET/PATCH /api/users/me`, `POST /api/users/me/password`,
`POST /api/users/me/sessions/revoke-all`. Tokens are still refused on these.
- Release process (#510): `release.yml` runs the whole PR suite (`pr.yml` via
`workflow_call`) against the dispatched commit and `build` needs it, so nothing
is built or published from a red commit. The duplicated bump + lockfile steps
Expand Down Expand Up @@ -76,6 +101,8 @@ All notable changes to this project are documented in this file. The format is b
it, which removes the `users` ↔ `audit_log` import cycle.

### Changed
- **Behaviour change:** `rate_limit_authenticated` (off by default) now keeps one budget per
signed-in user instead of one per client IP, so users behind one NAT no longer share it (#529).
- `scripts/bump_version.py` also bumps every `modules/*/package.json`; all
`package.json` versions are now in lockstep with the Python packages (they were
0.1.0, keycloak 0.0.0) (#510).
Expand Down
22 changes: 17 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ every Tailwind utility, so keep tokens in `theme.css` and rules in `styles.css`
`docs/framework/lifecycle.md`): `register_settings`, `register_menu_items`,
`register_permissions`, `register_feature_flags`, `register_event_handlers`,
`register_invalidations`, `register_health_checks`, `register_setup_steps`,
`register_public_routes`, `register_body_limits`, `register_csp_sources`,
`register_public_routes`, `register_body_limits`, `register_cors_policies`,
`register_rate_limits`,
`register_csp_sources`,
`register_design_packs`,
`register_audit_links`, `register_exception_handlers`, `register_middleware`,
`register_routes(api_router, view_router)`, `register_admin_routes(admin_router)`
Expand Down Expand Up @@ -209,8 +211,8 @@ every Tailwind utility, so keep tokens in `theme.css` and rules in `styles.css`
`docs/framework/permissions.md`.

**Middleware** (Starlette `add_middleware` is LIFO). Execution order per
request: `(ProxyHeaders if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging →
BodyLimit → GZip → SecurityHeaders → Session → <module middleware> → Tenant
request: `(ForwardedHost if trusted proxy + allowlist) → (ProxyHeaders if
SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → CORS → BodyLimit → GZip → SecurityHeaders → Session → <module middleware> → Tenant
(opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup →
Maintenance → CommitBeforeResponse → app`. Rules that follow from it:

Expand All @@ -220,9 +222,18 @@ Maintenance → CommitBeforeResponse → app`. Rules that follow from it:
page, set `Cache-Control`/ETag on the *document* response only.
- `RateLimit` runs after auth, so by default it throttles only anonymous
traffic to public routes (`rate_limit_public`; `rate_limit_authenticated` is
off by default; Redis via `SM_REDIS_URL`, else per-worker).
off by default and keyed per user; Redis via `SM_REDIS_URL`, else per-worker).
Per-route budgets for anonymous *and* signed-in traffic: `register_rate_limits`
rules (`RateLimitRule(rate, key="user"|"ip"|"user_or_ip"|"tenant")`) or the
`RateLimit("10/minute", key="user")` dependency from
`simple_module_hosting.rate_limit_deps`.
- Request bodies over `max_request_body_bytes` (10 MiB) are refused before any
module sees them; per-path overrides via `register_body_limits`.
- CORS is opt-in per path: `cors_allow_origins` registers a policy on `/api/`,
and a module adds its own via `register_cors_policies(registry)`
(`CorsPolicy`, most specific match wins). The dispatcher runs before
BodyLimit, so preflights skip auth and a 413 keeps its CORS headers.
`docs/framework/cors.md`.

Per-middleware detail: `docs/framework/middleware.md`,
`docs/framework/request-guards.md`.
Expand Down Expand Up @@ -465,7 +476,8 @@ settings not on `app.state`, `SM013`–`SM016` locale drift, `SM017` module
pages without `package.json`/`tsconfig.json`, `SM018` Inertia call to `/api`,
`SM019` views with no menu item or permission, `SM022`/`SM023` CSS in the wrong
file, `SM024` unique key without `tenant_id`, `SM028` heavy import in
`tasks.py`.
`tasks.py`, `SM031` `/api` route with no permission/`Authenticated()` gate and not public (dev boot
only: it needs the built app).

## Tests & fixtures

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export default defineConfig({
{ text: "Principal resolvers", link: "/framework/principal-resolvers" },
{ text: "User directory", link: "/framework/user-directory" },
{ text: "Public routes", link: "/framework/public-routes" },
{ text: "CORS", link: "/framework/cors" },
{ text: "Deprecations", link: "/framework/deprecations" },
{ text: "Events", link: "/framework/events" },
{ text: "Cache invalidation", link: "/framework/invalidation" },
Expand Down
23 changes: 23 additions & 0 deletions docs/database/tuning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Database pool and timeout tuning

The application PostgreSQL engine uses `SM_DB_POOL_TIMEOUT=30` seconds to wait
for a pooled connection and `SM_DB_CONNECT_TIMEOUT=10` seconds to connect.
`SM_DB_STATEMENT_TIMEOUT_MS=0` and `SM_DB_IDLE_IN_TX_TIMEOUT_MS=0` omit their
PostgreSQL startup parameters; positive values set limits in milliseconds.
All four settings require a process restart. SQLite ignores these settings,
including pool timeout. Alembic and the pre-app settings read do not use them.

Set asyncpg timeouts through the host settings, not URL query parameters.
Asyncpg receives server settings; psycopg/psycopg2 receive libpq startup options.
PgBouncer transaction pooling rejects startup parameters such as these timeout
settings. Leave the millisecond settings at zero and configure limits on the
server or database role when using that mode.

Background workers inherit the resolved host timeouts. Their sync engine and
each outbox thread's async engine have separate pools, defaulting to size 2,
overflow 3 and recycling after 1800 seconds. Size the database connection budget
for all processes and outbox threads. Override these through
`SM_BG_TASKS_DB_POOL_SIZE`, `SM_BG_TASKS_DB_MAX_OVERFLOW` and
`SM_BG_TASKS_DB_RECYCLE`. Optional `SM_BG_TASKS_DB_STATEMENT_TIMEOUT_MS`
overrides the host statement limit, including zero to omit it. Restart workers
after changing these settings.
48 changes: 48 additions & 0 deletions docs/framework/cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# CORS

Cross-origin access is opt-in per path. A request no policy matches passes
through untouched and gets no CORS headers.

## Host API policy

Set `cors_allow_origins` (Settings admin screen, group CORS, or
`SM_CORS_ALLOW_ORIGINS`) and the host registers one policy on the `/api/` prefix.
Empty (the default) registers nothing.

| Setting | Default |
|---|---|
| `cors_allow_origins` | `[]` (no policy) |
| `cors_allow_credentials` | `true` |
| `cors_allow_methods` | `GET, POST, PUT, PATCH, DELETE` |
| `cors_allow_headers` | `Authorization, Content-Type, X-CSRF-Token` |
| `cors_expose_headers` | `[]` |
| `cors_max_age` | `600` |

`*` is refused while `cors_allow_credentials` is on. All of these are
restart-required: the middleware is built at boot.

## Module policies

A module with a public, cross-origin surface declares it in
`register_cors_policies(registry)`:

```python
def register_cors_policies(self, registry):
registry.add_regex(
PUBLIC_PATH_PATTERN,
CorsPolicy(origins=("*",), allow_credentials=False, methods=("GET", "HEAD")),
enabled=lambda: self._toggle_is_on(),
)
```

`add_exact`, `add_prefix` and `add_regex` match on the request path. The most
specific match wins (exact, then regex, then longest prefix); a tie keeps the
first registration and logs a warning. `enabled=` is evaluated per request, so a
DB-backed toggle needs no restart.

## Where it runs

`CorsMiddleware` sits between `RequestLogging` and `BodyLimit`. A preflight is
answered before auth, rate limiting, the setup gate or maintenance mode can
reject it, and a `413` from `BodyLimit` still carries the CORS headers the
browser needs to read it. One Starlette `CORSMiddleware` is built per policy.
7 changes: 7 additions & 0 deletions docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ register_invalidations
register_health_checks
register_public_routes
register_body_limits
register_rate_limits
register_csp_sources
register_design_packs
register_audit_links
Expand Down Expand Up @@ -161,6 +162,12 @@ def register_body_limits(self, registry) -> None:

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

## `register_rate_limits(registry)`

Per-route rate limits for anonymous and signed-in requests, keyed by user, IP
or tenant: `registry.add_prefix("/api/x/", RateLimitRule("10/minute", key="user"))`.
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
9 changes: 9 additions & 0 deletions docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,23 @@ app.add_middleware(CorrelationIdMiddleware)

if settings.trusted_proxy:
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=...)
# ForwardedHost is added after ProxyHeaders so it runs first (still sees the proxy peer)
# Added last → executed first (outermost wrapper)
```

## Execution order (per request)

```
ForwardedHost (only if SM_TRUSTED_PROXY and a forwarded-host allowlist are set)
↓
ProxyHeaders (only if SM_TRUSTED_PROXY is set)
↓
CorrelationId
↓
RequestLogging
↓
CORS (preflights answered here, before auth / rate limit)
↓
BodyLimit (413 on an oversized body)
↓
GZip
Expand Down Expand Up @@ -94,6 +99,10 @@ It is still load-bearing for anything else that reads `request.url.scheme` or ca

Set it to a comma-separated list of proxy IPs/CIDRs, or `*` to trust any peer — correct when the container is only reachable through one proxy, wrong when anything else can connect.

### `ForwardedHostMiddleware` *(opt-in)*

Installed only when **both** `SM_TRUSTED_PROXY` and the `forwarded_host_allowlist` host setting (restart-required) are set. uvicorn's `ProxyHeadersMiddleware` fixes scheme and client but not the host, so behind a proxy that rewrites `Host`, `request.url_for(...)` and the tenants subdomain resolver see the upstream name. This middleware replaces the `host` header and `scope["server"]` with the rightmost `X-Forwarded-Host` value (the one the trusted proxy added) when the TCP peer is trusted (same `*` / IP / CIDR rule as uvicorn) **and** the value matches the allowlist: an exact host or `*.example.com` (any subdomain, never the apex), each with an optional `:port` (an entry without a port accepts any port). Anything else is ignored and logged at debug, so a forged header cannot poison absolute URLs. It is added after `ProxyHeaders` so it runs first and still sees the proxy's own address. `Forwarded:` (RFC 7239) is not supported. Email links are unaffected: they are built from configured base URLs (for example `tenants.public_base_url`), never from the request.

### `CorrelationIdMiddleware`

Reads the `X-Correlation-ID` header (or generates a UUID4 hex) and makes the value available three ways:
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ carries no tenant never blanks a row already stamped.
does — and its answer is final. The `tenants` resolver takes, in order: the
subdomain (with its `subdomain_base` setting — the one source that also works
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
then the session's choice validated against a membership (#363). Behind a proxy that rewrites `Host`, the subdomain is read from `X-Forwarded-Host` only if `SM_TRUSTED_PROXY` and `forwarded_host_allowlist` are set and the allowlist contains `*.<subdomain_base>` (see [middleware](middleware.md)). 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.
The `users` module's `User` is platform-global and has no `tenant_id`
Expand Down
4 changes: 4 additions & 0 deletions docs/framework/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ dependency) applies a fail-closed rule to every route:
refuses tokens with 403, gated or not;
- `Depends(TokenAllowed())` from `simple_module_hosting.token_guard` admits
tokens explicitly, without a permission;
- `Depends(Authenticated())` marks "any signed-in user may call this" (401 with
no principal). It satisfies diagnostic `SM031` and the OpenAPI export but is
**not** a token opt-in: alone, a token is still refused; add `TokenAllowed()`
to admit one too;
- every other route refuses tokens with 403 — except a public route, where the
token's principal is dropped and the request runs anonymously (no user, no
permissions, and the tenant an anonymous request would resolve to).
Expand Down
34 changes: 34 additions & 0 deletions docs/framework/request-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,40 @@ def register_public_routes(self, registry) -> None:
registry.add_prefix("/api/gis/stac", rate="off") # exempt this rule
```

### Per-route limits: `register_rate_limits`

Public-route `rate=` only covers anonymous traffic. To bound a signed-in
action (a POST that sends mail, an export), register a rule; it applies to
anonymous and signed-in requests, before the tiers above:

```python
from simple_module_core.rate_limit import RateLimitRule


def register_rate_limits(self, registry) -> None:
registry.add_exact("/api/invites", RateLimitRule("10/minute", key="user", methods={"POST"}))
registry.add_prefix("/api/reports/", RateLimitRule("30/hour", key="tenant"))
```

`key` picks whose budget it is: `user` (the principal id; an API token counts as
its owner), `ip` (the client address, after `ProxyHeaders`), `user_or_ip` (the
default) or `tenant` (the resolved tenant). Anonymous requests, and a tenant
that did not resolve, fall back to the IP. Two users behind one NAT each get the
full rate. The most specific rule wins (exact, regex, longest prefix); `enabled=`
is evaluated per request. Buckets are named
`rule:<kind>:<methods>:<pattern>:<keykind>=<id>`.

For a single handler, use the dependency (same store and key resolution; `429`
with `Retry-After`):

```python
from simple_module_hosting.rate_limit_deps import RateLimit

@router.post("/send", dependencies=[Depends(RateLimit("10/minute", key="user"))])
```

`rate_limit_authenticated` is now counted per user, not per IP.

### Storage

- **Redis** when `SM_REDIS_URL` is set (needs the `redis` package, which
Expand Down
18 changes: 18 additions & 0 deletions docs/guide/openapi-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ The bare `/openapi.json` is gone. It used to be served in production too; use
`/api/openapi.json`.
:::

## Security schemes

The schema declares two `components.securitySchemes`: `bearerAuth` (`http`,
`bearer` — personal access tokens, service keys and Keycloak access tokens) and
`sessionCookie` (`apiKey` in a cookie named `session`). Every operation carries
`security`: `[]` when anonymous callers are let through (a `register_public_routes`
rule matched on the templated path with each `{param}` replaced by a placeholder
segment, the auth provider's legacy public paths, or the schema URL itself), and
`[{bearerAuth: []}, {sessionCookie: []}]` otherwise, so a generated client sends
credentials only where needed. `bearerAuth` covers provider access tokens
(e.g. Keycloak) on every protected route, while personal API tokens are accepted
only on routes marked `TokenAllowed()`. Rules that need a specific path segment (a regex
on a slug) can over- or under-report here; `AuthMiddleware` stays the authority.

Diagnostic `SM031` warns at a dev boot about an `/api` route with no permission,
`TokenAllowed` or `Authenticated` gate that is not public (see
[diagnostic codes](/reference/diagnostic-codes)).

## Exporting without a server

```bash
Expand Down
10 changes: 10 additions & 0 deletions docs/modules/background_tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,13 @@ def convert(file_id: int) -> None:
```

It reads SYSTEM-scope overrides through the worker's sync session and caches per process for 30 seconds (`ttl=0` always re-reads; `clear_settings_cache()` drops the cache). The underlying sync helper is `simple_module_db.stored_settings.hydrate_settings_sync`.

## Worker database tuning

Sync task sessions and async outbox sessions inherit host pool, connect,
statement and idle-in-transaction timeouts. Worker pools default to size 2,
overflow 3 and recycling after 1800 seconds. Set `SM_BG_TASKS_DB_POOL_SIZE`,
`SM_BG_TASKS_DB_MAX_OVERFLOW`, `SM_BG_TASKS_DB_RECYCLE` and optionally
`SM_BG_TASKS_DB_STATEMENT_TIMEOUT_MS` to tune them; restart workers afterward.
Each outbox thread owns a separate pool. PgBouncer transaction pooling rejects
timeout startup parameters; see [database tuning](../database/tuning.md).
7 changes: 7 additions & 0 deletions docs/modules/file_storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ Set it with `public=true` on upload or `PATCH /files/{id}`; both need
so consumers never build the URL themselves — an `<img src>` on a public page
can use it, or `.../public/{id}/thumbnail?w=256`.

Public file reads are readable cross-origin: any origin, no credentials, `GET`/`HEAD`/`OPTIONS`,
`Range`, `If-Range` and `If-None-Match` allowed, and `Content-Range`, `Accept-Ranges`, `ETag` and
`Content-Length` exposed, so a page on another site can seek in a video or PDF. The DB-backed
setting `public_files_cors` (default on) turns this off without a restart. With the S3 backend a
read can redirect to a presigned bucket URL; the browser then applies the bucket's own CORS
configuration, which you must set on the bucket.

The public routes are exempt from `AuthMiddleware` through
`register_public_routes` (GET only; uploads, PATCH and deletes stay gated).
Serving rules:
Expand Down
Loading
Loading