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:
@@ -503,9 +503,10 @@ func main() {
|
||||
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// Public routes only; buildMux mounts the authenticated REST API (#149).
|
||||
public := map[string]http.Handler{}
|
||||
if webhookHandler != nil {
|
||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||
public["POST /webhooks/brain-sync"] = webhookHandler
|
||||
}
|
||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||
// configured. Static-Bearer-only deployments leave this empty so
|
||||
@@ -513,21 +514,14 @@ func main() {
|
||||
var resourceMetadataURL string
|
||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||
public["GET /.well-known/oauth-protected-resource"] = http.HandlerFunc(
|
||||
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||
if resourceURL != "" {
|
||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
|
||||
|
||||
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||
// tracker to file action items, so it only mounts when Gitea is
|
||||
@@ -549,7 +543,7 @@ func main() {
|
||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||
mux.Handle("POST /capture", captureH)
|
||||
public["POST /capture"] = captureH
|
||||
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||
@@ -579,9 +573,9 @@ func main() {
|
||||
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
|
||||
os.Exit(1)
|
||||
}
|
||||
mux.HandleFunc("GET /.well-known/oauth-authorization-server",
|
||||
public["GET /.well-known/oauth-authorization-server"] = http.HandlerFunc(
|
||||
oauth.MetadataHandler(issuer))
|
||||
mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
|
||||
public["POST /oauth/token"] = http.HandlerFunc(oauth.TokenHandler(oauth.TokenConfig{
|
||||
ClientID: oauthID,
|
||||
ClientSecret: oauthSecret,
|
||||
AccessToken: mcpToken,
|
||||
@@ -600,7 +594,17 @@ func main() {
|
||||
// real request latency. /metrics itself is excluded from its own
|
||||
// observation by registering it on the outer mux (post-wrap).
|
||||
reg := metrics.New()
|
||||
mux.HandleFunc("GET /metrics", reg.Handler())
|
||||
public["GET /metrics"] = http.HandlerFunc(reg.Handler())
|
||||
|
||||
// The whole router, built in one place so the test of buildMux is a test
|
||||
// of production wiring (PR #152 review). The REST API's credential is the
|
||||
// same as /mcp's.
|
||||
mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
|
||||
resourceMetadataURL: resourceMetadataURL}, h, public)
|
||||
if err != nil {
|
||||
logger.Error("build router", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("metrics endpoint registered", "path", "/metrics")
|
||||
|
||||
addr := ":" + port
|
||||
|
||||
Reference in New Issue
Block a user