Files
heicode-mananger/heicode/controller/agent_approval.go
T
chenchenandClaude Opus 4.8 0fe1d20d67 feat(agent): unify agnet→agent and implement client/runtime unification spec v0.1 core
按桌面客户端统一方案 v0.1 + agent_management Sub Mode Runtime 对接,强制全量统一,不留兼容。

命名统一(强制,无兼容):
- 全仓 agnet/Agnet/AGNET → agent/Agent/AGENT:后端 Go(路由 /api/agent/*、env AGENT_*、
  结构体/函数、19 个文件改名)、前端(agent-console/agent-hub、/api/agent 调用、i18n)、
  DB(表 agent_*、列 agent_id)、compose/.env、文档、脚本。
- DB 加幂等迁移 renameAgnetTablesToAgent():启动时 rename 老 agnet_* 表/列,保住生产数据。

统一方案核心(10 项):
- callback 统一 /api/agent/callbacks/runtime-events(路由/广播URL/函数名)。
- artifact 兜底判定改用 Runtime 权威信号 metadata.synthesized(§7.2)+ 结构化 artifact_type。
- Manager→Runtime 路径对齐 /api/agent/sub-agile/deployments(§2.2),{deployment_id} 回退 swarm_id。
- 状态裁决 display_status:Manager 唯一裁判,completed 无有效产物→needs_codegen/
  completed_without_deliverable(§10.6),接入 detail/timeline/workflow。
- GET /api/heicode/capabilities 能力发现(§6)。
- 模型策略 per_role(role_models)+ 收集 allowed_model_ids(§9)。
- resource_binding_id→secret_ref 服务端解析,客户端不再 inline secret_ref(§17.6)。
- 客户端统一路由层 /api/heicode/sub-agile|swarm/*(task≡deployment,复用控制面)+ workflow 投影。
- 日志分层 user_logs/debug_logs(§13)。

验证:go build ./... + go test(controller/router/model/middleware)全绿;前端 tsc -b + rsbuild build 通过。
待部署:VM .env 的 AGNET_*→AGENT_*;启动迁移自动 rename 表;其他三仓库需同步切到 /api/agent。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:45:10 +08:00

561 lines
19 KiB
Go

package controller
import (
"errors"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"gorm.io/gorm"
)
const (
agentApprovalStatusPending = "pending"
agentApprovalStatusApproved = "approved"
agentApprovalStatusRejected = "rejected"
agentApprovalStatusExpired = "expired"
agentLeaseStatusActive = "active"
agentLeaseStatusExpired = "expired"
agentLeaseStatusRevoked = "revoked"
defaultAgentApprovalTTLSeconds = 15 * 60
maxAgentApprovalTTLSeconds = 60 * 60
)
type agentApprovalPayload struct {
DeploymentID string `json:"deployment_id"`
BindingScope string `json:"binding_scope"`
Operation string `json:"operation"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ResourceScope string `json:"resource_scope"`
TargetRole string `json:"target_role"`
RiskLevel string `json:"risk_level"`
RequiresCredential bool `json:"requires_credential"`
SecretRef string `json:"secret_ref"`
TTLSeconds int `json:"ttl_seconds"`
Reason string `json:"reason"`
}
type agentDecisionPayload struct {
Reason string `json:"reason"`
}
type agentApprovalResponse struct {
ApprovalID string `json:"approval_id"`
UserId int `json:"user_id"`
DeploymentID string `json:"deployment_id"`
BindingScope string `json:"binding_scope"`
Operation string `json:"operation"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ResourceScope string `json:"resource_scope"`
TargetRole string `json:"target_role"`
RiskLevel string `json:"risk_level"`
RequiresCredential bool `json:"requires_credential"`
CredentialLeaseID string `json:"credential_lease_id,omitempty"`
Status string `json:"status"`
RequestedBy string `json:"requested_by"`
DecidedBy string `json:"decided_by,omitempty"`
RequestReason string `json:"request_reason,omitempty"`
DecisionReason string `json:"decision_reason,omitempty"`
TTLSeconds int `json:"ttl_seconds"`
ExpiresAt int64 `json:"expires_at"`
DecidedAt int64 `json:"decided_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CredentialLease *agentCredentialLeaseResponse `json:"credential_lease,omitempty"`
}
type agentCredentialLeaseResponse struct {
LeaseID string `json:"lease_id"`
CredentialRef string `json:"credential_ref"`
ApprovalID string `json:"approval_id"`
UserId int `json:"user_id"`
DeploymentID string `json:"deployment_id"`
BindingScope string `json:"binding_scope"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ResourceScope string `json:"resource_scope"`
TargetRole string `json:"target_role"`
Status string `json:"status"`
TTLSeconds int `json:"ttl_seconds"`
ExpiresAt int64 `json:"expires_at"`
RevokedAt int64 `json:"revoked_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func CreateAgentApprovalRequest(c *gin.Context) {
var payload agentApprovalPayload
if err := common.DecodeJson(c.Request.Body, &payload); err != nil {
common.ApiError(c, err)
return
}
payload, err := normalizeAgentApprovalPayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorMsg(c, "user authentication required")
return
}
now := time.Now().UnixMilli()
approval := model.AgentApprovalRequest{
ApprovalID: "appr_" + common.GetUUID(),
UserId: userID,
DeploymentID: payload.DeploymentID,
BindingScope: payload.BindingScope,
Operation: payload.Operation,
ResourceID: payload.ResourceID,
ResourceType: payload.ResourceType,
ResourceScope: payload.ResourceScope,
TargetRole: payload.TargetRole,
RiskLevel: payload.RiskLevel,
RequiresCredential: payload.RequiresCredential,
SecretRef: payload.SecretRef,
Status: agentApprovalStatusPending,
RequestedBy: agentActorForUser(userID),
RequestReason: payload.Reason,
TTLSeconds: payload.TTLSeconds,
ExpiresAt: now + int64(payload.TTLSeconds)*1000,
}
if err := model.DB.Create(&approval).Error; err != nil {
common.ApiError(c, err)
return
}
recordAgentApprovalAudit("approval.requested", &approval, nil, "ok", "")
common.ApiSuccess(c, agentApprovalToResponse(approval, nil))
}
func ListAgentApprovalRequests(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorMsg(c, "user authentication required")
return
}
q := model.DB.Where("user_id = ?", userID)
statusFilter := strings.TrimSpace(c.Query("status"))
if statusFilter != "" {
q = q.Where("status = ?", statusFilter)
}
if deploymentID := strings.TrimSpace(c.Query("deployment_id")); deploymentID != "" {
q = q.Where("deployment_id = ?", deploymentID)
}
var approvals []model.AgentApprovalRequest
if err := q.Order("created_at desc, id desc").Limit(200).Find(&approvals).Error; err != nil {
common.ApiError(c, err)
return
}
for i := range approvals {
expireAgentApprovalIfNeeded(&approvals[i])
}
items := make([]agentApprovalResponse, 0, len(approvals))
for _, approval := range approvals {
if statusFilter != "" && approval.Status != statusFilter {
continue
}
items = append(items, agentApprovalToResponse(approval, nil))
}
common.ApiSuccess(c, gin.H{"items": items})
}
func GetAgentApprovalRequest(c *gin.Context) {
approval, ok := findAgentApprovalForUser(c)
if !ok {
return
}
expireAgentApprovalIfNeeded(&approval)
lease := findAgentCredentialLeaseByApproval(approval.ApprovalID)
common.ApiSuccess(c, agentApprovalToResponse(approval, lease))
}
func ApproveAgentApprovalRequest(c *gin.Context) {
approval, ok := findAgentApprovalForUser(c)
if !ok {
return
}
if expireAgentApprovalIfNeeded(&approval) {
common.ApiErrorMsg(c, "approval request expired")
return
}
if approval.Status != agentApprovalStatusPending {
common.ApiErrorMsg(c, "approval request is not pending")
return
}
var payload agentDecisionPayload
_ = common.DecodeJson(c.Request.Body, &payload)
now := time.Now().UnixMilli()
approval.Status = agentApprovalStatusApproved
approval.DecidedBy = agentActorForUser(c.GetInt("id"))
approval.DecisionReason = strings.TrimSpace(payload.Reason)
approval.DecidedAt = now
var lease *model.AgentCredentialLease
err := model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&approval).Error; err != nil {
return err
}
if !approval.RequiresCredential {
return nil
}
createdLease := model.AgentCredentialLease{
LeaseID: "lease_" + common.GetUUID(),
ApprovalID: approval.ApprovalID,
UserId: approval.UserId,
DeploymentID: approval.DeploymentID,
BindingScope: approval.BindingScope,
ResourceID: approval.ResourceID,
ResourceType: approval.ResourceType,
ResourceScope: approval.ResourceScope,
TargetRole: approval.TargetRole,
SecretRef: approval.SecretRef,
Status: agentLeaseStatusActive,
TTLSeconds: approval.TTLSeconds,
ExpiresAt: now + int64(approval.TTLSeconds)*1000,
}
createdLease.CredentialRef = "lease://agent/" + createdLease.LeaseID
if err := tx.Create(&createdLease).Error; err != nil {
return err
}
approval.CredentialLeaseID = createdLease.LeaseID
if err := tx.Save(&approval).Error; err != nil {
return err
}
lease = &createdLease
return nil
})
if err != nil {
common.ApiError(c, err)
return
}
recordAgentApprovalAudit("approval.approved", &approval, nil, "ok", "")
if lease != nil {
recordAgentApprovalAudit("credential_lease.created", &approval, lease, "ok", "")
}
syncAgentRuntimeApprovalDecision(c, &approval, lease, agentApprovalStatusApproved)
common.ApiSuccess(c, agentApprovalToResponse(approval, lease))
}
func RejectAgentApprovalRequest(c *gin.Context) {
approval, ok := findAgentApprovalForUser(c)
if !ok {
return
}
if expireAgentApprovalIfNeeded(&approval) {
common.ApiErrorMsg(c, "approval request expired")
return
}
if approval.Status != agentApprovalStatusPending {
common.ApiErrorMsg(c, "approval request is not pending")
return
}
var payload agentDecisionPayload
_ = common.DecodeJson(c.Request.Body, &payload)
approval.Status = agentApprovalStatusRejected
approval.DecidedBy = agentActorForUser(c.GetInt("id"))
approval.DecisionReason = strings.TrimSpace(payload.Reason)
approval.DecidedAt = time.Now().UnixMilli()
if err := model.DB.Save(&approval).Error; err != nil {
common.ApiError(c, err)
return
}
recordAgentApprovalAudit("approval.rejected", &approval, nil, "ok", "")
syncAgentRuntimeApprovalDecision(c, &approval, nil, agentApprovalStatusRejected)
common.ApiSuccess(c, agentApprovalToResponse(approval, nil))
}
func ListAgentCredentialLeases(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorMsg(c, "user authentication required")
return
}
q := model.DB.Where("user_id = ?", userID)
statusFilter := strings.TrimSpace(c.Query("status"))
if statusFilter != "" {
q = q.Where("status = ?", statusFilter)
}
if deploymentID := strings.TrimSpace(c.Query("deployment_id")); deploymentID != "" {
q = q.Where("deployment_id = ?", deploymentID)
}
var leases []model.AgentCredentialLease
if err := q.Order("created_at desc, id desc").Limit(200).Find(&leases).Error; err != nil {
common.ApiError(c, err)
return
}
items := make([]agentCredentialLeaseResponse, 0, len(leases))
for i := range leases {
expireAgentCredentialLeaseIfNeeded(&leases[i])
if statusFilter != "" && leases[i].Status != statusFilter {
continue
}
items = append(items, agentLeaseToResponse(leases[i]))
}
common.ApiSuccess(c, gin.H{"items": items})
}
func RevokeAgentCredentialLease(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorMsg(c, "user authentication required")
return
}
leaseID := strings.TrimSpace(c.Param("lease_id"))
if leaseID == "" {
common.ApiErrorMsg(c, "lease_id required")
return
}
var lease model.AgentCredentialLease
if err := model.DB.Where("lease_id = ? AND user_id = ?", leaseID, userID).First(&lease).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "credential lease not found")
return
}
common.ApiError(c, err)
return
}
if expireAgentCredentialLeaseIfNeeded(&lease) {
common.ApiSuccess(c, agentLeaseToResponse(lease))
return
}
if lease.Status != agentLeaseStatusActive {
common.ApiErrorMsg(c, "credential lease is not active")
return
}
lease.Status = agentLeaseStatusRevoked
lease.RevokedAt = time.Now().UnixMilli()
if err := model.DB.Save(&lease).Error; err != nil {
common.ApiError(c, err)
return
}
var approval model.AgentApprovalRequest
if err := model.DB.Where("approval_id = ?", lease.ApprovalID).First(&approval).Error; err == nil {
recordAgentApprovalAudit("credential_lease.revoked", &approval, &lease, "ok", "")
}
common.ApiSuccess(c, agentLeaseToResponse(lease))
}
func normalizeAgentApprovalPayload(p agentApprovalPayload) (agentApprovalPayload, error) {
p.DeploymentID = strings.TrimSpace(p.DeploymentID)
p.BindingScope = strings.TrimSpace(p.BindingScope)
p.Operation = strings.TrimSpace(p.Operation)
p.ResourceID = strings.TrimSpace(p.ResourceID)
p.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType))
p.ResourceScope = strings.TrimSpace(p.ResourceScope)
p.TargetRole = strings.TrimSpace(p.TargetRole)
p.RiskLevel = strings.ToLower(strings.TrimSpace(p.RiskLevel))
p.SecretRef = strings.TrimSpace(p.SecretRef)
p.Reason = strings.TrimSpace(p.Reason)
if p.Operation == "" {
return p, errors.New("operation required")
}
if p.ResourceID == "" {
return p, errors.New("resource_id required")
}
if p.ResourceType == "" {
return p, errors.New("resource_type required")
}
if p.TargetRole == "" {
return p, errors.New("target_role required")
}
if p.RiskLevel == "" {
p.RiskLevel = "high"
}
if p.RiskLevel != "low" && p.RiskLevel != "medium" && p.RiskLevel != "high" && p.RiskLevel != "critical" {
return p, errors.New("risk_level must be low, medium, high, or critical")
}
if p.TTLSeconds <= 0 {
p.TTLSeconds = defaultAgentApprovalTTLSeconds
}
if p.TTLSeconds > maxAgentApprovalTTLSeconds {
p.TTLSeconds = maxAgentApprovalTTLSeconds
}
if p.RequiresCredential {
if p.SecretRef == "" {
return p, errors.New("secret_ref required when requires_credential is true")
}
if !strings.HasPrefix(p.SecretRef, "azkv://") {
return p, errors.New("secret_ref must use azkv:// Azure Key Vault reference")
}
}
return p, nil
}
func findAgentApprovalForUser(c *gin.Context) (model.AgentApprovalRequest, bool) {
userID := c.GetInt("id")
if userID <= 0 {
common.ApiErrorMsg(c, "user authentication required")
return model.AgentApprovalRequest{}, false
}
approvalID := strings.TrimSpace(c.Param("approval_id"))
if approvalID == "" {
common.ApiErrorMsg(c, "approval_id required")
return model.AgentApprovalRequest{}, false
}
var approval model.AgentApprovalRequest
if err := model.DB.Where("approval_id = ? AND user_id = ?", approvalID, userID).First(&approval).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "approval request not found")
return model.AgentApprovalRequest{}, false
}
common.ApiError(c, err)
return model.AgentApprovalRequest{}, false
}
return approval, true
}
func findAgentCredentialLeaseByApproval(approvalID string) *model.AgentCredentialLease {
var lease model.AgentCredentialLease
if err := model.DB.Where("approval_id = ?", approvalID).First(&lease).Error; err != nil {
return nil
}
expireAgentCredentialLeaseIfNeeded(&lease)
return &lease
}
func expireAgentApprovalIfNeeded(approval *model.AgentApprovalRequest) bool {
if approval == nil || approval.Status != agentApprovalStatusPending {
return false
}
if approval.ExpiresAt <= 0 || approval.ExpiresAt > time.Now().UnixMilli() {
return false
}
approval.Status = agentApprovalStatusExpired
approval.DecidedAt = time.Now().UnixMilli()
if err := model.DB.Save(approval).Error; err == nil {
recordAgentApprovalAudit("approval.expired", approval, nil, "ok", "")
}
return true
}
func expireAgentCredentialLeaseIfNeeded(lease *model.AgentCredentialLease) bool {
if lease == nil || lease.Status != agentLeaseStatusActive {
return false
}
if lease.ExpiresAt <= 0 || lease.ExpiresAt > time.Now().UnixMilli() {
return false
}
lease.Status = agentLeaseStatusExpired
if err := model.DB.Save(lease).Error; err != nil {
return false
}
var approval model.AgentApprovalRequest
if err := model.DB.Where("approval_id = ?", lease.ApprovalID).First(&approval).Error; err == nil {
recordAgentApprovalAudit("credential_lease.expired", &approval, lease, "ok", "")
}
return true
}
func agentApprovalToResponse(approval model.AgentApprovalRequest, lease *model.AgentCredentialLease) agentApprovalResponse {
resp := agentApprovalResponse{
ApprovalID: approval.ApprovalID,
UserId: approval.UserId,
DeploymentID: approval.DeploymentID,
BindingScope: approval.BindingScope,
Operation: approval.Operation,
ResourceID: approval.ResourceID,
ResourceType: approval.ResourceType,
ResourceScope: approval.ResourceScope,
TargetRole: approval.TargetRole,
RiskLevel: approval.RiskLevel,
RequiresCredential: approval.RequiresCredential,
CredentialLeaseID: approval.CredentialLeaseID,
Status: approval.Status,
RequestedBy: approval.RequestedBy,
DecidedBy: approval.DecidedBy,
RequestReason: approval.RequestReason,
DecisionReason: approval.DecisionReason,
TTLSeconds: approval.TTLSeconds,
ExpiresAt: approval.ExpiresAt,
DecidedAt: approval.DecidedAt,
CreatedAt: approval.CreatedAt,
UpdatedAt: approval.UpdatedAt,
}
if lease != nil {
leaseResp := agentLeaseToResponse(*lease)
resp.CredentialLease = &leaseResp
}
return resp
}
func agentLeaseToResponse(lease model.AgentCredentialLease) agentCredentialLeaseResponse {
return agentCredentialLeaseResponse{
LeaseID: lease.LeaseID,
CredentialRef: lease.CredentialRef,
ApprovalID: lease.ApprovalID,
UserId: lease.UserId,
DeploymentID: lease.DeploymentID,
BindingScope: lease.BindingScope,
ResourceID: lease.ResourceID,
ResourceType: lease.ResourceType,
ResourceScope: lease.ResourceScope,
TargetRole: lease.TargetRole,
Status: lease.Status,
TTLSeconds: lease.TTLSeconds,
ExpiresAt: lease.ExpiresAt,
RevokedAt: lease.RevokedAt,
CreatedAt: lease.CreatedAt,
UpdatedAt: lease.UpdatedAt,
}
}
func recordAgentApprovalAudit(event string, approval *model.AgentApprovalRequest, lease *model.AgentCredentialLease, result string, message string) {
if approval == nil {
return
}
details := map[string]any{
"approval_id": approval.ApprovalID,
"operation": approval.Operation,
"resource_id": approval.ResourceID,
"resource_type": approval.ResourceType,
"target_role": approval.TargetRole,
"risk_level": approval.RiskLevel,
"requires_credential": approval.RequiresCredential,
}
if lease != nil {
details["lease_id"] = lease.LeaseID
details["credential_ref"] = lease.CredentialRef
details["lease_status"] = lease.Status
details["lease_expires_at"] = lease.ExpiresAt
}
if message != "" {
details["message"] = message
}
detailsJSON := "{}"
if raw, err := common.Marshal(details); err == nil {
detailsJSON = string(raw)
}
model.InsertAgentAuditEvent(&model.AgentAuditEvent{
EventID: "evt_" + common.GetUUID(),
Event: event,
Actor: "manager",
Resource: fmt.Sprintf("%s:%s", approval.ResourceType, approval.ResourceID),
UserID: fmt.Sprintf("%d", approval.UserId),
BindingScope: approval.BindingScope,
DeploymentID: approval.DeploymentID,
CorrelationID: approval.ApprovalID,
Result: result,
OccurredAt: time.Now().UnixMilli(),
DetailsJSON: detailsJSON,
})
}
func agentActorForUser(userID int) string {
return fmt.Sprintf("user:%d", userID)
}