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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ SM_SECRET_KEY=change-me-in-production
# Dev-only: Vite asset URL (ignored in production builds)
SM_VITE_DEV_URL=http://localhost:5050

# Host-level anonymous-access path prefixes (JSON array). Escape hatch for
# exposing a route without a session when no module owns it. Modules should
# prefer the method-aware `register_public_routes` hook instead.
# SM_AUTH_PUBLIC_PATHS=["/api/integrations/webhook", "/status"]

# First-boot admin seed (optional). Only applied when the users table is empty.
# Leave unset and use `uv run sm-users create-admin` instead if you prefer.
# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
Expand Down
Binary file added .verify/01-public-landing-anonymous.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .verify/02-protected-redirects-to-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .verify/03-dashboard-authenticated.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .verify/04-public-route-mechanism.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions .verify/qa-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# QA Report: Auth-gating (issue #191 public-routes extension point)

**Date:** 2026-06-05
**Tester:** Claude QA (Senior)
**Target:** http://localhost:8000 (API) + Vite :5050
**Depth:** scoped (backend middleware change — no new UI)
**Iteration:** 1 of 3

## Scope rationale

Issue #191 adds a method-aware public-route extension point to `AuthMiddleware`.
It is a **backend change with no new UI**. The risk surface is therefore not a
page to fuzz, but **which routes the middleware gates vs. lets through
anonymously**. This QA validates exactly that, in a real browser + at the HTTP
layer, including a live end-to-end exercise of the new mechanism.

## Summary
| Category | Passed | Failed | Skipped |
|----------|--------|--------|---------|
| Public routes (anonymous load) | 3 | 0 | 0 |
| Protected-route gating | 2 | 0 | 0 |
| Login + authenticated access | 2 | 0 | 0 |
| New public-route mechanism (live) | 2 | 0 | 0 |
| **Total** | **9** | **0** | **0** |

## Critical / Major / Minor Issues
None.

## Observations (P3 — not bugs)
- **OBS-001** `GET /favicon.ico → 404` appears in the console on every page.
Cosmetic, pre-existing, unrelated to #191 (no favicon shipped by the host).
- **OBS-002** Dev-workspace has both `users` and `keycloak` installed as entry
points, so a bare boot trips `SM020` (multiple auth providers). Pre-existing,
unrelated to #191; worked around by `SM_MODULES_ENABLED` excluding Keycloak
(the same approach the existing test suite uses).

## Passed Tests
| # | Scenario | Result | Evidence |
|---|----------|--------|----------|
| TEST-001 | Public `/` loads anonymously | PASS | 00-landing-anonymous.png |
| TEST-002 | Public `/users/login` loads anonymously | PASS | 01-protected-redirects-to-login.png |
| TEST-003 | `/health` 200; `/api/users/auth/` 404 (passed gating) | PASS | curl smoke |
| TEST-004 | Protected `/dashboard` → 302 → login (anonymous) | PASS | 01-protected-redirects-to-login.png |
| TEST-005 | Protected API `/api/users/admin` → 401 (no redirect) | PASS | curl smoke |
| TEST-006 | Login as admin establishes session | PASS | 02-dashboard-authenticated.png |
| TEST-007 | Authenticated `/dashboard` renders | PASS | 02-dashboard-authenticated.png |
| TEST-008 | `SM_AUTH_PUBLIC_PATHS` flips gating live: 302 → 404 | PASS | 03-public-route-mechanism-404-not-redirect.png |
| TEST-009 | Control: unlisted path stays gated (302) | PASS | curl smoke |

