+ {approval.operation} +
++ {approval.resource_type}:{approval.resource_id} ·{' '} + {approval.target_role} +
+-
+
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
+ {approval.operation}
+
+ {approval.resource_type}:{approval.resource_id} ·{' '}
+ {approval.target_role}
+
+ {lease.credential_ref}
+
+ {lease.resource_type}:{lease.resource_id} · {lease.target_role}
+
+
+
+
+
+ {t('Approve or reject high-risk Agnet operations before credentials are leased.')} +
++ {t('Only short-lived lease references are shown. Revoke after the task ends.')} +
++ {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