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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ All notable changes to this project are documented in this file. The format is b

## [Unreleased]

### Added
- `tenants.contracts.UsageMeter`: an implementation-free usage-metering protocol next to
`EntitlementProvider` (`check`, `consume`, `record`, `refund`, `reserve`/`commit`/`release`,
`summary`) with `UsageOutcome`/`UsageReservation`/`UsageSummaryRow`, `UsageExceededError`,
an `UnlimitedUsageMeter` default on `app.state.tenants.usage` and `get_usage_meter(app)` (#616).
- `simple_module_hosting.csrf.rotate_csrf_token(request)` for principal changes that keep the
session (#615).

### Security
- Sign-in now rotates the session: users' `on_after_login` (password, OAuth, accept-invite)
clears it and keeps only the pending post-login redirect, so a CSRF token (or any other
session state) issued before sign-in no longer survives it. Keycloak sign-in rotates the CSRF
token (#615). **Behaviour change:** session keys a module stored
before sign-in are dropped at sign-in.
`simple_module_hosting.session.preserve_across_sign_in(app, key)` lets a module keep browser-bound
gate state across it; site_lock uses it so the unlock marker survives sign-in.

## [0.0.39] — 2026-10-24

### Added
Expand Down
13 changes: 13 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,19 @@ Callers echo the token back as `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`;
safe methods are never checked, and apps without `SessionMiddleware` (bare
test apps) are exempt.

Signing in through users (password, OAuth, invite acceptance or demo) clears
and rebuilds the signed-cookie session, preserving only the pending login
redirect and any key a module registered with
`simple_module_hosting.session.preserve_across_sign_in(app, key)` (browser-bound
gate state such as the site-lock unlock marker). Locale lives in a separate cookie. This discards the pre-login CSRF
token and cached principal; the next Inertia render supplies a fresh
`csrfToken` shared prop. Clients must read that prop again after sign-in.
Keycloak sign-in retains the session and calls
`rotate_csrf_token(request)` to discard its token (switching the active tenant
keeps the same principal and the same token). Use this public helper for
other principal changes that intentionally keep the session; clearing the
session already rotates the token. A stale `X-CSRF-Token` receives `403`.

### Error responses (HTML vs JSON)

401, 403, 404, 419, 422, 429, 500 and 503 are content-negotiated. Requests
Expand Down
36 changes: 36 additions & 0 deletions docs/modules/tenants.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,42 @@ The module ships no billing, but a billing module needs nothing more from it:
`TenantService.delete_tenant` — subscribe to clean up per-tenant state),
`MembershipAdded`, `MembershipRemoved`, `InvitationCreated`.

### Usage metering

`tenants.contracts` exports the runtime-checkable `UsageMeter` protocol and
frozen value objects `UsageOutcome`, `UsageReservation`, and `UsageSummaryRow`.
Consumers import these contracts instead of importing billing. A billing module
installs its implementation during startup with `app.state.tenants.usage = provider`.
`get_usage_meter(app)` returns that provider, or an `UnlimitedUsageMeter` when
tenants state is absent. The tenants services container also defaults to this
unlimited provider: every check and consumption is allowed with `limit=None`
and `used=0`, reservations are issued, accounting operations do nothing, and
summaries are empty. No billing storage or enforcement is included here.

All protocol methods are async and use string tenant IDs and meter keys such as
`"jobs.concurrent"`:

- `check(tenant_id, meter, quantity=1)` inspects capacity without side effects.
- `consume(tenant_id, meter, quantity=1, *, idempotency_key=None)` atomically
checks and increments usage. A rejection returns `allowed=False` and records
nothing; a successful outcome's `used` includes the consumed quantity.
- `record(tenant_id, meter, quantity, *, idempotency_key=None)` unconditionally
records usage after the fact; `refund` has the same signature and returns usage.
- `reserve(tenant_id, meter, quantity=1, *, ttl=None)` holds capacity and returns
a `UsageReservation`, or `None` if the limit would be exceeded. `ttl` is an
optional `timedelta`. Pass the reservation back to the same provider's
`commit(reservation)` to record it, or `release(reservation)` to return capacity.
- `summary(tenant_id)` returns a sequence of `UsageSummaryRow` objects with
`meter`, `used`, `limit`, and `resets_at`.

`UsageOutcome` carries `meter`, `allowed`, `limit`, `used`, `requested`, and
`resets_at`. `remaining` is `None` for unlimited meters, otherwise
`max(0, limit - used)`. Reset and expiry timestamps may be `None` when absent.
Providers own persistence, period boundaries, idempotency and hold expiry.
`UsageExceededError(outcome)` carries a denied outcome for implementers that
choose to raise it and map it to HTTP 402; the protocol's `consume` returns a
decision rather than requiring an exception.

## Models

`tenants_tenant` (id = the value stored in every `tenant_id`, slug, name,
Expand Down
17 changes: 15 additions & 2 deletions framework/hosting/simple_module_hosting/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

This lifts the design the pagebuilder module shipped first
(``pagebuilder/security.py``) into the framework, so every module shares
one header name and one token-discovery convention.
one header name and one token-discovery convention. Sign-in clears the users
session, rotating its token; principal changes that retain the session must
call ``rotate_csrf_token`` before rendering the next page.
"""

from __future__ import annotations
Expand All @@ -28,7 +30,7 @@

from fastapi import HTTPException, Request

__all__ = ["CSRF_HEADER", "RequiresCsrf", "get_csrf_token"]
__all__ = ["CSRF_HEADER", "RequiresCsrf", "get_csrf_token", "rotate_csrf_token"]

CSRF_HEADER = "X-CSRF-Token"
_SESSION_KEY = "sm_csrf_token"
Expand Down Expand Up @@ -61,6 +63,17 @@ def get_csrf_token(request: Request) -> str:
return token


def rotate_csrf_token(request: Request) -> None:
"""Discard the old token; the next page render mints a fresh one.

Use when changing the principal while deliberately retaining the session.
Clearing the whole session already discards the token. No session is a no-op.
"""
session = request.scope.get("session")
if session is not None:
session.pop(_SESSION_KEY, None)


class RequiresCsrf:
"""FastAPI dependency enforcing the CSRF header on unsafe methods.

Expand Down
22 changes: 22 additions & 0 deletions framework/hosting/simple_module_hosting/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@
which would leave the hole this key was added to close.
"""

_SIGN_IN_SURVIVORS_ATTR = "sign_in_preserved_session_keys"


def preserve_across_sign_in(app: Any, key: str) -> None:
"""Keep ``key`` when sign-in replaces the session (it is not user-bound state).

For gate state that belongs to the browser rather than the principal, such
as the site-lock unlock marker: clearing it at sign-in would re-prompt the
visitor who just passed the gate. Call from ``register_settings``.
"""
keys = getattr(app.state, _SIGN_IN_SURVIVORS_ATTR, None)
if keys is None:
keys = set()
setattr(app.state, _SIGN_IN_SURVIVORS_ATTR, keys)
keys.add(key)


def sign_in_preserved_keys(app: Any) -> frozenset[str]:
"""Keys this app registered through :func:`preserve_across_sign_in`."""
return frozenset(getattr(app.state, _SIGN_IN_SURVIVORS_ATTR, ()))


SESSION_COOKIE_NAME = "session"
"""The session cookie's name (Starlette's default, pinned so the OpenAPI export can cite it)."""

Expand Down
29 changes: 29 additions & 0 deletions framework/hosting/tests/test_hosting_csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,32 @@ async def test_enforcement_is_skipped_and_token_empty(self) -> None:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
assert (await c.get("/token")).json()["token"] == ""
assert (await c.post("/things")).status_code == 200


@pytest.mark.parametrize("with_session", [False, True])
def test_rotation_discards_only_csrf_state(with_session):
from simple_module_hosting.csrf import rotate_csrf_token

scope = {"type": "http"}
if with_session:
scope["session"] = {"user_id": "keep"}
request = Request(scope)
old = get_csrf_token(request)
rotate_csrf_token(request)
rotate_csrf_token(request) # safe even before another token has been minted
if with_session:
assert request.session == {"user_id": "keep"}
assert get_csrf_token(request) != old
else:
assert get_csrf_token(request) == ""


def test_sign_in_survivors_registry():
from types import SimpleNamespace

from simple_module_hosting.session import preserve_across_sign_in, sign_in_preserved_keys

app = SimpleNamespace(state=SimpleNamespace())
assert sign_in_preserved_keys(app) == frozenset()
preserve_across_sign_in(app, "gate_marker")
assert sign_in_preserved_keys(app) == {"gate_marker"}
2 changes: 2 additions & 0 deletions modules/keycloak/keycloak/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from fastapi import APIRouter, HTTPException, Request
from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none
from simple_module_hosting.csrf import rotate_csrf_token
from starlette.responses import RedirectResponse

if TYPE_CHECKING:
Expand Down Expand Up @@ -84,6 +85,7 @@ async def oidc_callback(request: Request):
cache_id = await provider._upsert_user_cache(request, claims)
user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id)

rotate_csrf_token(request)
request.session[_SESSION_USER_CTX] = user_ctx.to_session_dict()
request.session[_SESSION_ID_TOKEN] = id_token

Expand Down
46 changes: 46 additions & 0 deletions modules/keycloak/tests/test_keycloak_csrf_rotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""A successful OIDC callback must discard the previous principal's CSRF token."""

from types import SimpleNamespace
from unittest.mock import AsyncMock

from keycloak.endpoints import api
from simple_module_hosting.csrf import get_csrf_token
from starlette.requests import Request


async def test_oidc_callback_rotates_csrf(monkeypatch):
context = SimpleNamespace(to_session_dict=lambda: {"id": "new-user"})
provider = SimpleNamespace(
_upsert_user_cache=AsyncMock(return_value="new-user"),
_claims_to_user_context=lambda claims, cache_id: context,
)
app = SimpleNamespace(
state=SimpleNamespace(
keycloak=SimpleNamespace(
settings=SimpleNamespace(login_redirect_url="/dashboard/"),
jwks_cache=SimpleNamespace(validate_jwt=AsyncMock(return_value={"sub": "new"})),
),
auth=SimpleNamespace(auth_provider=provider),
)
)
request = Request(
{
"type": "http",
"app": app,
"query_string": b"code=code&state=valid",
"session": {"keycloak_oidc_state": "valid", "next": "/deep-link/"},
}
)
request.url_for = lambda name: "https://test/callback"
monkeypatch.setattr(
api,
"_get_oidc_client",
lambda req: SimpleNamespace(
exchange_code=AsyncMock(return_value={"access_token": "access", "id_token": "id"})
),
)
old = get_csrf_token(request)
response = await api.oidc_callback(request)
assert response.headers["location"] == "/deep-link/"
assert request.session["user_ctx"] == {"id": "new-user"}
assert get_csrf_token(request) != old
3 changes: 3 additions & 0 deletions modules/site_lock/site_lock/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.module_settings import register_module_settings
from simple_module_hosting.session import preserve_across_sign_in

from site_lock import constants as c

Expand All @@ -28,6 +29,8 @@ def register_settings(self, app: FastAPI) -> None:
from site_lock.settings import SiteLockSettings
from site_lock.state import SiteLockState

# Signing in replaces the session; the unlock marker must outlive it.
preserve_across_sign_in(app, c.SESSION_KEY)
register_module_settings(
app,
c.MODULE_PACKAGE,
Expand Down
16 changes: 16 additions & 0 deletions modules/tenants/tenants/contracts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,26 @@
UnlimitedEntitlements,
ensure_within_limit,
)
from tenants.contracts.usage import (
UnlimitedUsageMeter,
UsageExceededError,
UsageMeter,
UsageOutcome,
UsageReservation,
UsageSummaryRow,
get_usage_meter,
)

__all__ = [
"EntitlementExceededError",
"EntitlementProvider",
"UnlimitedEntitlements",
"UnlimitedUsageMeter",
"UsageExceededError",
"UsageMeter",
"UsageOutcome",
"UsageReservation",
"UsageSummaryRow",
"ensure_within_limit",
"get_usage_meter",
]
Loading
Loading