diff --git a/internal/admin/dashboard/templates/index.html b/internal/admin/dashboard/templates/index.html index c60dd4e3..b9fe2520 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -975,6 +975,7 @@

Audit Logs

alias + stream diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 238c541d..9f89a14e 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -4,6 +4,7 @@ package admin import ( "context" "errors" + "log/slog" "net/http" "net/url" "slices" @@ -15,6 +16,7 @@ import ( "gomodel/internal/aliases" "gomodel/internal/auditlog" + "gomodel/internal/authkeys" "gomodel/internal/core" "gomodel/internal/executionplans" "gomodel/internal/guardrails" @@ -27,6 +29,7 @@ type Handler struct { usageReader usage.UsageReader auditReader auditlog.Reader registry *providers.ModelRegistry + authKeys *authkeys.Service aliases *aliases.Service plans *executionplans.Service guardrails *guardrails.Registry @@ -69,6 +72,13 @@ func WithAliases(service *aliases.Service) Option { } } +// WithAuthKeys enables managed auth key administration endpoints. +func WithAuthKeys(service *authkeys.Service) Option { + return func(h *Handler) { + h.authKeys = service + } +} + // WithExecutionPlans enables execution-plan administration endpoints. func WithExecutionPlans(service *executionplans.Service) Option { return func(h *Handler) { @@ -613,6 +623,12 @@ type createExecutionPlanRequest struct { Payload executionplans.Payload `json:"plan_payload"` } +type createAuthKeyRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + func featureUnavailableError(message string) error { return core.NewInvalidRequestErrorWithStatus(http.StatusServiceUnavailable, message, nil). WithCode("feature_unavailable") @@ -622,6 +638,10 @@ func (h *Handler) aliasesUnavailableError() error { return featureUnavailableError("aliases feature is unavailable") } +func (h *Handler) authKeysUnavailableError() error { + return featureUnavailableError("auth keys feature is unavailable") +} + func (h *Handler) executionPlansUnavailableError() error { return featureUnavailableError("execution plans feature is unavailable") } @@ -646,6 +666,98 @@ func executionPlanWriteError(err error) error { return err } +func authKeyWriteError(err error) error { + if err == nil { + return nil + } + if authkeys.IsValidationError(err) { + return core.NewInvalidRequestError(err.Error(), err) + } + return err +} + +func deactivateByID( + c *echo.Context, + unavailableErr error, + idLabel string, + notFoundErr error, + notFoundMessage string, + deactivate func(context.Context, string) error, + writeError func(error) error, +) error { + if unavailableErr != nil { + return handleError(c, unavailableErr) + } + + id := strings.TrimSpace(c.Param("id")) + if id == "" { + return handleError(c, core.NewInvalidRequestError(idLabel+" id is required", nil)) + } + + if err := deactivate(c.Request().Context(), id); err != nil { + if errors.Is(err, notFoundErr) { + return handleError(c, core.NewNotFoundError(notFoundMessage+id)) + } + return handleError(c, writeError(err)) + } + return c.NoContent(http.StatusNoContent) +} + +// ListAuthKeys handles GET /admin/api/v1/auth-keys +func (h *Handler) ListAuthKeys(c *echo.Context) error { + if h.authKeys == nil { + return handleError(c, h.authKeysUnavailableError()) + } + views := h.authKeys.ListViews() + if views == nil { + views = []authkeys.View{} + } + return c.JSON(http.StatusOK, views) +} + +// CreateAuthKey handles POST /admin/api/v1/auth-keys +func (h *Handler) CreateAuthKey(c *echo.Context) error { + if h.authKeys == nil { + return handleError(c, h.authKeysUnavailableError()) + } + + var req createAuthKeyRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + + issued, err := h.authKeys.Create(c.Request().Context(), authkeys.CreateInput{ + Name: req.Name, + Description: req.Description, + ExpiresAt: req.ExpiresAt, + }) + if err != nil { + return handleError(c, authKeyWriteError(err)) + } + if issued == nil { + requestID := strings.TrimSpace(core.GetRequestID(c.Request().Context())) + slog.Error("auth key service returned nil issued key", "request_id", requestID, "path", c.Request().URL.Path) + return c.JSON(http.StatusInternalServerError, (&core.GatewayError{ + Type: core.ErrorType("internal_error"), + Message: "auth key creation failed unexpectedly", + StatusCode: http.StatusInternalServerError, + }).WithCode("auth_key_issue_failed").ToJSON()) + } + return c.JSON(http.StatusCreated, issued) +} + +// DeactivateAuthKey handles POST /admin/api/v1/auth-keys/:id/deactivate +func (h *Handler) DeactivateAuthKey(c *echo.Context) error { + var unavailableErr error + var deactivate func(context.Context, string) error + if h.authKeys == nil { + unavailableErr = h.authKeysUnavailableError() + } else { + deactivate = h.authKeys.Deactivate + } + return deactivateByID(c, unavailableErr, "auth key", authkeys.ErrNotFound, "auth key not found: ", deactivate, authKeyWriteError) +} + // ListAliases handles GET /admin/api/v1/aliases func (h *Handler) ListAliases(c *echo.Context) error { if h.aliases == nil { @@ -806,22 +918,14 @@ func (h *Handler) CreateExecutionPlan(c *echo.Context) error { // DeactivateExecutionPlan handles POST /admin/api/v1/execution-plans/:id/deactivate func (h *Handler) DeactivateExecutionPlan(c *echo.Context) error { + var unavailableErr error + var deactivate func(context.Context, string) error if h.plans == nil { - return handleError(c, h.executionPlansUnavailableError()) - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - return handleError(c, core.NewInvalidRequestError("execution plan id is required", nil)) - } - - if err := h.plans.Deactivate(c.Request().Context(), id); err != nil { - if errors.Is(err, executionplans.ErrNotFound) { - return handleError(c, core.NewNotFoundError("workflow not found: "+id)) - } - return handleError(c, executionPlanWriteError(err)) + unavailableErr = h.executionPlansUnavailableError() + } else { + deactivate = h.plans.Deactivate } - return c.NoContent(http.StatusNoContent) + return deactivateByID(c, unavailableErr, "execution plan", executionplans.ErrNotFound, "workflow not found: ", deactivate, executionPlanWriteError) } func (h *Handler) validateExecutionPlanGuardrails(payload executionplans.Payload) error { diff --git a/internal/admin/handler_authkeys_test.go b/internal/admin/handler_authkeys_test.go new file mode 100644 index 00000000..1faadf3d --- /dev/null +++ b/internal/admin/handler_authkeys_test.go @@ -0,0 +1,168 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labstack/echo/v5" + + "gomodel/internal/authkeys" +) + +type authKeyTestStore struct { + keys map[string]authkeys.AuthKey +} + +func newAuthKeyTestStore(keys ...authkeys.AuthKey) *authKeyTestStore { + store := &authKeyTestStore{keys: make(map[string]authkeys.AuthKey, len(keys))} + for _, key := range keys { + store.keys[key.ID] = key + } + return store +} + +func (s *authKeyTestStore) List(_ context.Context) ([]authkeys.AuthKey, error) { + result := make([]authkeys.AuthKey, 0, len(s.keys)) + for _, key := range s.keys { + result = append(result, key) + } + return result, nil +} + +func (s *authKeyTestStore) Create(_ context.Context, key authkeys.AuthKey) error { + s.keys[key.ID] = key + return nil +} + +func (s *authKeyTestStore) Deactivate(_ context.Context, id string, now time.Time) error { + key, ok := s.keys[id] + if !ok { + return authkeys.ErrNotFound + } + key.Enabled = false + key.UpdatedAt = now.UTC() + if key.DeactivatedAt == nil { + deactivatedAt := now.UTC() + key.DeactivatedAt = &deactivatedAt + } + s.keys[id] = key + return nil +} + +func (s *authKeyTestStore) Close() error { return nil } + +func newAuthKeyHandler(t *testing.T, store authkeys.Store) *Handler { + t.Helper() + service, err := authkeys.NewService(store) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + return NewHandler(nil, nil, WithAuthKeys(service)) +} + +func TestAuthKeyEndpointsReturn503WhenServiceUnavailable(t *testing.T) { + h := NewHandler(nil, nil) + e := echo.New() + + listCtx, listRec := newHandlerContext("/admin/api/v1/auth-keys") + if err := h.ListAuthKeys(listCtx); err != nil { + t.Fatalf("ListAuthKeys() error = %v", err) + } + if listRec.Code != http.StatusServiceUnavailable { + t.Fatalf("ListAuthKeys() status = %d, want 503", listRec.Code) + } + + createReq := httptest.NewRequest(http.MethodPost, "/admin/api/v1/auth-keys", bytes.NewBufferString(`{"name":"primary"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + createCtx := e.NewContext(createReq, createRec) + if err := h.CreateAuthKey(createCtx); err != nil { + t.Fatalf("CreateAuthKey() error = %v", err) + } + if createRec.Code != http.StatusServiceUnavailable { + t.Fatalf("CreateAuthKey() status = %d, want 503", createRec.Code) + } + + deactivateReq := httptest.NewRequest(http.MethodPost, "/admin/api/v1/auth-keys/test-key/deactivate", nil) + deactivateRec := httptest.NewRecorder() + deactivateCtx := e.NewContext(deactivateReq, deactivateRec) + deactivateCtx.SetPathValues(echo.PathValues{{Name: "id", Value: "test-key"}}) + if err := h.DeactivateAuthKey(deactivateCtx); err != nil { + t.Fatalf("DeactivateAuthKey() error = %v", err) + } + if deactivateRec.Code != http.StatusServiceUnavailable { + t.Fatalf("DeactivateAuthKey() status = %d, want 503", deactivateRec.Code) + } +} + +func TestCreateListAndDeactivateAuthKey(t *testing.T) { + h := newAuthKeyHandler(t, newAuthKeyTestStore()) + e := echo.New() + + createReq := httptest.NewRequest(http.MethodPost, "/admin/api/v1/auth-keys", bytes.NewBufferString(`{"name":"primary","description":"prod key"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + createCtx := e.NewContext(createReq, createRec) + + if err := h.CreateAuthKey(createCtx); err != nil { + t.Fatalf("CreateAuthKey() error = %v", err) + } + if createRec.Code != http.StatusCreated { + t.Fatalf("CreateAuthKey() status = %d, want 201", createRec.Code) + } + + var issued authkeys.IssuedKey + if err := json.Unmarshal(createRec.Body.Bytes(), &issued); err != nil { + t.Fatalf("unmarshal create response: %v", err) + } + if issued.Value == "" || issued.ID == "" { + t.Fatalf("issued response = %#v, want id and value", issued) + } + + listCtx, listRec := newHandlerContext("/admin/api/v1/auth-keys") + if err := h.ListAuthKeys(listCtx); err != nil { + t.Fatalf("ListAuthKeys() error = %v", err) + } + if listRec.Code != http.StatusOK { + t.Fatalf("ListAuthKeys() status = %d, want 200", listRec.Code) + } + + var views []authkeys.View + if err := json.Unmarshal(listRec.Body.Bytes(), &views); err != nil { + t.Fatalf("unmarshal list response: %v", err) + } + if len(views) != 1 || !views[0].Active { + t.Fatalf("list response = %#v, want one active key", views) + } + + deactivateReq := httptest.NewRequest(http.MethodPost, "/admin/api/v1/auth-keys/"+issued.ID+"/deactivate", nil) + deactivateRec := httptest.NewRecorder() + deactivateCtx := e.NewContext(deactivateReq, deactivateRec) + deactivateCtx.SetPathValues(echo.PathValues{{Name: "id", Value: issued.ID}}) + + if err := h.DeactivateAuthKey(deactivateCtx); err != nil { + t.Fatalf("DeactivateAuthKey() error = %v", err) + } + if deactivateRec.Code != http.StatusNoContent { + t.Fatalf("DeactivateAuthKey() status = %d, want 204", deactivateRec.Code) + } + + listCtx, listRec = newHandlerContext("/admin/api/v1/auth-keys") + if err := h.ListAuthKeys(listCtx); err != nil { + t.Fatalf("ListAuthKeys() error after deactivate = %v", err) + } + if err := json.Unmarshal(listRec.Body.Bytes(), &views); err != nil { + t.Fatalf("unmarshal list response after deactivate: %v", err) + } + if len(views) != 1 || views[0].Active { + t.Fatalf("list response after deactivate = %#v, want one inactive key", views) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 931adad7..9357ee86 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( "gomodel/internal/admin/dashboard" "gomodel/internal/aliases" "gomodel/internal/auditlog" + "gomodel/internal/authkeys" "gomodel/internal/batch" "gomodel/internal/core" "gomodel/internal/executionplans" @@ -38,6 +39,7 @@ type App struct { usage *usage.Result batch *batch.Result aliases *aliases.Result + authKeys *authkeys.Result executionPlans *executionplans.Result server *server.Server @@ -153,9 +155,6 @@ func New(ctx context.Context, cfg Config) (*App, error) { } app.aliases = aliasResult - // Log configuration status - app.logStartupInfo() - // Build runtime execution dependencies. Policy is passed explicitly into the // server; the live provider dependency remains the bare router. var provider core.RoutableProvider = app.providers.Router @@ -217,6 +216,32 @@ func New(ctx context.Context, cfg Config) (*App, error) { } app.executionPlans = executionPlanResult + var authKeyResult *authkeys.Result + sharedAuthKeyStorage := firstSharedStorage( + auditResult.Storage, + usageResult.Storage, + batchResult.Storage, + aliasResult.Storage, + executionPlanResult.Storage, + ) + if sharedAuthKeyStorage != nil { + authKeyResult, err = authkeys.NewWithSharedStorage(ctx, sharedAuthKeyStorage) + } else { + authKeyResult, err = authkeys.New(ctx, appCfg) + } + if err != nil { + closeErr := errors.Join(executionPlanResult.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + if closeErr != nil { + return nil, fmt.Errorf("failed to initialize auth keys: %w (also: close error: %v)", err, closeErr) + } + return nil, fmt.Errorf("failed to initialize auth keys: %w", err) + } + app.authKeys = authKeyResult + + // Log configuration status after auth has been initialized so the startup + // message reflects both bootstrap and managed auth modes. + app.logStartupInfo() + if featureCaps.Guardrails { if guardrailRegistry != nil && guardrailRegistry.Len() > 0 { translatedRequestPatcher = guardrails.NewPlannedRequestPatcher(executionPlanResult.Service) @@ -243,6 +268,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { allowPassthroughV1Alias := appCfg.Server.AllowPassthroughV1Alias serverCfg := &server.Config{ MasterKey: appCfg.Server.MasterKey, + Authenticator: authKeyResult.Service, MetricsEnabled: appCfg.Metrics.Enabled, MetricsEndpoint: appCfg.Metrics.Endpoint, BodySizeLimit: appCfg.Server.BodySizeLimit, @@ -277,6 +303,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { auditResult.Storage, usageResult.Storage, providerResult.Registry, + authKeyResult.Service, app.aliases.Service, executionPlanResult.Service, guardrailRegistry, @@ -315,19 +342,23 @@ func New(ctx context.Context, cfg Config) (*App, error) { if err != nil { var ( executionPlansCloseErr error + authKeysCloseErr error aliasCloseErr error batchCloseErr error ) if app.executionPlans != nil { executionPlansCloseErr = app.executionPlans.Close() } + if app.authKeys != nil { + authKeysCloseErr = app.authKeys.Close() + } if app.aliases != nil { aliasCloseErr = app.aliases.Close() } if app.batch != nil { batchCloseErr = app.batch.Close() } - closeErr := errors.Join(executionPlansCloseErr, aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(executionPlansCloseErr, authKeysCloseErr, aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to initialize response cache: %w (also: close error: %v)", err, closeErr) } @@ -483,7 +514,15 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 5. Close batch store (flushes pending entries) + // 5. Close managed auth keys subsystem. + if a.authKeys != nil { + if err := a.authKeys.Close(); err != nil { + slog.Error("auth keys close error", "error", err) + errs = append(errs, fmt.Errorf("auth keys close: %w", err)) + } + } + + // 6. Close batch store (flushes pending entries) if a.batch != nil { if err := a.batch.Close(); err != nil { slog.Error("batch store close error", "error", err) @@ -491,7 +530,7 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 6. Close usage tracking (flushes pending entries) + // 7. Close usage tracking (flushes pending entries) if a.usage != nil { if err := a.usage.Close(); err != nil { slog.Error("usage logger close error", "error", err) @@ -499,7 +538,7 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 7. Close audit logging (flushes pending logs) + // 8. Close audit logging (flushes pending logs) if a.audit != nil { if err := a.audit.Close(); err != nil { slog.Error("audit logger close error", "error", err) @@ -520,11 +559,17 @@ func (a *App) logStartupInfo() { cfg := a.config // Security warnings - if cfg.Server.MasterKey == "" { + managedKeysConfigured := a.authKeys != nil && a.authKeys.Service != nil && a.authKeys.Service.Enabled() + switch { + case cfg.Server.MasterKey != "" && managedKeysConfigured: + slog.Info("authentication enabled", "mode", "master_key+managed_keys", "managed_key_total", a.authKeys.Service.Total(), "managed_key_active", a.authKeys.Service.ActiveCount()) + case managedKeysConfigured: + slog.Info("authentication enabled", "mode", "managed_keys", "managed_key_total", a.authKeys.Service.Total(), "managed_key_active", a.authKeys.Service.ActiveCount()) + case cfg.Server.MasterKey == "": slog.Warn("SECURITY WARNING: GOMODEL_MASTER_KEY not set - server running in UNSAFE MODE", "security_risk", "unauthenticated access allowed", "recommendation", "set GOMODEL_MASTER_KEY environment variable to secure this gateway") - } else { + default: slog.Info("authentication enabled", "mode", "master_key") } @@ -567,6 +612,7 @@ func (a *App) logStartupInfo() { func initAdmin( auditStorage, usageStorage storage.Storage, registry *providers.ModelRegistry, + authKeyService *authkeys.Service, aliasService *aliases.Service, executionPlanService *executionplans.Service, guardrailRegistry *guardrails.Registry, @@ -606,6 +652,7 @@ func initAdmin( reader, registry, admin.WithAuditReader(auditReader), + admin.WithAuthKeys(authKeyService), admin.WithAliases(aliasService), admin.WithExecutionPlans(executionPlanService), admin.WithGuardrailsRegistry(guardrailRegistry), diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go index 4fde43a5..43df5d19 100644 --- a/internal/auditlog/auditlog.go +++ b/internal/auditlog/auditlog.go @@ -53,6 +53,7 @@ type LogEntry struct { // Extracted fields for efficient filtering (indexed in relational DBs) RequestID string `json:"request_id,omitempty" bson:"request_id,omitempty"` + AuthKeyID string `json:"auth_key_id,omitempty" bson:"auth_key_id,omitempty"` ClientIP string `json:"client_ip,omitempty" bson:"client_ip,omitempty"` Method string `json:"method,omitempty" bson:"method,omitempty"` Path string `json:"path,omitempty" bson:"path,omitempty"` diff --git a/internal/auditlog/auditlog_test.go b/internal/auditlog/auditlog_test.go index 97b2a491..788d9340 100644 --- a/internal/auditlog/auditlog_test.go +++ b/internal/auditlog/auditlog_test.go @@ -673,6 +673,32 @@ func TestMiddleware_StoresExecutionPlanVersionID(t *testing.T) { } } +func TestMiddleware_StoresAuthKeyIDFromContext(t *testing.T) { + logger := &capturingLogger{cfg: Config{Enabled: true}} + middleware := Middleware(logger) + e := echo.New() + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-4o-mini"}`)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(core.WithAuthKeyID(req.Context(), "key-123")) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + handler := middleware(func(c *echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + if err := handler(c); err != nil { + t.Fatalf("handler() error = %v", err) + } + if len(logger.entries) != 1 { + t.Fatalf("logger.entries len = %d, want 1", len(logger.entries)) + } + if got := logger.entries[0].AuthKeyID; got != "key-123" { + t.Fatalf("AuthKeyID = %q, want key-123", got) + } +} + func TestMiddleware_SkipsWriteWhenExecutionPlanDisablesAudit(t *testing.T) { e := echo.New() logger := &capturingLogger{ @@ -861,21 +887,21 @@ func TestCreateStreamEntry(t *testing.T) { // Test with valid entry baseEntry := &LogEntry{ - ID: "test-id", - Timestamp: time.Now(), - DurationNs: 1000, - Model: "claude-opus-4-6", - ResolvedModel: "openai/gpt-5-nano", - Provider: "openai", - AliasUsed: true, + ID: "test-id", + Timestamp: time.Now(), + DurationNs: 1000, + Model: "claude-opus-4-6", + ResolvedModel: "openai/gpt-5-nano", + Provider: "openai", + AliasUsed: true, ExecutionPlanVersionID: "plan-version-123", - CacheType: CacheTypeSemantic, - StatusCode: 200, - RequestID: "req-123", - ClientIP: "127.0.0.1", - Method: "POST", - Path: "/v1/chat/completions", - Stream: false, + CacheType: CacheTypeSemantic, + StatusCode: 200, + RequestID: "req-123", + ClientIP: "127.0.0.1", + Method: "POST", + Path: "/v1/chat/completions", + Stream: false, Data: &LogData{ UserAgent: "test", RequestHeaders: map[string]string{ diff --git a/internal/auditlog/middleware.go b/internal/auditlog/middleware.go index c2747dcc..af0aac70 100644 --- a/internal/auditlog/middleware.go +++ b/internal/auditlog/middleware.go @@ -96,6 +96,7 @@ func Middleware(logger LoggerInterface) echo.MiddlewareFunc { err := next(c) applyExecutionPlan(entry, c.Request().Context()) + applyAuthentication(entry, c.Request().Context()) if !auditEnabledForContext(c.Request().Context()) { return err @@ -163,6 +164,15 @@ func applyExecutionPlan(entry *LogEntry, ctx context.Context) { } } +func applyAuthentication(entry *LogEntry, ctx context.Context) { + if entry == nil || ctx == nil { + return + } + if authKeyID := strings.TrimSpace(core.GetAuthKeyID(ctx)); authKeyID != "" { + entry.AuthKeyID = authKeyID + } +} + func enrichEntryWithExecutionPlan(entry *LogEntry, plan *core.ExecutionPlan) { if entry == nil || plan == nil { return @@ -373,6 +383,25 @@ func EnrichEntryWithCacheType(c *echo.Context, cacheType string) { entry.CacheType = cacheType } +// EnrichEntryWithAuthKeyID attaches the authenticated managed auth key id to the live audit entry. +func EnrichEntryWithAuthKeyID(c *echo.Context, authKeyID string) { + entryVal := c.Get(string(LogEntryKey)) + if entryVal == nil { + return + } + + entry, ok := entryVal.(*LogEntry) + if !ok || entry == nil { + return + } + + authKeyID = strings.TrimSpace(authKeyID) + if authKeyID == "" { + return + } + entry.AuthKeyID = authKeyID +} + func auditEnabledForContext(ctx context.Context) bool { plan := core.GetExecutionPlan(ctx) return plan == nil || plan.AuditEnabled() diff --git a/internal/auditlog/reader_mongodb.go b/internal/auditlog/reader_mongodb.go index c6896a62..1d76d046 100644 --- a/internal/auditlog/reader_mongodb.go +++ b/internal/auditlog/reader_mongodb.go @@ -28,6 +28,7 @@ type mongoLogRow struct { CacheType string `bson:"cache_type"` StatusCode int `bson:"status_code"` RequestID string `bson:"request_id"` + AuthKeyID string `bson:"auth_key_id"` ClientIP string `bson:"client_ip"` Method string `bson:"method"` Path string `bson:"path"` @@ -49,6 +50,7 @@ func (r mongoLogRow) toLogEntry() *LogEntry { CacheType: normalizeCacheType(r.CacheType), StatusCode: r.StatusCode, RequestID: r.RequestID, + AuthKeyID: r.AuthKeyID, ClientIP: r.ClientIP, Method: r.Method, Path: r.Path, @@ -135,6 +137,7 @@ func (r *MongoDBReader) GetLogs(ctx context.Context, params LogQueryParams) (*Lo regex := bson.D{{Key: "$regex", Value: pattern}, {Key: "$options", Value: "i"}} matchFilters = append(matchFilters, bson.E{Key: "$or", Value: bson.A{ bson.D{{Key: "request_id", Value: regex}}, + bson.D{{Key: "auth_key_id", Value: regex}}, bson.D{{Key: "model", Value: regex}}, bson.D{{Key: "provider", Value: regex}}, bson.D{{Key: "method", Value: regex}}, diff --git a/internal/auditlog/reader_postgresql.go b/internal/auditlog/reader_postgresql.go index 0e174947..423426e9 100644 --- a/internal/auditlog/reader_postgresql.go +++ b/internal/auditlog/reader_postgresql.go @@ -65,7 +65,7 @@ func (r *PostgreSQLReader) GetLogs(ctx context.Context, params LogQueryParams) ( } if params.Search != "" { s := "%" + escapeLikeWildcards(params.Search) + "%" - conditions = append(conditions, fmt.Sprintf("(request_id ILIKE $%d ESCAPE '\\' OR model ILIKE $%d ESCAPE '\\' OR provider ILIKE $%d ESCAPE '\\' OR method ILIKE $%d ESCAPE '\\' OR path ILIKE $%d ESCAPE '\\' OR error_type ILIKE $%d ESCAPE '\\')", argIdx, argIdx, argIdx, argIdx, argIdx, argIdx)) + conditions = append(conditions, fmt.Sprintf("(request_id ILIKE $%d ESCAPE '\\' OR auth_key_id ILIKE $%d ESCAPE '\\' OR model ILIKE $%d ESCAPE '\\' OR provider ILIKE $%d ESCAPE '\\' OR method ILIKE $%d ESCAPE '\\' OR path ILIKE $%d ESCAPE '\\' OR error_type ILIKE $%d ESCAPE '\\')", argIdx, argIdx, argIdx, argIdx, argIdx, argIdx, argIdx)) args = append(args, s) argIdx++ } @@ -78,7 +78,7 @@ func (r *PostgreSQLReader) GetLogs(ctx context.Context, params LogQueryParams) ( return nil, fmt.Errorf("failed to count audit log entries: %w", err) } - dataQuery := fmt.Sprintf(`SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + dataQuery := fmt.Sprintf(`SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d`, where, argIdx, argIdx+1) dataArgs := append(append([]any(nil), args...), limit, offset) @@ -95,14 +95,18 @@ func (r *PostgreSQLReader) GetLogs(ctx context.Context, params LogQueryParams) ( var dataJSON *string var executionPlanVersionID *string var cacheType *string + var authKeyID *string if err := rows.Scan(&e.ID, &e.Timestamp, &e.DurationNs, &e.Model, &e.ResolvedModel, &e.Provider, &e.AliasUsed, &executionPlanVersionID, &cacheType, &e.StatusCode, - &e.RequestID, &e.ClientIP, &e.Method, &e.Path, &e.Stream, &e.ErrorType, &dataJSON); err != nil { + &e.RequestID, &authKeyID, &e.ClientIP, &e.Method, &e.Path, &e.Stream, &e.ErrorType, &dataJSON); err != nil { return nil, fmt.Errorf("failed to scan audit log row: %w", err) } if executionPlanVersionID != nil { e.ExecutionPlanVersionID = *executionPlanVersionID } + if authKeyID != nil { + e.AuthKeyID = *authKeyID + } if cacheType != nil { e.CacheType = normalizeCacheType(*cacheType) } @@ -133,7 +137,7 @@ func (r *PostgreSQLReader) GetLogs(ctx context.Context, params LogQueryParams) ( // GetLogByID returns a single audit log entry by ID. func (r *PostgreSQLReader) GetLogByID(ctx context.Context, id string) (*LogEntry, error) { - query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE id::text = $1 LIMIT 1` @@ -175,7 +179,7 @@ func pgDateRangeConditions(params QueryParams, argIdx int) (conditions []string, } func (r *PostgreSQLReader) findByResponseID(ctx context.Context, responseID string) (*LogEntry, error) { - query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE data->'response_body'->>'id' = $1 @@ -194,7 +198,7 @@ func (r *PostgreSQLReader) findByResponseID(ctx context.Context, responseID stri } func (r *PostgreSQLReader) findByPreviousResponseID(ctx context.Context, previousResponseID string) (*LogEntry, error) { - query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE data->'request_body'->>'previous_response_id' = $1 @@ -219,14 +223,18 @@ func scanPostgreSQLLogEntry(rows interface { var dataJSON *string var executionPlanVersionID *string var cacheType *string + var authKeyID *string if err := rows.Scan(&e.ID, &e.Timestamp, &e.DurationNs, &e.Model, &e.ResolvedModel, &e.Provider, &e.AliasUsed, &executionPlanVersionID, &cacheType, &e.StatusCode, - &e.RequestID, &e.ClientIP, &e.Method, &e.Path, &e.Stream, &e.ErrorType, &dataJSON); err != nil { + &e.RequestID, &authKeyID, &e.ClientIP, &e.Method, &e.Path, &e.Stream, &e.ErrorType, &dataJSON); err != nil { return nil, fmt.Errorf("failed to scan audit log row: %w", err) } if executionPlanVersionID != nil { e.ExecutionPlanVersionID = *executionPlanVersionID } + if authKeyID != nil { + e.AuthKeyID = *authKeyID + } if cacheType != nil { e.CacheType = normalizeCacheType(*cacheType) } diff --git a/internal/auditlog/reader_sqlite.go b/internal/auditlog/reader_sqlite.go index 75768372..d090416c 100644 --- a/internal/auditlog/reader_sqlite.go +++ b/internal/auditlog/reader_sqlite.go @@ -65,8 +65,8 @@ func (r *SQLiteReader) GetLogs(ctx context.Context, params LogQueryParams) (*Log } if params.Search != "" { s := "%" + escapeLikeWildcards(params.Search) + "%" - conditions = append(conditions, `(request_id LIKE ? ESCAPE '\' OR model LIKE ? ESCAPE '\' OR provider LIKE ? ESCAPE '\' OR method LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\' OR error_type LIKE ? ESCAPE '\')`) - args = append(args, s, s, s, s, s, s) + conditions = append(conditions, `(request_id LIKE ? ESCAPE '\' OR auth_key_id LIKE ? ESCAPE '\' OR model LIKE ? ESCAPE '\' OR provider LIKE ? ESCAPE '\' OR method LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\' OR error_type LIKE ? ESCAPE '\')`) + args = append(args, s, s, s, s, s, s, s) } where := buildWhereClause(conditions) @@ -78,7 +78,7 @@ func (r *SQLiteReader) GetLogs(ctx context.Context, params LogQueryParams) (*Log return nil, fmt.Errorf("failed to count audit log entries: %w", err) } - dataQuery := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + dataQuery := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs` + where + ` ORDER BY timestamp DESC LIMIT ? OFFSET ?` dataArgs := append(append([]any(nil), args...), limit, offset) @@ -98,9 +98,10 @@ func (r *SQLiteReader) GetLogs(ctx context.Context, params LogQueryParams) (*Log var dataJSON *string var executionPlanVersionID sql.NullString var cacheType sql.NullString + var authKeyID sql.NullString if err := rows.Scan(&e.ID, &ts, &e.DurationNs, &e.Model, &e.ResolvedModel, &e.Provider, &aliasUsedInt, &executionPlanVersionID, &cacheType, &e.StatusCode, - &e.RequestID, &e.ClientIP, &e.Method, &e.Path, &streamInt, &e.ErrorType, &dataJSON); err != nil { + &e.RequestID, &authKeyID, &e.ClientIP, &e.Method, &e.Path, &streamInt, &e.ErrorType, &dataJSON); err != nil { return nil, fmt.Errorf("failed to scan audit log row: %w", err) } @@ -110,6 +111,9 @@ func (r *SQLiteReader) GetLogs(ctx context.Context, params LogQueryParams) (*Log if executionPlanVersionID.Valid { e.ExecutionPlanVersionID = executionPlanVersionID.String } + if authKeyID.Valid { + e.AuthKeyID = authKeyID.String + } if cacheType.Valid { e.CacheType = normalizeCacheType(cacheType.String) } @@ -140,7 +144,7 @@ func (r *SQLiteReader) GetLogs(ctx context.Context, params LogQueryParams) (*Log // GetLogByID returns a single audit log entry by ID. func (r *SQLiteReader) GetLogByID(ctx context.Context, id string) (*LogEntry, error) { - query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE id = ? LIMIT 1` @@ -272,7 +276,7 @@ func parseSQLTimestamp(ts string, entryID string) time.Time { } func (r *SQLiteReader) findByResponseID(ctx context.Context, responseID string) (*LogEntry, error) { - query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE json_extract(data, '$.response_body.id') = ? @@ -291,7 +295,7 @@ func (r *SQLiteReader) findByResponseID(ctx context.Context, responseID string) } func (r *SQLiteReader) findByPreviousResponseID(ctx context.Context, previousResponseID string) (*LogEntry, error) { - query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, + query := `SELECT id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE json_extract(data, '$.request_body.previous_response_id') = ? @@ -317,9 +321,10 @@ func scanSQLiteLogEntry(rows *sql.Rows) (*LogEntry, error) { var dataJSON *string var executionPlanVersionID sql.NullString var cacheType sql.NullString + var authKeyID sql.NullString if err := rows.Scan(&e.ID, &ts, &e.DurationNs, &e.Model, &e.ResolvedModel, &e.Provider, &aliasUsedInt, &executionPlanVersionID, &cacheType, &e.StatusCode, - &e.RequestID, &e.ClientIP, &e.Method, &e.Path, &streamInt, &e.ErrorType, &dataJSON); err != nil { + &e.RequestID, &authKeyID, &e.ClientIP, &e.Method, &e.Path, &streamInt, &e.ErrorType, &dataJSON); err != nil { return nil, fmt.Errorf("failed to scan audit log row: %w", err) } @@ -329,6 +334,9 @@ func scanSQLiteLogEntry(rows *sql.Rows) (*LogEntry, error) { if executionPlanVersionID.Valid { e.ExecutionPlanVersionID = executionPlanVersionID.String } + if authKeyID.Valid { + e.AuthKeyID = authKeyID.String + } if cacheType.Valid { e.CacheType = normalizeCacheType(cacheType.String) } diff --git a/internal/auditlog/store_mongodb.go b/internal/auditlog/store_mongodb.go index 2c987a31..852dfcca 100644 --- a/internal/auditlog/store_mongodb.go +++ b/internal/auditlog/store_mongodb.go @@ -79,6 +79,9 @@ func NewMongoDBStore(database *mongo.Database, retentionDays int) (*MongoDBStore { Keys: bson.D{{Key: "request_id", Value: 1}}, }, + { + Keys: bson.D{{Key: "auth_key_id", Value: 1}}, + }, { Keys: bson.D{{Key: "client_ip", Value: 1}}, }, diff --git a/internal/auditlog/store_postgresql.go b/internal/auditlog/store_postgresql.go index 1a65463c..97fa26f8 100644 --- a/internal/auditlog/store_postgresql.go +++ b/internal/auditlog/store_postgresql.go @@ -14,14 +14,14 @@ import ( ) const ( - auditLogInsertColumnCount = 17 + auditLogInsertColumnCount = 18 postgresMaxBindParameters = 65535 auditLogInsertMaxRowsPerQuery = postgresMaxBindParameters / auditLogInsertColumnCount ) const auditLogInsertPrefix = ` INSERT INTO audit_logs (id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, - request_id, client_ip, method, path, stream, error_type, data) + request_id, auth_key_id, client_ip, method, path, stream, error_type, data) VALUES ` const auditLogInsertSuffix = ` @@ -64,6 +64,7 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore cache_type TEXT, status_code INTEGER DEFAULT 0, request_id TEXT, + auth_key_id TEXT, client_ip TEXT, method TEXT, path TEXT, @@ -81,6 +82,7 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore "ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS alias_used BOOLEAN DEFAULT FALSE", "ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS execution_plan_version_id TEXT", "ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS cache_type TEXT", + "ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS auth_key_id TEXT", } for _, migration := range migrations { if _, err := pool.Exec(ctx, migration); err != nil { @@ -96,6 +98,7 @@ func NewPostgreSQLStore(pool *pgxpool.Pool, retentionDays int) (*PostgreSQLStore "CREATE INDEX IF NOT EXISTS idx_audit_provider ON audit_logs(provider)", "CREATE INDEX IF NOT EXISTS idx_audit_execution_plan_version_id ON audit_logs(execution_plan_version_id)", "CREATE INDEX IF NOT EXISTS idx_audit_request_id ON audit_logs(request_id)", + "CREATE INDEX IF NOT EXISTS idx_audit_auth_key_id ON audit_logs(auth_key_id)", "CREATE INDEX IF NOT EXISTS idx_audit_client_ip ON audit_logs(client_ip)", "CREATE INDEX IF NOT EXISTS idx_audit_path ON audit_logs(path)", "CREATE INDEX IF NOT EXISTS idx_audit_error_type ON audit_logs(error_type)", @@ -218,6 +221,7 @@ func buildAuditLogInsert(entries []*LogEntry) (string, []any) { cacheTypeValue, entry.StatusCode, entry.RequestID, + entry.AuthKeyID, entry.ClientIP, entry.Method, entry.Path, diff --git a/internal/auditlog/store_postgresql_test.go b/internal/auditlog/store_postgresql_test.go index 2c004e01..399c582c 100644 --- a/internal/auditlog/store_postgresql_test.go +++ b/internal/auditlog/store_postgresql_test.go @@ -21,6 +21,7 @@ func TestBuildAuditLogInsert(t *testing.T) { CacheType: CacheTypeExact, StatusCode: 200, RequestID: "req-1", + AuthKeyID: "auth-key-1", ClientIP: "127.0.0.1", Method: "POST", Path: "/v1/chat/completions", @@ -50,12 +51,12 @@ func TestBuildAuditLogInsert(t *testing.T) { }) normalized := strings.Join(strings.Fields(query), " ") - wantQuery := "INSERT INTO audit_logs (id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, client_ip, method, path, stream, error_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17), ($18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34) ON CONFLICT (id) DO NOTHING" + wantQuery := "INSERT INTO audit_logs (id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, request_id, auth_key_id, client_ip, method, path, stream, error_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18), ($19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36) ON CONFLICT (id) DO NOTHING" if normalized != wantQuery { t.Fatalf("query = %q, want %q", normalized, wantQuery) } - if got, want := len(args), 34; got != want { + if got, want := len(args), 36; got != want { t.Fatalf("len(args) = %d, want %d", got, want) } if got := args[0]; got != "log-1" { @@ -64,21 +65,27 @@ func TestBuildAuditLogInsert(t *testing.T) { if got := args[8]; got != CacheTypeExact { t.Fatalf("args[8] = %v, want %q", got, CacheTypeExact) } - if got := args[17]; got != "log-2" { - t.Fatalf("args[17] = %v, want log-2", got) + if got, ok := args[11].(string); !ok || got != "auth-key-1" { + t.Fatalf("args[11] = (%T) %v, want (string) auth-key-1", args[11], args[11]) } - if got := string(args[16].([]byte)); got != `{"user_agent":"test-agent"}` { - t.Fatalf("args[16] = %q, want %q", got, `{"user_agent":"test-agent"}`) + if got := args[18]; got != "log-2" { + t.Fatalf("args[18] = %v, want log-2", got) } - if got := args[25]; got != nil { - t.Fatalf("args[25] = %v, want nil cache type", got) + if got, ok := args[29].(string); !ok || got != "" { + t.Fatalf("args[29] = (%T) %v, want (string) \"\"", args[29], args[29]) } - dataJSON, ok := args[33].([]byte) + if got := string(args[17].([]byte)); got != `{"user_agent":"test-agent"}` { + t.Fatalf("args[17] = %q, want %q", got, `{"user_agent":"test-agent"}`) + } + if got := args[26]; got != nil { + t.Fatalf("args[26] = %v, want nil cache type", got) + } + dataJSON, ok := args[35].([]byte) if !ok { - t.Fatalf("args[33] has type %T, want []byte", args[33]) + t.Fatalf("args[35] has type %T, want []byte", args[35]) } if dataJSON != nil { - t.Fatalf("args[33] = %v, want nil data", dataJSON) + t.Fatalf("args[35] = %v, want nil data", dataJSON) } } diff --git a/internal/auditlog/store_sqlite.go b/internal/auditlog/store_sqlite.go index 5a111788..abf5f0c7 100644 --- a/internal/auditlog/store_sqlite.go +++ b/internal/auditlog/store_sqlite.go @@ -11,12 +11,12 @@ import ( ) // SQLite has a default limit of 999 bindable parameters per query (SQLITE_MAX_VARIABLE_NUMBER). -// With 17 columns per log entry, we can safely insert up to 58 entries per batch (58 * 17 = 986). +// With 18 columns per log entry, we can safely insert up to 55 entries per batch (55 * 18 = 990). // We chunk larger batches to avoid hitting this limit. const ( maxSQLiteParams = 999 - columnsPerEntry = 17 - maxEntriesPerBatch = maxSQLiteParams / columnsPerEntry // 58 entries + columnsPerEntry = 18 + maxEntriesPerBatch = maxSQLiteParams / columnsPerEntry // 55 entries ) // SQLiteStore implements LogStore for SQLite databases. @@ -49,6 +49,7 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) { cache_type TEXT, status_code INTEGER DEFAULT 0, request_id TEXT, + auth_key_id TEXT, client_ip TEXT, method TEXT, path TEXT, @@ -66,6 +67,7 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) { "ALTER TABLE audit_logs ADD COLUMN alias_used INTEGER DEFAULT 0", "ALTER TABLE audit_logs ADD COLUMN execution_plan_version_id TEXT", "ALTER TABLE audit_logs ADD COLUMN cache_type TEXT", + "ALTER TABLE audit_logs ADD COLUMN auth_key_id TEXT", } for _, migration := range migrations { if _, err := db.Exec(migration); err != nil { @@ -83,6 +85,7 @@ func NewSQLiteStore(db *sql.DB, retentionDays int) (*SQLiteStore, error) { "CREATE INDEX IF NOT EXISTS idx_audit_provider ON audit_logs(provider)", "CREATE INDEX IF NOT EXISTS idx_audit_execution_plan_version_id ON audit_logs(execution_plan_version_id)", "CREATE INDEX IF NOT EXISTS idx_audit_request_id ON audit_logs(request_id)", + "CREATE INDEX IF NOT EXISTS idx_audit_auth_key_id ON audit_logs(auth_key_id)", "CREATE INDEX IF NOT EXISTS idx_audit_client_ip ON audit_logs(client_ip)", "CREATE INDEX IF NOT EXISTS idx_audit_path ON audit_logs(path)", "CREATE INDEX IF NOT EXISTS idx_audit_error_type ON audit_logs(error_type)", @@ -126,7 +129,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*LogEntry) error values := make([]any, 0, len(chunk)*columnsPerEntry) for j, e := range chunk { - placeholders[j] = "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + placeholders[j] = "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" dataJSON := marshalLogData(e.Data, e.ID) @@ -162,6 +165,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*LogEntry) error cacheTypeValue, e.StatusCode, e.RequestID, + e.AuthKeyID, e.ClientIP, e.Method, e.Path, @@ -172,7 +176,7 @@ func (s *SQLiteStore) WriteBatch(ctx context.Context, entries []*LogEntry) error } query := `INSERT OR IGNORE INTO audit_logs (id, timestamp, duration_ns, model, resolved_model, provider, alias_used, execution_plan_version_id, cache_type, status_code, - request_id, client_ip, method, path, stream, error_type, data) VALUES ` + + request_id, auth_key_id, client_ip, method, path, stream, error_type, data) VALUES ` + strings.Join(placeholders, ",") _, err := s.db.ExecContext(ctx, query, values...) diff --git a/internal/auditlog/stream_wrapper.go b/internal/auditlog/stream_wrapper.go index 947b6dc6..459a7dc3 100644 --- a/internal/auditlog/stream_wrapper.go +++ b/internal/auditlog/stream_wrapper.go @@ -93,6 +93,7 @@ func CreateStreamEntry(baseEntry *LogEntry) *LogEntry { StatusCode: baseEntry.StatusCode, // Copy extracted fields RequestID: baseEntry.RequestID, + AuthKeyID: baseEntry.AuthKeyID, ClientIP: baseEntry.ClientIP, Method: baseEntry.Method, Path: baseEntry.Path, diff --git a/internal/authkeys/factory.go b/internal/authkeys/factory.go new file mode 100644 index 00000000..7808b600 --- /dev/null +++ b/internal/authkeys/factory.go @@ -0,0 +1,110 @@ +package authkeys + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + + "github.com/jackc/pgx/v5/pgxpool" + "go.mongodb.org/mongo-driver/v2/mongo" + + "gomodel/config" + "gomodel/internal/storage" +) + +// Result holds the initialized auth key service and any owned resources. +type Result struct { + Service *Service + Store Store + Storage storage.Storage + + stopRefresh func() + closeOnce sync.Once + closeErr error +} + +// Close releases resources held by the auth key subsystem. +func (r *Result) Close() error { + if r == nil { + return nil + } + r.closeOnce.Do(func() { + if r.stopRefresh != nil { + r.stopRefresh() + r.stopRefresh = nil + } + + var errs []error + if r.Store != nil { + if err := r.Store.Close(); err != nil { + errs = append(errs, fmt.Errorf("store close: %w", err)) + } + } + if r.Storage != nil { + if err := r.Storage.Close(); err != nil { + errs = append(errs, fmt.Errorf("storage close: %w", err)) + } + } + if len(errs) > 0 { + r.closeErr = fmt.Errorf("close errors: %w", errors.Join(errs...)) + } + }) + return r.closeErr +} + +// New creates an auth key subsystem with its own storage connection. +func New(ctx context.Context, cfg *config.Config) (*Result, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + storeConn, err := storage.New(ctx, cfg.Storage.BackendConfig()) + if err != nil { + return nil, fmt.Errorf("failed to create storage: %w", err) + } + result, err := newResult(ctx, storeConn) + if err != nil { + _ = storeConn.Close() + return nil, err + } + result.Storage = storeConn + return result, nil +} + +// NewWithSharedStorage creates an auth key subsystem using an existing storage connection. +func NewWithSharedStorage(ctx context.Context, shared storage.Storage) (*Result, error) { + if shared == nil { + return nil, fmt.Errorf("shared storage is required") + } + return newResult(ctx, shared) +} + +func newResult(ctx context.Context, storeConn storage.Storage) (*Result, error) { + store, err := createStore(ctx, storeConn) + if err != nil { + return nil, err + } + service, err := NewService(store) + if err != nil { + return nil, err + } + if err := service.Refresh(ctx); err != nil { + return nil, err + } + + return &Result{ + Service: service, + Store: store, + stopRefresh: service.StartBackgroundRefresh(defaultRefreshInterval), + }, nil +} + +func createStore(ctx context.Context, store storage.Storage) (Store, error) { + return storage.ResolveBackend[Store]( + store, + func(db *sql.DB) (Store, error) { return NewSQLiteStore(db) }, + func(pool *pgxpool.Pool) (Store, error) { return NewPostgreSQLStore(ctx, pool) }, + func(db *mongo.Database) (Store, error) { return NewMongoDBStore(db) }, + ) +} diff --git a/internal/authkeys/service.go b/internal/authkeys/service.go new file mode 100644 index 00000000..f163c6f4 --- /dev/null +++ b/internal/authkeys/service.go @@ -0,0 +1,410 @@ +package authkeys + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const defaultRefreshInterval = time.Minute + +type snapshot struct { + order []string + byID map[string]AuthKey + bySecretHash map[string]AuthKey + activeByHash map[string]AuthKey +} + +// Service keeps managed auth keys cached in memory for request authentication. +type Service struct { + store Store + + mu sync.RWMutex + snapshot snapshot +} + +// NewService creates a managed auth key service backed by storage. +func NewService(store Store) (*Service, error) { + if store == nil { + return nil, fmt.Errorf("store is required") + } + return &Service{ + store: store, + snapshot: snapshot{ + order: []string{}, + byID: map[string]AuthKey{}, + bySecretHash: map[string]AuthKey{}, + activeByHash: map[string]AuthKey{}, + }, + }, nil +} + +// Refresh reloads keys from storage and atomically swaps the in-memory snapshot. +func (s *Service) Refresh(ctx context.Context) error { + keys, err := s.store.List(ctx) + if err != nil { + return fmt.Errorf("list auth keys: %w", err) + } + + now := time.Now().UTC() + next := snapshot{ + order: make([]string, 0, len(keys)), + byID: make(map[string]AuthKey, len(keys)), + bySecretHash: make(map[string]AuthKey, len(keys)), + activeByHash: make(map[string]AuthKey, len(keys)), + } + + for _, key := range keys { + key.ID = normalizeID(key.ID) + if key.ID == "" { + return fmt.Errorf("load auth key %q: missing id", key.Name) + } + next.order = append(next.order, key.ID) + next.byID[key.ID] = key + next.bySecretHash[key.SecretHash] = key + if key.Active(now) { + next.activeByHash[key.SecretHash] = key + } + } + + sort.Slice(next.order, func(i, j int) bool { + left := next.byID[next.order[i]] + right := next.byID[next.order[j]] + if !left.CreatedAt.Equal(right.CreatedAt) { + return left.CreatedAt.After(right.CreatedAt) + } + if left.Name != right.Name { + return left.Name < right.Name + } + return left.ID < right.ID + }) + + s.mu.Lock() + s.snapshot = next + s.mu.Unlock() + return nil +} + +// Enabled reports whether managed auth keys should be enforced. +func (s *Service) Enabled() bool { + if s == nil { + return false + } + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.snapshot.byID) > 0 +} + +// Total returns the number of persisted managed auth keys in the current snapshot. +func (s *Service) Total() int { + if s == nil { + return 0 + } + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.snapshot.byID) +} + +// ActiveCount returns the number of currently active auth keys. +func (s *Service) ActiveCount() int { + if s == nil { + return 0 + } + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.snapshot.activeByHash) +} + +// ListViews returns all cached keys in admin-facing form. +func (s *Service) ListViews() []View { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now().UTC() + result := make([]View, 0, len(s.snapshot.order)) + for _, id := range s.snapshot.order { + key := s.snapshot.byID[id] + result = append(result, View{ + AuthKey: key, + Active: key.Active(now), + }) + } + return result +} + +// Create issues a new managed auth key, persists it, updates the in-memory +// snapshot immediately, and then best-effort reconciles from storage. +func (s *Service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) { + if s == nil { + return nil, fmt.Errorf("auth key service is required") + } + + normalized, err := normalizeCreateInput(input) + if err != nil { + return nil, err + } + + value, redactedValue, secretHash, err := generateTokenMaterial() + if err != nil { + return nil, fmt.Errorf("generate auth key: %w", err) + } + + now := time.Now().UTC() + key := AuthKey{ + ID: uuid.NewString(), + Name: normalized.Name, + Description: normalized.Description, + RedactedValue: redactedValue, + SecretHash: secretHash, + Enabled: true, + ExpiresAt: normalized.ExpiresAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.store.Create(ctx, key); err != nil { + return nil, fmt.Errorf("create auth key: %w", err) + } + s.applyUpsert(key, now) + s.refreshBestEffort(ctx, "create") + + return &IssuedKey{ + View: View{ + AuthKey: key, + Active: key.Active(now), + }, + Value: value, + }, nil +} + +// Deactivate marks a managed auth key inactive while preserving its record and +// best-effort reconciles the snapshot from storage afterward. +func (s *Service) Deactivate(ctx context.Context, id string) error { + if s == nil { + return fmt.Errorf("auth key service is required") + } + id = normalizeID(id) + if id == "" { + return newValidationError("auth key id is required", nil) + } + + now := time.Now().UTC() + if err := s.store.Deactivate(ctx, id, now); err != nil { + return fmt.Errorf("deactivate auth key: %w", err) + } + s.applyDeactivate(id, now) + s.refreshBestEffort(ctx, "deactivate") + return nil +} + +// Authenticate validates a presented bearer token against the in-memory snapshot +// and returns the internal auth key id on success. +func (s *Service) Authenticate(_ context.Context, token string) (string, error) { + if s == nil { + return "", ErrInvalidToken + } + + secret, err := parseTokenSecret(token) + if err != nil { + return "", err + } + secretHash := hashSecret(secret) + now := time.Now().UTC() + + s.mu.RLock() + active, ok := s.snapshot.activeByHash[secretHash] + if ok { + s.mu.RUnlock() + return authenticateKey(active, now) + } + key, exists := s.snapshot.bySecretHash[secretHash] + s.mu.RUnlock() + if !exists { + return "", ErrInvalidToken + } + return authenticateKey(key, now) +} + +// StartBackgroundRefresh periodically reloads auth keys from storage until stopped. +func (s *Service) StartBackgroundRefresh(interval time.Duration) func() { + if interval <= 0 { + interval = defaultRefreshInterval + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + var once sync.Once + + go func() { + defer close(done) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refreshCtx, refreshCancel := context.WithTimeout(ctx, 30*time.Second) + _ = s.Refresh(refreshCtx) + refreshCancel() + } + } + }() + + return func() { + once.Do(func() { + cancel() + <-done + }) + } +} + +func authenticateKey(key AuthKey, now time.Time) (string, error) { + if !key.Enabled || key.DeactivatedAt != nil { + return "", ErrInactive + } + if key.ExpiresAt != nil && !key.ExpiresAt.After(now) { + return "", ErrExpired + } + if strings.TrimSpace(key.ID) == "" { + return "", ErrInvalidToken + } + return key.ID, nil +} + +func (s *Service) refreshBestEffort(ctx context.Context, operation string) { + if err := s.Refresh(ctx); err != nil { + slog.Warn("auth key snapshot reconciliation failed", "operation", operation, "error", err) + } +} + +func (s *Service) applyUpsert(key AuthKey, now time.Time) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + + next := cloneSnapshot(s.snapshot) + if previous, exists := next.byID[key.ID]; exists && previous.SecretHash != "" && previous.SecretHash != key.SecretHash { + delete(next.bySecretHash, previous.SecretHash) + delete(next.activeByHash, previous.SecretHash) + } + if _, exists := next.byID[key.ID]; !exists { + next.order = append(next.order, key.ID) + } + next.byID[key.ID] = key + next.bySecretHash[key.SecretHash] = key + if key.Active(now) { + next.activeByHash[key.SecretHash] = key + } else { + delete(next.activeByHash, key.SecretHash) + } + sortSnapshotOrder(&next) + s.snapshot = next +} + +func (s *Service) applyDeactivate(id string, now time.Time) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + + next := cloneSnapshot(s.snapshot) + key, exists := next.byID[id] + if !exists { + s.snapshot = next + return + } + key.Enabled = false + key.UpdatedAt = now.UTC() + if key.DeactivatedAt == nil { + deactivatedAt := now.UTC() + key.DeactivatedAt = &deactivatedAt + } + next.byID[id] = key + next.bySecretHash[key.SecretHash] = key + delete(next.activeByHash, key.SecretHash) + s.snapshot = next +} + +func cloneSnapshot(src snapshot) snapshot { + next := snapshot{ + order: append([]string(nil), src.order...), + byID: make(map[string]AuthKey, len(src.byID)), + bySecretHash: make(map[string]AuthKey, len(src.bySecretHash)), + activeByHash: make(map[string]AuthKey, len(src.activeByHash)), + } + for id, key := range src.byID { + next.byID[id] = key + } + for hash, key := range src.bySecretHash { + next.bySecretHash[hash] = key + } + for hash, key := range src.activeByHash { + next.activeByHash[hash] = key + } + return next +} + +func sortSnapshotOrder(next *snapshot) { + sort.Slice(next.order, func(i, j int) bool { + left := next.byID[next.order[i]] + right := next.byID[next.order[j]] + if !left.CreatedAt.Equal(right.CreatedAt) { + return left.CreatedAt.After(right.CreatedAt) + } + if left.Name != right.Name { + return left.Name < right.Name + } + return left.ID < right.ID + }) +} + +func generateTokenMaterial() (value string, redactedValue string, secretHash string, err error) { + secretBytesBuf := make([]byte, secretBytes) + if _, err := rand.Read(secretBytesBuf); err != nil { + return "", "", "", err + } + secret := base64.RawURLEncoding.EncodeToString(secretBytesBuf) + value = TokenPrefix + secret + return value, redactTokenValue(value), hashSecret(secret), nil +} + +func parseTokenSecret(token string) (string, error) { + token = strings.TrimSpace(token) + if !strings.HasPrefix(token, TokenPrefix) { + return "", ErrInvalidToken + } + secret := strings.TrimPrefix(token, TokenPrefix) + if secret == "" { + return "", ErrInvalidToken + } + return secret, nil +} + +func hashSecret(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +func redactTokenValue(value string) string { + if len(value) <= len(TokenPrefix)+4 { + return TokenPrefix + "..." + } + return TokenPrefix + "..." + value[len(value)-4:] +} diff --git a/internal/authkeys/service_test.go b/internal/authkeys/service_test.go new file mode 100644 index 00000000..fbc72501 --- /dev/null +++ b/internal/authkeys/service_test.go @@ -0,0 +1,212 @@ +package authkeys + +import ( + "context" + "errors" + "testing" + "time" +) + +type testStore struct { + keys map[string]AuthKey + listErr error + createErr error + deactivateErr error +} + +func newTestStore(keys ...AuthKey) *testStore { + store := &testStore{keys: make(map[string]AuthKey, len(keys))} + for _, key := range keys { + store.keys[key.ID] = key + } + return store +} + +func (s *testStore) List(_ context.Context) ([]AuthKey, error) { + if s.listErr != nil { + return nil, s.listErr + } + result := make([]AuthKey, 0, len(s.keys)) + for _, key := range s.keys { + result = append(result, key) + } + return result, nil +} + +func (s *testStore) Create(_ context.Context, key AuthKey) error { + if s.createErr != nil { + return s.createErr + } + s.keys[key.ID] = key + return nil +} + +func (s *testStore) Deactivate(_ context.Context, id string, now time.Time) error { + if s.deactivateErr != nil { + return s.deactivateErr + } + key, ok := s.keys[id] + if !ok { + return ErrNotFound + } + key.Enabled = false + key.UpdatedAt = now.UTC() + if key.DeactivatedAt == nil { + timestamp := now.UTC() + key.DeactivatedAt = ×tamp + } + s.keys[id] = key + return nil +} + +func (s *testStore) Close() error { return nil } + +func TestServiceCreateAuthenticateAndDeactivate(t *testing.T) { + service, err := NewService(newTestStore()) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if service.Enabled() { + t.Fatal("Enabled() = true, want false before any keys exist") + } + + issued, err := service.Create(context.Background(), CreateInput{Name: "primary"}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if issued == nil { + t.Fatal("Create() = nil, want issued key") + } + if len(issued.Value) <= len(TokenPrefix) || issued.Value[:len(TokenPrefix)] != TokenPrefix { + t.Fatalf("issued value = %q, want %q prefix", issued.Value, TokenPrefix) + } + if !service.Enabled() { + t.Fatal("Enabled() = false, want true after create") + } + + authKeyID, err := service.Authenticate(context.Background(), issued.Value) + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if authKeyID != issued.ID { + t.Fatalf("Authenticate() id = %q, want %q", authKeyID, issued.ID) + } + + if err := service.Deactivate(context.Background(), issued.ID); err != nil { + t.Fatalf("Deactivate() error = %v", err) + } + if _, err := service.Authenticate(context.Background(), issued.Value); err != ErrInactive { + t.Fatalf("Authenticate() after deactivate error = %v, want %v", err, ErrInactive) + } + + views := service.ListViews() + if len(views) != 1 { + t.Fatalf("ListViews() len = %d, want 1", len(views)) + } + if views[0].Active { + t.Fatal("ListViews()[0].Active = true, want false after deactivation") + } +} + +func TestServiceAuthenticateExpiredKey(t *testing.T) { + expiredAt := time.Now().UTC().Add(-time.Minute) + key := AuthKey{ + ID: "key-expired", + Name: "expired", + RedactedValue: TokenPrefix + "...zzzz", + SecretHash: hashSecret("secret"), + Enabled: true, + ExpiresAt: &expiredAt, + CreatedAt: time.Now().UTC().Add(-2 * time.Hour), + UpdatedAt: time.Now().UTC().Add(-2 * time.Hour), + } + service, err := NewService(newTestStore(key)) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + if _, err := service.Authenticate(context.Background(), TokenPrefix+"secret"); err != ErrExpired { + t.Fatalf("Authenticate() error = %v, want %v", err, ErrExpired) + } +} + +func TestServiceAuthenticateRechecksStaleActiveSnapshot(t *testing.T) { + expiredAt := time.Now().UTC().Add(-time.Minute) + key := AuthKey{ + ID: "key-expired", + Name: "expired", + RedactedValue: TokenPrefix + "...zzzz", + SecretHash: hashSecret("secret"), + Enabled: true, + ExpiresAt: &expiredAt, + CreatedAt: time.Now().UTC().Add(-2 * time.Hour), + UpdatedAt: time.Now().UTC().Add(-2 * time.Hour), + } + service, err := NewService(newTestStore()) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + service.snapshot = snapshot{ + order: []string{key.ID}, + byID: map[string]AuthKey{key.ID: key}, + bySecretHash: map[string]AuthKey{key.SecretHash: key}, + activeByHash: map[string]AuthKey{key.SecretHash: key}, + } + + if _, err := service.Authenticate(context.Background(), TokenPrefix+"secret"); err != ErrExpired { + t.Fatalf("Authenticate() error = %v, want %v", err, ErrExpired) + } +} + +func TestServiceWriteOperationsIgnoreRefreshReconciliationFailures(t *testing.T) { + t.Run("create still succeeds when refresh reconciliation fails", func(t *testing.T) { + store := newTestStore() + service, err := NewService(store) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + store.listErr = errors.New("transient list failure") + issued, err := service.Create(context.Background(), CreateInput{Name: "primary"}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if issued == nil { + t.Fatal("Create() = nil, want issued key") + } + if got, err := service.Authenticate(context.Background(), issued.Value); err != nil || got != issued.ID { + t.Fatalf("Authenticate() = (%q, %v), want (%q, nil)", got, err, issued.ID) + } + }) + + t.Run("deactivate still succeeds when refresh reconciliation fails", func(t *testing.T) { + key := AuthKey{ + ID: "key-1", + Name: "primary", + RedactedValue: TokenPrefix + "...abcd", + SecretHash: hashSecret("secret"), + Enabled: true, + CreatedAt: time.Now().UTC().Add(-time.Hour), + UpdatedAt: time.Now().UTC().Add(-time.Hour), + } + store := newTestStore(key) + service, err := NewService(store) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + store.listErr = errors.New("transient list failure") + if err := service.Deactivate(context.Background(), key.ID); err != nil { + t.Fatalf("Deactivate() error = %v", err) + } + if _, err := service.Authenticate(context.Background(), TokenPrefix+"secret"); err != ErrInactive { + t.Fatalf("Authenticate() error = %v, want %v", err, ErrInactive) + } + }) +} diff --git a/internal/authkeys/store.go b/internal/authkeys/store.go new file mode 100644 index 00000000..5e1e641b --- /dev/null +++ b/internal/authkeys/store.go @@ -0,0 +1,103 @@ +package authkeys + +import ( + "context" + "errors" + "strings" + "time" +) + +var ( + // ErrNotFound indicates a requested auth key record does not exist. + ErrNotFound = errors.New("auth key not found") + // ErrInvalidToken indicates the presented token does not match a known key. + ErrInvalidToken = errors.New("invalid API key") + // ErrInactive indicates the presented token belongs to an inactive key. + ErrInactive = errors.New("API key is inactive") + // ErrExpired indicates the presented token belongs to an expired key. + ErrExpired = errors.New("API key expired") +) + +// ValidationError indicates invalid auth key input or state. +type ValidationError struct { + Message string + Err error +} + +func (e *ValidationError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func (e *ValidationError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func newValidationError(message string, err error) error { + return &ValidationError{Message: message, Err: err} +} + +// IsValidationError reports whether err is a validation error. +func IsValidationError(err error) bool { + _, ok := errors.AsType[*ValidationError](err) + return ok +} + +// Store defines persistence operations for managed auth keys. +type Store interface { + List(ctx context.Context) ([]AuthKey, error) + Create(ctx context.Context, key AuthKey) error + Deactivate(ctx context.Context, id string, now time.Time) error + Close() error +} + +type authKeyScanner interface { + Scan(dest ...any) error +} + +type authKeyRows interface { + authKeyScanner + Next() bool + Err() error +} + +func normalizeCreateInput(input CreateInput) (CreateInput, error) { + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + if input.Name == "" { + return CreateInput{}, newValidationError("name is required", nil) + } + if input.ExpiresAt != nil { + expiresAt := input.ExpiresAt.UTC() + now := time.Now().UTC() + if !expiresAt.After(now) { + return CreateInput{}, newValidationError("expires_at must be in the future", nil) + } + input.ExpiresAt = &expiresAt + } + return input, nil +} + +func normalizeID(id string) string { + return strings.TrimSpace(id) +} + +func collectAuthKeys(rows authKeyRows, scan func(authKeyScanner) (AuthKey, error)) ([]AuthKey, error) { + result := make([]AuthKey, 0) + for rows.Next() { + item, err := scan(rows) + if err != nil { + return nil, err + } + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/authkeys/store_mongodb.go b/internal/authkeys/store_mongodb.go new file mode 100644 index 00000000..16c593bf --- /dev/null +++ b/internal/authkeys/store_mongodb.go @@ -0,0 +1,141 @@ +package authkeys + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type mongoAuthKeyDocument struct { + ID string `bson:"_id"` + Name string `bson:"name"` + Description string `bson:"description,omitempty"` + RedactedValue string `bson:"redacted_value"` + SecretHash string `bson:"secret_hash"` + Enabled bool `bson:"enabled"` + ExpiresAt *time.Time `bson:"expires_at,omitempty"` + DeactivatedAt *time.Time `bson:"deactivated_at,omitempty"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +type mongoAuthKeyIDFilter struct { + ID string `bson:"_id"` +} + +// MongoDBStore stores auth keys in MongoDB. +type MongoDBStore struct { + collection *mongo.Collection +} + +// NewMongoDBStore creates collection indexes if needed. +func NewMongoDBStore(database *mongo.Database) (*MongoDBStore, error) { + if database == nil { + return nil, fmt.Errorf("database is required") + } + coll := database.Collection("auth_keys") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: "secret_hash", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "enabled", Value: 1}}}, + {Keys: bson.D{{Key: "created_at", Value: -1}}}, + } + if _, err := coll.Indexes().CreateMany(ctx, indexes); err != nil { + return nil, fmt.Errorf("create auth_keys indexes: %w", err) + } + return &MongoDBStore{collection: coll}, nil +} + +func (s *MongoDBStore) List(ctx context.Context) ([]AuthKey, error) { + cursor, err := s.collection.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}, {Key: "_id", Value: 1}})) + if err != nil { + return nil, fmt.Errorf("list auth keys: %w", err) + } + defer cursor.Close(ctx) + + result := make([]AuthKey, 0) + for cursor.Next(ctx) { + var doc mongoAuthKeyDocument + if err := cursor.Decode(&doc); err != nil { + return nil, fmt.Errorf("decode auth key: %w", err) + } + result = append(result, authKeyFromMongo(doc)) + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("iterate auth keys: %w", err) + } + return result, nil +} + +func (s *MongoDBStore) Create(ctx context.Context, key AuthKey) error { + _, err := s.collection.InsertOne(ctx, mongoAuthKeyDocument{ + ID: key.ID, + Name: key.Name, + Description: key.Description, + RedactedValue: key.RedactedValue, + SecretHash: key.SecretHash, + Enabled: key.Enabled, + ExpiresAt: key.ExpiresAt, + DeactivatedAt: key.DeactivatedAt, + CreatedAt: key.CreatedAt.UTC(), + UpdatedAt: key.UpdatedAt.UTC(), + }) + if err != nil { + return fmt.Errorf("create auth key: %w", err) + } + return nil +} + +func (s *MongoDBStore) Deactivate(ctx context.Context, id string, now time.Time) error { + now = now.UTC() + result, err := s.collection.UpdateOne(ctx, mongoAuthKeyIDFilter{ID: normalizeID(id)}, mongo.Pipeline{ + {{ + Key: "$set", + Value: bson.D{ + {Key: "enabled", Value: false}, + {Key: "updated_at", Value: now}, + {Key: "deactivated_at", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$deactivated_at", now}}}}, + }, + }}, + }) + if err != nil { + return fmt.Errorf("deactivate auth key: %w", err) + } + if result.MatchedCount == 0 { + return ErrNotFound + } + return nil +} + +func (s *MongoDBStore) Close() error { + return nil +} + +func authKeyFromMongo(doc mongoAuthKeyDocument) AuthKey { + return AuthKey{ + ID: doc.ID, + Name: doc.Name, + Description: doc.Description, + RedactedValue: doc.RedactedValue, + SecretHash: doc.SecretHash, + Enabled: doc.Enabled, + ExpiresAt: timePtrUTC(doc.ExpiresAt), + DeactivatedAt: timePtrUTC(doc.DeactivatedAt), + CreatedAt: doc.CreatedAt.UTC(), + UpdatedAt: doc.UpdatedAt.UTC(), + } +} + +func timePtrUTC(value *time.Time) *time.Time { + if value == nil { + return nil + } + t := value.UTC() + return &t +} diff --git a/internal/authkeys/store_postgresql.go b/internal/authkeys/store_postgresql.go new file mode 100644 index 00000000..14b7d5d1 --- /dev/null +++ b/internal/authkeys/store_postgresql.go @@ -0,0 +1,147 @@ +package authkeys + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgreSQLStore stores auth keys in PostgreSQL. +type PostgreSQLStore struct { + pool *pgxpool.Pool +} + +// NewPostgreSQLStore creates the auth_keys table and indexes if needed. +func NewPostgreSQLStore(ctx context.Context, pool *pgxpool.Pool) (*PostgreSQLStore, error) { + if ctx == nil { + return nil, fmt.Errorf("context is required") + } + if pool == nil { + return nil, fmt.Errorf("connection pool is required") + } + + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS auth_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + redacted_value TEXT NOT NULL, + secret_hash TEXT NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + expires_at BIGINT, + deactivated_at BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) + `) + if err != nil { + return nil, fmt.Errorf("failed to create auth_keys table: %w", err) + } + for _, index := range []string{ + `CREATE INDEX IF NOT EXISTS idx_auth_keys_enabled ON auth_keys(enabled)`, + `CREATE INDEX IF NOT EXISTS idx_auth_keys_created_at ON auth_keys(created_at DESC)`, + } { + if _, err := pool.Exec(ctx, index); err != nil { + return nil, fmt.Errorf("failed to create auth_keys index: %w", err) + } + } + return &PostgreSQLStore{pool: pool}, nil +} + +func (s *PostgreSQLStore) List(ctx context.Context) ([]AuthKey, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, name, description, redacted_value, secret_hash, enabled, expires_at, deactivated_at, created_at, updated_at + FROM auth_keys + ORDER BY created_at DESC, id ASC + `) + if err != nil { + return nil, fmt.Errorf("list auth keys: %w", err) + } + defer rows.Close() + result, err := collectAuthKeys(rows, scanPostgreSQLAuthKey) + if err != nil { + return nil, fmt.Errorf("iterate auth keys: %w", err) + } + return result, nil +} + +func (s *PostgreSQLStore) Create(ctx context.Context, key AuthKey) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO auth_keys (id, name, description, redacted_value, secret_hash, enabled, expires_at, deactivated_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, key.ID, key.Name, key.Description, key.RedactedValue, key.SecretHash, key.Enabled, pgUnixOrNil(key.ExpiresAt), pgUnixOrNil(key.DeactivatedAt), key.CreatedAt.Unix(), key.UpdatedAt.Unix()) + if err != nil { + return fmt.Errorf("create auth key: %w", err) + } + return nil +} + +func (s *PostgreSQLStore) Deactivate(ctx context.Context, id string, now time.Time) error { + cmd, err := s.pool.Exec(ctx, ` + UPDATE auth_keys + SET enabled = FALSE, + deactivated_at = COALESCE(deactivated_at, $1), + updated_at = $2 + WHERE id = $3 + `, now.Unix(), now.Unix(), normalizeID(id)) + if err != nil { + return fmt.Errorf("deactivate auth key: %w", err) + } + if cmd.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgreSQLStore) Close() error { + return nil +} + +func scanPostgreSQLAuthKey(scanner authKeyScanner) (AuthKey, error) { + var key AuthKey + var expiresAt *int64 + var deactivatedAt *int64 + var createdAt int64 + var updatedAt int64 + if err := scanner.Scan( + &key.ID, + &key.Name, + &key.Description, + &key.RedactedValue, + &key.SecretHash, + &key.Enabled, + &expiresAt, + &deactivatedAt, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return AuthKey{}, ErrNotFound + } + return AuthKey{}, err + } + key.ExpiresAt = int64PtrToTime(expiresAt) + key.DeactivatedAt = int64PtrToTime(deactivatedAt) + key.CreatedAt = time.Unix(createdAt, 0).UTC() + key.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return key, nil +} + +func pgUnixOrNil(value *time.Time) any { + if value == nil { + return nil + } + return value.UTC().Unix() +} + +func int64PtrToTime(value *int64) *time.Time { + if value == nil { + return nil + } + t := time.Unix(*value, 0).UTC() + return &t +} diff --git a/internal/authkeys/store_sqlite.go b/internal/authkeys/store_sqlite.go new file mode 100644 index 00000000..7eb2f52a --- /dev/null +++ b/internal/authkeys/store_sqlite.go @@ -0,0 +1,156 @@ +package authkeys + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// SQLiteStore stores auth keys in SQLite. +type SQLiteStore struct { + db *sql.DB +} + +// NewSQLiteStore creates the auth_keys table and indexes if needed. +func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) { + if db == nil { + return nil, fmt.Errorf("database connection is required") + } + + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS auth_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + redacted_value TEXT NOT NULL, + secret_hash TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + expires_at INTEGER, + deactivated_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + if err != nil { + return nil, fmt.Errorf("failed to create auth_keys table: %w", err) + } + for _, index := range []string{ + `CREATE INDEX IF NOT EXISTS idx_auth_keys_enabled ON auth_keys(enabled)`, + `CREATE INDEX IF NOT EXISTS idx_auth_keys_created_at ON auth_keys(created_at DESC)`, + } { + if _, err := db.Exec(index); err != nil { + return nil, fmt.Errorf("failed to create auth_keys index: %w", err) + } + } + + return &SQLiteStore{db: db}, nil +} + +func (s *SQLiteStore) List(ctx context.Context) ([]AuthKey, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, name, description, redacted_value, secret_hash, enabled, expires_at, deactivated_at, created_at, updated_at + FROM auth_keys + ORDER BY created_at DESC, id ASC + `) + if err != nil { + return nil, fmt.Errorf("list auth keys: %w", err) + } + defer rows.Close() + result, err := collectAuthKeys(rows, scanSQLiteAuthKey) + if err != nil { + return nil, fmt.Errorf("iterate auth keys: %w", err) + } + return result, nil +} + +func (s *SQLiteStore) Create(ctx context.Context, key AuthKey) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO auth_keys (id, name, description, redacted_value, secret_hash, enabled, expires_at, deactivated_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, key.ID, key.Name, key.Description, key.RedactedValue, key.SecretHash, boolToSQLite(key.Enabled), unixOrNil(key.ExpiresAt), unixOrNil(key.DeactivatedAt), key.CreatedAt.Unix(), key.UpdatedAt.Unix()) + if err != nil { + return fmt.Errorf("create auth key: %w", err) + } + return nil +} + +func (s *SQLiteStore) Deactivate(ctx context.Context, id string, now time.Time) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE auth_keys + SET enabled = 0, + deactivated_at = COALESCE(deactivated_at, ?), + updated_at = ? + WHERE id = ? + `, now.Unix(), now.Unix(), normalizeID(id)) + if err != nil { + return fmt.Errorf("deactivate auth key: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read deactivate rows affected: %w", err) + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) Close() error { + return nil +} + +func scanSQLiteAuthKey(scanner authKeyScanner) (AuthKey, error) { + var key AuthKey + var enabled int + var expiresAt sql.NullInt64 + var deactivatedAt sql.NullInt64 + var createdAt int64 + var updatedAt int64 + if err := scanner.Scan( + &key.ID, + &key.Name, + &key.Description, + &key.RedactedValue, + &key.SecretHash, + &enabled, + &expiresAt, + &deactivatedAt, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return AuthKey{}, ErrNotFound + } + return AuthKey{}, err + } + key.Enabled = enabled != 0 + key.ExpiresAt = unixPtr(expiresAt) + key.DeactivatedAt = unixPtr(deactivatedAt) + key.CreatedAt = time.Unix(createdAt, 0).UTC() + key.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return key, nil +} + +func boolToSQLite(v bool) int { + if v { + return 1 + } + return 0 +} + +func unixOrNil(value *time.Time) any { + if value == nil { + return nil + } + return value.UTC().Unix() +} + +func unixPtr(value sql.NullInt64) *time.Time { + if !value.Valid { + return nil + } + t := time.Unix(value.Int64, 0).UTC() + return &t +} diff --git a/internal/authkeys/types.go b/internal/authkeys/types.go new file mode 100644 index 00000000..89f1ea4a --- /dev/null +++ b/internal/authkeys/types.go @@ -0,0 +1,56 @@ +package authkeys + +import "time" + +const ( + // TokenPrefix is the managed API key prefix returned to clients. + TokenPrefix = "sk_gom_" + secretBytes = 32 +) + +// AuthKey is the persisted auth key record. +type AuthKey struct { + ID string `json:"id" bson:"_id"` + Name string `json:"name" bson:"name"` + Description string `json:"description,omitempty" bson:"description,omitempty"` + RedactedValue string `json:"redacted_value" bson:"redacted_value"` + SecretHash string `json:"-" bson:"secret_hash"` + Enabled bool `json:"enabled" bson:"enabled"` + ExpiresAt *time.Time `json:"expires_at,omitempty" bson:"expires_at,omitempty"` + DeactivatedAt *time.Time `json:"deactivated_at,omitempty" bson:"deactivated_at,omitempty"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` +} + +// View is the admin-facing representation of a managed auth key. +type View struct { + AuthKey + Active bool `json:"active"` +} + +// IssuedKey is returned once on create and includes the plaintext token value. +type IssuedKey struct { + View + Value string `json:"value"` +} + +// CreateInput captures the admin request for issuing a new auth key. +type CreateInput struct { + Name string + Description string + ExpiresAt *time.Time +} + +// Active reports whether the key can currently authenticate requests. +func (k AuthKey) Active(now time.Time) bool { + if !k.Enabled { + return false + } + if k.DeactivatedAt != nil { + return false + } + if k.ExpiresAt != nil && !k.ExpiresAt.After(now) { + return false + } + return true +} diff --git a/internal/core/context.go b/internal/core/context.go index 14f8065d..6a5c5aad 100644 --- a/internal/core/context.go +++ b/internal/core/context.go @@ -14,6 +14,8 @@ const ( whiteBoxPromptKey contextKey = "white-box-prompt" // executionPlanKey stores the request-scoped execution plan chosen for handling. executionPlanKey contextKey = "execution-plan" + // authKeyIDKey stores the internal managed auth key id for the request. + authKeyIDKey contextKey = "auth-key-id" // batchPreparationMetadataKey stores request-scoped batch preprocessing metadata. batchPreparationMetadataKey contextKey = "batch-preparation-metadata" @@ -94,6 +96,21 @@ func GetExecutionPlan(ctx context.Context) *ExecutionPlan { return nil } +// WithAuthKeyID returns a new context with the authenticated managed auth key id attached. +func WithAuthKeyID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, authKeyIDKey, id) +} + +// GetAuthKeyID retrieves the managed auth key id from the context. +func GetAuthKeyID(ctx context.Context) string { + if v := ctx.Value(authKeyIDKey); v != nil { + if id, ok := v.(string); ok { + return id + } + } + return "" +} + // WithBatchPreparationMetadata returns a new context with batch preprocessing metadata attached. func WithBatchPreparationMetadata(ctx context.Context, metadata *BatchPreparationMetadata) context.Context { return context.WithValue(ctx, batchPreparationMetadataKey, metadata) diff --git a/internal/server/auth.go b/internal/server/auth.go index 8a7f3918..4785191c 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -1,22 +1,38 @@ package server import ( + "context" "crypto/subtle" + "errors" "strings" "github.com/labstack/echo/v5" + "gomodel/internal/auditlog" "gomodel/internal/core" ) +// BearerTokenAuthenticator authenticates managed bearer tokens and returns +// their internal auth key id on success. +type BearerTokenAuthenticator interface { + Enabled() bool + Authenticate(ctx context.Context, token string) (string, error) +} + // AuthMiddleware creates an Echo middleware that validates the master key // if it's configured. If masterKey is empty, no authentication is required. // skipPaths is a list of paths that should bypass authentication. func AuthMiddleware(masterKey string, skipPaths []string) echo.MiddlewareFunc { + return AuthMiddlewareWithAuthenticator(masterKey, nil, skipPaths) +} + +// AuthMiddlewareWithAuthenticator validates the legacy master key and, when +// configured, managed auth keys from the auth key service. +func AuthMiddlewareWithAuthenticator(masterKey string, authenticator BearerTokenAuthenticator, skipPaths []string) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { - // If no master key is configured, allow all requests - if masterKey == "" { + // If no auth mechanism is configured, allow all requests. + if masterKey == "" && (authenticator == nil || !authenticator.Enabled()) { return next(c) } @@ -37,25 +53,61 @@ func AuthMiddleware(masterKey string, skipPaths []string) echo.MiddlewareFunc { // Get Authorization header authHeader := c.Request().Header.Get("Authorization") if authHeader == "" { - authErr := core.NewAuthenticationError("", "missing authorization header") + authErr := authenticationError(c, "missing authorization header") return c.JSON(authErr.HTTPStatusCode(), authErr.ToJSON()) } // Extract Bearer token const prefix = "Bearer " if !strings.HasPrefix(authHeader, prefix) { - authErr := core.NewAuthenticationError("", "invalid authorization header format, expected 'Bearer '") + authErr := authenticationError(c, "invalid authorization header format, expected 'Bearer '") return c.JSON(authErr.HTTPStatusCode(), authErr.ToJSON()) } token := strings.TrimPrefix(authHeader, prefix) - if subtle.ConstantTimeCompare([]byte(token), []byte(masterKey)) != 1 { - authErr := core.NewAuthenticationError("", "invalid master key") + if masterKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(masterKey)) == 1 { + return next(c) + } + + if authenticator != nil && authenticator.Enabled() { + authKeyID, err := authenticator.Authenticate(c.Request().Context(), token) + if err == nil { + ctx := core.WithAuthKeyID(c.Request().Context(), authKeyID) + c.SetRequest(c.Request().WithContext(ctx)) + auditlog.EnrichEntryWithAuthKeyID(c, authKeyID) + return next(c) + } + + authErr := authenticationErrorWithAudit(c, authFailureMessage(err), "authentication failed") return c.JSON(authErr.HTTPStatusCode(), authErr.ToJSON()) } - // Authentication successful, proceed to next handler - return next(c) + authErr := authenticationError(c, "invalid master key") + return c.JSON(authErr.HTTPStatusCode(), authErr.ToJSON()) } } } + +func authFailureMessage(err error) string { + if err == nil { + return "invalid API key" + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return "authentication unavailable" + } + message := strings.TrimSpace(err.Error()) + if message == "" { + return "invalid API key" + } + return message +} + +func authenticationError(c *echo.Context, message string) *core.GatewayError { + auditlog.EnrichEntryWithError(c, string(core.ErrorTypeAuthentication), message) + return core.NewAuthenticationError("", message) +} + +func authenticationErrorWithAudit(c *echo.Context, auditMessage, responseMessage string) *core.GatewayError { + auditlog.EnrichEntryWithError(c, string(core.ErrorTypeAuthentication), auditMessage) + return core.NewAuthenticationError("", responseMessage) +} diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go index 94c299c5..5eb5cd42 100644 --- a/internal/server/auth_test.go +++ b/internal/server/auth_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/subtle" "net/http" "net/http/httptest" @@ -10,8 +11,32 @@ import ( "github.com/labstack/echo/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "gomodel/internal/auditlog" + "gomodel/internal/core" ) +type mockAuthenticator struct { + enabled bool + tokenToID map[string]string + err error +} + +func (m mockAuthenticator) Enabled() bool { + return m.enabled +} + +func (m mockAuthenticator) Authenticate(_ context.Context, token string) (string, error) { + if m.err != nil { + return "", m.err + } + id, ok := m.tokenToID[token] + if !ok { + return "", assert.AnError + } + return id, nil +} + func TestAuthMiddleware(t *testing.T) { tests := []struct { name string @@ -150,6 +175,70 @@ func TestAuthMiddleware_Integration(t *testing.T) { }) } +func TestAuthMiddlewareWithAuthenticator_ManagedKeyEnrichesContextAndAudit(t *testing.T) { + e := echo.New() + testHandler := func(c *echo.Context) error { + if got := core.GetAuthKeyID(c.Request().Context()); got != "key-123" { + t.Fatalf("auth key id in context = %q, want key-123", got) + } + entryVal := c.Get(string(auditlog.LogEntryKey)) + entry, ok := entryVal.(*auditlog.LogEntry) + if !ok || entry == nil { + t.Fatal("audit log entry missing from context") + } + if entry.AuthKeyID != "key-123" { + t.Fatalf("audit entry auth key id = %q, want key-123", entry.AuthKeyID) + } + return c.String(http.StatusOK, "ok") + } + + handler := AuthMiddlewareWithAuthenticator("", mockAuthenticator{ + enabled: true, + tokenToID: map[string]string{"sk_gom_token": "key-123"}, + }, nil)(testHandler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer sk_gom_token") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set(string(auditlog.LogEntryKey), &auditlog.LogEntry{Data: &auditlog.LogData{}}) + + err := handler(c) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "ok", rec.Body.String()) +} + +func TestAuthMiddlewareWithAuthenticator_ManagedKeyFailureUsesGenericClientMessage(t *testing.T) { + e := echo.New() + handler := AuthMiddlewareWithAuthenticator("", mockAuthenticator{ + enabled: true, + err: context.DeadlineExceeded, + }, nil)(func(c *echo.Context) error { + t.Fatal("next handler should not be called") + return nil + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer sk_gom_token") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set(string(auditlog.LogEntryKey), &auditlog.LogEntry{Data: &auditlog.LogData{}}) + + err := handler(c) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.JSONEq(t, `{"error":{"message":"authentication failed","type":"authentication_error","param":null,"code":null}}`, rec.Body.String()) + + entryVal := c.Get(string(auditlog.LogEntryKey)) + entry, ok := entryVal.(*auditlog.LogEntry) + require.True(t, ok) + require.NotNil(t, entry) + require.NotNil(t, entry.Data) + assert.Equal(t, string(core.ErrorTypeAuthentication), entry.ErrorType) + assert.Equal(t, "authentication unavailable", entry.Data.ErrorMessage) +} + func TestAuthMiddleware_SkipPaths(t *testing.T) { t.Run("skips authentication for specified paths", func(t *testing.T) { e := echo.New() @@ -214,9 +303,9 @@ func TestAuthMiddleware_WildcardSkipPaths(t *testing.T) { skipPaths := []string{"/admin/dashboard", "/admin/dashboard/*", "/admin/static/*"} tests := []struct { - name string - path string - wantSkip bool + name string + path string + wantSkip bool }{ { name: "exact match /admin/dashboard", diff --git a/internal/server/http.go b/internal/server/http.go index f7ed319a..067d5fc1 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -35,6 +35,7 @@ type Server struct { // Config holds server configuration options type Config struct { MasterKey string // Optional: Master key for authentication + Authenticator BearerTokenAuthenticator // Optional: managed API key authenticator MetricsEnabled bool // Whether to expose Prometheus metrics endpoint MetricsEndpoint string // HTTP path for metrics endpoint (default: /metrics) BodySizeLimit string // Max request body size (e.g., "10M", "1024K") @@ -131,6 +132,11 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { if cfg != nil && cfg.AdminUIEnabled && cfg.DashboardHandler != nil { authSkipPaths = append(authSkipPaths, "/admin/dashboard", "/admin/dashboard/*", "/admin/static/*") } + // When no bootstrap master key is configured, keep admin APIs reachable so + // the dashboard can recover managed-key access instead of locking itself out. + if cfg != nil && cfg.MasterKey == "" && cfg.AdminEndpointsEnabled && cfg.AdminHandler != nil { + authSkipPaths = append(authSkipPaths, "/admin/api/v1/*") + } if cfg != nil && cfg.SwaggerEnabled { authSkipPaths = append(authSkipPaths, "/swagger/*") } @@ -217,8 +223,8 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { e.Use(ExecutionPlanningWithResolverAndPolicy(provider, modelResolver, executionPolicyResolver)) // Authentication (skips public paths) - if cfg != nil && cfg.MasterKey != "" { - e.Use(AuthMiddleware(cfg.MasterKey, authSkipPaths)) + if cfg != nil && (cfg.MasterKey != "" || cfg.Authenticator != nil) { + e.Use(AuthMiddlewareWithAuthenticator(cfg.MasterKey, cfg.Authenticator, authSkipPaths)) } // Public routes @@ -279,6 +285,9 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { adminAPI.GET("/audit/conversation", cfg.AdminHandler.AuditConversation) adminAPI.GET("/models", cfg.AdminHandler.ListModels) adminAPI.GET("/models/categories", cfg.AdminHandler.ListCategories) + adminAPI.GET("/auth-keys", cfg.AdminHandler.ListAuthKeys) + adminAPI.POST("/auth-keys", cfg.AdminHandler.CreateAuthKey) + adminAPI.POST("/auth-keys/:id/deactivate", cfg.AdminHandler.DeactivateAuthKey) adminAPI.GET("/aliases", cfg.AdminHandler.ListAliases) adminAPI.PUT("/aliases/:name", cfg.AdminHandler.UpsertAlias) adminAPI.DELETE("/aliases/:name", cfg.AdminHandler.DeleteAlias) diff --git a/internal/server/http_test.go b/internal/server/http_test.go index 2f565dcb..38793656 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -310,6 +310,9 @@ func TestAdminExecutionPlanEndpoints_AreRegistered(t *testing.T) { path string }{ {method: http.MethodGet, path: "/admin/api/v1/dashboard/config"}, + {method: http.MethodGet, path: "/admin/api/v1/auth-keys"}, + {method: http.MethodPost, path: "/admin/api/v1/auth-keys"}, + {method: http.MethodPost, path: "/admin/api/v1/auth-keys/test-key/deactivate"}, {method: http.MethodGet, path: "/admin/api/v1/execution-plans"}, {method: http.MethodGet, path: "/admin/api/v1/execution-plans/guardrails"}, {method: http.MethodPost, path: "/admin/api/v1/execution-plans"}, @@ -444,6 +447,32 @@ func TestAdminAPI_RequiresAuth(t *testing.T) { } } +func TestAdminAPI_SkipsAuthWithoutMasterKey(t *testing.T) { + mock := &mockProvider{} + adminHandler := admin.NewHandler(nil, nil) + srv := New(mock, &Config{ + Authenticator: mockAuthenticator{enabled: true, tokenToID: map[string]string{"managed-token": "key-123"}}, + AdminEndpointsEnabled: true, + AdminHandler: adminHandler, + }) + + adminReq := httptest.NewRequest(http.MethodGet, "/admin/api/v1/models", nil) + adminRec := httptest.NewRecorder() + srv.ServeHTTP(adminRec, adminReq) + + if adminRec.Code != http.StatusOK { + t.Fatalf("expected admin API 200 without auth when master key is unset, got %d body=%s", adminRec.Code, adminRec.Body.String()) + } + + modelReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + modelRec := httptest.NewRecorder() + srv.ServeHTTP(modelRec, modelReq) + + if modelRec.Code != http.StatusUnauthorized { + t.Fatalf("expected model API 401 without auth when managed keys are enabled, got %d body=%s", modelRec.Code, modelRec.Body.String()) + } +} + func TestAdminStaticAssets_SkipAuth(t *testing.T) { mock := &mockProvider{} dashHandler := newDashboardHandler(t) diff --git a/tests/integration/dbassert/auditlog.go b/tests/integration/dbassert/auditlog.go index afda8bbe..d827f262 100644 --- a/tests/integration/dbassert/auditlog.go +++ b/tests/integration/dbassert/auditlog.go @@ -6,6 +6,7 @@ package dbassert import ( "context" + "database/sql" "testing" "time" @@ -27,6 +28,7 @@ type AuditLogEntry struct { Provider string StatusCode int RequestID string + AuthKeyID string ClientIP string Method string Path string @@ -43,7 +45,7 @@ func QueryAuditLogsByRequestID(t *testing.T, pool *pgxpool.Pool, requestID strin query := ` SELECT id, timestamp, duration_ns, model, provider, status_code, - request_id, client_ip, method, path, stream, error_type, data + request_id, auth_key_id, client_ip, method, path, stream, error_type, data FROM audit_logs WHERE request_id = $1 ORDER BY timestamp ASC @@ -56,15 +58,19 @@ func QueryAuditLogsByRequestID(t *testing.T, pool *pgxpool.Pool, requestID strin var entries []AuditLogEntry for rows.Next() { var entry AuditLogEntry + var authKeyID sql.NullString var dataJSON []byte err := rows.Scan( &entry.ID, &entry.Timestamp, &entry.DurationNs, &entry.Model, &entry.Provider, &entry.StatusCode, - &entry.RequestID, &entry.ClientIP, &entry.Method, + &entry.RequestID, &authKeyID, &entry.ClientIP, &entry.Method, &entry.Path, &entry.Stream, &entry.ErrorType, &dataJSON, ) require.NoError(t, err, "failed to scan audit log row") + if authKeyID.Valid { + entry.AuthKeyID = authKeyID.String + } if dataJSON != nil { entry.Data = unmarshalLogData(t, dataJSON) } @@ -144,6 +150,9 @@ func bsonToAuditLogEntry(t *testing.T, doc bson.M) AuditLogEntry { if v, ok := doc["request_id"].(string); ok { entry.RequestID = v } + if v, ok := doc["auth_key_id"].(string); ok { + entry.AuthKeyID = v + } if v, ok := doc["client_ip"].(string); ok { entry.ClientIP = v }