## Verdict
**ALL CLEAN.** The #191 change preserves every existing auth-gating behavior
(public routes load anonymously; protected routes redirect/401; login works;
authenticated access works) and the new public-route mechanism works end-to-end
in the live app with no over-matching. No bugs found → no fix loop required.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ modules/<name>/<name>/
```

**Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed:
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order).
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).

**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request:
`CorrelationId → RequestLogging → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → app`. 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.
Expand Down
19 changes: 19 additions & 0 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,25 @@ a 302 redirect to the provider's login URL.
Boot-time diagnostic `SM020` fails if multiple auth providers are installed.
`SM021` warns if none is installed.

### Public routes (anonymous access)

To expose a route without a session — a read-only STAC / OGC API, a TileJSON
endpoint, an inbound webhook — a module overrides `register_public_routes`:

```python
def register_public_routes(self, registry):
registry.add_prefix("/api/gis/stac")
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"})
```

Rules are **method-aware**, so a GET read route can be exempted while sibling
`POST`/`PATCH` mutations under the same prefix stay gated. The host aggregates
every module's rules into one `PublicRouteRegistry` (plus host-level
`SM_AUTH_PUBLIC_PATHS` prefixes) and publishes it at `app.state.public_routes`,
which `AuthMiddleware` consults on every request. See
[`docs/framework/public-routes.md`](framework/public-routes.md) for match kinds
and resolution order.

## Events

Base class: `Event` from `simple_module_core.events`. Subclass per domain event:
Expand Down
102 changes: 102 additions & 0 deletions docs/framework/public-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Public routes (anonymous access)

`AuthMiddleware` (in `auth/middleware.py`) gates **every** request behind the
active auth provider — unauthenticated browser requests get a 302 to the login
URL, unauthenticated API requests get a 401. Modules that need to expose a
route without a session (a read-only STAC / OGC API, a TileJSON endpoint, an
inbound webhook, a status page) declare those routes through the
`register_public_routes` hook. The host collects every module's contributions
into one `PublicRouteRegistry` at boot, and the middleware consults it on every
request.

This is the supported alternative to the legacy `AuthProvider.get_public_paths`
contract, which is **prefix-only and method-agnostic** — it cannot express
"expose `GET /api/gis/datasets/{id}/tilejson` but keep `PATCH …/visibility`
authenticated" because read and write routes share a prefix.

## The hook

```python
from simple_module_core import ModuleBase, ModuleMeta, PublicRouteRegistry

class GisModule(ModuleBase):
meta = ModuleMeta(name="Gis", route_prefix="/api/gis")

