diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go index b6e9b12..1205c1e 100644 --- a/cmd/hyperguild/http.go +++ b/cmd/hyperguild/http.go @@ -20,6 +20,7 @@ const defaultBrainURL = "http://koala:30330" // shell-friendly client; MCP framing is unnecessary. type brainClient struct { baseURL string + token string // BRAIN_MCP_TOKEN; the REST API requires it since hyperguild#149 http *http.Client } @@ -30,6 +31,7 @@ func newBrainClient() *brainClient { } return &brainClient{ baseURL: u, + token: os.Getenv("BRAIN_MCP_TOKEN"), http: &http.Client{Timeout: 5 * time.Second}, } } @@ -62,6 +64,7 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") + c.authorize(req) resp, err := c.http.Do(req) if err != nil { @@ -105,6 +108,7 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") + c.authorize(req) resp, err := c.http.Do(req) if err != nil { @@ -158,3 +162,10 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass } return &out, nil } + +// authorize adds the bearer token when one is configured. +func (c *brainClient) authorize(req *http.Request) { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } +} diff --git a/cmd/hyperguild/http_auth_test.go b/cmd/hyperguild/http_auth_test.go new file mode 100644 index 0000000..97a8e56 --- /dev/null +++ b/cmd/hyperguild/http_auth_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI +// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call. +func TestBrainClientSendsBrainMCPToken(t *testing.T) { + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.URL.Path+" "+r.Header.Get("Authorization")) + if r.URL.Path == "/write" { + _, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`)) + return + } + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + t.Setenv("BRAIN_MCP_TOKEN", "cli-test-token") + + c := newBrainClient() + _, err := c.Query(context.Background(), "x", 1) + require.NoError(t, err) + _, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y")) + require.NoError(t, err) + assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token"}, got) +} + +func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) { + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + t.Setenv("BRAIN_MCP_TOKEN", "") + _, err := newBrainClient().Query(context.Background(), "x", 1) + require.NoError(t, err) + assert.Empty(t, auth) +} diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index bc38f11..b0193d0 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -504,17 +504,6 @@ func main() { } mux := http.NewServeMux() - mux.HandleFunc("POST /query", h.Query) - mux.HandleFunc("POST /write", h.Write) - mux.HandleFunc("POST /index", h.Index) - mux.HandleFunc("POST /ingest", h.Ingest) - mux.HandleFunc("POST /ingest-path", h.IngestPath) - mux.HandleFunc("POST /ingest-raw", h.IngestRaw) - mux.HandleFunc("POST /backfill-refs", h.BackfillRefs) - mux.HandleFunc("GET /pending", h.Pending) - mux.HandleFunc("POST /promote", h.Promote) - mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) - mux.HandleFunc("GET /pass-rate", h.PassRate) if webhookHandler != nil { mux.Handle("POST /webhooks/brain-sync", webhookHandler) } @@ -532,6 +521,13 @@ func main() { } mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)) + // The REST API, authenticated as a whole (hyperguild#149). Same credential + // as /mcp: callers (brain-ui, brain-ingest-ntfy, the hyperguild CLI) send + // BRAIN_MCP_TOKEN. Registered after the public routes on purpose only for + // reading order; ServeMux precedence is by pattern specificity. + mountREST(mux, h, func(next http.Handler) http.Handler { + return chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, next) + }) // POST /capture (#53/#54): the uniform capture REST door. Needs a ticket // tracker to file action items, so it only mounts when Gitea is diff --git a/ingestion/cmd/server/routes.go b/ingestion/cmd/server/routes.go new file mode 100644 index 0000000..b4584db --- /dev/null +++ b/ingestion/cmd/server/routes.go @@ -0,0 +1,30 @@ +package main + +import ( + "net/http" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" +) + +// mountREST registers the REST API on its own mux and mounts it at "/" behind +// auth (hyperguild#149). Authentication is a property of the mux, not of each +// route: anything not registered on the outer mux as explicitly public (/mcp, +// /capture, the HMAC webhook, OAuth metadata, /metrics) lands here and is +// authenticated, including routes added later. Before this, only /mcp was +// wrapped, and the REST routes beside it were open; infra#517 found them on +// the internet. +func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) { + rest := http.NewServeMux() + rest.HandleFunc("POST /query", h.Query) + rest.HandleFunc("POST /write", h.Write) + rest.HandleFunc("POST /index", h.Index) + rest.HandleFunc("POST /ingest", h.Ingest) + rest.HandleFunc("POST /ingest-path", h.IngestPath) + rest.HandleFunc("POST /ingest-raw", h.IngestRaw) + rest.HandleFunc("POST /backfill-refs", h.BackfillRefs) + rest.HandleFunc("GET /pending", h.Pending) + rest.HandleFunc("POST /promote", h.Promote) + rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) + rest.HandleFunc("GET /pass-rate", h.PassRate) + mux.Handle("/", auth(rest)) +} diff --git a/ingestion/cmd/server/routes_test.go b/ingestion/cmd/server/routes_test.go new file mode 100644 index 0000000..4e24265 --- /dev/null +++ b/ingestion/cmd/server/routes_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" + "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" +) + +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 { + 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) + }) + return mux +} + +func do(t *testing.T, h http.Handler, method, path, authz string) int { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`)) + if authz != "" { + req.Header.Set("Authorization", authz) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code +} + +func TestRESTRoutesRefuseRequestsWithoutAToken(t *testing.T) { + r := testRouter(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) + } +} + +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)) +} + +// 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", "")) +} + +// 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", "")) +} diff --git a/internal/brain/client.go b/internal/brain/client.go index 3a5e6e8..8ccbc0b 100644 --- a/internal/brain/client.go +++ b/internal/brain/client.go @@ -13,6 +13,7 @@ import ( "io" "log/slog" "net/http" + "os" "strings" ) @@ -42,6 +43,10 @@ func Query(ctx context.Context, baseURL, query string, limit int) (string, error return "", nil } req.Header.Set("Content-Type", "application/json") + // The REST API requires a bearer token since hyperguild#149. + if tok := os.Getenv("BRAIN_MCP_TOKEN"); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/brain/client_test.go b/internal/brain/client_test.go index fb11b98..1936744 100644 --- a/internal/brain/client_test.go +++ b/internal/brain/client_test.go @@ -65,3 +65,18 @@ func TestQueryUnavailableServerReturnsEmpty(t *testing.T) { require.NoError(t, err) assert.Empty(t, result) } + +// hyperguild#149: the REST API requires a bearer token. +func TestQuerySendsBrainMCPToken(t *testing.T) { + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_MCP_TOKEN", "lib-test-token") + _, _ = brain.Query(context.Background(), srv.URL, "x", 1) + if auth != "Bearer lib-test-token" { + t.Fatalf("Authorization = %q, want the bearer token", auth) + } +}