Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/admin/dashboard/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,7 @@ <h2>Audit Logs</h2>
<span class="provider-badge audit-alias-badge" x-show="entry.alias_used">alias</span>
<span class="provider-badge mono" x-show="entry.alias_used && entry.resolved_model" x-text="'resolved: ' + entry.resolved_model"></span>
<span class="provider-badge mono" x-text="'request_id: ' + (entry.request_id || '-')"></span>
<span class="provider-badge mono" x-show="entry.auth_key_id" x-text="'auth_key_id: ' + entry.auth_key_id"></span>
<span class="provider-badge mono" x-show="entry.client_ip" x-text="'ip: ' + entry.client_ip"></span>
<span class="provider-badge" x-show="entry.stream">stream</span>
<span class="provider-badge" x-show="entry.error_type" x-text="entry.error_type"></span>
Expand Down
132 changes: 118 additions & 14 deletions internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package admin
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"slices"
Expand All @@ -15,6 +16,7 @@ import (

"gomodel/internal/aliases"
"gomodel/internal/auditlog"
"gomodel/internal/authkeys"
"gomodel/internal/core"
"gomodel/internal/executionplans"
"gomodel/internal/guardrails"
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
Expand All @@ -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())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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 {
Expand Down
168 changes: 168 additions & 0 deletions internal/admin/handler_authkeys_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
}
Loading
Loading