feat(19.7-01): in-process dispatch marker and compiled handler lookup

- bouncer.Dispatch, WithDispatch, Dispatched: context-only pre-resolved
  principal marker honoured by Registry.Middleware for listed guard names
- cabana.ResolveBackend honours a backend-kind marker; additive
  ResolveBackendToken, ScopeLimitFor and BackendPrincipal resolvers
- surf.Router.Handler and compileHandlers: one compiled chain per route,
  shared by BuildRouter validation and compile
- smoke tests TestDispatchSmoke, TestDispatchedSmoke, TestDispatchMarkerSmoke
- READMEs and docs/services/{authentication,routing}.md updated
This commit is contained in:
Jakub Zych
2026-10-22 16:00:19 +02:00
parent a74a218cd9
commit 1bd91a4522
14 changed files with 522 additions and 11 deletions

View File

@@ -126,6 +126,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
| `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. |
| `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. |
| `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. |
| `surf.Router.Handler` | Returns the compiled middleware chain of one registered route by method and pattern, for an in-process dispatch that must run the same guards, throttles, body limit, locale and recovery as an HTTP request. False for an unknown route or before `surf.BuildRouter` compiled the routes. |
| `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. |
| `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. |
| `surf.Use` | Builds a middleware name list for a group. |

View File

@@ -0,0 +1,142 @@
package surf
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Smoke test for the in-process dispatch tracer of phase 19.7 (D-17): a
// request carrying the bouncer dispatch marker runs a guarded route through
// surf's compiled chain as the pre-resolved principal, while nothing an HTTP
// request can carry is treated as one.
// refusingGuard never authenticates a request and writes its own 401.
type refusingGuard struct{}
func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
return nil, nil, errors.New("acme: no token")
}
func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`))
}
// dispatchPlugin registers bouncer guard "acme" as middleware "acme.auth"
// and one guarded route.
type dispatchPlugin struct{}
func (dispatchPlugin) ID() string { return "acme.items" }
func (dispatchPlugin) Requires() []string { return nil }
func (dispatchPlugin) Register(*backpack.App) error { return nil }
func (dispatchPlugin) Boot(*backpack.App) error { return nil }
func (p dispatchPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
func (dispatchPlugin) Routes(r pact.Router) error {
r.Group("/acme", Use("acme.auth"), func(g pact.Router) {
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
user, _ := bouncer.User(req.Context())
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"user":` + itoa(user.ID) + `,"item":"` + req.PathValue("id") + `"}`))
})
})
return nil
}
func itoa(n uint) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func TestDispatchSmoke(t *testing.T) {
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits})
router, err := BuildRouter(app, []party.Plugin{dispatchPlugin{}})
if err != nil {
t.Fatal(err)
}
if _, ok := router.Handler("GET", "/acme/items/{id}/missing"); ok {
t.Fatal("Handler found an unregistered route")
}
h, ok := router.Handler("GET", "/acme/items/{id}")
if !ok || h == nil {
t.Fatal("Handler did not find GET /acme/items/{id} after BuildRouter")
}
var nilRouter *Router
if _, ok := nilRouter.Handler("GET", "/acme/items/{id}"); ok {
t.Fatal("nil router found a handler")
}
serve := func(set func(*http.Request) *http.Request) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/acme/items/7", nil)
req.SetPathValue("id", "7")
req.Pattern = "GET /acme/items/{id}"
req = set(req)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
marker := func(guards ...string) func(*http.Request) *http.Request {
return func(req *http.Request) *http.Request {
d := &bouncer.Dispatch{Guards: guards, Principal: &bouncer.Principal{ID: 42}}
return req.WithContext(bouncer.WithDispatch(req.Context(), d))
}
}
rec := serve(marker("acme"))
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"user":42,"item":"7"}` {
t.Fatalf("dispatch with the marker = %d %s", rec.Code, rec.Body.String())
}
if rec := serve(func(req *http.Request) *http.Request { return req }); rec.Code != http.StatusUnauthorized {
t.Fatalf("request without the marker = %d %s", rec.Code, rec.Body.String())
}
forged := serve(func(req *http.Request) *http.Request {
req.Header.Set("X-Summer-Dispatch", "42")
req.Header.Set("Authorization", "Dispatch 42")
req.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "42"})
return req
})
if forged.Code != http.StatusUnauthorized {
t.Fatalf("forged headers = %d %s", forged.Code, forged.Body.String())
}
if rec := serve(marker("other")); rec.Code != http.StatusUnauthorized {
t.Fatalf("marker for another guard = %d %s", rec.Code, rec.Body.String())
}
// The assembled handler serves the same route over HTTP unchanged.
full, err := router.compile()
if err != nil {
t.Fatal(err)
}
rec = httptest.NewRecorder()
full.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/items/7", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("HTTP request without a token = %d %s", rec.Code, rec.Body.String())
}
}

