feat(admin): 优化后台运维与任务管理
完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。 新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。 验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user