feat: release manager 1.4.4 agnet persistence
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
1.4.3
|
1.4.4
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -272,6 +272,190 @@ func agnetRequestID(c *gin.Context) string {
|
|||||||
return common.GetUUID()
|
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) {
|
func agnetError(c *gin.Context, code string, message string) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -735,6 +919,11 @@ func AgnetCreateDeployment(c *gin.Context) {
|
|||||||
OccurredAt: now,
|
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()
|
agnetMu.Lock()
|
||||||
agnetDeployments[deploymentID] = record
|
agnetDeployments[deploymentID] = record
|
||||||
agnetMu.Unlock()
|
agnetMu.Unlock()
|
||||||
@@ -757,9 +946,7 @@ func AgnetGetDeployment(c *gin.Context) {
|
|||||||
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
agnetMu.RLock()
|
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||||
record, ok := agnetDeployments[deploymentID]
|
|
||||||
agnetMu.RUnlock()
|
|
||||||
if !ok {
|
if !ok {
|
||||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||||
return
|
return
|
||||||
@@ -770,19 +957,7 @@ func AgnetGetDeployment(c *gin.Context) {
|
|||||||
func AgnetListDeployments(c *gin.Context) {
|
func AgnetListDeployments(c *gin.Context) {
|
||||||
userID := strings.TrimSpace(c.Query("user_id"))
|
userID := strings.TrimSpace(c.Query("user_id"))
|
||||||
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
||||||
items := make([]agnetDeploymentRecord, 0)
|
items := listAgnetDeploymentRecords(userID, bindingScope)
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
common.ApiSuccess(c, gin.H{
|
common.ApiSuccess(c, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
@@ -797,10 +972,8 @@ func AgnetStopDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agnetMu.Lock()
|
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||||
record, ok := agnetDeployments[deploymentID]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
agnetMu.Unlock()
|
|
||||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -814,7 +987,14 @@ func AgnetStopDeployment(c *gin.Context) {
|
|||||||
record.AgentInstances[i].FailureReason = ""
|
record.AgentInstances[i].FailureReason = ""
|
||||||
}
|
}
|
||||||
record.UpdatedAt = agnetNow()
|
record.UpdatedAt = agnetNow()
|
||||||
|
agnetMu.Lock()
|
||||||
agnetDeployments[deploymentID] = record
|
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{
|
stopEvent := agnetEvent{
|
||||||
EventID: "evt_" + common.GetUUID()[:12],
|
EventID: "evt_" + common.GetUUID()[:12],
|
||||||
Event: "instance.phase_changed",
|
Event: "instance.phase_changed",
|
||||||
@@ -826,7 +1006,6 @@ func AgnetStopDeployment(c *gin.Context) {
|
|||||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||||
OccurredAt: agnetNow(),
|
OccurredAt: agnetNow(),
|
||||||
}
|
}
|
||||||
agnetMu.Unlock()
|
|
||||||
recordAgnetAuditEvent(stopEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
recordAgnetAuditEvent(stopEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||||
|
|
||||||
common.ApiSuccess(c, gin.H{
|
common.ApiSuccess(c, gin.H{
|
||||||
@@ -867,9 +1046,7 @@ func AgnetListDeploymentLogs(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agnetMu.RLock()
|
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||||
record, ok := agnetDeployments[deploymentID]
|
|
||||||
agnetMu.RUnlock()
|
|
||||||
if !ok {
|
if !ok {
|
||||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||||
return
|
return
|
||||||
@@ -927,9 +1104,7 @@ func AgnetGetDeploymentMetrics(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agnetMu.RLock()
|
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||||
record, ok := agnetDeployments[deploymentID]
|
|
||||||
agnetMu.RUnlock()
|
|
||||||
if !ok {
|
if !ok {
|
||||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||||
return
|
return
|
||||||
@@ -965,11 +1140,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) {
|
|||||||
active := 0
|
active := 0
|
||||||
pending := 0
|
pending := 0
|
||||||
stopped := 0
|
stopped := 0
|
||||||
agnetMu.RLock()
|
for _, record := range listAgnetDeploymentRecords("", bindingScope) {
|
||||||
for _, record := range agnetDeployments {
|
|
||||||
if !planHasBindingScope(record.Plan, bindingScope) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if record.Status == "accepted" {
|
if record.Status == "accepted" {
|
||||||
active++
|
active++
|
||||||
}
|
}
|
||||||
@@ -980,7 +1151,6 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) {
|
|||||||
stopped++
|
stopped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
agnetMu.RUnlock()
|
|
||||||
|
|
||||||
common.ApiSuccess(c, gin.H{
|
common.ApiSuccess(c, gin.H{
|
||||||
"binding_scope": bindingScope,
|
"binding_scope": bindingScope,
|
||||||
@@ -1003,10 +1173,8 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agnetMu.Lock()
|
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||||
record, ok := agnetDeployments[deploymentID]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
agnetMu.Unlock()
|
|
||||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1205,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
agnetMu.Lock()
|
||||||
agnetSnapshots[deploymentID] = snapshots
|
agnetSnapshots[deploymentID] = snapshots
|
||||||
|
agnetMu.Unlock()
|
||||||
snapEvent := agnetEvent{
|
snapEvent := agnetEvent{
|
||||||
EventID: "evt_" + common.GetUUID()[:12],
|
EventID: "evt_" + common.GetUUID()[:12],
|
||||||
Event: "sk_snapshot_refreshed",
|
Event: "sk_snapshot_refreshed",
|
||||||
@@ -1049,7 +1219,6 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
|||||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||||
OccurredAt: now,
|
OccurredAt: now,
|
||||||
}
|
}
|
||||||
agnetMu.Unlock()
|
|
||||||
recordAgnetAuditEvent(snapEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
recordAgnetAuditEvent(snapEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||||
|
|
||||||
common.ApiSuccess(c, gin.H{
|
common.ApiSuccess(c, gin.H{
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/heicode/manager/common"
|
"github.com/heicode/manager/common"
|
||||||
|
"github.com/heicode/manager/model"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type agnetCreateTestEnvelope struct {
|
type agnetCreateTestEnvelope struct {
|
||||||
@@ -34,6 +38,40 @@ func resetAgnetControlPlaneState(t *testing.T) {
|
|||||||
// to flush here.
|
// 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 {
|
func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
|
||||||
return agnetOrchestrationPlan{
|
return agnetOrchestrationPlan{
|
||||||
IntentID: "intent-resource-grant",
|
IntentID: "intent-resource-grant",
|
||||||
@@ -139,6 +177,226 @@ func postAgnetCreateDeployment(t *testing.T, plan agnetOrchestrationPlan) (*http
|
|||||||
return recorder, envelope
|
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) {
|
func TestAgnetCreateDeploymentAcceptsP1ResourceGrantModel(t *testing.T) {
|
||||||
resetAgnetControlPlaneState(t)
|
resetAgnetControlPlaneState(t)
|
||||||
|
|
||||||
|
|||||||
@@ -399,9 +399,16 @@ func ListResources(c *gin.Context) {
|
|||||||
if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" {
|
if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" {
|
||||||
query = query.Where("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))
|
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
|
var resources []model.ResourceBinding
|
||||||
if err := query.Order("id desc").Find(&resources).Error; err != nil {
|
if err := query.Order("id desc").Find(&resources).Error; err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
@@ -500,16 +507,33 @@ func UpdateResource(c *gin.Context) {
|
|||||||
|
|
||||||
func DeleteResource(c *gin.Context) {
|
func DeleteResource(c *gin.Context) {
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceBinding{})
|
var resource model.ResourceBinding
|
||||||
if res.Error != nil {
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil {
|
||||||
common.ApiError(c, res.Error)
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
common.ApiErrorMsg(c, "resource not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
now := common.GetTimestamp()
|
||||||
common.ApiErrorMsg(c, "resource not found")
|
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
|
return
|
||||||
}
|
}
|
||||||
common.ApiSuccess(c, gin.H{"deleted": true})
|
common.ApiSuccess(c, resourceToResponse(resource))
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpsertResourceSecret(c *gin.Context) {
|
func UpsertResourceSecret(c *gin.Context) {
|
||||||
@@ -647,6 +671,12 @@ func ListResourceGrants(c *gin.Context) {
|
|||||||
if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" {
|
if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" {
|
||||||
query = query.Where("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
|
var grants []model.ResourceGrant
|
||||||
if err := query.Order("id desc").Find(&grants).Error; err != nil {
|
if err := query.Order("id desc").Find(&grants).Error; err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
@@ -802,16 +832,29 @@ func UpdateResourceGrant(c *gin.Context) {
|
|||||||
|
|
||||||
func DeleteResourceGrant(c *gin.Context) {
|
func DeleteResourceGrant(c *gin.Context) {
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceGrant{})
|
var grant model.ResourceGrant
|
||||||
if res.Error != nil {
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&grant).Error; err != nil {
|
||||||
common.ApiError(c, res.Error)
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
common.ApiErrorMsg(c, "resource grant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
grant.Status = "revoked"
|
||||||
common.ApiErrorMsg(c, "resource grant not found")
|
if grant.RevokedAt == 0 {
|
||||||
|
grant.RevokedAt = common.GetTimestamp()
|
||||||
|
}
|
||||||
|
if err := model.DB.Save(&grant).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
return
|
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) {
|
func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, string, error) {
|
||||||
|
|||||||
@@ -103,6 +103,62 @@ func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) {
|
|||||||
require.Contains(t, w.Body.String(), "plaintext secrets are not allowed")
|
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) {
|
func TestCreateResourceGrantAssignsBoundResourceToRoleAgnet(t *testing.T) {
|
||||||
db := setupResourceControllerTestDB(t)
|
db := setupResourceControllerTestDB(t)
|
||||||
resource := model.ResourceBinding{
|
resource := model.ResourceBinding{
|
||||||
@@ -220,6 +276,59 @@ func TestGenerateResourceGrantManifestIncludesActiveGrantsOnly(t *testing.T) {
|
|||||||
require.NotContains(t, w.Body.String(), `"allowed_actions":["write"]`)
|
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) {
|
func TestUpsertResourceSecretWritesAzureKeyVaultAndStoresOnlySecretRef(t *testing.T) {
|
||||||
db := setupResourceControllerTestDB(t)
|
db := setupResourceControllerTestDB(t)
|
||||||
resource := model.ResourceBinding{
|
resource := model.ResourceBinding{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -283,6 +283,9 @@ func migrateDB() error {
|
|||||||
&GitSource{},
|
&GitSource{},
|
||||||
&ResourceBinding{},
|
&ResourceBinding{},
|
||||||
&ResourceGrant{},
|
&ResourceGrant{},
|
||||||
|
&AgnetApprovalRequest{},
|
||||||
|
&AgnetCredentialLease{},
|
||||||
|
&AgnetDeployment{},
|
||||||
// V2 device-binding: X25519 keypair the Manager uses for ECDH
|
// V2 device-binding: X25519 keypair the Manager uses for ECDH
|
||||||
// body decryption. See model/server_key.go.
|
// body decryption. See model/server_key.go.
|
||||||
&ServerKey{},
|
&ServerKey{},
|
||||||
@@ -349,6 +352,10 @@ func migrateDBFast() error {
|
|||||||
{&GitSource{}, "GitSource"},
|
{&GitSource{}, "GitSource"},
|
||||||
{&ResourceBinding{}, "ResourceBinding"},
|
{&ResourceBinding{}, "ResourceBinding"},
|
||||||
{&ResourceGrant{}, "ResourceGrant"},
|
{&ResourceGrant{}, "ResourceGrant"},
|
||||||
|
{&AgnetApprovalRequest{}, "AgnetApprovalRequest"},
|
||||||
|
{&AgnetCredentialLease{}, "AgnetCredentialLease"},
|
||||||
|
{&AgnetDeployment{}, "AgnetDeployment"},
|
||||||
|
{&AgnetAuditEvent{}, "AgnetAuditEvent"},
|
||||||
}
|
}
|
||||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||||
errChan := make(chan error, len(migrations))
|
errChan := make(chan error, len(migrations))
|
||||||
|
|||||||
@@ -88,41 +88,64 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
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) {
|
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
var fullRequestURL string
|
var fullRequestURL string
|
||||||
|
baseURL := normalizeAliBaseURL(info.ChannelBaseUrl)
|
||||||
switch info.RelayFormat {
|
switch info.RelayFormat {
|
||||||
case types.RelayFormatClaude:
|
case types.RelayFormatClaude:
|
||||||
if supportsAliAnthropicMessages(info.UpstreamModelName) {
|
if supportsAliAnthropicMessages(info.UpstreamModelName) {
|
||||||
fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl)
|
fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", baseURL)
|
||||||
} else {
|
} else {
|
||||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl)
|
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", baseURL)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
switch info.RelayMode {
|
switch info.RelayMode {
|
||||||
case constant.RelayModeEmbeddings:
|
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:
|
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:
|
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:
|
case constant.RelayModeImagesGenerations:
|
||||||
if isSyncImageModel(info.OriginModelName) {
|
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 {
|
} 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:
|
case constant.RelayModeImagesEdits:
|
||||||
if isOldWanModel(info.OriginModelName) {
|
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) {
|
} 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 {
|
} 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:
|
case constant.RelayModeCompletions:
|
||||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
|
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", baseURL)
|
||||||
default:
|
default:
|
||||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl)
|
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", baseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -488,6 +488,19 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
deploymentsRoute.DELETE("/:id", controller.DeleteDeployment)
|
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)
|
// Agnet orchestration control plane (minimal integration endpoints)
|
||||||
agnetRoute := apiRouter.Group("/agnet")
|
agnetRoute := apiRouter.Group("/agnet")
|
||||||
agnetRoute.Use(middleware.AdminAuth())
|
agnetRoute.Use(middleware.AdminAuth())
|
||||||
|
|||||||
@@ -129,14 +129,35 @@ export type AgnetCreateDeploymentResult = {
|
|||||||
role?: string
|
role?: string
|
||||||
phase?: 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 = {
|
export type AgnetDeployment = {
|
||||||
deployment_id: string
|
deployment_id: string
|
||||||
status: string
|
status: string
|
||||||
phase: string
|
phase: string
|
||||||
|
runtime_state?: string
|
||||||
|
failure_reason?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
permission_manifest?: AgnetPermissionManifest
|
||||||
orchestration_plan: {
|
orchestration_plan: {
|
||||||
intent_id?: string
|
intent_id?: string
|
||||||
template_hint?: 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 }
|
type ApiEnvelope<T> = { success: boolean; data?: T; message?: string }
|
||||||
|
|
||||||
export type GitSourceUsage = 'project' | 'sk' | 'combined'
|
export type GitSourceUsage = 'project' | 'sk' | 'combined'
|
||||||
@@ -259,6 +325,70 @@ export async function getAgnetAuditLogs() {
|
|||||||
return res.data?.data?.items ?? []
|
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) {
|
export async function getAgnetSnapshots(deploymentId: string) {
|
||||||
const res = await api.get<
|
const res = await api.get<
|
||||||
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
|
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
|
||||||
|
|||||||
@@ -59,8 +59,15 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import {
|
import {
|
||||||
|
approveAgnetApproval,
|
||||||
getAgnetDeploymentEvents,
|
getAgnetDeploymentEvents,
|
||||||
|
listAgnetApprovals,
|
||||||
|
listAgnetCredentialLeases,
|
||||||
listAgnetDeployments,
|
listAgnetDeployments,
|
||||||
|
rejectAgnetApproval,
|
||||||
|
revokeAgnetCredentialLease,
|
||||||
|
type AgnetApprovalRequest,
|
||||||
|
type AgnetCredentialLease,
|
||||||
type AgnetDeployment,
|
type AgnetDeployment,
|
||||||
type AgnetRuntimeExecution,
|
type AgnetRuntimeExecution,
|
||||||
type AgnetSKAccessPolicy,
|
type AgnetSKAccessPolicy,
|
||||||
@@ -256,6 +263,13 @@ function describeScope(dep: AgnetDeployment): string {
|
|||||||
function collectResourceGrants(
|
function collectResourceGrants(
|
||||||
dep: AgnetDeployment
|
dep: AgnetDeployment
|
||||||
): Record<string, unknown>[] {
|
): 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 (
|
return (
|
||||||
dep.orchestration_plan?.agents
|
dep.orchestration_plan?.agents
|
||||||
?.flatMap((agent) => agent.resource_grants || [])
|
?.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() {
|
export function AgnetAuditPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [scope, setScope] = useState('')
|
const [scope, setScope] = useState('')
|
||||||
const [actor, setActor] = useState('')
|
const [actor, setActor] = useState('')
|
||||||
const [actionFilter, setActionFilter] = 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 {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
isLoading,
|
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.'
|
'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>
|
</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
|
<QueryState
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={auditError}
|
error={auditError}
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-4
@@ -414,8 +414,8 @@ export async function updateResource(
|
|||||||
return env.data
|
return env.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeResource(id: string): Promise<{ id: string; status: ResourceStatus }> {
|
export async function revokeResource(id: string): Promise<ResourceBinding> {
|
||||||
const env = await mcpFetch<Envelope<{ id: string; status: ResourceStatus }>>(
|
const env = await mcpFetch<Envelope<ResourceBinding>>(
|
||||||
`/api/resources/${encodeURIComponent(id)}`,
|
`/api/resources/${encodeURIComponent(id)}`,
|
||||||
{ method: 'DELETE' }
|
{ 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.
|
// 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 = {
|
export type ResourceGrant = {
|
||||||
id: string
|
id: string
|
||||||
@@ -499,7 +499,7 @@ export async function createResourceGrant(body: CreateGrantBody): Promise<Resour
|
|||||||
return env.data
|
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>>(
|
const env = await mcpFetch<Envelope<ResourceGrant>>(
|
||||||
`/api/resource-grants/${encodeURIComponent(id)}`,
|
`/api/resource-grants/${encodeURIComponent(id)}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
|
|||||||
Reference in New Issue
Block a user