fix(ingestion): authenticate the REST API; confine ingest paths to brain/raw/ (#149) #152
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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", ""))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user