0.11.4: 旗舰版第五轮完善(智能体节点任务下发/个人智能体安全策略)
- 节点任务:管理端向节点池下发(Prompt/HTTP/MCP/Skill/数字员工/自定义), 指定节点或池路由,认领 SKIP LOCKED + 15 分钟租约,认领令牌防重放上报, 失败 30s×次数退避重入队,达上限 failed,支持取消/重试,完成与失败站内信。 - 个人智能体安全策略:auto_approve_tools 跳过个人调用审批门; rate_limit_multiplier 按 (tool,user) 独立窗口放宽个人限流(全局额度不受影响)。 - 修复存量缺陷:/v1/agent/nodes/ 未挂 publicMux,节点心跳/认领端点在部署 拓扑下不可达。 - 迁移 000046;任务全链路集成测试连真实库通过,HTTP 端到端验证 (下发→认领→伪造令牌拒绝→上报→succeeded,列表不泄露认领令牌); 25 包测试通过,前后端构建通过。
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aigateway.local/core/internal/identity"
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AgentPolicy 是个人智能体安全策略。
|
||||
type AgentPolicy struct {
|
||||
AutoApproveTools bool `json:"auto_approve_tools"`
|
||||
RateLimitMultiplier int `json:"rate_limit_multiplier"`
|
||||
}
|
||||
|
||||
// AgentPolicyService 管理个人智能体安全策略。
|
||||
type AgentPolicyService struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAgentPolicyService(pool *pgxpool.Pool) *AgentPolicyService {
|
||||
return &AgentPolicyService{pool: pool}
|
||||
}
|
||||
|
||||
// Get 返回用户的策略(未配置时返回默认值)。
|
||||
func (s *AgentPolicyService) Get(ctx context.Context, portalUserID string) (AgentPolicy, error) {
|
||||
var policy AgentPolicy
|
||||
if s == nil || s.pool == nil {
|
||||
return policy, errors.New("安全策略服务不可用")
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `SELECT auto_approve_tools,rate_limit_multiplier FROM gateway.portal_agent_policies WHERE portal_user_id=$1`, portalUserID).Scan(&policy.AutoApproveTools, &policy.RateLimitMultiplier)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
policy.RateLimitMultiplier = 1
|
||||
return policy, nil
|
||||
}
|
||||
if err != nil {
|
||||
return policy, err
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// Set 更新用户的策略。
|
||||
func (s *AgentPolicyService) Set(ctx context.Context, portalUserID string, policy AgentPolicy) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return errors.New("安全策略服务不可用")
|
||||
}
|
||||
if policy.RateLimitMultiplier < 1 || policy.RateLimitMultiplier > 100 {
|
||||
return errors.New("限流倍数必须在 1-100 之间")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.portal_agent_policies(portal_user_id,auto_approve_tools,rate_limit_multiplier) VALUES($1,$2,$3)
|
||||
ON CONFLICT(portal_user_id) DO UPDATE SET auto_approve_tools=$2,rate_limit_multiplier=$3,updated_at=clock_timestamp()`,
|
||||
portalUserID, policy.AutoApproveTools, policy.RateLimitMultiplier)
|
||||
return err
|
||||
}
|
||||
|
||||
// AgentPolicyHTTPHandler 门户个人智能体安全策略。
|
||||
type AgentPolicyHTTPHandler struct {
|
||||
service *AgentPolicyService
|
||||
identity *identity.Service
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewAgentPolicyHTTPHandler(service *AgentPolicyService, identityService *identity.Service) *AgentPolicyHTTPHandler {
|
||||
h := &AgentPolicyHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("GET /api/v1/portal/agent-policy", h.get)
|
||||
h.mux.HandleFunc("PUT /api/v1/portal/agent-policy", h.put)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) {
|
||||
account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
return account, true
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
policy, err := h.service.Get(r.Context(), a.ID)
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusServiceUnavailable, "安全策略查询失败")
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, policy)
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) put(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
AutoApproveTools bool `json:"auto_approve_tools"`
|
||||
RateLimitMultiplier int `json:"rate_limit_multiplier"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.service.Set(r.Context(), a.ID, AgentPolicy{AutoApproveTools: input.AutoApproveTools, RateLimitMultiplier: input.RateLimitMultiplier}); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, strings.TrimSpace(err.Error()))
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"saved": true})
|
||||
}
|
||||
@@ -132,6 +132,10 @@ func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft {
|
||||
return []inboxDraft{{RecipientKind: "portal", Category: "approval", Title: "资源申请已处理", Body: "您的资源权限申请(" + payloadValue(payload, "resource_type") + " " + payloadValue(payload, "resource_code") + ")已被" + text, Link: "/portal/requests", UserID: payloadValue(payload, "portal_user_id")}}
|
||||
case "tool_approval.requested":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "工具使用待审批", Body: "工具 " + payloadValue(payload, "tool_code") + " 首次被调用,需审批后才能使用", Link: "/system/approvals", AllAdmins: true}}
|
||||
case "agent_task.completed":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务已完成", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行成功", Link: "/security/agent-tasks", AllAdmins: true}}
|
||||
case "agent_task.failed":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务执行失败", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行失败: " + payloadValue(payload, "error"), Link: "/security/agent-tasks", AllAdmins: true}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+53
-11
@@ -217,12 +217,19 @@ var ErrToolRateLimited = errors.New("工具调用频率超限,请稍后重试"
|
||||
|
||||
// enforceGovernance 在工具执行前做治理校验:审批标记 + 调用频率上限。
|
||||
// 审批缺失时自动发起一次申请(每工具至多一个待审项);限流用固定窗口原子
|
||||
// upsert,多实例共享同一额度。返回 (allowed, err)。
|
||||
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, error) {
|
||||
// upsert,多实例共享同一额度。apiKeyID 对应的门户用户若配置了个人智能体
|
||||
// 安全策略:auto_approve_tools 跳过审批门,rate_limit_multiplier 按倍数
|
||||
// 放宽个人限流(个人窗口独立计数)。返回 (allowed, err)。
|
||||
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool, apiKeyID string) (bool, error) {
|
||||
if s == nil || s.assets == nil || s.assets.pool == nil {
|
||||
return false, errors.New("工具服务不可用")
|
||||
}
|
||||
if tool.ApprovalRequired {
|
||||
// 解析调用者的个人策略(仅门户运行时凭据可能命中)。
|
||||
portalUserID, policy, err := s.personalPolicy(ctx, apiKeyID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if tool.ApprovalRequired && !policy.AutoApproveTools {
|
||||
var approved bool
|
||||
if err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.tool_approval_requests WHERE tool_id=$1 AND status='approved')`, tool.ID).Scan(&approved); err != nil {
|
||||
return false, err
|
||||
@@ -258,22 +265,56 @@ func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, e
|
||||
return false, fmt.Errorf("%w: %s(已自动发起审批)", ErrToolApprovalRequired, tool.Name)
|
||||
}
|
||||
}
|
||||
if tool.RateLimitRPM > 0 {
|
||||
limit := tool.RateLimitRPM
|
||||
if portalUserID != "" && policy.RateLimitMultiplier > 1 {
|
||||
limit = tool.RateLimitRPM * policy.RateLimitMultiplier
|
||||
}
|
||||
if limit > 0 {
|
||||
userKey := portalUserID
|
||||
if userKey == "" {
|
||||
userKey = personalPolicySentinel
|
||||
}
|
||||
var count int64
|
||||
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,window_start,call_count)
|
||||
VALUES($1,date_trunc('minute',clock_timestamp()),1)
|
||||
ON CONFLICT (tool_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
|
||||
RETURNING call_count`, tool.ID).Scan(&count)
|
||||
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,portal_user_id,window_start,call_count)
|
||||
VALUES($1,$2,date_trunc('minute',clock_timestamp()),1)
|
||||
ON CONFLICT (tool_id,portal_user_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
|
||||
RETURNING call_count`, tool.ID, userKey).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > int64(tool.RateLimitRPM) {
|
||||
if count > int64(limit) {
|
||||
return false, ErrToolRateLimited
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// personalPolicySentinel 是非个人调用的限流窗口占位键(全零 UUID)。
|
||||
const personalPolicySentinel = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
// personalPolicy 解析 API Key 对应的门户用户及其个人智能体安全策略。
|
||||
// 非门户 Key 返回空 userID 与默认策略(不跳过审批、倍数 1)。
|
||||
func (s *ToolService) personalPolicy(ctx context.Context, apiKeyID string) (string, AgentPolicy, error) {
|
||||
if strings.TrimSpace(apiKeyID) == "" {
|
||||
return "", AgentPolicy{RateLimitMultiplier: 1}, nil
|
||||
}
|
||||
var portalUserID *string
|
||||
err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.api_keys WHERE id=$1`, apiKeyID).Scan(&portalUserID)
|
||||
if err != nil || portalUserID == nil || *portalUserID == "" {
|
||||
return "", AgentPolicy{RateLimitMultiplier: 1}, nil
|
||||
}
|
||||
var policy AgentPolicy
|
||||
err = s.assets.pool.QueryRow(ctx, `SELECT auto_approve_tools,rate_limit_multiplier FROM gateway.portal_agent_policies WHERE portal_user_id=$1`, *portalUserID).Scan(&policy.AutoApproveTools, &policy.RateLimitMultiplier)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
policy.RateLimitMultiplier = 1
|
||||
return *portalUserID, policy, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", AgentPolicy{}, err
|
||||
}
|
||||
return *portalUserID, policy, nil
|
||||
}
|
||||
|
||||
// ListApprovalRequests 返回工具审批申请(含工具信息)。
|
||||
func (s *ToolService) ListApprovalRequests(ctx context.Context, status string) ([]map[string]any, error) {
|
||||
if s == nil || s.assets == nil || s.assets.pool == nil {
|
||||
@@ -351,8 +392,9 @@ func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]a
|
||||
if err = validateToolInput(tool.InputSchema, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 治理校验:审批标记 + 频率上限。被拒时不算一次成功调用(但会记 tool_runs 失败)。
|
||||
if allowed, governanceErr := s.enforceGovernance(ctx, tool); !allowed {
|
||||
// 治理校验:审批标记 + 频率上限(个人策略可跳过审批/放宽限流)。被拒时
|
||||
// 不算一次成功调用(但会记 tool_runs 失败)。
|
||||
if allowed, governanceErr := s.enforceGovernance(ctx, tool, apiKeyID); !allowed {
|
||||
return nil, governanceErr
|
||||
}
|
||||
headers, err := s.headers(tool)
|
||||
|
||||
Reference in New Issue
Block a user