diff --git a/heicode/VERSION b/heicode/VERSION index 428b770e..1c99cf0e 100644 --- a/heicode/VERSION +++ b/heicode/VERSION @@ -1 +1 @@ -1.4.3 +1.4.4 diff --git a/heicode/controller/agnet_approval.go b/heicode/controller/agnet_approval.go new file mode 100644 index 00000000..41cae89a --- /dev/null +++ b/heicode/controller/agnet_approval.go @@ -0,0 +1,558 @@ +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 ( + agnetApprovalStatusPending = "pending" + agnetApprovalStatusApproved = "approved" + agnetApprovalStatusRejected = "rejected" + agnetApprovalStatusExpired = "expired" + + agnetLeaseStatusActive = "active" + agnetLeaseStatusExpired = "expired" + agnetLeaseStatusRevoked = "revoked" + + defaultAgnetApprovalTTLSeconds = 15 * 60 + maxAgnetApprovalTTLSeconds = 60 * 60 +) + +type agnetApprovalPayload 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 agnetDecisionPayload struct { + Reason string `json:"reason"` +} + +type agnetApprovalResponse 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 *agnetCredentialLeaseResponse `json:"credential_lease,omitempty"` +} + +type agnetCredentialLeaseResponse 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 CreateAgnetApprovalRequest(c *gin.Context) { + var payload agnetApprovalPayload + if err := common.DecodeJson(c.Request.Body, &payload); err != nil { + common.ApiError(c, err) + return + } + payload, err := normalizeAgnetApprovalPayload(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.AgnetApprovalRequest{ + 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: agnetApprovalStatusPending, + RequestedBy: agnetActorForUser(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 + } + recordAgnetApprovalAudit("approval.requested", &approval, nil, "ok", "") + common.ApiSuccess(c, agnetApprovalToResponse(approval, nil)) +} + +func ListAgnetApprovalRequests(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.AgnetApprovalRequest + 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 { + expireAgnetApprovalIfNeeded(&approvals[i]) + } + + items := make([]agnetApprovalResponse, 0, len(approvals)) + for _, approval := range approvals { + if statusFilter != "" && approval.Status != statusFilter { + continue + } + items = append(items, agnetApprovalToResponse(approval, nil)) + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +func GetAgnetApprovalRequest(c *gin.Context) { + approval, ok := findAgnetApprovalForUser(c) + if !ok { + return + } + expireAgnetApprovalIfNeeded(&approval) + lease := findAgnetCredentialLeaseByApproval(approval.ApprovalID) + common.ApiSuccess(c, agnetApprovalToResponse(approval, lease)) +} + +func ApproveAgnetApprovalRequest(c *gin.Context) { + approval, ok := findAgnetApprovalForUser(c) + if !ok { + return + } + if expireAgnetApprovalIfNeeded(&approval) { + common.ApiErrorMsg(c, "approval request expired") + return + } + if approval.Status != agnetApprovalStatusPending { + common.ApiErrorMsg(c, "approval request is not pending") + return + } + + var payload agnetDecisionPayload + _ = common.DecodeJson(c.Request.Body, &payload) + now := time.Now().UnixMilli() + approval.Status = agnetApprovalStatusApproved + approval.DecidedBy = agnetActorForUser(c.GetInt("id")) + approval.DecisionReason = strings.TrimSpace(payload.Reason) + approval.DecidedAt = now + + var lease *model.AgnetCredentialLease + 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.AgnetCredentialLease{ + 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: agnetLeaseStatusActive, + TTLSeconds: approval.TTLSeconds, + ExpiresAt: now + int64(approval.TTLSeconds)*1000, + } + createdLease.CredentialRef = "lease://agnet/" + 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 + } + recordAgnetApprovalAudit("approval.approved", &approval, nil, "ok", "") + if lease != nil { + recordAgnetApprovalAudit("credential_lease.created", &approval, lease, "ok", "") + } + common.ApiSuccess(c, agnetApprovalToResponse(approval, lease)) +} + +func RejectAgnetApprovalRequest(c *gin.Context) { + approval, ok := findAgnetApprovalForUser(c) + if !ok { + return + } + if expireAgnetApprovalIfNeeded(&approval) { + common.ApiErrorMsg(c, "approval request expired") + return + } + if approval.Status != agnetApprovalStatusPending { + common.ApiErrorMsg(c, "approval request is not pending") + return + } + + var payload agnetDecisionPayload + _ = common.DecodeJson(c.Request.Body, &payload) + approval.Status = agnetApprovalStatusRejected + approval.DecidedBy = agnetActorForUser(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 + } + recordAgnetApprovalAudit("approval.rejected", &approval, nil, "ok", "") + common.ApiSuccess(c, agnetApprovalToResponse(approval, nil)) +} + +func ListAgnetCredentialLeases(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.AgnetCredentialLease + if err := q.Order("created_at desc, id desc").Limit(200).Find(&leases).Error; err != nil { + common.ApiError(c, err) + return + } + items := make([]agnetCredentialLeaseResponse, 0, len(leases)) + for i := range leases { + expireAgnetCredentialLeaseIfNeeded(&leases[i]) + if statusFilter != "" && leases[i].Status != statusFilter { + continue + } + items = append(items, agnetLeaseToResponse(leases[i])) + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +func RevokeAgnetCredentialLease(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.AgnetCredentialLease + 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 expireAgnetCredentialLeaseIfNeeded(&lease) { + common.ApiSuccess(c, agnetLeaseToResponse(lease)) + return + } + if lease.Status != agnetLeaseStatusActive { + common.ApiErrorMsg(c, "credential lease is not active") + return + } + lease.Status = agnetLeaseStatusRevoked + lease.RevokedAt = time.Now().UnixMilli() + if err := model.DB.Save(&lease).Error; err != nil { + common.ApiError(c, err) + return + } + var approval model.AgnetApprovalRequest + if err := model.DB.Where("approval_id = ?", lease.ApprovalID).First(&approval).Error; err == nil { + recordAgnetApprovalAudit("credential_lease.revoked", &approval, &lease, "ok", "") + } + common.ApiSuccess(c, agnetLeaseToResponse(lease)) +} + +func normalizeAgnetApprovalPayload(p agnetApprovalPayload) (agnetApprovalPayload, 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 = defaultAgnetApprovalTTLSeconds + } + if p.TTLSeconds > maxAgnetApprovalTTLSeconds { + p.TTLSeconds = maxAgnetApprovalTTLSeconds + } + 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 findAgnetApprovalForUser(c *gin.Context) (model.AgnetApprovalRequest, bool) { + userID := c.GetInt("id") + if userID <= 0 { + common.ApiErrorMsg(c, "user authentication required") + return model.AgnetApprovalRequest{}, false + } + approvalID := strings.TrimSpace(c.Param("approval_id")) + if approvalID == "" { + common.ApiErrorMsg(c, "approval_id required") + return model.AgnetApprovalRequest{}, false + } + var approval model.AgnetApprovalRequest + 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.AgnetApprovalRequest{}, false + } + common.ApiError(c, err) + return model.AgnetApprovalRequest{}, false + } + return approval, true +} + +func findAgnetCredentialLeaseByApproval(approvalID string) *model.AgnetCredentialLease { + var lease model.AgnetCredentialLease + if err := model.DB.Where("approval_id = ?", approvalID).First(&lease).Error; err != nil { + return nil + } + expireAgnetCredentialLeaseIfNeeded(&lease) + return &lease +} + +func expireAgnetApprovalIfNeeded(approval *model.AgnetApprovalRequest) bool { + if approval == nil || approval.Status != agnetApprovalStatusPending { + return false + } + if approval.ExpiresAt <= 0 || approval.ExpiresAt > time.Now().UnixMilli() { + return false + } + approval.Status = agnetApprovalStatusExpired + approval.DecidedAt = time.Now().UnixMilli() + if err := model.DB.Save(approval).Error; err == nil { + recordAgnetApprovalAudit("approval.expired", approval, nil, "ok", "") + } + return true +} + +func expireAgnetCredentialLeaseIfNeeded(lease *model.AgnetCredentialLease) bool { + if lease == nil || lease.Status != agnetLeaseStatusActive { + return false + } + if lease.ExpiresAt <= 0 || lease.ExpiresAt > time.Now().UnixMilli() { + return false + } + lease.Status = agnetLeaseStatusExpired + if err := model.DB.Save(lease).Error; err != nil { + return false + } + var approval model.AgnetApprovalRequest + if err := model.DB.Where("approval_id = ?", lease.ApprovalID).First(&approval).Error; err == nil { + recordAgnetApprovalAudit("credential_lease.expired", &approval, lease, "ok", "") + } + return true +} + +func agnetApprovalToResponse(approval model.AgnetApprovalRequest, lease *model.AgnetCredentialLease) agnetApprovalResponse { + resp := agnetApprovalResponse{ + 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 := agnetLeaseToResponse(*lease) + resp.CredentialLease = &leaseResp + } + return resp +} + +func agnetLeaseToResponse(lease model.AgnetCredentialLease) agnetCredentialLeaseResponse { + return agnetCredentialLeaseResponse{ + 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 recordAgnetApprovalAudit(event string, approval *model.AgnetApprovalRequest, lease *model.AgnetCredentialLease, 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.InsertAgnetAuditEvent(&model.AgnetAuditEvent{ + 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 agnetActorForUser(userID int) string { + return fmt.Sprintf("user:%d", userID) +} diff --git a/heicode/controller/agnet_approval_test.go b/heicode/controller/agnet_approval_test.go new file mode 100644 index 00000000..1587eab0 --- /dev/null +++ b/heicode/controller/agnet_approval_test.go @@ -0,0 +1,218 @@ +package controller + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/heicode/manager/common" + "github.com/heicode/manager/model" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupAgnetApprovalTestDB(t *testing.T) *gorm.DB { + t.Helper() + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate( + &model.AgnetApprovalRequest{}, + &model.AgnetCredentialLease{}, + &model.AgnetAuditEvent{}, + )) + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func performAgnetApprovalRequest(handler gin.HandlerFunc, userID int, method string, routePath string, requestPath string, body string) *httptest.ResponseRecorder { + r := gin.New() + r.Handle(method, routePath, func(c *gin.Context) { + c.Set("id", userID) + handler(c) + }) + req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func decodeAgnetApprovalEnvelope(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var envelope map[string]any + require.NoError(t, common.Unmarshal(w.Body.Bytes(), &envelope)) + return envelope +} + +func createAgnetApprovalForTest(t *testing.T, userID int, body string) (map[string]any, string) { + t.Helper() + w := performAgnetApprovalRequest(CreateAgnetApprovalRequest, userID, http.MethodPost, "/approvals", "/approvals", body) + require.Equal(t, http.StatusOK, w.Code) + envelope := decodeAgnetApprovalEnvelope(t, w) + require.Equal(t, true, envelope["success"]) + data := envelope["data"].(map[string]any) + approvalID := data["approval_id"].(string) + require.NotEmpty(t, approvalID) + return data, approvalID +} + +func TestAgnetApprovalApproveCreatesShortLivedLeaseAndAudit(t *testing.T) { + db := setupAgnetApprovalTestDB(t) + body := `{ + "deployment_id":"dep_approval_1", + "binding_scope":"project-alpha", + "operation":"write_repository", + "resource_id":"repo-main", + "resource_type":"git", + "resource_scope":"https://example.invalid/acme/repo#main", + "target_role":"backend", + "risk_level":"high", + "requires_credential":true, + "secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main", + "ttl_seconds":600, + "reason":"需要写入功能分支" + }` + + _, approvalID := createAgnetApprovalForTest(t, 7, body) + w := performAgnetApprovalRequest(ApproveAgnetApprovalRequest, 7, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{"reason":"允许本次任务"}`) + require.Equal(t, http.StatusOK, w.Code) + envelope := decodeAgnetApprovalEnvelope(t, w) + require.Equal(t, true, envelope["success"]) + data := envelope["data"].(map[string]any) + require.Equal(t, "approved", data["status"]) + lease := data["credential_lease"].(map[string]any) + require.Equal(t, "active", lease["status"]) + require.Contains(t, lease["credential_ref"], "lease://agnet/") + require.NotContains(t, w.Body.String(), "do-not-store") + require.NotContains(t, w.Body.String(), "azkv://", "API responses must not expose internal Key Vault secret_ref") + + var storedLease model.AgnetCredentialLease + require.NoError(t, db.Where("approval_id = ?", approvalID).First(&storedLease).Error) + require.Equal(t, "azkv://heicode-kv.vault.azure.net/secrets/repo-main", storedLease.SecretRef) + require.Equal(t, "active", storedLease.Status) + require.Greater(t, storedLease.ExpiresAt, storedLease.CreatedAt) + require.NotContains(t, storedLease.CredentialRef, "azkv://") + + var auditRows []model.AgnetAuditEvent + require.NoError(t, db.Order("id asc").Find(&auditRows).Error) + require.Len(t, auditRows, 3) + require.Equal(t, "approval.requested", auditRows[0].Event) + require.Equal(t, "approval.approved", auditRows[1].Event) + require.Equal(t, "credential_lease.created", auditRows[2].Event) + require.NotContains(t, auditRows[2].DetailsJSON, "repo-main-secret-value") +} + +func TestAgnetApprovalRejectDoesNotCreateLease(t *testing.T) { + db := setupAgnetApprovalTestDB(t) + body := `{ + "deployment_id":"dep_approval_2", + "binding_scope":"project-alpha", + "operation":"delete_resource", + "resource_id":"vm-prod", + "resource_type":"cloud_resource", + "resource_scope":"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/prod", + "target_role":"ops", + "risk_level":"critical", + "requires_credential":true, + "secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/azure-sp", + "ttl_seconds":300 + }` + + _, approvalID := createAgnetApprovalForTest(t, 8, body) + w := performAgnetApprovalRequest(RejectAgnetApprovalRequest, 8, http.MethodPost, "/approvals/:approval_id/reject", "/approvals/"+approvalID+"/reject", `{"reason":"风险过高"}`) + require.Equal(t, http.StatusOK, w.Code) + envelope := decodeAgnetApprovalEnvelope(t, w) + require.Equal(t, true, envelope["success"]) + data := envelope["data"].(map[string]any) + require.Equal(t, "rejected", data["status"]) + + var leases int64 + require.NoError(t, db.Model(&model.AgnetCredentialLease{}).Count(&leases).Error) + require.Equal(t, int64(0), leases) +} + +func TestAgnetApprovalExpiredRequestCannotBeApproved(t *testing.T) { + db := setupAgnetApprovalTestDB(t) + body := `{ + "deployment_id":"dep_approval_3", + "binding_scope":"project-alpha", + "operation":"write_repository", + "resource_id":"repo-main", + "resource_type":"git", + "resource_scope":"https://example.invalid/acme/repo#main", + "target_role":"backend", + "risk_level":"high", + "requires_credential":true, + "secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main", + "ttl_seconds":1 + }` + + _, approvalID := createAgnetApprovalForTest(t, 9, body) + require.NoError(t, db.Model(&model.AgnetApprovalRequest{}).Where("approval_id = ?", approvalID).Updates(map[string]any{ + "expires_at": 1, + }).Error) + + w := performAgnetApprovalRequest(ApproveAgnetApprovalRequest, 9, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{}`) + require.Equal(t, http.StatusOK, w.Code) + envelope := decodeAgnetApprovalEnvelope(t, w) + require.Equal(t, false, envelope["success"]) + require.Contains(t, envelope["message"], "approval request expired") + + var approval model.AgnetApprovalRequest + require.NoError(t, db.Where("approval_id = ?", approvalID).First(&approval).Error) + require.Equal(t, "expired", approval.Status) +} + +func TestAgnetCredentialLeaseCanBeRevoked(t *testing.T) { + db := setupAgnetApprovalTestDB(t) + body := `{ + "deployment_id":"dep_approval_4", + "binding_scope":"project-alpha", + "operation":"write_repository", + "resource_id":"repo-main", + "resource_type":"git", + "resource_scope":"https://example.invalid/acme/repo#main", + "target_role":"backend", + "risk_level":"high", + "requires_credential":true, + "secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main", + "ttl_seconds":600 + }` + + _, approvalID := createAgnetApprovalForTest(t, 10, body) + w := performAgnetApprovalRequest(ApproveAgnetApprovalRequest, 10, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{}`) + require.Equal(t, http.StatusOK, w.Code) + var storedLease model.AgnetCredentialLease + require.NoError(t, db.Where("approval_id = ?", approvalID).First(&storedLease).Error) + + revokePath := "/credential-leases/" + storedLease.LeaseID + "/revoke" + w = performAgnetApprovalRequest(RevokeAgnetCredentialLease, 10, http.MethodPost, "/credential-leases/:lease_id/revoke", revokePath, `{"reason":"任务结束"}`) + require.Equal(t, http.StatusOK, w.Code) + envelope := decodeAgnetApprovalEnvelope(t, w) + require.Equal(t, true, envelope["success"]) + data := envelope["data"].(map[string]any) + require.Equal(t, "revoked", data["status"]) + + require.NoError(t, db.Where("lease_id = ?", storedLease.LeaseID).First(&storedLease).Error) + require.Equal(t, "revoked", storedLease.Status) + require.Greater(t, storedLease.RevokedAt, int64(0)) +} diff --git a/heicode/controller/agnet_control_plane.go b/heicode/controller/agnet_control_plane.go index 6cf746d2..f59c271b 100644 --- a/heicode/controller/agnet_control_plane.go +++ b/heicode/controller/agnet_control_plane.go @@ -272,6 +272,190 @@ func agnetRequestID(c *gin.Context) string { return common.GetUUID() } +func agnetTimestampMs(s string) int64 { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UnixMilli() + } + return time.Now().UnixMilli() +} + +func marshalAgnetSnapshot(v any) (string, error) { + data, err := common.Marshal(v) + if err != nil { + return "", err + } + return string(data), nil +} + +func agnetDeploymentModelToRecord(row model.AgnetDeployment) (agnetDeploymentRecord, error) { + var record agnetDeploymentRecord + record.DeploymentID = row.DeploymentID + record.Status = row.Status + record.Phase = row.Phase + record.RuntimeState = row.RuntimeState + record.FailureReason = row.FailureReason + record.CreatedAt = row.CreatedAtText + record.UpdatedAt = row.UpdatedAtText + + if row.PlanJSON != "" { + if err := common.UnmarshalJsonStr(row.PlanJSON, &record.Plan); err != nil { + return record, err + } + } + if row.AgentInstancesJSON != "" { + if err := common.UnmarshalJsonStr(row.AgentInstancesJSON, &record.AgentInstances); err != nil { + return record, err + } + } + if row.PermissionManifestJSON != "" { + if err := common.UnmarshalJsonStr(row.PermissionManifestJSON, &record.ResourceGrantManifest); err != nil { + return record, err + } + } + if record.AgentInstances == nil { + record.AgentInstances = []agnetAgentInstance{} + } + if record.ResourceGrantManifest.ResourceGrants == nil { + record.ResourceGrantManifest.ResourceGrants = []agnetManifestGrant{} + } + return record, nil +} + +func persistAgnetDeploymentRecord(record agnetDeploymentRecord, req agnetDeploymentRequest) error { + if model.DB == nil { + return nil + } + planJSON, err := marshalAgnetSnapshot(record.Plan) + if err != nil { + return err + } + instancesJSON, err := marshalAgnetSnapshot(record.AgentInstances) + if err != nil { + return err + } + manifestJSON, err := marshalAgnetSnapshot(record.ResourceGrantManifest) + if err != nil { + return err + } + payloadJSON, err := marshalAgnetSnapshot(req) + if err != nil { + return err + } + + row := model.AgnetDeployment{ + DeploymentID: record.DeploymentID, + UserID: record.Plan.UserContext.UserID, + ChannelID: record.Plan.UserContext.ChannelID, + BindingScope: firstPlanBindingScope(record.Plan), + CorrelationID: record.Plan.Metadata.CorrelationID, + Status: record.Status, + Phase: record.Phase, + RuntimeState: record.RuntimeState, + FailureReason: record.FailureReason, + CreatedAtText: record.CreatedAt, + UpdatedAtText: record.UpdatedAt, + CreatedAtMs: agnetTimestampMs(record.CreatedAt), + UpdatedAtMs: agnetTimestampMs(record.UpdatedAt), + PlanJSON: planJSON, + AgentInstancesJSON: instancesJSON, + PermissionManifestJSON: manifestJSON, + PayloadJSON: payloadJSON, + } + return model.DB.Create(&row).Error +} + +func updateAgnetDeploymentRecord(record agnetDeploymentRecord) error { + if model.DB == nil { + return nil + } + instancesJSON, err := marshalAgnetSnapshot(record.AgentInstances) + if err != nil { + return err + } + manifestJSON, err := marshalAgnetSnapshot(record.ResourceGrantManifest) + if err != nil { + return err + } + return model.DB.Model(&model.AgnetDeployment{}). + Where("deployment_id = ?", record.DeploymentID). + Updates(map[string]any{ + "status": record.Status, + "phase": record.Phase, + "runtime_state": record.RuntimeState, + "failure_reason": record.FailureReason, + "updated_at_text": record.UpdatedAt, + "updated_at_ms": agnetTimestampMs(record.UpdatedAt), + "agent_instances_json": instancesJSON, + "permission_manifest_json": manifestJSON, + }).Error +} + +func findAgnetDeploymentRecord(deploymentID string) (agnetDeploymentRecord, bool) { + agnetMu.RLock() + record, ok := agnetDeployments[deploymentID] + agnetMu.RUnlock() + if ok { + return record, true + } + if model.DB == nil { + return agnetDeploymentRecord{}, false + } + + var row model.AgnetDeployment + if err := model.DB.Where("deployment_id = ?", deploymentID).First(&row).Error; err != nil { + return agnetDeploymentRecord{}, false + } + record, err := agnetDeploymentModelToRecord(row) + if err != nil { + common.SysLog("findAgnetDeploymentRecord: " + err.Error()) + return agnetDeploymentRecord{}, false + } + agnetMu.Lock() + agnetDeployments[deploymentID] = record + agnetMu.Unlock() + return record, true +} + +func listAgnetDeploymentRecords(userID string, bindingScope string) []agnetDeploymentRecord { + items := make([]agnetDeploymentRecord, 0) + if model.DB != nil { + q := model.DB.Model(&model.AgnetDeployment{}) + if userID != "" { + q = q.Where("user_id = ?", userID) + } + var rows []model.AgnetDeployment + if err := q.Order("created_at_ms desc, id desc").Limit(500).Find(&rows).Error; err == nil { + for _, row := range rows { + record, err := agnetDeploymentModelToRecord(row) + if err != nil { + common.SysLog("listAgnetDeploymentRecords: " + err.Error()) + continue + } + if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) { + continue + } + items = append(items, record) + } + return items + } else { + common.SysLog("listAgnetDeploymentRecords: " + err.Error()) + } + } + + agnetMu.RLock() + for _, record := range agnetDeployments { + if userID != "" && record.Plan.UserContext.UserID != userID { + continue + } + if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) { + continue + } + items = append(items, record) + } + agnetMu.RUnlock() + return items +} + func agnetError(c *gin.Context, code string, message string) { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -735,6 +919,11 @@ func AgnetCreateDeployment(c *gin.Context) { OccurredAt: now, } + if err := persistAgnetDeploymentRecord(record, agnetDeploymentRequest{Plan: plan}); err != nil { + common.SysLog("AgnetCreateDeployment persist: " + err.Error()) + agnetError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agnet deployment") + return + } agnetMu.Lock() agnetDeployments[deploymentID] = record agnetMu.Unlock() @@ -757,9 +946,7 @@ func AgnetGetDeployment(c *gin.Context) { agnetError(c, "POLICY_REJECTED", "deployment_id is required") return } - agnetMu.RLock() - record, ok := agnetDeployments[deploymentID] - agnetMu.RUnlock() + record, ok := findAgnetDeploymentRecord(deploymentID) if !ok { agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found") return @@ -770,19 +957,7 @@ func AgnetGetDeployment(c *gin.Context) { func AgnetListDeployments(c *gin.Context) { userID := strings.TrimSpace(c.Query("user_id")) bindingScope := strings.TrimSpace(c.Query("binding_scope")) - items := make([]agnetDeploymentRecord, 0) - - agnetMu.RLock() - for _, record := range agnetDeployments { - if userID != "" && record.Plan.UserContext.UserID != userID { - continue - } - if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) { - continue - } - items = append(items, record) - } - agnetMu.RUnlock() + items := listAgnetDeploymentRecords(userID, bindingScope) common.ApiSuccess(c, gin.H{ "items": items, @@ -797,10 +972,8 @@ func AgnetStopDeployment(c *gin.Context) { return } - agnetMu.Lock() - record, ok := agnetDeployments[deploymentID] + record, ok := findAgnetDeploymentRecord(deploymentID) if !ok { - agnetMu.Unlock() agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found") return } @@ -814,7 +987,14 @@ func AgnetStopDeployment(c *gin.Context) { record.AgentInstances[i].FailureReason = "" } record.UpdatedAt = agnetNow() + agnetMu.Lock() agnetDeployments[deploymentID] = record + agnetMu.Unlock() + if err := updateAgnetDeploymentRecord(record); err != nil { + common.SysLog("AgnetStopDeployment persist: " + err.Error()) + agnetError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agnet deployment") + return + } stopEvent := agnetEvent{ EventID: "evt_" + common.GetUUID()[:12], Event: "instance.phase_changed", @@ -826,7 +1006,6 @@ func AgnetStopDeployment(c *gin.Context) { CorrelationID: record.Plan.Metadata.CorrelationID, OccurredAt: agnetNow(), } - agnetMu.Unlock() recordAgnetAuditEvent(stopEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok") common.ApiSuccess(c, gin.H{ @@ -867,9 +1046,7 @@ func AgnetListDeploymentLogs(c *gin.Context) { return } - agnetMu.RLock() - record, ok := agnetDeployments[deploymentID] - agnetMu.RUnlock() + record, ok := findAgnetDeploymentRecord(deploymentID) if !ok { agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found") return @@ -927,9 +1104,7 @@ func AgnetGetDeploymentMetrics(c *gin.Context) { return } - agnetMu.RLock() - record, ok := agnetDeployments[deploymentID] - agnetMu.RUnlock() + record, ok := findAgnetDeploymentRecord(deploymentID) if !ok { agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found") return @@ -965,11 +1140,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) { active := 0 pending := 0 stopped := 0 - agnetMu.RLock() - for _, record := range agnetDeployments { - if !planHasBindingScope(record.Plan, bindingScope) { - continue - } + for _, record := range listAgnetDeploymentRecords("", bindingScope) { if record.Status == "accepted" { active++ } @@ -980,7 +1151,6 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) { stopped++ } } - agnetMu.RUnlock() common.ApiSuccess(c, gin.H{ "binding_scope": bindingScope, @@ -1003,10 +1173,8 @@ func AgnetResolveSKSnapshots(c *gin.Context) { return } - agnetMu.Lock() - record, ok := agnetDeployments[deploymentID] + record, ok := findAgnetDeploymentRecord(deploymentID) if !ok { - agnetMu.Unlock() agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found") return } @@ -1037,7 +1205,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) { }) } } + agnetMu.Lock() agnetSnapshots[deploymentID] = snapshots + agnetMu.Unlock() snapEvent := agnetEvent{ EventID: "evt_" + common.GetUUID()[:12], Event: "sk_snapshot_refreshed", @@ -1049,7 +1219,6 @@ func AgnetResolveSKSnapshots(c *gin.Context) { CorrelationID: record.Plan.Metadata.CorrelationID, OccurredAt: now, } - agnetMu.Unlock() recordAgnetAuditEvent(snapEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok") common.ApiSuccess(c, gin.H{ diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index 8e2f3ff9..bd58cdfd 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -1,14 +1,18 @@ package controller import ( + "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/heicode/manager/common" + "github.com/heicode/manager/model" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) type agnetCreateTestEnvelope struct { @@ -34,6 +38,40 @@ func resetAgnetControlPlaneState(t *testing.T) { // to flush here. } +func setupAgnetControlPlaneTestDB(t *testing.T) *gorm.DB { + t.Helper() + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate( + &model.ResourceBinding{}, + &model.ResourceGrant{}, + &model.AgnetAuditEvent{}, + &model.AgnetDeployment{}, + )) + t.Cleanup(func() { + if model.DB == db { + model.DB = nil + } + if model.LOG_DB == db { + model.LOG_DB = nil + } + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + func baseAgnetResourceGrantPlan() agnetOrchestrationPlan { return agnetOrchestrationPlan{ IntentID: "intent-resource-grant", @@ -139,6 +177,226 @@ func postAgnetCreateDeployment(t *testing.T, plan agnetOrchestrationPlan) (*http return recorder, envelope } +func TestAgnetDeploymentSurvivesInProcessStateReset(t *testing.T) { + db := setupAgnetControlPlaneTestDB(t) + resetAgnetControlPlaneState(t) + + recorder, envelope := postAgnetCreateDeployment(t, baseAgnetResourceGrantPlan()) + require.Equal(t, http.StatusOK, recorder.Code) + require.True(t, envelope.Success) + + var createBody map[string]any + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &createBody)) + deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string) + require.NotEmpty(t, deploymentID) + + var stored model.AgnetDeployment + require.NoError(t, db.Where("deployment_id = ?", deploymentID).First(&stored).Error) + require.Equal(t, "user-p1", stored.UserID) + require.Equal(t, "channel-p1", stored.ChannelID) + require.Contains(t, stored.PayloadJSON, `"orchestration_plan"`) + require.Contains(t, stored.PermissionManifestJSON, `"grant-git-builder"`) + require.Contains(t, stored.PermissionManifestJSON, `"secret_ref"`) + require.NotContains(t, strings.ToLower(stored.PayloadJSON), "password") + require.NotContains(t, strings.ToLower(stored.PayloadJSON), "private_key") + require.NotContains(t, strings.ToLower(stored.PermissionManifestJSON), "access_token") + + resetAgnetControlPlaneState(t) + + getRecorder := httptest.NewRecorder() + getCtx, _ := gin.CreateTestContext(getRecorder) + getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}} + getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil) + AgnetGetDeployment(getCtx) + + var getBody map[string]any + require.NoError(t, common.Unmarshal(getRecorder.Body.Bytes(), &getBody)) + require.Equal(t, true, getBody["success"]) + data := getBody["data"].(map[string]any) + require.Equal(t, deploymentID, data["deployment_id"]) + require.Equal(t, "accepted", data["status"]) + + listRecorder := httptest.NewRecorder() + listCtx, _ := gin.CreateTestContext(listRecorder) + listCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments?user_id=user-p1", nil) + AgnetListDeployments(listCtx) + + var listBody map[string]any + require.NoError(t, common.Unmarshal(listRecorder.Body.Bytes(), &listBody)) + require.Equal(t, true, listBody["success"]) + listData := listBody["data"].(map[string]any) + require.Equal(t, float64(1), listData["total"]) +} + +func TestAgnetStopDeploymentPersistsState(t *testing.T) { + setupAgnetControlPlaneTestDB(t) + resetAgnetControlPlaneState(t) + + recorder, envelope := postAgnetCreateDeployment(t, baseAgnetResourceGrantPlan()) + require.Equal(t, http.StatusOK, recorder.Code) + require.True(t, envelope.Success) + var createBody map[string]any + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &createBody)) + deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string) + + stopRecorder := httptest.NewRecorder() + stopCtx, _ := gin.CreateTestContext(stopRecorder) + stopCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}} + stopCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/deployments/"+deploymentID+"/stop", nil) + AgnetStopDeployment(stopCtx) + require.Equal(t, http.StatusOK, stopRecorder.Code) + require.Contains(t, stopRecorder.Body.String(), `"status":"stopped"`) + + resetAgnetControlPlaneState(t) + + getRecorder := httptest.NewRecorder() + getCtx, _ := gin.CreateTestContext(getRecorder) + getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}} + getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil) + AgnetGetDeployment(getCtx) + + require.Equal(t, http.StatusOK, getRecorder.Code) + require.Contains(t, getRecorder.Body.String(), `"status":"stopped"`) + require.Contains(t, getRecorder.Body.String(), `"runtime_state":"stopped"`) +} + +func TestManagerOnlyAgnetSmokeFlow(t *testing.T) { + setupAgnetControlPlaneTestDB(t) + resetAgnetControlPlaneState(t) + + resourceBody := `{ + "binding_scope":"project-smoke", + "name":"Smoke project repo", + "resource_type":"git", + "provider":"gitee", + "external_id":"https://example.invalid/acme/smoke.git", + "secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/project-smoke", + "metadata":{"repo_url":"https://example.invalid/acme/smoke.git","ref":"main"}, + "permission_scope":{"actions":["repo:read"]}, + "constraints":{"allowed_paths":"src/**,docs/**"} + }` + resourceRecorder := performResourceRequest(CreateResource, 7, http.MethodPost, "/", resourceBody) + require.Equal(t, http.StatusOK, resourceRecorder.Code) + require.Contains(t, resourceRecorder.Body.String(), `"success":true`) + require.NotContains(t, strings.ToLower(resourceRecorder.Body.String()), "password") + var resourceEnv map[string]any + require.NoError(t, common.Unmarshal(resourceRecorder.Body.Bytes(), &resourceEnv)) + resource := resourceEnv["data"].(map[string]any) + resourceID := int(resource["id"].(float64)) + + grantBody := fmt.Sprintf(`{ + "binding_scope":"project-smoke", + "resource_id":%d, + "role":"builder", + "agnet_id":"agent-builder-1", + "permission_scope":{"actions":["repo:read","repo:write:feature-branches"]}, + "constraints":{"allowed_paths":"src/**,docs/**","ref":"main"} + }`, resourceID) + grantRecorder := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", grantBody) + require.Equal(t, http.StatusOK, grantRecorder.Code) + require.Contains(t, grantRecorder.Body.String(), `"success":true`) + var grantEnv map[string]any + require.NoError(t, common.Unmarshal(grantRecorder.Body.Bytes(), &grantEnv)) + grant := grantEnv["data"].(map[string]any) + grantID := int(grant["id"].(float64)) + + manifestRecorder := performResourceRequestWithRoute( + GenerateResourceGrantManifest, + 7, + http.MethodGet, + "/manifest", + "/manifest?binding_scope=project-smoke&role=builder&agnet_id=agent-builder-1", + "", + ) + require.Equal(t, http.StatusOK, manifestRecorder.Code) + require.Contains(t, manifestRecorder.Body.String(), `"resource_grants":[`) + require.Contains(t, manifestRecorder.Body.String(), `"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/project-smoke"`) + + plan := baseAgnetResourceGrantPlan() + plan.UserContext.UserID = "7" + plan.UserContext.ChannelID = "smoke-channel" + plan.BillingContext.NewAPIUserRef = "newapi-user-smoke" + plan.BillingContext.NewAPIGroup = "vip" + plan.BillingContext.QuotaRef = "quota-smoke" + plan.Metadata.CorrelationID = "corr-manager-smoke" + plan.Agents[0].ResourceGrants = []agnetResourceGrant{ + { + GrantID: fmt.Sprintf("grant-%d", grantID), + ResourceID: fmt.Sprintf("%d", resourceID), + ResourceType: agnetResourceGit, + UserID: "7", + BindingScope: "project-smoke", + TargetRole: "builder", + TargetAgentRef: "agent-builder-1", + PermissionScope: []string{"repo:read", "repo:write:feature-branches"}, + Constraints: map[string]string{"allowed_paths": "src/**,docs/**", "ref": "main"}, + Metadata: map[string]string{ + "provider": "gitee", + "resource_ref": "https://example.invalid/acme/smoke.git", + }, + Status: agnetGrantStatusActive, + SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/project-smoke", + Audit: map[string]string{"confirmed_by": "7"}, + }, + } + + deployRecorder, envelope := postAgnetCreateDeployment(t, plan) + require.Equal(t, http.StatusOK, deployRecorder.Code) + require.True(t, envelope.Success) + var deployEnv map[string]any + require.NoError(t, common.Unmarshal(deployRecorder.Body.Bytes(), &deployEnv)) + deploymentID := deployEnv["data"].(map[string]any)["deployment_id"].(string) + require.NotEmpty(t, deploymentID) + + logRecorder := httptest.NewRecorder() + logCtx, _ := gin.CreateTestContext(logRecorder) + logCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}} + logCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID+"/logs", nil) + AgnetListDeploymentLogs(logCtx) + require.Equal(t, http.StatusOK, logRecorder.Code) + require.Contains(t, logRecorder.Body.String(), `"redacted":true`) + require.NotContains(t, strings.ToLower(logRecorder.Body.String()), "access_token") + + stopRecorder := httptest.NewRecorder() + stopCtx, _ := gin.CreateTestContext(stopRecorder) + stopCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}} + stopCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/deployments/"+deploymentID+"/stop", nil) + AgnetStopDeployment(stopCtx) + require.Equal(t, http.StatusOK, stopRecorder.Code) + require.Contains(t, stopRecorder.Body.String(), `"status":"stopped"`) + + resetAgnetControlPlaneState(t) + getRecorder := httptest.NewRecorder() + getCtx, _ := gin.CreateTestContext(getRecorder) + getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}} + getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil) + AgnetGetDeployment(getCtx) + require.Equal(t, http.StatusOK, getRecorder.Code) + require.Contains(t, getRecorder.Body.String(), `"status":"stopped"`) + + revokeGrantRecorder := performResourceRequestWithRoute( + DeleteResourceGrant, + 7, + http.MethodDelete, + "/:id", + fmt.Sprintf("/%d", grantID), + "", + ) + require.Equal(t, http.StatusOK, revokeGrantRecorder.Code) + require.Contains(t, revokeGrantRecorder.Body.String(), `"status":"revoked"`) + + emptyManifestRecorder := performResourceRequestWithRoute( + GenerateResourceGrantManifest, + 7, + http.MethodGet, + "/manifest", + "/manifest?binding_scope=project-smoke&role=builder&agnet_id=agent-builder-1", + "", + ) + require.Equal(t, http.StatusOK, emptyManifestRecorder.Code) + require.Contains(t, emptyManifestRecorder.Body.String(), `"resource_grants":[]`) +} + func TestAgnetCreateDeploymentAcceptsP1ResourceGrantModel(t *testing.T) { resetAgnetControlPlaneState(t) diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index cd4c731c..87100350 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -399,9 +399,16 @@ func ListResources(c *gin.Context) { if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" { query = query.Where("binding_scope = ?", bindingScope) } - if resourceType := strings.TrimSpace(c.Query("resource_type")); resourceType != "" { + resourceType := strings.TrimSpace(c.Query("resource_type")) + if resourceType == "" { + resourceType = strings.TrimSpace(c.Query("type")) + } + if resourceType != "" { query = query.Where("resource_type = ?", strings.ToLower(resourceType)) } + if status := strings.TrimSpace(c.Query("status")); status != "" { + query = query.Where("status = ?", strings.ToLower(status)) + } var resources []model.ResourceBinding if err := query.Order("id desc").Find(&resources).Error; err != nil { common.ApiError(c, err) @@ -500,16 +507,33 @@ func UpdateResource(c *gin.Context) { func DeleteResource(c *gin.Context) { userId := c.GetInt("id") - res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceBinding{}) - if res.Error != nil { - common.ApiError(c, res.Error) + var resource model.ResourceBinding + if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "resource not found") + return + } + common.ApiError(c, err) return } - if res.RowsAffected == 0 { - common.ApiErrorMsg(c, "resource not found") + now := common.GetTimestamp() + err := model.DB.Transaction(func(tx *gorm.DB) error { + resource.Status = "revoked" + if err := tx.Save(&resource).Error; err != nil { + return err + } + return tx.Model(&model.ResourceGrant{}). + Where("user_id = ? AND resource_id = ? AND status <> ?", userId, resource.Id, "revoked"). + Updates(map[string]any{ + "status": "revoked", + "revoked_at": now, + }).Error + }) + if err != nil { + common.ApiError(c, err) return } - common.ApiSuccess(c, gin.H{"deleted": true}) + common.ApiSuccess(c, resourceToResponse(resource)) } func UpsertResourceSecret(c *gin.Context) { @@ -647,6 +671,12 @@ func ListResourceGrants(c *gin.Context) { if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" { query = query.Where("agnet_id = ?", agnetId) } + if resourceId := strings.TrimSpace(c.Query("resource_id")); resourceId != "" { + query = query.Where("resource_id = ?", resourceId) + } + if status := strings.TrimSpace(c.Query("status")); status != "" { + query = query.Where("status = ?", strings.ToLower(status)) + } var grants []model.ResourceGrant if err := query.Order("id desc").Find(&grants).Error; err != nil { common.ApiError(c, err) @@ -802,16 +832,29 @@ func UpdateResourceGrant(c *gin.Context) { func DeleteResourceGrant(c *gin.Context) { userId := c.GetInt("id") - res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceGrant{}) - if res.Error != nil { - common.ApiError(c, res.Error) + var grant model.ResourceGrant + if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&grant).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "resource grant not found") + return + } + common.ApiError(c, err) return } - if res.RowsAffected == 0 { - common.ApiErrorMsg(c, "resource grant not found") + grant.Status = "revoked" + if grant.RevokedAt == 0 { + grant.RevokedAt = common.GetTimestamp() + } + if err := model.DB.Save(&grant).Error; err != nil { + common.ApiError(c, err) return } - common.ApiSuccess(c, gin.H{"deleted": true}) + var resource model.ResourceBinding + resourcePtr := (*model.ResourceBinding)(nil) + if err := model.DB.Where("id = ? AND user_id = ?", grant.ResourceId, userId).First(&resource).Error; err == nil { + resourcePtr = &resource + } + common.ApiSuccess(c, resourceGrantToResponse(grant, resourcePtr)) } func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, string, error) { diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index 20afb7ff..597f0877 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -103,6 +103,62 @@ func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) { require.Contains(t, w.Body.String(), "plaintext secrets are not allowed") } +func TestDeleteResourceRevokesBindingAndActiveGrants(t *testing.T) { + db := setupResourceControllerTestDB(t) + resource := model.ResourceBinding{ + UserId: 7, + BindingScope: "project-alpha", + Name: "Project repo", + ResourceType: "git", + Provider: "gitee", + SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/project-alpha", + Status: "active", + } + require.NoError(t, db.Create(&resource).Error) + grant := model.ResourceGrant{ + UserId: 7, + BindingScope: "project-alpha", + ResourceId: resource.Id, + Role: "backend", + AgnetId: "agent-backend-1", + PermissionScope: `{"actions":["repo:read"]}`, + Status: "active", + } + require.NoError(t, db.Create(&grant).Error) + + w := performResourceRequestWithRoute( + DeleteResource, + 7, + http.MethodDelete, + "/:id", + fmt.Sprintf("/%d", resource.Id), + "", + ) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"status":"revoked"`) + + var storedResource model.ResourceBinding + require.NoError(t, db.First(&storedResource, resource.Id).Error) + require.Equal(t, "revoked", storedResource.Status) + + var storedGrant model.ResourceGrant + require.NoError(t, db.First(&storedGrant, grant.Id).Error) + require.Equal(t, "revoked", storedGrant.Status) + require.NotZero(t, storedGrant.RevokedAt) + + listActive := performResourceRequestWithRoute( + ListResources, + 7, + http.MethodGet, + "/", + "/?status=active", + "", + ) + require.Equal(t, http.StatusOK, listActive.Code) + require.Contains(t, listActive.Body.String(), `"items":[]`) +} + func TestCreateResourceGrantAssignsBoundResourceToRoleAgnet(t *testing.T) { db := setupResourceControllerTestDB(t) resource := model.ResourceBinding{ @@ -220,6 +276,59 @@ func TestGenerateResourceGrantManifestIncludesActiveGrantsOnly(t *testing.T) { require.NotContains(t, w.Body.String(), `"allowed_actions":["write"]`) } +func TestDeleteResourceGrantRevokesInsteadOfDeleting(t *testing.T) { + db := setupResourceControllerTestDB(t) + resource := model.ResourceBinding{ + UserId: 7, + BindingScope: "project-alpha", + Name: "Project repo", + ResourceType: "git", + Provider: "gitee", + SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/project-alpha", + Status: "active", + } + require.NoError(t, db.Create(&resource).Error) + grant := model.ResourceGrant{ + UserId: 7, + BindingScope: "project-alpha", + ResourceId: resource.Id, + Role: "backend", + AgnetId: "agent-backend-1", + PermissionScope: `{"actions":["repo:read","repo:write"]}`, + Constraints: `{"paths":["src/**"]}`, + Status: "active", + } + require.NoError(t, db.Create(&grant).Error) + + w := performResourceRequestWithRoute( + DeleteResourceGrant, + 7, + http.MethodDelete, + "/:id", + fmt.Sprintf("/%d", grant.Id), + "", + ) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"status":"revoked"`) + + var stored model.ResourceGrant + require.NoError(t, db.First(&stored, grant.Id).Error) + require.Equal(t, "revoked", stored.Status) + require.NotZero(t, stored.RevokedAt) + + manifestRecorder := performResourceRequestWithRoute( + GenerateResourceGrantManifest, + 7, + http.MethodGet, + "/manifest", + "/manifest?binding_scope=project-alpha", + "", + ) + require.Equal(t, http.StatusOK, manifestRecorder.Code) + require.Contains(t, manifestRecorder.Body.String(), `"resource_grants":[]`) +} + func TestUpsertResourceSecretWritesAzureKeyVaultAndStoresOnlySecretRef(t *testing.T) { db := setupResourceControllerTestDB(t) resource := model.ResourceBinding{ diff --git a/heicode/model/agnet_approval.go b/heicode/model/agnet_approval.go new file mode 100644 index 00000000..0fed09f9 --- /dev/null +++ b/heicode/model/agnet_approval.go @@ -0,0 +1,64 @@ +package model + +// AgnetApprovalRequest records a user-visible approval gate for a +// high-risk Agnet operation. It intentionally stores only a Secret +// Store reference for credential-backed operations; plaintext secrets +// must never be written to this table. +type AgnetApprovalRequest struct { + Id int `json:"id" gorm:"primaryKey"` + ApprovalID string `json:"approval_id" gorm:"type:varchar(64);uniqueIndex;not null"` + UserId int `json:"user_id" gorm:"index;not null"` + DeploymentID string `json:"deployment_id" gorm:"type:varchar(64);index"` + BindingScope string `json:"binding_scope" gorm:"type:varchar(512);index"` + Operation string `json:"operation" gorm:"type:varchar(128);index;not null"` + ResourceID string `json:"resource_id" gorm:"type:varchar(128);index"` + ResourceType string `json:"resource_type" gorm:"type:varchar(32);index"` + ResourceScope string `json:"resource_scope" gorm:"type:varchar(512)"` + TargetRole string `json:"target_role" gorm:"type:varchar(128);index"` + RiskLevel string `json:"risk_level" gorm:"type:varchar(32);index;not null"` + RequiresCredential bool `json:"requires_credential" gorm:"default:false"` + SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"` + CredentialLeaseID string `json:"credential_lease_id" gorm:"type:varchar(64);index"` + Status string `json:"status" gorm:"type:varchar(32);index;not null"` + RequestedBy string `json:"requested_by" gorm:"type:varchar(64)"` + DecidedBy string `json:"decided_by" gorm:"type:varchar(64)"` + RequestReason string `json:"request_reason" gorm:"type:text"` + DecisionReason string `json:"decision_reason" gorm:"type:text"` + TTLSeconds int `json:"ttl_seconds" gorm:"default:0"` + ExpiresAt int64 `json:"expires_at" gorm:"bigint;index"` + DecidedAt int64 `json:"decided_at" gorm:"bigint;default:0"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` +} + +func (AgnetApprovalRequest) TableName() string { + return "agnet_approval_requests" +} + +// AgnetCredentialLease is the Manager-side short-lived credential +// handle produced after an approval succeeds. CredentialRef is the +// external handle; SecretRef is internal and points at Azure Key Vault. +type AgnetCredentialLease struct { + Id int `json:"id" gorm:"primaryKey"` + LeaseID string `json:"lease_id" gorm:"type:varchar(64);uniqueIndex;not null"` + CredentialRef string `json:"credential_ref" gorm:"type:varchar(128);uniqueIndex;not null"` + ApprovalID string `json:"approval_id" gorm:"type:varchar(64);index;not null"` + UserId int `json:"user_id" gorm:"index;not null"` + DeploymentID string `json:"deployment_id" gorm:"type:varchar(64);index"` + BindingScope string `json:"binding_scope" gorm:"type:varchar(512);index"` + ResourceID string `json:"resource_id" gorm:"type:varchar(128);index"` + ResourceType string `json:"resource_type" gorm:"type:varchar(32);index"` + ResourceScope string `json:"resource_scope" gorm:"type:varchar(512)"` + TargetRole string `json:"target_role" gorm:"type:varchar(128);index"` + SecretRef string `json:"secret_ref" gorm:"type:varchar(512);not null"` + Status string `json:"status" gorm:"type:varchar(32);index;not null"` + TTLSeconds int `json:"ttl_seconds" gorm:"default:0"` + ExpiresAt int64 `json:"expires_at" gorm:"bigint;index"` + RevokedAt int64 `json:"revoked_at" gorm:"bigint;default:0"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` +} + +func (AgnetCredentialLease) TableName() string { + return "agnet_credential_leases" +} diff --git a/heicode/model/agnet_deployment.go b/heicode/model/agnet_deployment.go new file mode 100644 index 00000000..673cf19c --- /dev/null +++ b/heicode/model/agnet_deployment.go @@ -0,0 +1,30 @@ +package model + +// AgnetDeployment stores the Manager-side deployment placeholder. +// It is intentionally a control-plane snapshot: the real Agnet platform +// execution state can attach later, but Manager must not lose the accepted +// plan, manifest, status, or audit context across container restarts. +type AgnetDeployment struct { + Id int `gorm:"primaryKey" json:"id"` + DeploymentID string `gorm:"type:varchar(64);uniqueIndex" json:"deployment_id"` + UserID string `gorm:"type:varchar(64);index" json:"user_id"` + ChannelID string `gorm:"type:varchar(64)" json:"channel_id"` + BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"` + CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"` + Status string `gorm:"type:varchar(32);index" json:"status"` + Phase string `gorm:"type:varchar(32);index" json:"phase"` + RuntimeState string `gorm:"type:varchar(32)" json:"runtime_state"` + FailureReason string `gorm:"type:varchar(512)" json:"failure_reason"` + CreatedAtText string `gorm:"type:varchar(32)" json:"created_at"` + UpdatedAtText string `gorm:"type:varchar(32)" json:"updated_at"` + CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"` + UpdatedAtMs int64 `gorm:"bigint;index" json:"updated_at_ms"` + PlanJSON string `gorm:"type:text" json:"plan_json"` + AgentInstancesJSON string `gorm:"type:text" json:"agent_instances_json"` + PermissionManifestJSON string `gorm:"type:text" json:"permission_manifest_json"` + PayloadJSON string `gorm:"type:text" json:"payload_json"` +} + +func (AgnetDeployment) TableName() string { + return "agnet_deployments" +} diff --git a/heicode/model/main.go b/heicode/model/main.go index 5a88f54d..12a31d73 100644 --- a/heicode/model/main.go +++ b/heicode/model/main.go @@ -283,6 +283,9 @@ func migrateDB() error { &GitSource{}, &ResourceBinding{}, &ResourceGrant{}, + &AgnetApprovalRequest{}, + &AgnetCredentialLease{}, + &AgnetDeployment{}, // V2 device-binding: X25519 keypair the Manager uses for ECDH // body decryption. See model/server_key.go. &ServerKey{}, @@ -349,6 +352,10 @@ func migrateDBFast() error { {&GitSource{}, "GitSource"}, {&ResourceBinding{}, "ResourceBinding"}, {&ResourceGrant{}, "ResourceGrant"}, + {&AgnetApprovalRequest{}, "AgnetApprovalRequest"}, + {&AgnetCredentialLease{}, "AgnetCredentialLease"}, + {&AgnetDeployment{}, "AgnetDeployment"}, + {&AgnetAuditEvent{}, "AgnetAuditEvent"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/heicode/relay/channel/ali/adaptor.go b/heicode/relay/channel/ali/adaptor.go index a2cf1051..b69a9c35 100644 --- a/heicode/relay/channel/ali/adaptor.go +++ b/heicode/relay/channel/ali/adaptor.go @@ -88,41 +88,64 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } +func normalizeAliBaseURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return baseURL + } + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + + for _, suffix := range []string{ + "/api/v2/apps/protocols/compatible-mode/v1", + "/compatible-mode/v1", + "/compatible-mode", + "/api/v1", + } { + if strings.HasSuffix(baseURL, suffix) { + return strings.TrimSuffix(baseURL, suffix) + } + } + return baseURL +} + func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { var fullRequestURL string + baseURL := normalizeAliBaseURL(info.ChannelBaseUrl) switch info.RelayFormat { case types.RelayFormatClaude: if supportsAliAnthropicMessages(info.UpstreamModelName) { - fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", baseURL) } else { - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", baseURL) } default: switch info.RelayMode { case constant.RelayModeEmbeddings: - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", baseURL) case constant.RelayModeRerank: - fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", baseURL) case constant.RelayModeResponses: - fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", baseURL) case constant.RelayModeImagesGenerations: if isSyncImageModel(info.OriginModelName) { - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", baseURL) } else { - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", baseURL) } case constant.RelayModeImagesEdits: if isOldWanModel(info.OriginModelName) { - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", baseURL) } else if isWanModel(info.OriginModelName) { - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", baseURL) } else { - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", baseURL) } case constant.RelayModeCompletions: - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", baseURL) default: - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", baseURL) } } diff --git a/heicode/relay/channel/ali/adaptor_test.go b/heicode/relay/channel/ali/adaptor_test.go new file mode 100644 index 00000000..20f59d81 --- /dev/null +++ b/heicode/relay/channel/ali/adaptor_test.go @@ -0,0 +1,58 @@ +package ali + +import ( + "testing" + + relaycommon "github.com/heicode/manager/relay/common" + relayconstant "github.com/heicode/manager/relay/constant" + "github.com/heicode/manager/types" +) + +func TestGetRequestURLNormalizesInternationalCompatibleBaseURL(t *testing.T) { + adaptor := &Adaptor{} + tests := []string{ + "dashscope-intl.aliyuncs.com/compatible-mode/v1", + "https://dashscope-intl.aliyuncs.com/compatible-mode", + } + + for _, baseURL := range tests { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: baseURL, + }, + RelayFormat: types.RelayFormatOpenAI, + RelayMode: relayconstant.RelayModeChatCompletions, + } + + got, err := adaptor.GetRequestURL(info) + if err != nil { + t.Fatalf("GetRequestURL returned error: %v", err) + } + + want := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions" + if got != want { + t.Fatalf("GetRequestURL(%q) = %q, want %q", baseURL, got, want) + } + } +} + +func TestGetRequestURLKeepsInternationalRootBaseURL(t *testing.T) { + adaptor := &Adaptor{} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: "https://dashscope-intl.aliyuncs.com", + }, + RelayFormat: types.RelayFormatOpenAI, + RelayMode: relayconstant.RelayModeEmbeddings, + } + + got, err := adaptor.GetRequestURL(info) + if err != nil { + t.Fatalf("GetRequestURL returned error: %v", err) + } + + want := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/embeddings" + if got != want { + t.Fatalf("GetRequestURL() = %q, want %q", got, want) + } +} diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index d3c7fe09..489b7dfe 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -488,6 +488,19 @@ func SetApiRouter(router *gin.Engine) { deploymentsRoute.DELETE("/:id", controller.DeleteDeployment) } + // Agnet user approval gates and short-lived credential leases. + agnetApprovalRoute := apiRouter.Group("/agnet") + agnetApprovalRoute.Use(middleware.UserAuth()) + { + agnetApprovalRoute.GET("/approvals", controller.ListAgnetApprovalRequests) + agnetApprovalRoute.POST("/approvals", controller.CreateAgnetApprovalRequest) + agnetApprovalRoute.GET("/approvals/:approval_id", controller.GetAgnetApprovalRequest) + agnetApprovalRoute.POST("/approvals/:approval_id/approve", controller.ApproveAgnetApprovalRequest) + agnetApprovalRoute.POST("/approvals/:approval_id/reject", controller.RejectAgnetApprovalRequest) + agnetApprovalRoute.GET("/credential-leases", controller.ListAgnetCredentialLeases) + agnetApprovalRoute.POST("/credential-leases/:lease_id/revoke", controller.RevokeAgnetCredentialLease) + } + // Agnet orchestration control plane (minimal integration endpoints) agnetRoute := apiRouter.Group("/agnet") agnetRoute.Use(middleware.AdminAuth()) diff --git a/heicode/web/default/src/features/agnet-console/api.ts b/heicode/web/default/src/features/agnet-console/api.ts index e836a936..82bf5ec5 100644 --- a/heicode/web/default/src/features/agnet-console/api.ts +++ b/heicode/web/default/src/features/agnet-console/api.ts @@ -129,14 +129,35 @@ export type AgnetCreateDeploymentResult = { role?: string phase?: string }> + permission_manifest?: AgnetPermissionManifest +} + +export type AgnetPermissionManifest = { + user_id?: string + binding_scope?: string + agent_role?: string + target_agent_ref?: string + resource_grants?: Array<{ + grant_id?: string + resource_id?: string + resource_type?: string + resource_ref?: string + allowed_actions?: string[] + constraints?: Record + secret_ref?: string + status?: string + }> } export type AgnetDeployment = { deployment_id: string status: string phase: string + runtime_state?: string + failure_reason?: string created_at: string updated_at: string + permission_manifest?: AgnetPermissionManifest orchestration_plan: { intent_id?: string template_hint?: string @@ -149,6 +170,51 @@ export type AgnetDeployment = { } } +export type AgnetApprovalRequest = { + approval_id: string + user_id: number + deployment_id?: string + binding_scope?: string + operation: string + resource_id: string + resource_type: string + resource_scope?: string + target_role: string + risk_level: 'low' | 'medium' | 'high' | 'critical' + requires_credential: boolean + credential_lease_id?: string + status: 'pending' | 'approved' | 'rejected' | 'expired' + requested_by?: string + decided_by?: string + request_reason?: string + decision_reason?: string + ttl_seconds: number + expires_at: number + decided_at?: number + created_at?: number + updated_at?: number + credential_lease?: AgnetCredentialLease +} + +export type AgnetCredentialLease = { + lease_id: string + credential_ref: string + approval_id: string + user_id: number + deployment_id?: string + binding_scope?: string + resource_id: string + resource_type: string + resource_scope?: string + target_role: string + status: 'active' | 'expired' | 'revoked' + ttl_seconds: number + expires_at: number + revoked_at?: number + created_at?: number + updated_at?: number +} + type ApiEnvelope = { success: boolean; data?: T; message?: string } export type GitSourceUsage = 'project' | 'sk' | 'combined' @@ -259,6 +325,70 @@ export async function getAgnetAuditLogs() { return res.data?.data?.items ?? [] } +export async function listAgnetApprovals(params?: { + status?: string + deployment_id?: string +}): Promise { + const res = await api.get>( + '/api/agnet/approvals', + { params } + ) + return res.data?.data?.items ?? [] +} + +export async function approveAgnetApproval( + approvalId: string, + reason?: string +): Promise { + const res = await api.post>( + `/api/agnet/approvals/${approvalId}/approve`, + { reason } + ) + if (!res.data?.success || !res.data.data) { + throw new Error(res.data?.message || 'Approve request failed') + } + return res.data.data +} + +export async function rejectAgnetApproval( + approvalId: string, + reason?: string +): Promise { + const res = await api.post>( + `/api/agnet/approvals/${approvalId}/reject`, + { reason } + ) + if (!res.data?.success || !res.data.data) { + throw new Error(res.data?.message || 'Reject request failed') + } + return res.data.data +} + +export async function listAgnetCredentialLeases(params?: { + status?: string + deployment_id?: string +}): Promise { + const res = await api.get>( + '/api/agnet/credential-leases', + { params } + ) + return res.data?.data?.items ?? [] +} + +export async function revokeAgnetCredentialLease( + leaseId: string, + reason?: string +): Promise { + const res = await api.post>( + `/api/agnet/credential-leases/${leaseId}/revoke`, + { reason } + ) + if (!res.data?.success || !res.data.data) { + throw new Error(res.data?.message || 'Revoke lease failed') + } + return res.data.data +} + export async function getAgnetSnapshots(deploymentId: string) { const res = await api.get< ApiEnvelope<{ items?: Array> }> diff --git a/heicode/web/default/src/features/agnet-console/pages.tsx b/heicode/web/default/src/features/agnet-console/pages.tsx index 5cf4c200..9798d483 100644 --- a/heicode/web/default/src/features/agnet-console/pages.tsx +++ b/heicode/web/default/src/features/agnet-console/pages.tsx @@ -59,8 +59,15 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { QueryState } from '@/components/query-state' import { + approveAgnetApproval, getAgnetDeploymentEvents, + listAgnetApprovals, + listAgnetCredentialLeases, listAgnetDeployments, + rejectAgnetApproval, + revokeAgnetCredentialLease, + type AgnetApprovalRequest, + type AgnetCredentialLease, type AgnetDeployment, type AgnetRuntimeExecution, type AgnetSKAccessPolicy, @@ -256,6 +263,13 @@ function describeScope(dep: AgnetDeployment): string { function collectResourceGrants( dep: AgnetDeployment ): Record[] { + const manifestGrants = dep.permission_manifest?.resource_grants + if (manifestGrants && manifestGrants.length > 0) { + return manifestGrants.map((grant) => ({ + ...grant, + permission_scope: grant.allowed_actions || [], + })) + } return ( dep.orchestration_plan?.agents ?.flatMap((agent) => agent.resource_grants || []) @@ -1169,12 +1183,157 @@ function RedactedField({ ) } +function formatUnixMs(value?: number) { + if (!value) return '—' + return new Date(value).toLocaleString() +} + +function AgnetApprovalCard({ + approval, + approveBusy, + rejectBusy, + onApprove, + onReject, +}: { + approval: AgnetApprovalRequest + approveBusy: boolean + rejectBusy: boolean + onApprove: () => void + onReject: () => void +}) { + return ( +
+
+
+

+ {approval.operation} +

+

+ {approval.resource_type}:{approval.resource_id} ·{' '} + {approval.target_role} +

+
+ +
+
+ + + + +
+
+ + +
+
+ ) +} + +function AgnetLeaseCard({ + lease, + busy, + onRevoke, +}: { + lease: AgnetCredentialLease + busy: boolean + onRevoke: () => void +}) { + return ( +
+
+
+

+ {lease.credential_ref} +

+

+ {lease.resource_type}:{lease.resource_id} · {lease.target_role} +

+
+ +
+
+ + +
+
+ +
+
+ ) +} + export function AgnetAuditPage() { const { t } = useTranslation() + const queryClient = useQueryClient() const [scope, setScope] = useState('') const [actor, setActor] = useState('') const [actionFilter, setActionFilter] = useState('') + const approvalsQuery = useQuery({ + queryKey: ['agnet', 'approvals', 'pending'], + queryFn: () => listAgnetApprovals({ status: 'pending' }), + refetchInterval: 30_000, + }) + + const leasesQuery = useQuery({ + queryKey: ['agnet', 'credential-leases', 'active'], + queryFn: () => listAgnetCredentialLeases({ status: 'active' }), + refetchInterval: 30_000, + }) + + const refreshApprovalState = () => { + void queryClient.invalidateQueries({ queryKey: ['agnet', 'approvals'] }) + void queryClient.invalidateQueries({ + queryKey: ['agnet', 'credential-leases'], + }) + void queryClient.invalidateQueries({ queryKey: ['heicode', 'agnet', 'audit'] }) + } + + const approveMutation = useMutation({ + mutationFn: (approvalId: string) => + approveAgnetApproval(approvalId, t('Approved from Manager audit page')), + onSuccess: () => { + toast.success(t('Approval accepted')) + refreshApprovalState() + }, + }) + + const rejectMutation = useMutation({ + mutationFn: (approvalId: string) => + rejectAgnetApproval(approvalId, t('Rejected from Manager audit page')), + onSuccess: () => { + toast.success(t('Approval rejected')) + refreshApprovalState() + }, + }) + + const revokeMutation = useMutation({ + mutationFn: (leaseId: string) => + revokeAgnetCredentialLease(leaseId, t('Revoked from Manager audit page')), + onSuccess: () => { + toast.success(t('Credential lease revoked')) + refreshApprovalState() + }, + }) + const { data = [], isLoading, @@ -1236,6 +1395,72 @@ export function AgnetAuditPage() { 'Per docs §6: plaintext credentials never appear here. Long-lived secrets live in the secret vault; only secret_ref and redacted previews are shown.' )}

+
+
+
+
+

{t('Pending approvals')}

+

+ {t('Approve or reject high-risk Agnet operations before credentials are leased.')} +

+
+ +
+ void approvalsQuery.refetch()} + loadingFallback={} + emptyTitle={t('No pending approvals')} + emptyDescription={t('High-risk operations will appear here.')} + > +
+ {(approvalsQuery.data ?? []).map((approval) => ( + approveMutation.mutate(approval.approval_id)} + onReject={() => rejectMutation.mutate(approval.approval_id)} + /> + ))} +
+
+
+
+
+
+

{t('Active credential leases')}

+

+ {t('Only short-lived lease references are shown. Revoke after the task ends.')} +

+
+ +
+ void leasesQuery.refetch()} + loadingFallback={} + emptyTitle={t('No active credential leases')} + emptyDescription={t('Approved credential leases will appear here.')} + > +
+ {(leasesQuery.data ?? []).map((lease) => ( + revokeMutation.mutate(lease.lease_id)} + /> + ))} +
+
+
+
{children} +

+ {t('Contact')}{' '} + + november@taijiaicloud.com + +

diff --git a/heicode/web/default/src/lib/heicode-mcp.ts b/heicode/web/default/src/lib/heicode-mcp.ts index 8662e323..fbbe2505 100644 --- a/heicode/web/default/src/lib/heicode-mcp.ts +++ b/heicode/web/default/src/lib/heicode-mcp.ts @@ -414,8 +414,8 @@ export async function updateResource( return env.data } -export async function revokeResource(id: string): Promise<{ id: string; status: ResourceStatus }> { - const env = await mcpFetch>( +export async function revokeResource(id: string): Promise { + const env = await mcpFetch>( `/api/resources/${encodeURIComponent(id)}`, { method: 'DELETE' } ) @@ -430,7 +430,7 @@ export async function revokeResource(id: string): Promise<{ id: string; status: // Field shape per contract §3.1; status enums per §3.0. // ============================================================================= -export type GrantStatus = 'active' | 'suspended' | 'revoked' | 'expired' +export type GrantStatus = 'pending' | 'active' | 'disabled' | 'revoked' export type ResourceGrant = { id: string @@ -499,7 +499,7 @@ export async function createResourceGrant(body: CreateGrantBody): Promise { +export async function revokeResourceGrant(id: string): Promise { const env = await mcpFetch>( `/api/resource-grants/${encodeURIComponent(id)}`, { method: 'DELETE' }