From 325e64b879899db680914067a8b24ab9fa5f75ef Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Mon, 8 Jun 2026 16:00:23 +0530 Subject: [PATCH 1/3] feat: add taxonomy service auth boundary --- .env.example | 9 ++ charts/hub/templates/NOTES.txt | 8 ++ charts/hub/templates/secret.yaml | 6 ++ charts/hub/values.yaml | 9 ++ cmd/api/app.go | 13 ++- cmd/api/app_test.go | 93 +++++++++++++++++++ .../api/handlers/taxonomy_internal_handler.go | 23 +++++ internal/config/config.go | 18 ++++ internal/config/config_test.go | 51 ++++++++++ internal/service/taxonomy_client.go | 88 ++++++++++++++++++ internal/service/taxonomy_client_test.go | 81 ++++++++++++++++ 11 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/taxonomy_internal_handler.go create mode 100644 internal/service/taxonomy_client.go create mode 100644 internal/service/taxonomy_client_test.go diff --git a/.env.example b/.env.example index 26db4bc..d20d711 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,15 @@ PORT=8080 # Valid values: debug, info, warn, error LOG_LEVEL=info +# Taxonomy service integration (optional; beta) +# TAXONOMY_SERVICE_URL is the internal URL Hub uses to call the standalone taxonomy service. +# TAXONOMY_SERVICE_TOKEN is sent by Hub as Authorization: Bearer to the taxonomy service. +# HUB_INTERNAL_API_TOKEN protects Hub internal taxonomy APIs called by the taxonomy service. +# These values are server-side secrets/config only; do not expose them to browsers. +# TAXONOMY_SERVICE_URL=http://localhost:8000 +# TAXONOMY_SERVICE_TOKEN=dev-taxonomy-service-token +# HUB_INTERNAL_API_TOKEN=dev-hub-internal-api-token + # Message publisher: event channel buffer size (optional). Default: 1024 MESSAGE_PUBLISHER_QUEUE_MAX_SIZE=16384 diff --git a/charts/hub/templates/NOTES.txt b/charts/hub/templates/NOTES.txt index 2bc76b0..b8b37db 100644 --- a/charts/hub/templates/NOTES.txt +++ b/charts/hub/templates/NOTES.txt @@ -18,6 +18,12 @@ Recommended production configuration - PUBLIC_BASE_URL (ConfigMap/env): set this when Hub is exposed through ingress, TLS termination, or a reverse-proxy path prefix so /openapi.yaml and /openapi.json advertise the correct public URL. - hub-worker: keep values.worker.enabled=true unless another deployment processes the same River queues. + +Beta taxonomy configuration +--------------------------- +- TAXONOMY_SERVICE_URL (ConfigMap/env): internal URL for the standalone taxonomy service. +- TAXONOMY_SERVICE_TOKEN (Secret): token Hub sends to the standalone taxonomy service. +- HUB_INTERNAL_API_TOKEN (Secret): protects /internal/v1/taxonomy/* when taxonomy internals are enabled. {{- if .Values.embeddings.enabled }} - embeddings: provide values.embeddings.huggingFace.token or values.embeddings.huggingFace.existingSecret only when using private or gated models. @@ -28,6 +34,8 @@ Secret management options (choose one): values.secrets.create=true and set: values.secrets.stringData.DATABASE_URL values.secrets.stringData.API_KEY + values.secrets.stringData.TAXONOMY_SERVICE_TOKEN + values.secrets.stringData.HUB_INTERNAL_API_TOKEN 2) Existing Secret: values.secrets.existingSecret= diff --git a/charts/hub/templates/secret.yaml b/charts/hub/templates/secret.yaml index d229834..78e45e8 100644 --- a/charts/hub/templates/secret.yaml +++ b/charts/hub/templates/secret.yaml @@ -18,4 +18,10 @@ stringData: {{- with .Values.secrets.stringData.EMBEDDING_PROVIDER_API_KEY }} EMBEDDING_PROVIDER_API_KEY: {{ . | quote }} {{- end }} + {{- with .Values.secrets.stringData.TAXONOMY_SERVICE_TOKEN }} + TAXONOMY_SERVICE_TOKEN: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.stringData.HUB_INTERNAL_API_TOKEN }} + HUB_INTERNAL_API_TOKEN: {{ . | quote }} + {{- end }} {{- end }} diff --git a/charts/hub/values.yaml b/charts/hub/values.yaml index b58e27d..600dc83 100644 --- a/charts/hub/values.yaml +++ b/charts/hub/values.yaml @@ -318,6 +318,9 @@ config: # Optional: use with EMBEDDING_PROVIDER=openai to target a self-hosted OpenAI-compatible embeddings endpoint. # Example: http://text-embeddings-inference.default.svc.cluster.local/v1 EMBEDDING_BASE_URL: "" + # Optional: internal URL Hub uses to start taxonomy generation jobs. + # Example: http://taxonomy.default.svc.cluster.local:8000 + TAXONOMY_SERVICE_URL: "" secrets: create: true @@ -330,6 +333,10 @@ secrets: API_KEY: "" # Optional: required only when EMBEDDING_PROVIDER is openai or google (AI Studio) EMBEDDING_PROVIDER_API_KEY: "" + # Optional: required when Hub starts taxonomy generation jobs + TAXONOMY_SERVICE_TOKEN: "" + # Optional: required when Hub internal taxonomy APIs are enabled + HUB_INTERNAL_API_TOKEN: "" externalSecrets: enabled: false # Name of existing SecretStore resource for AWS Secrets Manager @@ -343,6 +350,8 @@ secrets: - DATABASE_URL - API_KEY - EMBEDDING_PROVIDER_API_KEY + - TAXONOMY_SERVICE_TOKEN + - HUB_INTERNAL_API_TOKEN extraEnv: [] extraEnvFrom: [] diff --git a/cmd/api/app.go b/cmd/api/app.go index ffe999c..d53f61c 100644 --- a/cmd/api/app.go +++ b/cmd/api/app.go @@ -322,6 +322,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) { tenantDataHandler := handlers.NewTenantDataHandler(tenantDataService) feedbackRecordsHandler := handlers.NewFeedbackRecordsHandler(feedbackRecordsService) + taxonomyInternalHandler := handlers.NewTaxonomyInternalHandler() healthHandler := handlers.NewHealthHandler() openapiHandler, err := handlers.NewOpenAPIHandler(handlers.ResolveOpenAPISpecPath(), cfg.Server.PublicBaseURL) @@ -333,6 +334,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) { server := newHTTPServer( cfg, healthHandler, openapiHandler, feedbackRecordsHandler, webhooksHandler, tenantDataHandler, searchHandler, + taxonomyInternalHandler, meterProvider, tracerProvider, ) @@ -348,7 +350,8 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) { }, nil } -// newHTTPServer builds the HTTP server and muxes (no auth on /health or /openapi.*, API key on /v1/). +// newHTTPServer builds the HTTP server and muxes (no auth on /health or /openapi.*, API key on /v1/, +// internal taxonomy token on /internal/v1/taxonomy/ when configured). // Handler chain: RequestID -> otelhttp(Logging(mux)) so access logs get trace_id/span_id from context. func newHTTPServer( cfg *config.Config, @@ -358,6 +361,7 @@ func newHTTPServer( webhooks *handlers.WebhooksHandler, tenantData *handlers.TenantDataHandler, search *handlers.SearchHandler, + taxonomyInternal *handlers.TaxonomyInternalHandler, meterProvider *sdkmetric.MeterProvider, tracerProvider *sdktrace.TracerProvider, ) *http.Server { @@ -386,8 +390,15 @@ func newHTTPServer( protected.HandleFunc("GET /v1/feedback-records/{id}/similar", search.SimilarFeedback) protectedWithAuth := middleware.Auth(cfg.Server.HubAPIKey)(protected) + mux := http.NewServeMux() mux.Handle("/v1/", protectedWithAuth) + if cfg.Taxonomy.HubInternalAPIToken != "" { + internalTaxonomy := http.NewServeMux() + internalTaxonomy.HandleFunc("GET /internal/v1/taxonomy/auth-check", taxonomyInternal.AuthCheck) + internalTaxonomyWithAuth := middleware.Auth(cfg.Taxonomy.HubInternalAPIToken)(internalTaxonomy) + mux.Handle("/internal/v1/taxonomy/", internalTaxonomyWithAuth) + } mux.Handle("/", public) otelOpts := []otelhttp.Option{ diff --git a/cmd/api/app_test.go b/cmd/api/app_test.go index 984e1b7..e203989 100644 --- a/cmd/api/app_test.go +++ b/cmd/api/app_test.go @@ -258,6 +258,89 @@ func TestNewHTTPServerKeepsTenantDataRoutesProtected(t *testing.T) { } } +func TestNewHTTPServerInternalTaxonomyRouteRequiresInternalToken(t *testing.T) { + server := newTestHTTPServer(t) + + tests := []struct { + name string + authHeader string + wantStatus int + wantBodyMatch string + }{ + { + name: "missing auth", + wantStatus: http.StatusUnauthorized, + }, + { + name: "malformed auth", + authHeader: "Basic test-internal-token", + wantStatus: http.StatusUnauthorized, + }, + { + name: "public API key rejected", + authHeader: "Bearer test-api-key", + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong internal token rejected", + authHeader: "Bearer wrong-token", + wantStatus: http.StatusUnauthorized, + }, + { + name: "internal token accepted", + authHeader: "Bearer test-internal-token", + wantStatus: http.StatusOK, + wantBodyMatch: "hub-taxonomy-internal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + "/internal/v1/taxonomy/auth-check", + http.NoBody, + ) + if tt.authHeader != "" { + request.Header.Set("Authorization", tt.authHeader) + } + + server.Handler.ServeHTTP(recorder, request) + + if recorder.Code != tt.wantStatus { + t.Fatalf("GET /internal/v1/taxonomy/auth-check status = %d, want %d; body=%s", + recorder.Code, tt.wantStatus, recorder.Body.String()) + } + + if tt.wantBodyMatch != "" && !strings.Contains(recorder.Body.String(), tt.wantBodyMatch) { + t.Fatalf("GET /internal/v1/taxonomy/auth-check body = %s, want %q", + recorder.Body.String(), tt.wantBodyMatch) + } + }) + } +} + +func TestNewHTTPServerInternalTaxonomyRouteDisabledWithoutToken(t *testing.T) { + server := newTestHTTPServerWithConfig(t, "https://hub.example.com/base", config.TaxonomyConfig{}) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext( + context.Background(), + http.MethodGet, + "/internal/v1/taxonomy/auth-check", + http.NoBody, + ) + + server.Handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("GET /internal/v1/taxonomy/auth-check status = %d, want %d", + recorder.Code, http.StatusNotFound) + } +} + func newTestHTTPServer(t *testing.T) *http.Server { t.Helper() @@ -267,11 +350,20 @@ func newTestHTTPServer(t *testing.T) *http.Server { func newTestHTTPServerWithPublicBaseURL(t *testing.T, publicBaseURL string) *http.Server { t.Helper() + return newTestHTTPServerWithConfig(t, publicBaseURL, config.TaxonomyConfig{ + HubInternalAPIToken: "test-internal-token", + }) +} + +func newTestHTTPServerWithConfig(t *testing.T, publicBaseURL string, taxonomy config.TaxonomyConfig) *http.Server { + t.Helper() + cfg := &config.Config{ Server: config.ServerConfig{ Port: "0", HubAPIKey: "test-api-key", }, + Taxonomy: taxonomy, } return newHTTPServer( @@ -282,6 +374,7 @@ func newTestHTTPServerWithPublicBaseURL(t *testing.T, publicBaseURL string) *htt handlers.NewWebhooksHandler(nil), handlers.NewTenantDataHandler(nil), handlers.NewSearchHandler(nil), + handlers.NewTaxonomyInternalHandler(), nil, nil, ) diff --git a/internal/api/handlers/taxonomy_internal_handler.go b/internal/api/handlers/taxonomy_internal_handler.go new file mode 100644 index 0000000..4d526bb --- /dev/null +++ b/internal/api/handlers/taxonomy_internal_handler.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "net/http" + + "github.com/formbricks/hub/internal/api/response" +) + +// TaxonomyInternalHandler hosts internal taxonomy service endpoints. +type TaxonomyInternalHandler struct{} + +// NewTaxonomyInternalHandler creates a taxonomy internal handler. +func NewTaxonomyInternalHandler() *TaxonomyInternalHandler { + return &TaxonomyInternalHandler{} +} + +// AuthCheck verifies that the caller passed the internal Hub API token. +func (h *TaxonomyInternalHandler) AuthCheck(w http.ResponseWriter, _ *http.Request) { + response.RespondJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "service": "hub-taxonomy-internal", + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 5f83e81..b3f2476 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ var ( ErrDatabaseMinConnsExceedsMax = errors.New("DATABASE_MIN_CONNS must not exceed DATABASE_MAX_CONNS") ErrInvalidPublicBaseURL = errors.New("PUBLIC_BASE_URL must be an absolute http(s) URL without query or fragment") ErrInvalidEmbeddingBaseURL = errors.New("EMBEDDING_BASE_URL must be an absolute http(s) URL without query or fragment") + ErrInvalidTaxonomyServiceURL = errors.New("TAXONOMY_SERVICE_URL must be an absolute http(s) URL without query or fragment") ) // DefaultDatabaseURL is the default connection URL when DATABASE_URL is unset (local/test only). @@ -44,6 +45,7 @@ type Config struct { Webhook WebhookConfig MessagePublisher MessagePublisherConfig Embedding EmbeddingConfig + Taxonomy TaxonomyConfig Observability ObservabilityConfig } @@ -124,6 +126,13 @@ type EmbeddingConfig struct { GoogleCloudLocation string `env:"EMBEDDING_GOOGLE_CLOUD_LOCATION"` } +// TaxonomyConfig holds Hub-to-taxonomy service settings. +type TaxonomyConfig struct { + ServiceURL string `env:"TAXONOMY_SERVICE_URL"` + ServiceToken string `env:"TAXONOMY_SERVICE_TOKEN"` + HubInternalAPIToken string `env:"HUB_INTERNAL_API_TOKEN"` +} + // ObservabilityConfig holds OpenTelemetry settings. type ObservabilityConfig struct { MetricsExporter string `env:"OTEL_METRICS_EXPORTER"` @@ -331,6 +340,15 @@ func validate(cfg *Config) error { cfg.Embedding.BaseURL = normalized } + if cfg.Taxonomy.ServiceURL != "" { + normalized, err := normalizeHTTPBaseURL(cfg.Taxonomy.ServiceURL, ErrInvalidTaxonomyServiceURL) + if err != nil { + return err + } + + cfg.Taxonomy.ServiceURL = normalized + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7443ac2..1776563 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -323,6 +323,57 @@ func TestLoad_EmbeddingBaseURLValidation(t *testing.T) { } } +func TestLoad_TaxonomyConfig(t *testing.T) { + t.Setenv("TAXONOMY_SERVICE_URL", "https://taxonomy.example.com/root/") + t.Setenv("TAXONOMY_SERVICE_TOKEN", "taxonomy-service-token") + t.Setenv("HUB_INTERNAL_API_TOKEN", "hub-internal-token") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Taxonomy.ServiceURL != "https://taxonomy.example.com/root" { + t.Errorf("Taxonomy.ServiceURL = %q, want https://taxonomy.example.com/root", cfg.Taxonomy.ServiceURL) + } + + if cfg.Taxonomy.ServiceToken != "taxonomy-service-token" { + t.Errorf("Taxonomy.ServiceToken = %q, want taxonomy-service-token", cfg.Taxonomy.ServiceToken) + } + + if cfg.Taxonomy.HubInternalAPIToken != "hub-internal-token" { + t.Errorf("Taxonomy.HubInternalAPIToken = %q, want hub-internal-token", cfg.Taxonomy.HubInternalAPIToken) + } +} + +func TestLoad_TaxonomyServiceURLValidation(t *testing.T) { + tests := []struct { + name string + value string + }{ + {name: "rejects relative url", value: "/taxonomy"}, + {name: "rejects unsupported scheme", value: "ftp://taxonomy.example.com"}, + {name: "rejects query", value: "https://taxonomy.example.com?x=1"}, + {name: "rejects fragment", value: "https://taxonomy.example.com#frag"}, + {name: "rejects user info", value: "https://user:pass@taxonomy.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TAXONOMY_SERVICE_URL", tt.value) + + _, err := Load() + if err == nil { + t.Fatalf("Load() error = nil, want error") + } + + if !errors.Is(err, ErrInvalidTaxonomyServiceURL) { + t.Fatalf("Load() error = %v, want %v", err, ErrInvalidTaxonomyServiceURL) + } + }) + } +} + func TestLoad_PublicBaseURLValidation(t *testing.T) { tests := []struct { name string diff --git a/internal/service/taxonomy_client.go b/internal/service/taxonomy_client.go new file mode 100644 index 0000000..6ce6c27 --- /dev/null +++ b/internal/service/taxonomy_client.go @@ -0,0 +1,88 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/formbricks/hub/internal/observability" +) + +var ( + ErrTaxonomyServiceURLRequired = errors.New("TAXONOMY_SERVICE_URL is required") + ErrTaxonomyServiceTokenRequired = errors.New("TAXONOMY_SERVICE_TOKEN is required") +) + +const defaultTaxonomyClientTimeout = 30 * time.Second + +// TaxonomyClientConfig configures the outbound client Hub uses to call the taxonomy service. +type TaxonomyClientConfig struct { + ServiceURL string + ServiceToken string +} + +// TaxonomyClient calls the standalone taxonomy service. +type TaxonomyClient struct { + baseURL string + token string + httpClient *http.Client +} + +// NewTaxonomyClient creates a Hub-to-taxonomy-service client. +func NewTaxonomyClient(cfg TaxonomyClientConfig, httpClient *http.Client) (*TaxonomyClient, error) { + baseURL := strings.TrimRight(strings.TrimSpace(cfg.ServiceURL), "/") + if baseURL == "" { + return nil, ErrTaxonomyServiceURLRequired + } + + token := strings.TrimSpace(cfg.ServiceToken) + if token == "" { + return nil, ErrTaxonomyServiceTokenRequired + } + + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultTaxonomyClientTimeout} + } + + return &TaxonomyClient{ + baseURL: baseURL, + token: token, + httpClient: httpClient, + }, nil +} + +// StartRun asks the taxonomy service to start compute for a Hub-created run. +func (c *TaxonomyClient) StartRun(ctx context.Context, runID string) error { + endpoint, err := url.JoinPath(c.baseURL, "/v1/runs", runID, "start") + if err != nil { + return fmt.Errorf("build taxonomy start URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, http.NoBody) + if err != nil { + return fmt.Errorf("create taxonomy start request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + if requestID := observability.RequestIDFromContext(ctx); requestID != "" { + req.Header.Set("X-Request-ID", requestID) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("start taxonomy run: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("start taxonomy run: taxonomy service returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/service/taxonomy_client_test.go b/internal/service/taxonomy_client_test.go new file mode 100644 index 0000000..593aafa --- /dev/null +++ b/internal/service/taxonomy_client_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/formbricks/hub/internal/observability" +) + +func TestNewTaxonomyClientRequiresConfig(t *testing.T) { + _, err := NewTaxonomyClient(TaxonomyClientConfig{}, nil) + if !errors.Is(err, ErrTaxonomyServiceURLRequired) { + t.Fatalf("NewTaxonomyClient() error = %v, want %v", err, ErrTaxonomyServiceURLRequired) + } + + _, err = NewTaxonomyClient(TaxonomyClientConfig{ServiceURL: "https://taxonomy.test"}, nil) + if !errors.Is(err, ErrTaxonomyServiceTokenRequired) { + t.Fatalf("NewTaxonomyClient() error = %v, want %v", err, ErrTaxonomyServiceTokenRequired) + } +} + +func TestTaxonomyClientStartRunSendsBearerTokenAndRequestID(t *testing.T) { + var gotAuth string + var gotRequestID string + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotRequestID = r.Header.Get("X-Request-ID") + gotPath = r.URL.Path + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + client, err := NewTaxonomyClient(TaxonomyClientConfig{ + ServiceURL: server.URL, + ServiceToken: "taxonomy-service-token", + }, server.Client()) + if err != nil { + t.Fatalf("NewTaxonomyClient() error = %v", err) + } + + ctx := context.WithValue(context.Background(), observability.RequestIDKey, "request-1") + if err := client.StartRun(ctx, "run-1"); err != nil { + t.Fatalf("StartRun() error = %v", err) + } + + if gotAuth != "Bearer taxonomy-service-token" { + t.Fatalf("Authorization header = %q, want Bearer taxonomy-service-token", gotAuth) + } + + if gotRequestID != "request-1" { + t.Fatalf("X-Request-ID header = %q, want request-1", gotRequestID) + } + + if gotPath != "/v1/runs/run-1/start" { + t.Fatalf("path = %q, want /v1/runs/run-1/start", gotPath) + } +} + +func TestTaxonomyClientStartRunReturnsNon2xxError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + client, err := NewTaxonomyClient(TaxonomyClientConfig{ + ServiceURL: server.URL, + ServiceToken: "taxonomy-service-token", + }, server.Client()) + if err != nil { + t.Fatalf("NewTaxonomyClient() error = %v", err) + } + + if err := client.StartRun(context.Background(), "run-1"); err == nil { + t.Fatal("StartRun() error = nil, want non-2xx error") + } +} From 344d5cf6cb7d51357b7631ab6318a17c694ae4bf Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Mon, 8 Jun 2026 17:17:34 +0530 Subject: [PATCH 2/3] fix: satisfy taxonomy auth lint rules --- cmd/api/app.go | 2 ++ cmd/api/app_test.go | 1 + internal/service/taxonomy_client.go | 12 ++++++++++-- internal/service/taxonomy_client_test.go | 14 +++++++++----- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cmd/api/app.go b/cmd/api/app.go index d53f61c..6c1c475 100644 --- a/cmd/api/app.go +++ b/cmd/api/app.go @@ -393,12 +393,14 @@ func newHTTPServer( mux := http.NewServeMux() mux.Handle("/v1/", protectedWithAuth) + if cfg.Taxonomy.HubInternalAPIToken != "" { internalTaxonomy := http.NewServeMux() internalTaxonomy.HandleFunc("GET /internal/v1/taxonomy/auth-check", taxonomyInternal.AuthCheck) internalTaxonomyWithAuth := middleware.Auth(cfg.Taxonomy.HubInternalAPIToken)(internalTaxonomy) mux.Handle("/internal/v1/taxonomy/", internalTaxonomyWithAuth) } + mux.Handle("/", public) otelOpts := []otelhttp.Option{ diff --git a/cmd/api/app_test.go b/cmd/api/app_test.go index e203989..df1da30 100644 --- a/cmd/api/app_test.go +++ b/cmd/api/app_test.go @@ -297,6 +297,7 @@ func TestNewHTTPServerInternalTaxonomyRouteRequiresInternalToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext( context.Background(), http.MethodGet, diff --git a/internal/service/taxonomy_client.go b/internal/service/taxonomy_client.go index 6ce6c27..43fc81b 100644 --- a/internal/service/taxonomy_client.go +++ b/internal/service/taxonomy_client.go @@ -13,8 +13,14 @@ import ( ) var ( - ErrTaxonomyServiceURLRequired = errors.New("TAXONOMY_SERVICE_URL is required") + // ErrTaxonomyServiceURLRequired is returned when TAXONOMY_SERVICE_URL is missing. + ErrTaxonomyServiceURLRequired = errors.New("TAXONOMY_SERVICE_URL is required") + + // ErrTaxonomyServiceTokenRequired is returned when TAXONOMY_SERVICE_TOKEN is missing. ErrTaxonomyServiceTokenRequired = errors.New("TAXONOMY_SERVICE_TOKEN is required") + + // ErrTaxonomyServiceUnexpectedStatus is returned when the taxonomy service returns a non-2xx response. + ErrTaxonomyServiceUnexpectedStatus = errors.New("taxonomy service returned non-success status") ) const defaultTaxonomyClientTimeout = 30 * time.Second @@ -68,6 +74,7 @@ func (c *TaxonomyClient) StartRun(ctx context.Context, runID string) error { } req.Header.Set("Authorization", "Bearer "+c.token) + if requestID := observability.RequestIDFromContext(ctx); requestID != "" { req.Header.Set("X-Request-ID", requestID) } @@ -76,12 +83,13 @@ func (c *TaxonomyClient) StartRun(ctx context.Context, runID string) error { if err != nil { return fmt.Errorf("start taxonomy run: %w", err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("start taxonomy run: taxonomy service returned status %d", resp.StatusCode) + return fmt.Errorf("%w: status %d", ErrTaxonomyServiceUnexpectedStatus, resp.StatusCode) } return nil diff --git a/internal/service/taxonomy_client_test.go b/internal/service/taxonomy_client_test.go index 593aafa..6024eab 100644 --- a/internal/service/taxonomy_client_test.go +++ b/internal/service/taxonomy_client_test.go @@ -23,14 +23,17 @@ func TestNewTaxonomyClientRequiresConfig(t *testing.T) { } func TestTaxonomyClientStartRunSendsBearerTokenAndRequestID(t *testing.T) { - var gotAuth string - var gotRequestID string - var gotPath string + var ( + gotAuth string + gotRequestID string + gotPath string + ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotRequestID = r.Header.Get("X-Request-ID") gotPath = r.URL.Path + w.WriteHeader(http.StatusAccepted) })) defer server.Close() @@ -75,7 +78,8 @@ func TestTaxonomyClientStartRunReturnsNon2xxError(t *testing.T) { t.Fatalf("NewTaxonomyClient() error = %v", err) } - if err := client.StartRun(context.Background(), "run-1"); err == nil { - t.Fatal("StartRun() error = nil, want non-2xx error") + err = client.StartRun(context.Background(), "run-1") + if !errors.Is(err, ErrTaxonomyServiceUnexpectedStatus) { + t.Fatalf("StartRun() error = %v, want %v", err, ErrTaxonomyServiceUnexpectedStatus) } } From cd1f1388eb2ab0408542d68a85530ebbafcd776d Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Mon, 8 Jun 2026 22:32:19 +0530 Subject: [PATCH 3/3] fix: clarify taxonomy auth check docs --- internal/api/handlers/taxonomy_internal_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/handlers/taxonomy_internal_handler.go b/internal/api/handlers/taxonomy_internal_handler.go index 4d526bb..ecd3149 100644 --- a/internal/api/handlers/taxonomy_internal_handler.go +++ b/internal/api/handlers/taxonomy_internal_handler.go @@ -14,7 +14,7 @@ func NewTaxonomyInternalHandler() *TaxonomyInternalHandler { return &TaxonomyInternalHandler{} } -// AuthCheck verifies that the caller passed the internal Hub API token. +// AuthCheck returns success after middleware.Auth enforces the internal Hub API token. func (h *TaxonomyInternalHandler) AuthCheck(w http.ResponseWriter, _ *http.Request) { response.RespondJSON(w, http.StatusOK, map[string]string{ "status": "ok",