def register_public_routes(self, registry: PublicRouteRegistry) -> None:
# Whole read-only subtrees — any verb, any subpath.
registry.add_prefix("/api/gis/stac")
registry.add_prefix("/api/gis/ogc/")
# A single anonymous search endpoint.
registry.add_exact("/api/gis/catalog/search")
# A GET read route nested under a mutation-bearing prefix: expose the
# read, keep POST/PATCH siblings gated.
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"})
```

The hook runs once at boot, in dependency order, alongside the other
`register_*` registration hooks.

## Match kinds

A `PublicRoute` is **method-aware** and supports four match kinds. Every helper
takes an optional `methods=` set (case-insensitive); omitting it means *any*
verb matches.

| Helper | Matches when | Use for |
|---|---|---|
| `registry.add_prefix(p)` | `path.startswith(p)` | a whole read-only subtree |
| `registry.add_exact(p)` | `path == p` | a single endpoint |
| `registry.add_suffix(p)` | `path.endswith(p)` | a tail shared across resources |
| `registry.add_regex(p)` | `re.match(p, path)` (anchored at start) | a read route nested under a mutation prefix |

`registry.add(route_or_pattern, *, methods=, kind=)` is the general form;
pass a prebuilt `PublicRoute` or a string.

## Why method-awareness matters

`/api/gis/datasets/{id}/` carries both reads and mutations:

- `GET /api/gis/datasets/{id}/tilejson` — safe to expose anonymously
- `PATCH /api/gis/datasets/{id}/visibility` — must stay authenticated
- `POST /api/gis/datasets/{id}/reprocess` — must stay authenticated

A prefix rule would open all three. A method-scoped regex
(`methods={"GET"}`) exempts only the read, so the mutations keep returning 401
to anonymous callers.

## Resolution order

For each request, `AuthMiddleware` treats the path as public if **any** of:

1. **Framework defaults** — `/health`, `/static/`, `/api/docs`, `/openapi.json`,
`/i18n/`, the root `/`.
2. **`app.state.public_routes`** — the `PublicRouteRegistry` (module hooks +
`SM_AUTH_PUBLIC_PATHS`). Method-aware.
3. **`provider.get_public_paths()`** — the auth provider's own login / register
routes (legacy, prefix-only). Kept for back-compat.

A public path still resolves the user when a valid session is present (so a
public landing page can show "Open Dashboard" to a logged-in visitor) — it just
never *redirects* an anonymous caller away.

## Host-level escape hatch

When no module owns a route, an app can expose prefixes from the environment
without writing a module:

```bash
SM_AUTH_PUBLIC_PATHS='["/api/integrations/webhook", "/status"]'
```

These are seeded as prefix rules (method-agnostic). Prefer the
`register_public_routes` hook inside a module when you need method-awareness or
want the exemption to travel with the module that owns the route.

## Where it lives

- `PublicRoute` / `PublicRouteRegistry` — `simple_module_core.public_routes`
- The hook — `ModuleBase.register_public_routes`
- Wiring — `simple_module_hosting.app_builder.create_app` populates the
registry and publishes it at `app.state.public_routes` (also
`app.state.sm.public_routes`)
- Enforcement — `auth.middleware.AuthMiddleware`
3 changes: 3 additions & 0 deletions framework/core/simple_module_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
from simple_module_core.module import ModuleBase, ModuleMeta
from simple_module_core.permissions import PermissionRegistry
from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
from simple_module_core.services import Services
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility

Expand Down Expand Up @@ -60,6 +61,8 @@
"ModuleMeta",
"NotFoundError",
"PermissionRegistry",
"PublicRoute",
"PublicRouteRegistry",
"Services",
"Translator",
"ValidationError",
Expand Down
1 change: 1 addition & 0 deletions framework/core/simple_module_core/diagnostics/_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def _check_empty_modules(self, modules: list[ModuleBase]) -> list[Diagnostic]:
"register_event_handlers",
"register_middleware",
"register_health_checks",
"register_public_routes",
"register_exception_handlers",
"register_settings",
"template_dirs",
Expand Down
19 changes: 19 additions & 0 deletions framework/core/simple_module_core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from simple_module_core.health import HealthRegistry
from simple_module_core.menu import MenuRegistry
from simple_module_core.permissions import PermissionRegistry
from simple_module_core.public_routes import PublicRouteRegistry


@dataclass(frozen=True)
Expand Down Expand Up @@ -118,6 +119,24 @@ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) ->
def register_health_checks(self, registry: HealthRegistry) -> None:
"""Contribute health checks for the ``/health/ready`` endpoint."""

def register_public_routes(self, registry: PublicRouteRegistry) -> None:
"""Declare routes that must bypass authentication (anonymous access).

``AuthMiddleware`` gates every request behind the active auth provider.
Override this hook to exempt read-only or webhook routes that are meant
to be reached without a session — e.g. a STAC / OGC API surface::

def register_public_routes(self, registry):
registry.add_prefix("/api/gis/stac")
registry.add_regex(
r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}
)

Rules are method-aware, so a GET read route nested under a prefix that
also carries POST/PATCH mutations can be exempted without opening the
mutations. Called once at boot, in dependency order.
"""

def register_middleware(self, app: FastAPI) -> None:
"""Add middleware to the application.

Expand Down
129 changes: 129 additions & 0 deletions framework/core/simple_module_core/public_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Public-route registry — modules declare routes the auth layer must NOT gate.

``AuthMiddleware`` gates every request behind the active auth provider. Modules
that expose anonymous read APIs (STAC / OGC API / TileJSON, public webhooks,
status pages) contribute exemptions here via
:meth:`~simple_module_core.module.ModuleBase.register_public_routes`. The host
collects them into one registry at boot and the middleware consults it on every
request.

Unlike the legacy ``AuthProvider.get_public_paths`` contract — a flat tuple of
prefixes matched with ``str.startswith`` — a :class:`PublicRoute` is
**method-aware** and supports prefix / exact / suffix / regex matching. That
lets a module expose ``GET /api/gis/datasets/{id}/tilejson`` while leaving
``PATCH``/``POST`` siblings under the same prefix authenticated.
"""

from __future__ import annotations

import re
from collections.abc import Iterable

_MatchKind = str # one of: "prefix" | "exact" | "suffix" | "regex"
_VALID_KINDS = ("prefix", "exact", "suffix", "regex")


class PublicRoute:
"""A single anonymous-access rule.

Args:
pattern: The path (or path fragment / regex) to match against
``request.url.path``.
methods: HTTP methods this rule applies to (case-insensitive). ``None``
(the default) means *any* method — the rule matches every verb.
kind: How ``pattern`` is interpreted — ``"prefix"`` (default, matches
any path that starts with it), ``"exact"``, ``"suffix"``, or
``"regex"`` (anchored at the start of the path via ``re.match``).
"""

__slots__ = ("_regex", "kind", "methods", "pattern")

def __init__(
self,
pattern: str,
*,
methods: Iterable[str] | None = None,
kind: _MatchKind = "prefix",
) -> None:
if kind not in _VALID_KINDS:
raise ValueError(f"Unknown match kind {kind!r}; expected one of {_VALID_KINDS}")
self.pattern = pattern
self.methods: frozenset[str] | None = (
None if methods is None else frozenset(m.upper() for m in methods)
)
self.kind = kind
self._regex = re.compile(pattern) if kind == "regex" else None

def matches(self, method: str, path: str) -> bool:
"""Return ``True`` if *method* + *path* are exempt under this rule."""
if self.methods is not None and method.upper() not in self.methods:
return False
if self.kind == "prefix":
return path.startswith(self.pattern)
if self.kind == "exact":
return path == self.pattern
if self.kind == "suffix":
return path.endswith(self.pattern)
assert self._regex is not None # kind == "regex"
return self._regex.match(path) is not None

def __repr__(self) -> str:
methods = "*" if self.methods is None else ",".join(sorted(self.methods))
return f"PublicRoute({self.pattern!r}, kind={self.kind!r}, methods={methods})"


class PublicRouteRegistry:
"""Aggregates every module's :class:`PublicRoute` rules.

Populated once during boot (``register_public_routes`` hook) and read on
every unauthenticated request by ``AuthMiddleware`` — effectively immutable
after the registration phase.
"""

def __init__(self) -> None:
self._routes: list[PublicRoute] = []

def add(
self,
route: PublicRoute | str,
*,
methods: Iterable[str] | None = None,
kind: _MatchKind = "prefix",
) -> None:
"""Register a rule — either a prebuilt :class:`PublicRoute` or a pattern.

Passing a string builds a :class:`PublicRoute` from ``methods``/``kind``;
passing a :class:`PublicRoute` ignores those keyword arguments.
"""
if isinstance(route, PublicRoute):
self._routes.append(route)
else:
self._routes.append(PublicRoute(route, methods=methods, kind=kind))

def add_prefix(self, prefix: str, *, methods: Iterable[str] | None = None) -> None:
"""Exempt any path starting with *prefix*."""
self._routes.append(PublicRoute(prefix, methods=methods, kind="prefix"))

def add_exact(self, path: str, *, methods: Iterable[str] | None = None) -> None:
"""Exempt exactly *path*."""
self._routes.append(PublicRoute(path, methods=methods, kind="exact"))

def add_suffix(self, suffix: str, *, methods: Iterable[str] | None = None) -> None:
"""Exempt any path ending with *suffix*."""
self._routes.append(PublicRoute(suffix, methods=methods, kind="suffix"))

def add_regex(self, pattern: str, *, methods: Iterable[str] | None = None) -> None:
"""Exempt any path whose start matches *pattern* (``re.match`` semantics)."""
self._routes.append(PublicRoute(pattern, methods=methods, kind="regex"))

def matches(self, method: str, path: str) -> bool:
"""Return ``True`` if any registered rule exempts *method* + *path*."""
return any(route.matches(method, path) for route in self._routes)

@property
def routes(self) -> list[PublicRoute]:
"""All registered rules (a copy — mutating it doesn't affect the registry)."""
return list(self._routes)


__all__ = ["PublicRoute", "PublicRouteRegistry"]
Loading
Loading