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
}