View File

@@ -65,6 +65,9 @@ type Router struct {
defaultBytes int64
uploadBytes int64
built map[string]pact.Middleware
// handlers holds the compiled middleware chain of every route, keyed
// "METHOD /pattern", filled by compileHandlers; Handler reads it.
handlers map[string]http.Handler
app *backpack.App
adminPrefix string
adminMounted bool
@@ -351,17 +354,42 @@ func (r *Router) add(pluginID, prefix string, groupMW []string, method, path str
})
}
// compileHandlers wraps every registered route that has no compiled chain
// yet (constraints, named middleware, body limit, locale, recovery, CORS
// profile and the access-log pattern) and records it under "METHOD /pattern".
// BuildRouter calls it to validate the routes, compile reuses the chains and
// Handler hands one out for an in-process dispatch.
func (r *Router) compileHandlers() error {
if r == nil {
return fmt.Errorf("surf: router is nil")
}
if r.handlers == nil {
r.handlers = make(map[string]http.Handler)
}
for _, rt := range r.routes {
key := rt.method + " " + rt.path
if _, done := r.handlers[key]; done {
continue
}
h, err := r.wrap(rt)
if err != nil {
return err
}
r.handlers[key] = h
}
return nil
}
func (r *Router) compile() (http.Handler, error) {
if r.compileErr != nil {
return nil, r.compileErr
}
if err := r.compileHandlers(); err != nil {
return nil, err
}
handlers := make([]http.Handler, len(r.routes))
for i, rt := range r.routes {
h, err := r.wrap(rt)
if err != nil {
return nil, err
}
handlers[i] = h
handlers[i] = r.handlers[rt.method+" "+rt.path]
}
// Routes that ServeMux refuses side by side but Where constraints keep
// apart register as overlap families (overlap.go); the rest as before.
@@ -652,10 +680,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
r.pages.setAdminPrefix(admin.Prefix)
r.adminMounted = true
}
for _, rt := range r.routes {
if _, err := r.wrap(rt); err != nil {
return nil, err
}
if err := r.compileHandlers(); err != nil {
return nil, err
}
warnTurnstileWithoutSecret(app, turnstile, r.routes)
if err := r.addCORSPreflights(); err != nil {

View File

@@ -1,5 +1,7 @@
package surf
import "net/http"
// RouteInfo is a read-only snapshot of one registered route after Assemble
// or BuildRouter.
type RouteInfo struct {
@@ -37,3 +39,17 @@ func (r *Router) Routes() []RouteInfo {
}
return out
}
// Handler returns the compiled middleware chain of the route registered for
// method and pattern (as listed by Routes), for an in-process dispatch that
// must run the same constraints, guards, throttles, body limit, locale and
// recovery as an HTTP request. The caller sets the path values and Pattern
// on its request before serving, as the mux would. False when no such route
// exists or BuildRouter has not compiled the routes yet.
func (r *Router) Handler(method, pattern string) (http.Handler, bool) {
if r == nil || r.handlers == nil {
return nil, false
}
h, ok := r.handlers[method+" "+pattern]
return h, ok && h != nil
}