fix(ingestion): close the PR #152 review findings on REST auth
- main.go's wiring was untested (the test built its own copy of the router, so dropping auth in main or re-registering POST /query as public stayed green). Routing is now one function, buildMux, which main calls and the tests call: it constructs the bearer middleware itself, registers public handlers only for patterns on a fixed allowlist (a public REST pattern would beat the authenticated catch-all by specificity), refuses an empty token, and returns an http.Handler so nothing can add a route to the result. - hyperguild CLI: PassRate sent no token and would 401. Every request now goes through brainClient.do, which adds it; the test covers Query, Write and PassRate and is named for that. - ResolveIngestPath returns one refusal for every bad path; the raw EvalSymlinks error let a caller probe which server files exist and leaked the brain directory. - PROJECT.md (and derived adapters) and the pass-rate runbook state that the REST API needs BRAIN_MCP_TOKEN and is tailnet/in-cluster. Refs #149 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
@@ -18,22 +17,27 @@ import (
|
||||
|
||||
const routesTok = "routes-test-token"
|
||||
|
||||
// hyperguild#149: only /mcp was authenticated; every REST route on the same
|
||||
// mux was open, and infra#517 found them on the internet. The REST API is now
|
||||
// mounted at "/" behind the bearer middleware, so any route not registered
|
||||
// as explicitly public is authenticated, including routes added later.
|
||||
func testRouter(t *testing.T) http.Handler {
|
||||
func okHandler(body string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
|
||||
}
|
||||
|
||||
// testMux builds the router exactly as main does: buildMux with the full
|
||||
// public set main registers (stub handlers) and real bearer config.
|
||||
// hyperguild#149: the REST API is authenticated as a whole; the PR #152
|
||||
// review showed a test of a COPY of the router let main.go regress unseen.
|
||||
func testMux(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
mountREST(mux, h, func(next http.Handler) http.Handler {
|
||||
return chassisauth.BearerMiddleware(routesTok, nil, "brain", "", next)
|
||||
})
|
||||
public := map[string]http.Handler{}
|
||||
for p := range publicRoutes {
|
||||
public[p] = okHandler("public " + p)
|
||||
}
|
||||
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public)
|
||||
require.NoError(t, err)
|
||||
return mux
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, path, authz string) int {
|
||||
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
|
||||
if authz != "" {
|
||||
@@ -41,34 +45,62 @@ func do(t *testing.T, h http.Handler, method, path, authz string) int {
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
func TestRESTRoutesRefuseRequestsWithoutAToken(t *testing.T) {
|
||||
r := testRouter(t)
|
||||
func TestBuildMuxRefusesRESTRoutesWithoutAValidToken(t *testing.T) {
|
||||
r := testMux(t)
|
||||
for _, rt := range []struct{ method, path string }{
|
||||
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
|
||||
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
|
||||
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
|
||||
} {
|
||||
assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, ""), "%s %s without a token", rt.method, rt.path)
|
||||
assert.Equal(t, http.StatusUnauthorized, do(t, r, rt.method, rt.path, "Bearer wrong"), "%s %s with a wrong token", rt.method, rt.path)
|
||||
code, _ := do(t, r, rt.method, rt.path, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, code, "%s %s without a token", rt.method, rt.path)
|
||||
code, _ = do(t, r, rt.method, rt.path, "Bearer wrong")
|
||||
assert.Equal(t, http.StatusUnauthorized, code, "%s %s with a wrong token", rt.method, rt.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTRoutesServeAValidToken(t *testing.T) {
|
||||
r := testRouter(t)
|
||||
assert.NotEqual(t, http.StatusUnauthorized, do(t, r, "POST", "/query", "Bearer "+routesTok))
|
||||
assert.Equal(t, http.StatusOK, do(t, r, "GET", "/pending", "Bearer "+routesTok))
|
||||
func TestBuildMuxServesRESTRoutesToAValidToken(t *testing.T) {
|
||||
r := testMux(t)
|
||||
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
}
|
||||
|
||||
// Default-deny: a path nobody registered is still behind auth, so a route
|
||||
// added to the REST mux later cannot ship open by omission.
|
||||
func TestUnregisteredPathIsAuthenticatedByDefault(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, do(t, testRouter(t), "POST", "/some-future-route", ""))
|
||||
func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
|
||||
code, _ := do(t, testMux(t), "POST", "/some-future-route", "")
|
||||
assert.Equal(t, http.StatusUnauthorized, code)
|
||||
}
|
||||
|
||||
// Explicitly public routes registered on the outer mux keep working.
|
||||
func TestMetricsStaysOpenForPrometheus(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, do(t, testRouter(t), "GET", "/metrics", ""))
|
||||
// buildMux registers public handlers as given, without adding auth: each
|
||||
// public route owns its own (bearer on /mcp, HMAC on the webhook, client
|
||||
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata).
|
||||
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
|
||||
r := testMux(t)
|
||||
for p := range publicRoutes {
|
||||
method, path, ok := strings.Cut(p, " ")
|
||||
if !ok {
|
||||
method, path = "GET", p
|
||||
}
|
||||
code, body := do(t, r, method, path, "")
|
||||
assert.Equal(t, http.StatusOK, code, p)
|
||||
assert.Equal(t, "public "+p, body, p)
|
||||
}
|
||||
}
|
||||
|
||||
// The allowlist is the control: a REST pattern registered as public would win
|
||||
// over the authenticated catch-all (more specific pattern), so it is refused.
|
||||
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
|
||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
|
||||
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")})
|
||||
assert.Error(t, err, "public pattern %q must be refused", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
|
||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
||||
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user