feat: release manager 1.4.4 agnet persistence

This commit is contained in:
gongzhiyong
2026-05-26 16:01:49 +08:00
parent 1ebaa1ccce
commit a80c3e0c35
17 changed files with 1980 additions and 66 deletions
+1 -1
View File
@@ -1 +1 @@
1.4.3
1.4.4
+558
View File
@@ -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)
}
+218
View File
@@ -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))
}
+205 -36
View File
@@ -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{
@@ -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)
+56 -13
View File
@@ -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) {
+109
View File
@@ -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{
+64
View File
@@ -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"
}
+30
View File
@@ -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"
}
+7
View File
@@ -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))
+35 -12
View File
@@ -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)
}
}
+58
View File
@@ -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)
}
}
+13
View File
@@ -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())
+130
View File
@@ -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<string, string>
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<T> = { 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<AgnetApprovalRequest[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetApprovalRequest[] }>>(
'/api/agnet/approvals',
{ params }
)
return res.data?.data?.items ?? []
}
export async function approveAgnetApproval(
approvalId: string,
reason?: string
): Promise<AgnetApprovalRequest> {
const res = await api.post<ApiEnvelope<AgnetApprovalRequest>>(
`/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<AgnetApprovalRequest> {
const res = await api.post<ApiEnvelope<AgnetApprovalRequest>>(
`/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<AgnetCredentialLease[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetCredentialLease[] }>>(
'/api/agnet/credential-leases',
{ params }
)
return res.data?.data?.items ?? []
}
export async function revokeAgnetCredentialLease(
leaseId: string,
reason?: string
): Promise<AgnetCredentialLease> {
const res = await api.post<ApiEnvelope<AgnetCredentialLease>>(
`/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<Record<string, unknown>> }>
+225
View File
@@ -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<string, unknown>[] {
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 (
<article className='rounded-xl border border-border/70 bg-background/45 p-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='font-mono text-xs font-semibold break-all'>
{approval.operation}
</p>
<p className='text-muted-foreground mt-1 text-xs break-all'>
{approval.resource_type}:{approval.resource_id} ·{' '}
{approval.target_role}
</p>
</div>
<StatusBadge phase={approval.risk_level} />
</div>
<dl className='mt-3 grid gap-2 text-xs sm:grid-cols-2'>
<RedactedField k='scope' v={approval.binding_scope || '—'} />
<RedactedField k='resource_scope' v={approval.resource_scope || '—'} />
<RedactedField k='expires_at' v={formatUnixMs(approval.expires_at)} />
<RedactedField
k='credential'
v={approval.requires_credential ? 'required' : 'not required'}
/>
</dl>
<div className='mt-3 flex flex-wrap justify-end gap-2'>
<Button
size='sm'
variant='outline'
disabled={rejectBusy || approveBusy}
onClick={onReject}
>
Reject
</Button>
<Button
size='sm'
disabled={approveBusy || rejectBusy}
onClick={onApprove}
>
Approve
</Button>
</div>
</article>
)
}
function AgnetLeaseCard({
lease,
busy,
onRevoke,
}: {
lease: AgnetCredentialLease
busy: boolean
onRevoke: () => void
}) {
return (
<article className='rounded-xl border border-border/70 bg-background/45 p-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='font-mono text-xs font-semibold break-all'>
{lease.credential_ref}
</p>
<p className='text-muted-foreground mt-1 text-xs break-all'>
{lease.resource_type}:{lease.resource_id} · {lease.target_role}
</p>
</div>
<StatusBadge phase={lease.status} />
</div>
<dl className='mt-3 grid gap-2 text-xs'>
<RedactedField k='approval' v={lease.approval_id} mono />
<RedactedField k='expires_at' v={formatUnixMs(lease.expires_at)} />
</dl>
<div className='mt-3 flex justify-end'>
<Button size='sm' variant='outline' disabled={busy} onClick={onRevoke}>
Revoke
</Button>
</div>
</article>
)
}
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.'
)}
</p>
<section className='grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]'>
<div className='rounded-xl border border-border/70 bg-card/70 p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>{t('Pending approvals')}</h3>
<p className='text-muted-foreground text-xs'>
{t('Approve or reject high-risk Agnet operations before credentials are leased.')}
</p>
</div>
<StatusBadge phase='pending' />
</div>
<QueryState
isLoading={approvalsQuery.isLoading}
error={approvalsQuery.error}
isEmpty={(approvalsQuery.data ?? []).length === 0}
retry={() => void approvalsQuery.refetch()}
loadingFallback={<LoadingGrid rows={2} height='h-24' />}
emptyTitle={t('No pending approvals')}
emptyDescription={t('High-risk operations will appear here.')}
>
<div className='grid gap-3'>
{(approvalsQuery.data ?? []).map((approval) => (
<AgnetApprovalCard
key={approval.approval_id}
approval={approval}
approveBusy={approveMutation.isPending}
rejectBusy={rejectMutation.isPending}
onApprove={() => approveMutation.mutate(approval.approval_id)}
onReject={() => rejectMutation.mutate(approval.approval_id)}
/>
))}
</div>
</QueryState>
</div>
<div className='rounded-xl border border-border/70 bg-card/70 p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>{t('Active credential leases')}</h3>
<p className='text-muted-foreground text-xs'>
{t('Only short-lived lease references are shown. Revoke after the task ends.')}
</p>
</div>
<ShieldCheck className='text-primary size-4' />
</div>
<QueryState
isLoading={leasesQuery.isLoading}
error={leasesQuery.error}
isEmpty={(leasesQuery.data ?? []).length === 0}
retry={() => void leasesQuery.refetch()}
loadingFallback={<LoadingGrid rows={2} height='h-24' />}
emptyTitle={t('No active credential leases')}
emptyDescription={t('Approved credential leases will appear here.')}
>
<div className='grid gap-3'>
{(leasesQuery.data ?? []).map((lease) => (
<AgnetLeaseCard
key={lease.lease_id}
lease={lease}
busy={revokeMutation.isPending}
onRevoke={() => revokeMutation.mutate(lease.lease_id)}
/>
))}
</div>
</QueryState>
</div>
</section>
<QueryState
isLoading={isLoading}
error={auditError}
+9
View File
@@ -155,6 +155,15 @@ export function AuthLayout({ children }: AuthLayoutProps) {
</div>
</div>
{children}
<p className='mt-6 text-center text-xs text-muted-foreground'>
{t('Contact')}{' '}
<a
href='mailto:november@taijiaicloud.com'
className='font-medium text-primary underline-offset-4 hover:underline'
>
november@taijiaicloud.com
</a>
</p>
</div>
</main>
</div>
+4 -4
View File
@@ -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<Envelope<{ id: string; status: ResourceStatus }>>(
export async function revokeResource(id: string): Promise<ResourceBinding> {
const env = await mcpFetch<Envelope<ResourceBinding>>(
`/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<Resour
return env.data
}
export async function revokeResourceGrant(id: string): Promise<ResourceGrant | { id: string; status: GrantStatus }> {
export async function revokeResourceGrant(id: string): Promise<ResourceGrant> {
const env = await mcpFetch<Envelope<ResourceGrant>>(
`/api/resource-grants/${encodeURIComponent(id)}`,
{ method: 'DELETE' }