feat(admin): 优化后台运维与任务管理

完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。

新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。

验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
2026-07-25 01:32:49 +08:00
parent b29d539d49
commit de10875439
38 changed files with 5015 additions and 425 deletions
@@ -0,0 +1,213 @@
package httpapi
import (
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
// listAdminTasks godoc
// @Summary 管理员列出任务
// @Description 跨租户分页查询任务;请求、结果和执行快照中的常见敏感字段默认脱敏。
// @Tags admin-tasks
// @Produce json
// @Security BearerAuth
// @Param q query string false "关键词,匹配任务、用户、租户、模型、平台和 API Key"
// @Param tenantId query string false "网关租户 ID"
// @Param userId query string false "网关用户 ID"
// @Param userGroupId query string false "用户组 ID"
// @Param status query string false "任务状态"
// @Param platformId query string false "执行平台 ID"
// @Param model query string false "调用或实际模型名称"
// @Param modelType query string false "模型类型"
// @Param runMode query string false "运行模式"
// @Param billingStatus query string false "计费状态"
// @Param apiKey query string false "API Key ID、名称或前缀"
// @Param createdFrom query string false "创建时间起点,支持 RFC3339 或日期格式"
// @Param createdTo query string false "创建时间终点,支持 RFC3339 或日期格式"
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页数量" default(50)
// @Success 200 {object} AdminTaskListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/tasks [get]
func (s *Server) listAdminTasks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
page, err := positiveQueryInt(query.Get("page"), 1)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid page")
return
}
pageSize, err := positiveQueryInt(query.Get("pageSize"), 50)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid pageSize")
return
}
createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid createdFrom")
return
}
createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid createdTo")
return
}
for name, value := range map[string]string{
"tenantId": query.Get("tenantId"),
"userId": query.Get("userId"),
"userGroupId": query.Get("userGroupId"),
"platformId": query.Get("platformId"),
} {
if value != "" && !validUUID(value) {
writeError(w, http.StatusBadRequest, "invalid "+name)
return
}
}
result, err := s.store.ListAdminTasks(r.Context(), store.AdminTaskListFilter{
Query: firstNonEmpty(query.Get("q"), query.Get("query")),
GatewayTenant: query.Get("tenantId"),
GatewayUser: query.Get("userId"),
UserGroup: query.Get("userGroupId"),
Status: query.Get("status"),
Platform: query.Get("platformId"),
Model: query.Get("model"),
ModelType: query.Get("modelType"),
RunMode: query.Get("runMode"),
BillingStatus: query.Get("billingStatus"),
APIKey: query.Get("apiKey"),
CreatedFrom: createdFrom,
CreatedTo: createdTo,
Page: page,
PageSize: pageSize,
})
if err != nil {
s.logger.Error("list admin tasks failed", "error", err)
writeError(w, http.StatusInternalServerError, "list admin tasks failed")
return
}
for index := range result.Items {
result.Items[index] = store.MaskAdminGatewayTask(result.Items[index])
}
writeJSON(w, http.StatusOK, map[string]any{
"items": result.Items,
"total": result.Total,
"page": result.Page,
"pageSize": result.PageSize,
})
}
// getAdminTask godoc
// @Summary 管理员获取任务详情
// @Description 返回任意租户的任务详情;默认脱敏,sensitive=full 时返回完整存储内容。
// @Tags admin-tasks
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Param sensitive query string false "敏感字段模式" Enums(masked,full) default(masked)
// @Success 200 {object} store.AdminGatewayTask
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/tasks/{taskID} [get]
func (s *Server) getAdminTask(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("taskID")
if !validUUID(taskID) {
writeError(w, http.StatusBadRequest, "invalid taskID")
return
}
full, ok := parseAdminTaskSensitiveMode(r.URL.Query().Get("sensitive"))
if !ok {
writeError(w, http.StatusBadRequest, "invalid sensitive mode")
return
}
task, err := s.store.GetAdminTask(r.Context(), taskID)
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return
}
s.logger.Error("get admin task failed", "taskID", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "get admin task failed")
return
}
task.GatewayTask, err = s.hydrateTaskResult(r.Context(), task.GatewayTask)
if err != nil {
writeStoredBinaryResultError(w, err)
return
}
if !full {
task = store.MaskAdminGatewayTask(task)
}
writeJSON(w, http.StatusOK, task)
}
// adminTaskParamPreprocessing godoc
// @Summary 管理员获取任务参数预处理日志
// @Description 返回任意租户任务的参数改写、校验或模板处理日志;默认脱敏。
// @Tags admin-tasks
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Param sensitive query string false "敏感字段模式" Enums(masked,full) default(masked)
// @Success 200 {object} TaskParamPreprocessingLogListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/tasks/{taskID}/param-preprocessing [get]
func (s *Server) adminTaskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("taskID")
if !validUUID(taskID) {
writeError(w, http.StatusBadRequest, "invalid taskID")
return
}
full, ok := parseAdminTaskSensitiveMode(r.URL.Query().Get("sensitive"))
if !ok {
writeError(w, http.StatusBadRequest, "invalid sensitive mode")
return
}
if _, err := s.store.GetAdminTask(r.Context(), taskID); err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return
}
s.logger.Error("get admin task failed", "taskID", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "get admin task failed")
return
}
items, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), taskID)
if err != nil {
s.logger.Error("list admin task parameter preprocessing logs failed", "taskID", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "list admin task parameter preprocessing logs failed")
return
}
if !full {
items = store.MaskTaskParamPreprocessingLogs(items)
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func parseAdminTaskSensitiveMode(value string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "masked":
return false, true
case "full":
return true, true
default:
return false, false
}
}
func validUUID(value string) bool {
_, err := uuid.Parse(strings.TrimSpace(value))
return err == nil
}
@@ -0,0 +1,22 @@
package httpapi
import "testing"
func TestParseAdminTaskSensitiveMode(t *testing.T) {
tests := []struct {
value string
full bool
accepted bool
}{
{value: "", full: false, accepted: true},
{value: "masked", full: false, accepted: true},
{value: "FULL", full: true, accepted: true},
{value: "invalid", full: false, accepted: false},
}
for _, test := range tests {
full, accepted := parseAdminTaskSensitiveMode(test.value)
if full != test.full || accepted != test.accepted {
t.Fatalf("parseAdminTaskSensitiveMode(%q)=(%v,%v), want (%v,%v)", test.value, full, accepted, test.full, test.accepted)
}
}
}
@@ -0,0 +1,321 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestAdminTasksCrossTenantQueryAndRedaction(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the PostgreSQL integration flow")
}
ctx := context.Background()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
createTenant := func(key, name string) store.GatewayTenant {
t.Helper()
tenant, err := db.CreateTenant(ctx, store.GatewayTenantInput{
TenantKey: key + "-" + suffix,
Name: name,
Source: "gateway",
Status: "active",
})
if err != nil {
t.Fatalf("create tenant %s: %v", name, err)
}
return tenant
}
createUser := func(tenant store.GatewayTenant, prefix string, roles []string) store.GatewayUser {
t.Helper()
username := prefix + "_" + suffix
user, err := db.CreateGatewayUser(ctx, store.GatewayUserInput{
UserKey: "gateway:" + username,
Username: username,
DisplayName: prefix + " display",
Email: username + "@example.com",
Source: "gateway",
GatewayTenantID: tenant.ID,
TenantKey: tenant.TenantKey,
Roles: roles,
Status: "active",
})
if err != nil {
t.Fatalf("create gateway user %s: %v", prefix, err)
}
return user
}
authUser := func(user store.GatewayUser, apiKeyName string) *auth.User {
return &auth.User{
ID: user.ID,
Username: user.Username,
Roles: user.Roles,
Source: user.Source,
GatewayUserID: user.ID,
GatewayTenantID: user.GatewayTenantID,
TenantKey: user.TenantKey,
APIKeyID: "key-" + apiKeyName + "-" + suffix,
APIKeyName: apiKeyName,
APIKeyPrefix: "sk-" + apiKeyName,
}
}
createTask := func(user *auth.User, model, secret string) store.GatewayTask {
t.Helper()
task, err := db.CreateTask(ctx, store.CreateTaskInput{
Kind: "chat.completions",
Model: model,
RunMode: "simulation",
Request: map[string]any{
"model": model,
"diagnostic": map[string]any{
"apiKey": secret,
},
},
}, user)
if err != nil {
t.Fatalf("create task for %s: %v", user.Username, err)
}
return task
}
tenantA := createTenant("admin-task-a", "Admin Task Tenant A")
tenantB := createTenant("admin-task-b", "Admin Task Tenant B")
operator := createUser(tenantA, "admin_task_operator", []string{"operator"})
userA := createUser(tenantA, "admin_task_user_a", []string{"user"})
userB := createUser(tenantB, "admin_task_user_b", []string{"user"})
userAAuth := authUser(userA, "alpha-key")
userBAuth := authUser(userB, "bravo-key")
modelA := "admin-task-model-a-" + suffix
modelB := "admin-task-model-b-" + suffix
taskA := createTask(userAAuth, modelA, "alpha-secret-"+suffix)
taskBSecret := "bravo-secret-" + suffix
taskB := createTask(userBAuth, modelB, taskBSecret)
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
Provider: "openai",
PlatformKey: "admin-task-platform-" + suffix,
Name: "Admin Task Platform " + suffix,
BaseURL: "https://example.invalid/v1",
AuthType: "bearer",
Credentials: map[string]any{"apiKey": "platform-secret-" + suffix},
Status: "enabled",
})
if err != nil {
t.Fatalf("create platform: %v", err)
}
attemptID, err := db.CreateTaskAttempt(ctx, store.CreateTaskAttemptInput{
TaskID: taskB.ID,
AttemptNo: 1,
PlatformID: platform.ID,
QueueKey: "admin-task-integration-" + suffix,
Status: "succeeded",
Simulated: true,
})
if err != nil {
t.Fatalf("create task attempt: %v", err)
}
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_tasks
SET status = 'succeeded',
model_type = 'text_generate',
requested_model = $2,
resolved_model = $2,
request_id = $3,
billing_status = 'settled',
result = $4::jsonb,
finished_at = now(),
updated_at = now()
WHERE id = $1::uuid`,
taskB.ID,
modelB,
"request-admin-task-"+suffix,
`{"authorization":"Bearer result-secret","output":"ok"}`,
); err != nil {
t.Fatalf("enrich task: %v", err)
}
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_task_attempts
SET request_snapshot = $2::jsonb,
response_snapshot = $3::jsonb,
finished_at = now()
WHERE id = $1::uuid`,
attemptID,
`{"headers":{"authorization":"Bearer attempt-secret"}}`,
`{"cookie":"attempt-cookie","status":"ok"}`,
); err != nil {
t.Fatalf("enrich task attempt: %v", err)
}
if _, err := db.Pool().Exec(ctx, `
INSERT INTO gateway_task_param_preprocessing_logs (
task_id, attempt_id, attempt_no, model_type, platform_id, client_id,
changed, change_count, actual_input, converted_output, changes, model_snapshot
)
VALUES (
$1::uuid, $2::uuid, 1, 'text_generate', $3::uuid, $4,
true, 1, $5::jsonb, $6::jsonb, $7::jsonb, '{}'::jsonb
)`,
taskB.ID,
attemptID,
platform.ID,
"admin-task-client-"+suffix,
`{"password":"input-secret","prompt":"hello"}`,
`{"token":"converted-secret","prompt":"hello"}`,
`[{"path":"credentials.secret","before":"old-secret","after":"new-secret"}]`,
); err != nil {
t.Fatalf("create parameter preprocessing log: %v", err)
}
const jwtSecret = "admin-task-integration-jwt-secret"
handlerCtx, cancelHandler := context.WithCancel(ctx)
defer cancelHandler()
server := httptest.NewServer(NewServerWithContext(handlerCtx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: jwtSecret,
CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
authenticator := auth.New(jwtSecret, "", "")
signToken := func(user *auth.User) string {
t.Helper()
token, err := authenticator.SignJWT(user, time.Hour)
if err != nil {
t.Fatalf("sign JWT for %s: %v", user.Username, err)
}
return token
}
operatorToken := signToken(&auth.User{
ID: operator.ID,
Username: operator.Username,
Roles: operator.Roles,
Source: operator.Source,
GatewayUserID: operator.ID,
GatewayTenantID: operator.GatewayTenantID,
TenantKey: operator.TenantKey,
})
userAToken := signToken(userAAuth)
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", userAToken, nil, http.StatusForbidden, nil)
query := url.Values{
"q": {taskB.ID},
"tenantId": {tenantB.ID},
"userId": {userB.ID},
"status": {"succeeded"},
"platformId": {platform.ID},
"model": {modelB},
"modelType": {"text_generate"},
"runMode": {"simulation"},
"billingStatus": {"settled"},
"apiKey": {"bravo-key"},
"page": {"1"},
"pageSize": {"10"},
}
var listResponse struct {
Items []store.AdminGatewayTask `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks?"+query.Encode(), operatorToken, nil, http.StatusOK, &listResponse)
if len(listResponse.Items) != 1 || listResponse.Total != 1 || listResponse.Page != 1 || listResponse.PageSize != 10 {
t.Fatalf("unexpected filtered admin task list: %+v", listResponse)
}
listed := listResponse.Items[0]
if listed.ID != taskB.ID || listed.AdminContext.User == nil || listed.AdminContext.User.ID != userB.ID ||
listed.AdminContext.Tenant == nil || listed.AdminContext.Tenant.ID != tenantB.ID ||
listed.AdminContext.LatestPlatform == nil || listed.AdminContext.LatestPlatform.ID != platform.ID {
t.Fatalf("admin task list should expose cross-tenant identity and platform summaries: %+v", listed)
}
if nestedString(listed.Request, "diagnostic", "apiKey") != "***" ||
nestedString(listed.Result, "authorization") != "***" ||
nestedString(listed.Attempts[0].RequestSnapshot, "headers", "authorization") != "***" {
t.Fatalf("admin task list should mask task and attempt secrets: %+v", listed)
}
var maskedDetail store.AdminGatewayTask
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID, operatorToken, nil, http.StatusOK, &maskedDetail)
if nestedString(maskedDetail.Request, "diagnostic", "apiKey") != "***" {
t.Fatalf("admin task detail should be masked by default: %+v", maskedDetail.Request)
}
var fullDetail store.AdminGatewayTask
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"?sensitive=full", operatorToken, nil, http.StatusOK, &fullDetail)
if nestedString(fullDetail.Request, "diagnostic", "apiKey") != taskBSecret {
t.Fatalf("explicit full task detail should expose stored content: %+v", fullDetail.Request)
}
var maskedLogs struct {
Items []store.TaskParamPreprocessingLog `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing", operatorToken, nil, http.StatusOK, &maskedLogs)
if len(maskedLogs.Items) != 1 ||
nestedString(maskedLogs.Items[0].ActualInput, "password") != "***" ||
nestedString(maskedLogs.Items[0].ConvertedOutput, "token") != "***" {
t.Fatalf("admin task preprocessing logs should be masked by default: %+v", maskedLogs.Items)
}
var fullLogs struct {
Items []store.TaskParamPreprocessingLog `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing?sensitive=full", operatorToken, nil, http.StatusOK, &fullLogs)
if len(fullLogs.Items) != 1 ||
nestedString(fullLogs.Items[0].ActualInput, "password") != "input-secret" ||
nestedString(fullLogs.Items[0].ConvertedOutput, "token") != "converted-secret" {
t.Fatalf("explicit full preprocessing logs should expose stored content: %+v", fullLogs.Items)
}
var ownTasks struct {
Items []store.GatewayTask `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?pageSize=100", userAToken, nil, http.StatusOK, &ownTasks)
if !gatewayTaskListHasID(ownTasks.Items, taskA.ID) || gatewayTaskListHasID(ownTasks.Items, taskB.ID) {
t.Fatalf("workspace task list must remain scoped to the current user: %+v", ownTasks.Items)
}
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID, userAToken, nil, http.StatusNotFound, nil)
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID+"/param-preprocessing", userAToken, nil, http.StatusNotFound, nil)
}
func nestedString(value map[string]any, path ...string) string {
var current any = value
for _, key := range path {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
result, _ := current.(string)
return result
}
func gatewayTaskListHasID(items []store.GatewayTask, taskID string) bool {
for _, item := range items {
if item.ID == taskID {
return true
}
}
return false
}
@@ -1695,6 +1695,64 @@ WHERE m.platform_id = $1::uuid
if !taskListContains(workspaceTaskList.Items, taskResponse.Task.ID) || !taskListContains(workspaceTaskList.Items, pricingTask.Task.ID) {
t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items)
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", ordinaryLoginResponse.AccessToken, nil, http.StatusForbidden, nil)
if _, err := testPool.Exec(ctx, `
UPDATE gateway_tasks
SET request = request || '{"diagnostic":{"apiKey":"private-admin-task-test-key"}}'::jsonb
WHERE id = $1::uuid`, taskResponse.Task.ID); err != nil {
t.Fatalf("seed admin task redaction payload: %v", err)
}
var adminTaskList struct {
Items []struct {
ID string `json:"id"`
Request map[string]any `json:"request"`
Context struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"tenant"`
} `json:"adminContext"`
} `json:"items"`
Total int `json:"total"`
}
adminTaskQuery := "/api/admin/tasks?q=" + taskResponse.Task.ID +
"&userId=" + smokeGatewayUserID +
"&status=succeeded" +
"&platformId=" + platform.ID +
"&model=" + defaultTextModel +
"&modelType=text_generate" +
"&runMode=simulation" +
"&billingStatus=settled" +
"&apiKey=smoke" +
"&pageSize=10"
doJSON(t, server.URL, http.MethodGet, adminTaskQuery, loginResponse.AccessToken, nil, http.StatusOK, &adminTaskList)
if len(adminTaskList.Items) != 1 || adminTaskList.Total != 1 || adminTaskList.Items[0].Context.User.Username != username {
t.Fatalf("admin task list should expose the matching task and identity summary: %+v", adminTaskList)
}
maskedDiagnostic, _ := adminTaskList.Items[0].Request["diagnostic"].(map[string]any)
if maskedDiagnostic["apiKey"] != "***" {
t.Fatalf("admin task list must mask nested secrets: %+v", adminTaskList.Items[0].Request)
}
var maskedAdminTask struct {
Request map[string]any `json:"request"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskResponse.Task.ID, loginResponse.AccessToken, nil, http.StatusOK, &maskedAdminTask)
maskedDiagnostic, _ = maskedAdminTask.Request["diagnostic"].(map[string]any)
if maskedDiagnostic["apiKey"] != "***" {
t.Fatalf("admin task detail must be masked by default: %+v", maskedAdminTask.Request)
}
var fullAdminTask struct {
Request map[string]any `json:"request"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskResponse.Task.ID+"?sensitive=full", loginResponse.AccessToken, nil, http.StatusOK, &fullAdminTask)
fullDiagnostic, _ := fullAdminTask.Request["diagnostic"].(map[string]any)
if fullDiagnostic["apiKey"] != "private-admin-task-test-key" {
t.Fatalf("explicit full admin task detail should return stored content: %+v", fullAdminTask.Request)
}
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+pricingTask.Task.ID+"/events", nil)
if err != nil {
@@ -189,6 +189,13 @@ type TaskListResponse struct {
PageSize int `json:"pageSize" example:"50"`
}
type AdminTaskListResponse struct {
Items []store.AdminGatewayTask `json:"items"`
Total int `json:"total" example:"42"`
Page int `json:"page" example:"1"`
PageSize int `json:"pageSize" example:"50"`
}
type TaskParamPreprocessingLogListResponse struct {
Items []store.TaskParamPreprocessingLog `json:"items"`
}
+3
View File
@@ -184,6 +184,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/admin/access-rules/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules)))
mux.Handle("PATCH /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule)))
mux.Handle("DELETE /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule)))
mux.Handle("GET /api/admin/tasks", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAdminTasks)))
mux.Handle("GET /api/admin/tasks/{taskID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getAdminTask)))
mux.Handle("GET /api/admin/tasks/{taskID}/param-preprocessing", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.adminTaskParamPreprocessing)))
mux.Handle("GET /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
@@ -0,0 +1,94 @@
package store
import "strings"
const maskedAdminTaskValue = "***"
func MaskAdminGatewayTask(task AdminGatewayTask) AdminGatewayTask {
task.Request = maskSensitiveObject(task.Request)
task.Result = maskSensitiveObject(task.Result)
task.Billings = maskSensitiveArray(task.Billings)
task.Usage = maskSensitiveObject(task.Usage)
task.Metrics = maskSensitiveObject(task.Metrics)
task.BillingSummary = maskSensitiveObject(task.BillingSummary)
task.PricingSnapshot = maskSensitiveObject(task.PricingSnapshot)
task.CompatibilitySubmitHeaders = maskSensitiveObject(task.CompatibilitySubmitHeaders)
task.CompatibilitySubmitBody = maskSensitiveObject(task.CompatibilitySubmitBody)
task.Attempts = append([]TaskAttempt(nil), task.Attempts...)
for index := range task.Attempts {
task.Attempts[index].Usage = maskSensitiveObject(task.Attempts[index].Usage)
task.Attempts[index].Metrics = maskSensitiveObject(task.Attempts[index].Metrics)
task.Attempts[index].RequestSnapshot = maskSensitiveObject(task.Attempts[index].RequestSnapshot)
task.Attempts[index].ResponseSnapshot = maskSensitiveObject(task.Attempts[index].ResponseSnapshot)
task.Attempts[index].PricingSnapshot = maskSensitiveObject(task.Attempts[index].PricingSnapshot)
}
return task
}
func MaskTaskParamPreprocessingLogs(items []TaskParamPreprocessingLog) []TaskParamPreprocessingLog {
masked := make([]TaskParamPreprocessingLog, len(items))
for index, item := range items {
item.ActualInput = maskSensitiveObject(item.ActualInput)
item.ConvertedOutput = maskSensitiveObject(item.ConvertedOutput)
item.Changes = maskSensitiveArray(item.Changes)
item.ModelSnapshot = maskSensitiveObject(item.ModelSnapshot)
masked[index] = item
}
return masked
}
func maskSensitiveObject(value map[string]any) map[string]any {
if value == nil {
return nil
}
masked, _ := maskSensitiveValue(value).(map[string]any)
return masked
}
func maskSensitiveArray(value []any) []any {
if value == nil {
return nil
}
masked, _ := maskSensitiveValue(value).([]any)
return masked
}
func maskSensitiveValue(value any) any {
switch typed := value.(type) {
case map[string]any:
masked := make(map[string]any, len(typed))
for key, item := range typed {
if adminTaskSensitiveKey(key) {
masked[key] = maskedAdminTaskValue
continue
}
masked[key] = maskSensitiveValue(item)
}
return masked
case []any:
masked := make([]any, len(typed))
for index, item := range typed {
masked[index] = maskSensitiveValue(item)
}
return masked
default:
return value
}
}
func adminTaskSensitiveKey(key string) bool {
normalized := strings.NewReplacer("_", "", "-", "", ".", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(key)))
switch normalized {
case "authorization", "proxyauthorization", "apikey", "xapikey", "accesstoken", "refreshtoken",
"idtoken", "token", "secret", "clientsecret", "password", "passwd", "cookie", "setcookie",
"credentials", "credential", "privatekey":
return true
default:
return strings.HasSuffix(normalized, "apikey") ||
strings.HasSuffix(normalized, "accesstoken") ||
strings.HasSuffix(normalized, "refreshtoken") ||
strings.HasSuffix(normalized, "clientsecret") ||
strings.HasSuffix(normalized, "password") ||
strings.HasSuffix(normalized, "privatekey")
}
}
@@ -0,0 +1,87 @@
package store
import (
"strings"
"testing"
)
func TestMaskAdminGatewayTaskRecursivelyMasksSecretsWithoutMutatingSource(t *testing.T) {
source := AdminGatewayTask{
GatewayTask: GatewayTask{
Request: map[string]any{
"model": "example",
"headers": map[string]any{
"Authorization": "Bearer private",
"X-Api-Key": "secret-key",
},
"usage": map[string]any{"input_tokens": float64(12)},
},
Result: map[string]any{
"nested": []any{map[string]any{"password": "private-password"}},
},
Attempts: []TaskAttempt{{
RequestSnapshot: map[string]any{"client_secret": "private-client-secret"},
}},
},
}
masked := MaskAdminGatewayTask(source)
headers := masked.Request["headers"].(map[string]any)
if headers["Authorization"] != maskedAdminTaskValue || headers["X-Api-Key"] != maskedAdminTaskValue {
t.Fatalf("sensitive headers were not masked: %#v", headers)
}
if masked.Request["usage"].(map[string]any)["input_tokens"] != float64(12) {
t.Fatalf("token usage must remain visible: %#v", masked.Request)
}
nested := masked.Result["nested"].([]any)[0].(map[string]any)
if nested["password"] != maskedAdminTaskValue {
t.Fatalf("nested password was not masked: %#v", nested)
}
if masked.Attempts[0].RequestSnapshot["client_secret"] != maskedAdminTaskValue {
t.Fatalf("attempt snapshot secret was not masked: %#v", masked.Attempts[0])
}
if source.Request["headers"].(map[string]any)["Authorization"] != "Bearer private" {
t.Fatal("masking mutated the source request")
}
if source.Attempts[0].RequestSnapshot["client_secret"] != "private-client-secret" {
t.Fatal("masking mutated the source attempts")
}
}
func TestBuildAdminTaskWhereUsesSharedPlaceholdersAndAllFilters(t *testing.T) {
where, args := buildAdminTaskWhere(AdminTaskListFilter{
Query: "needle",
GatewayTenant: "00000000-0000-0000-0000-000000000001",
GatewayUser: "00000000-0000-0000-0000-000000000002",
UserGroup: "00000000-0000-0000-0000-000000000003",
Status: "failed",
Platform: "00000000-0000-0000-0000-000000000004",
Model: "model-a",
ModelType: "image_generate",
RunMode: "production",
BillingStatus: "settled",
APIKey: "ops",
})
sql := strings.Join(where, "\n")
if strings.Contains(sql, "%!") || strings.Contains(sql, "$%d") {
t.Fatalf("SQL contains an unresolved placeholder: %s", sql)
}
if len(args) != 11 {
t.Fatalf("argument count=%d, want 11", len(args))
}
if strings.Count(sql, "$1") < 10 {
t.Fatalf("keyword search should reuse one placeholder, got: %s", sql)
}
for _, fragment := range []string{
"t.gateway_tenant_id",
"t.gateway_user_id",
"t.user_group_id",
"platform_attempt.platform_id",
"t.billing_status",
"t.api_key_prefix",
} {
if !strings.Contains(sql, fragment) {
t.Fatalf("SQL is missing %q: %s", fragment, sql)
}
}
}
+385
View File
@@ -0,0 +1,385 @@
package store
import (
"context"
"fmt"
"strings"
"time"
)
type AdminTaskListFilter struct {
Query string
GatewayTenant string
GatewayUser string
UserGroup string
Status string
Platform string
Model string
ModelType string
RunMode string
BillingStatus string
APIKey string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int
PageSize int
}
type AdminTaskUserSummary struct {
ID string `json:"id"`
Username string `json:"username,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
Source string `json:"source,omitempty"`
}
type AdminTaskTenantSummary struct {
ID string `json:"id"`
TenantKey string `json:"tenantKey,omitempty"`
Name string `json:"name,omitempty"`
}
type AdminTaskPlatformSummary struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
}
type AdminTaskContext struct {
User *AdminTaskUserSummary `json:"user,omitempty"`
Tenant *AdminTaskTenantSummary `json:"tenant,omitempty"`
LatestPlatform *AdminTaskPlatformSummary `json:"latestPlatform,omitempty"`
}
type AdminGatewayTask struct {
GatewayTask
AdminContext AdminTaskContext `json:"adminContext"`
}
type AdminTaskListResult struct {
Items []AdminGatewayTask
Total int
Page int
PageSize int
}
func (s *Store) ListAdminTasks(ctx context.Context, filter AdminTaskListFilter) (AdminTaskListResult, error) {
page := filter.Page
if page <= 0 {
page = 1
}
pageSize := filter.PageSize
if pageSize <= 0 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
where, args := buildAdminTaskWhere(filter)
var total int
if err := s.pool.QueryRow(ctx, `
SELECT count(*)
FROM gateway_tasks t
WHERE `+strings.Join(where, "\n AND "), args...).Scan(&total); err != nil {
return AdminTaskListResult{}, err
}
queryArgs := append([]any{}, args...)
queryArgs = append(queryArgs, pageSize, (page-1)*pageSize)
rows, err := s.pool.Query(ctx, `
SELECT t.id::text
FROM gateway_tasks t
WHERE `+strings.Join(where, "\n AND ")+`
ORDER BY t.created_at DESC, t.id DESC
LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2), queryArgs...)
if err != nil {
return AdminTaskListResult{}, err
}
defer rows.Close()
taskIDs := make([]string, 0, pageSize)
for rows.Next() {
var taskID string
if err := rows.Scan(&taskID); err != nil {
return AdminTaskListResult{}, err
}
taskIDs = append(taskIDs, taskID)
}
if err := rows.Err(); err != nil {
return AdminTaskListResult{}, err
}
items, err := s.loadAdminTasksByIDs(ctx, taskIDs)
if err != nil {
return AdminTaskListResult{}, err
}
return AdminTaskListResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (s *Store) GetAdminTask(ctx context.Context, taskID string) (AdminGatewayTask, error) {
task, err := s.GetTask(ctx, taskID)
if err != nil {
return AdminGatewayTask{}, err
}
contexts, err := s.loadAdminTaskContexts(ctx, []string{task.ID})
if err != nil {
return AdminGatewayTask{}, err
}
return adminTaskWithContext(task, contexts[task.ID]), nil
}
func buildAdminTaskWhere(filter AdminTaskListFilter) ([]string, []any) {
where := []string{"TRUE"}
args := make([]any, 0, 14)
add := func(clause string, value any) {
args = append(args, value)
placeholder := fmt.Sprintf("$%d", len(args))
where = append(where, strings.ReplaceAll(clause, "$%d", placeholder))
}
if query := strings.TrimSpace(filter.Query); query != "" {
add(`(
t.id::text ILIKE $%d
OR COALESCE(t.request_id, '') ILIKE $%d
OR t.kind ILIKE $%d
OR t.model ILIKE $%d
OR COALESCE(t.requested_model, '') ILIKE $%d
OR COALESCE(t.resolved_model, '') ILIKE $%d
OR COALESCE(t.api_key_id, '') ILIKE $%d
OR COALESCE(t.api_key_name, '') ILIKE $%d
OR COALESCE(t.api_key_prefix, '') ILIKE $%d
OR COALESCE(t.model_type, '') ILIKE $%d
OR EXISTS (
SELECT 1
FROM gateway_users admin_user
WHERE admin_user.id = t.gateway_user_id
AND (
admin_user.username ILIKE $%d
OR COALESCE(admin_user.display_name, '') ILIKE $%d
OR COALESCE(admin_user.email, '') ILIKE $%d
OR admin_user.user_key ILIKE $%d
)
)
OR EXISTS (
SELECT 1
FROM gateway_tenants admin_tenant
WHERE admin_tenant.id = t.gateway_tenant_id
AND (
admin_tenant.name ILIKE $%d
OR admin_tenant.tenant_key ILIKE $%d
)
)
OR EXISTS (
SELECT 1
FROM gateway_task_attempts admin_attempt
LEFT JOIN integration_platforms admin_platform ON admin_platform.id = admin_attempt.platform_id
LEFT JOIN platform_models admin_model ON admin_model.id = admin_attempt.platform_model_id
WHERE admin_attempt.task_id = t.id
AND (
COALESCE(admin_platform.name, '') ILIKE $%d
OR COALESCE(admin_platform.internal_name, '') ILIKE $%d
OR COALESCE(admin_platform.provider, '') ILIKE $%d
OR COALESCE(admin_model.model_name, '') ILIKE $%d
OR COALESCE(admin_model.provider_model_name, '') ILIKE $%d
OR COALESCE(admin_model.model_alias, '') ILIKE $%d
)
)
)`, "%"+query+"%")
}
if value := strings.TrimSpace(filter.GatewayTenant); value != "" {
add("t.gateway_tenant_id = $%d::uuid", value)
}
if value := strings.TrimSpace(filter.GatewayUser); value != "" {
add("t.gateway_user_id = $%d::uuid", value)
}
if value := strings.TrimSpace(filter.UserGroup); value != "" {
add("t.user_group_id = $%d::uuid", value)
}
if value := strings.TrimSpace(filter.Status); value != "" {
add("t.status = $%d", value)
}
if value := strings.TrimSpace(filter.Platform); value != "" {
add(`EXISTS (
SELECT 1
FROM gateway_task_attempts platform_attempt
WHERE platform_attempt.task_id = t.id
AND platform_attempt.platform_id = $%d::uuid
)`, value)
}
if value := strings.TrimSpace(filter.Model); value != "" {
add(`(
t.model = $%d
OR COALESCE(t.requested_model, '') = $%d
OR COALESCE(t.resolved_model, '') = $%d
OR EXISTS (
SELECT 1
FROM gateway_task_attempts model_attempt
LEFT JOIN platform_models selected_model ON selected_model.id = model_attempt.platform_model_id
WHERE model_attempt.task_id = t.id
AND (
COALESCE(selected_model.model_name, '') = $%d
OR COALESCE(selected_model.provider_model_name, '') = $%d
OR COALESCE(selected_model.model_alias, '') = $%d
)
)
)`, value)
}
if value := strings.TrimSpace(filter.ModelType); value != "" {
add("COALESCE(t.model_type, '') = $%d", value)
}
if value := strings.TrimSpace(filter.RunMode); value != "" {
add("t.run_mode = $%d", value)
}
if value := strings.TrimSpace(filter.BillingStatus); value != "" {
add("t.billing_status = $%d", value)
}
if value := strings.TrimSpace(filter.APIKey); value != "" {
add(`(
COALESCE(t.api_key_id, '') ILIKE $%d
OR COALESCE(t.api_key_name, '') ILIKE $%d
OR COALESCE(t.api_key_prefix, '') ILIKE $%d
)`, "%"+value+"%")
}
if filter.CreatedFrom != nil {
add("t.created_at >= $%d::timestamptz", *filter.CreatedFrom)
}
if filter.CreatedTo != nil {
add("t.created_at <= $%d::timestamptz", *filter.CreatedTo)
}
return where, args
}
func (s *Store) loadAdminTasksByIDs(ctx context.Context, taskIDs []string) ([]AdminGatewayTask, error) {
if len(taskIDs) == 0 {
return []AdminGatewayTask{}, nil
}
rows, err := s.pool.Query(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
WHERE id::text = ANY($1)`, taskIDs)
if err != nil {
return nil, err
}
defer rows.Close()
tasksByID := make(map[string]GatewayTask, len(taskIDs))
tasks := make([]GatewayTask, 0, len(taskIDs))
for rows.Next() {
task, err := scanGatewayTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, task)
}
if err := rows.Err(); err != nil {
return nil, err
}
tasks, err = s.attachTaskAttempts(ctx, tasks)
if err != nil {
return nil, err
}
for _, task := range tasks {
tasksByID[task.ID] = task
}
contexts, err := s.loadAdminTaskContexts(ctx, taskIDs)
if err != nil {
return nil, err
}
items := make([]AdminGatewayTask, 0, len(taskIDs))
for _, taskID := range taskIDs {
task, ok := tasksByID[taskID]
if !ok {
continue
}
items = append(items, adminTaskWithContext(task, contexts[taskID]))
}
return items, nil
}
func (s *Store) loadAdminTaskContexts(ctx context.Context, taskIDs []string) (map[string]AdminTaskContext, error) {
contexts := make(map[string]AdminTaskContext, len(taskIDs))
if len(taskIDs) == 0 {
return contexts, nil
}
rows, err := s.pool.Query(ctx, `
SELECT t.id::text,
COALESCE(u.id::text, ''), COALESCE(u.username, ''), COALESCE(u.display_name, ''),
COALESCE(u.email, ''), COALESCE(u.source, ''),
COALESCE(tenant.id::text, ''), COALESCE(tenant.tenant_key, ''), COALESCE(tenant.name, '')
FROM gateway_tasks t
LEFT JOIN gateway_users u ON u.id = t.gateway_user_id
LEFT JOIN gateway_tenants tenant ON tenant.id = t.gateway_tenant_id
WHERE t.id::text = ANY($1)`, taskIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var taskID string
var user AdminTaskUserSummary
var tenant AdminTaskTenantSummary
if err := rows.Scan(
&taskID,
&user.ID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Source,
&tenant.ID,
&tenant.TenantKey,
&tenant.Name,
); err != nil {
return nil, err
}
context := AdminTaskContext{}
if user.ID != "" {
context.User = &user
}
if tenant.ID != "" {
context.Tenant = &tenant
}
contexts[taskID] = context
}
return contexts, rows.Err()
}
func adminTaskWithContext(task GatewayTask, context AdminTaskContext) AdminGatewayTask {
if context.User == nil {
userID := strings.TrimSpace(task.GatewayUserID)
if userID == "" {
userID = strings.TrimSpace(task.UserID)
}
if userID != "" {
context.User = &AdminTaskUserSummary{ID: userID, Source: task.UserSource}
}
}
if context.Tenant == nil {
tenantID := strings.TrimSpace(task.GatewayTenantID)
if tenantID == "" {
tenantID = strings.TrimSpace(task.TenantID)
}
if tenantID != "" || strings.TrimSpace(task.TenantKey) != "" {
context.Tenant = &AdminTaskTenantSummary{ID: tenantID, TenantKey: task.TenantKey}
}
}
for index := len(task.Attempts) - 1; index >= 0; index-- {
attempt := task.Attempts[index]
if strings.TrimSpace(attempt.PlatformID) == "" {
continue
}
context.LatestPlatform = &AdminTaskPlatformSummary{
ID: attempt.PlatformID,
Name: attempt.PlatformName,
Provider: attempt.Provider,
}
break
}
return AdminGatewayTask{GatewayTask: task, AdminContext: context}
}