diff --git a/README.md b/README.md index de6fc06..2de88f5 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,17 @@ or compact `system` / `user` fields. The completed job result contains - `GET /healthz` returns process health. - `GET /readyz` checks PostgreSQL readiness. +All `/api/v1/*` endpoints require `Authorization: Bearer ` +when `AI_SERVICE_TOKEN` is configured. Health and readiness endpoints stay open +for Kubernetes probes. + ## Configuration - `HTTP_HOST`, default `0.0.0.0` - `HTTP_PORT`, default `8080` - `DATABASE_URL`, required - `MIGRATE_ON_START`, default `true` +- `AI_SERVICE_TOKEN`, optional bearer token for service-to-service API calls - `LLM_BASE_URL`, primary OpenAI-compatible LLM endpoint - `LLM_API_KEY`, primary LLM API key - `LLM_MODEL`, default `qwen2.5-14b` diff --git a/internal/config/config.go b/internal/config/config.go index 284e7b8..fadad08 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ type Config struct { HTTPPort int DatabaseURL string MigrateOnStart bool + APIAuthToken string LLMBaseURL string LLMAPIKey string @@ -30,6 +31,7 @@ func Load() Config { HTTPPort: envInt("HTTP_PORT", 8080), DatabaseURL: envString("DATABASE_URL", ""), MigrateOnStart: envBool("MIGRATE_ON_START", true), + APIAuthToken: envString("AI_SERVICE_TOKEN", ""), LLMBaseURL: envString("LLM_BASE_URL", ""), LLMAPIKey: envString("LLM_API_KEY", ""), diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..b088beb --- /dev/null +++ b/internal/httpapi/auth.go @@ -0,0 +1,30 @@ +package httpapi + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +func (s *Server) requireAPIToken(path string, r *http.Request) bool { + if !strings.HasPrefix(path, "/api/v1/") && path != "/api/v1" { + return true + } + expected := strings.TrimSpace(s.cfg.APIAuthToken) + if expected == "" { + return true + } + got := bearerToken(r.Header.Get("Authorization")) + if got == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1 +} + +func bearerToken(header string) string { + header = strings.TrimSpace(header) + if len(header) < len("Bearer ") || !strings.EqualFold(header[:len("Bearer ")], "Bearer ") { + return "" + } + return strings.TrimSpace(header[len("Bearer "):]) +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go new file mode 100644 index 0000000..81af410 --- /dev/null +++ b/internal/httpapi/auth_test.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "ai-service/internal/config" +) + +func TestAPITokenProtectsAPIRoutes(t *testing.T) { + srv := NewServer(nil, config.Config{APIAuthToken: "secret"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized API request to be 401, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/not-found", nil) + req.Header.Set("Authorization", "Bearer wrong") + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected wrong token to be 401, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/not-found", nil) + req.Header.Set("Authorization", "Bearer secret") + rec = httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected authorized unknown route to be 404, got %d", rec.Code) + } +} + +func TestAPITokenDoesNotProtectHealth(t *testing.T) { + srv := NewServer(nil, config.Config{APIAuthToken: "secret"}) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected healthz to stay open, got %d", rec.Code) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 570bd1a..58cb5e8 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -29,6 +29,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if path == "" { path = "/" } + if !s.requireAPIToken(path, r) { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } switch { case r.Method == http.MethodGet && path == "/healthz": writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml index 41b1a07..3680a8c 100644 --- a/k8s/secrets.yaml +++ b/k8s/secrets.yaml @@ -18,3 +18,4 @@ type: Opaque stringData: DATABASE_URL: "postgres://ai_service:ai_service@postgres:5432/ai_service?sslmode=disable" LLM_API_KEY: "sk-111f838ccec43406e078cd9094b6797307cb895236179f32" + AI_SERVICE_TOKEN: "d18bcacf9e02bae1806ee6b6eeda62b95be6a915c0a22936d9a700128b275442"