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()
|
||||
}
|
||||
|
||||
func agnetTimestampMs(s string) int64 {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
func marshalAgnetSnapshot(v any) (string, error) {
|
||||
data, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func agnetDeploymentModelToRecord(row model.AgnetDeployment) (agnetDeploymentRecord, error) {
|
||||
var record agnetDeploymentRecord
|
||||
record.DeploymentID = row.DeploymentID
|
||||
record.Status = row.Status
|
||||
record.Phase = row.Phase
|
||||
record.RuntimeState = row.RuntimeState
|
||||
record.FailureReason = row.FailureReason
|
||||
record.CreatedAt = row.CreatedAtText
|
||||
record.UpdatedAt = row.UpdatedAtText
|
||||
|
||||
if row.PlanJSON != "" {
|
||||
if err := common.UnmarshalJsonStr(row.PlanJSON, &record.Plan); err != nil {
|
||||
return record, err
|
||||
}
|
||||
}
|
||||
if row.AgentInstancesJSON != "" {
|
||||
if err := common.UnmarshalJsonStr(row.AgentInstancesJSON, &record.AgentInstances); err != nil {
|
||||
return record, err
|
||||
}
|
||||
}
|
||||
if row.PermissionManifestJSON != "" {
|
||||
if err := common.UnmarshalJsonStr(row.PermissionManifestJSON, &record.ResourceGrantManifest); err != nil {
|
||||
return record, err
|
||||
}
|
||||
}
|
||||
if record.AgentInstances == nil {
|
||||
record.AgentInstances = []agnetAgentInstance{}
|
||||
}
|
||||
if record.ResourceGrantManifest.ResourceGrants == nil {
|
||||
record.ResourceGrantManifest.ResourceGrants = []agnetManifestGrant{}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func persistAgnetDeploymentRecord(record agnetDeploymentRecord, req agnetDeploymentRequest) error {
|
||||
if model.DB == nil {
|
||||
return nil
|
||||
}
|
||||
planJSON, err := marshalAgnetSnapshot(record.Plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instancesJSON, err := marshalAgnetSnapshot(record.AgentInstances)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifestJSON, err := marshalAgnetSnapshot(record.ResourceGrantManifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadJSON, err := marshalAgnetSnapshot(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := model.AgnetDeployment{
|
||||
DeploymentID: record.DeploymentID,
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
ChannelID: record.Plan.UserContext.ChannelID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
Status: record.Status,
|
||||
Phase: record.Phase,
|
||||
RuntimeState: record.RuntimeState,
|
||||
FailureReason: record.FailureReason,
|
||||
CreatedAtText: record.CreatedAt,
|
||||
UpdatedAtText: record.UpdatedAt,
|
||||
CreatedAtMs: agnetTimestampMs(record.CreatedAt),
|
||||
UpdatedAtMs: agnetTimestampMs(record.UpdatedAt),
|
||||
PlanJSON: planJSON,
|
||||
AgentInstancesJSON: instancesJSON,
|
||||
PermissionManifestJSON: manifestJSON,
|
||||
PayloadJSON: payloadJSON,
|
||||
}
|
||||
return model.DB.Create(&row).Error
|
||||
}
|
||||
|
||||
func updateAgnetDeploymentRecord(record agnetDeploymentRecord) error {
|
||||
if model.DB == nil {
|
||||
return nil
|
||||
}
|
||||
instancesJSON, err := marshalAgnetSnapshot(record.AgentInstances)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifestJSON, err := marshalAgnetSnapshot(record.ResourceGrantManifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return model.DB.Model(&model.AgnetDeployment{}).
|
||||
Where("deployment_id = ?", record.DeploymentID).
|
||||
Updates(map[string]any{
|
||||
"status": record.Status,
|
||||
"phase": record.Phase,
|
||||
"runtime_state": record.RuntimeState,
|
||||
"failure_reason": record.FailureReason,
|
||||
"updated_at_text": record.UpdatedAt,
|
||||
"updated_at_ms": agnetTimestampMs(record.UpdatedAt),
|
||||
"agent_instances_json": instancesJSON,
|
||||
"permission_manifest_json": manifestJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func findAgnetDeploymentRecord(deploymentID string) (agnetDeploymentRecord, bool) {
|
||||
agnetMu.RLock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
if ok {
|
||||
return record, true
|
||||
}
|
||||
if model.DB == nil {
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
|
||||
var row model.AgnetDeployment
|
||||
if err := model.DB.Where("deployment_id = ?", deploymentID).First(&row).Error; err != nil {
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
record, err := agnetDeploymentModelToRecord(row)
|
||||
if err != nil {
|
||||
common.SysLog("findAgnetDeploymentRecord: " + err.Error())
|
||||
return agnetDeploymentRecord{}, false
|
||||
}
|
||||
agnetMu.Lock()
|
||||
agnetDeployments[deploymentID] = record
|
||||
agnetMu.Unlock()
|
||||
return record, true
|
||||
}
|
||||
|
||||
func listAgnetDeploymentRecords(userID string, bindingScope string) []agnetDeploymentRecord {
|
||||
items := make([]agnetDeploymentRecord, 0)
|
||||
if model.DB != nil {
|
||||
q := model.DB.Model(&model.AgnetDeployment{})
|
||||
if userID != "" {
|
||||
q = q.Where("user_id = ?", userID)
|
||||
}
|
||||
var rows []model.AgnetDeployment
|
||||
if err := q.Order("created_at_ms desc, id desc").Limit(500).Find(&rows).Error; err == nil {
|
||||
for _, row := range rows {
|
||||
record, err := agnetDeploymentModelToRecord(row)
|
||||
if err != nil {
|
||||
common.SysLog("listAgnetDeploymentRecords: " + err.Error())
|
||||
continue
|
||||
}
|
||||
if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
|
||||
continue
|
||||
}
|
||||
items = append(items, record)
|
||||
}
|
||||
return items
|
||||
} else {
|
||||
common.SysLog("listAgnetDeploymentRecords: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
agnetMu.RLock()
|
||||
for _, record := range agnetDeployments {
|
||||
if userID != "" && record.Plan.UserContext.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
|
||||
continue
|
||||
}
|
||||
items = append(items, record)
|
||||
}
|
||||
agnetMu.RUnlock()
|
||||
return items
|
||||
}
|
||||
|
||||
func agnetError(c *gin.Context, code string, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -735,6 +919,11 @@ func AgnetCreateDeployment(c *gin.Context) {
|
||||
OccurredAt: now,
|
||||
}
|
||||
|
||||
if err := persistAgnetDeploymentRecord(record, agnetDeploymentRequest{Plan: plan}); err != nil {
|
||||
common.SysLog("AgnetCreateDeployment persist: " + err.Error())
|
||||
agnetError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agnet deployment")
|
||||
return
|
||||
}
|
||||
agnetMu.Lock()
|
||||
agnetDeployments[deploymentID] = record
|
||||
agnetMu.Unlock()
|
||||
@@ -757,9 +946,7 @@ func AgnetGetDeployment(c *gin.Context) {
|
||||
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
||||
return
|
||||
}
|
||||
agnetMu.RLock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||
if !ok {
|
||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||
return
|
||||
@@ -770,19 +957,7 @@ func AgnetGetDeployment(c *gin.Context) {
|
||||
func AgnetListDeployments(c *gin.Context) {
|
||||
userID := strings.TrimSpace(c.Query("user_id"))
|
||||
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
||||
items := make([]agnetDeploymentRecord, 0)
|
||||
|
||||
agnetMu.RLock()
|
||||
for _, record := range agnetDeployments {
|
||||
if userID != "" && record.Plan.UserContext.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
|
||||
continue
|
||||
}
|
||||
items = append(items, record)
|
||||
}
|
||||
agnetMu.RUnlock()
|
||||
items := listAgnetDeploymentRecords(userID, bindingScope)
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": items,
|
||||
@@ -797,10 +972,8 @@ func AgnetStopDeployment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agnetMu.Lock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||
if !ok {
|
||||
agnetMu.Unlock()
|
||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||
return
|
||||
}
|
||||
@@ -814,7 +987,14 @@ func AgnetStopDeployment(c *gin.Context) {
|
||||
record.AgentInstances[i].FailureReason = ""
|
||||
}
|
||||
record.UpdatedAt = agnetNow()
|
||||
agnetMu.Lock()
|
||||
agnetDeployments[deploymentID] = record
|
||||
agnetMu.Unlock()
|
||||
if err := updateAgnetDeploymentRecord(record); err != nil {
|
||||
common.SysLog("AgnetStopDeployment persist: " + err.Error())
|
||||
agnetError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agnet deployment")
|
||||
return
|
||||
}
|
||||
stopEvent := agnetEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "instance.phase_changed",
|
||||
@@ -826,7 +1006,6 @@ func AgnetStopDeployment(c *gin.Context) {
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: agnetNow(),
|
||||
}
|
||||
agnetMu.Unlock()
|
||||
recordAgnetAuditEvent(stopEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
@@ -867,9 +1046,7 @@ func AgnetListDeploymentLogs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agnetMu.RLock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||
if !ok {
|
||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||
return
|
||||
@@ -927,9 +1104,7 @@ func AgnetGetDeploymentMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agnetMu.RLock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||
if !ok {
|
||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||
return
|
||||
@@ -965,11 +1140,7 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) {
|
||||
active := 0
|
||||
pending := 0
|
||||
stopped := 0
|
||||
agnetMu.RLock()
|
||||
for _, record := range agnetDeployments {
|
||||
if !planHasBindingScope(record.Plan, bindingScope) {
|
||||
continue
|
||||
}
|
||||
for _, record := range listAgnetDeploymentRecords("", bindingScope) {
|
||||
if record.Status == "accepted" {
|
||||
active++
|
||||
}
|
||||
@@ -980,7 +1151,6 @@ func AgnetProjectDashboardSnapshot(c *gin.Context) {
|
||||
stopped++
|
||||
}
|
||||
}
|
||||
agnetMu.RUnlock()
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"binding_scope": bindingScope,
|
||||
@@ -1003,10 +1173,8 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agnetMu.Lock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
record, ok := findAgnetDeploymentRecord(deploymentID)
|
||||
if !ok {
|
||||
agnetMu.Unlock()
|
||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||
return
|
||||
}
|
||||
@@ -1037,7 +1205,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
}
|
||||
agnetMu.Lock()
|
||||
agnetSnapshots[deploymentID] = snapshots
|
||||
agnetMu.Unlock()
|
||||
snapEvent := agnetEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "sk_snapshot_refreshed",
|
||||
@@ -1049,7 +1219,6 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: now,
|
||||
}
|
||||
agnetMu.Unlock()
|
||||
recordAgnetAuditEvent(snapEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type agnetCreateTestEnvelope struct {
|
||||
@@ -34,6 +38,40 @@ func resetAgnetControlPlaneState(t *testing.T) {
|
||||
// to flush here.
|
||||
}
|
||||
|
||||
func setupAgnetControlPlaneTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.UsingSQLite = true
|
||||
common.UsingMySQL = false
|
||||
common.UsingPostgreSQL = false
|
||||
common.RedisEnabled = false
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
model.DB = db
|
||||
model.LOG_DB = db
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.ResourceBinding{},
|
||||
&model.ResourceGrant{},
|
||||
&model.AgnetAuditEvent{},
|
||||
&model.AgnetDeployment{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
if model.DB == db {
|
||||
model.DB = nil
|
||||
}
|
||||
if model.LOG_DB == db {
|
||||
model.LOG_DB = nil
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
|
||||
return agnetOrchestrationPlan{
|
||||
IntentID: "intent-resource-grant",
|
||||
@@ -139,6 +177,226 @@ func postAgnetCreateDeployment(t *testing.T, plan agnetOrchestrationPlan) (*http
|
||||
return recorder, envelope
|
||||
}
|
||||
|
||||
func TestAgnetDeploymentSurvivesInProcessStateReset(t *testing.T) {
|
||||
db := setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
recorder, envelope := postAgnetCreateDeployment(t, baseAgnetResourceGrantPlan())
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.True(t, envelope.Success)
|
||||
|
||||
var createBody map[string]any
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &createBody))
|
||||
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
|
||||
require.NotEmpty(t, deploymentID)
|
||||
|
||||
var stored model.AgnetDeployment
|
||||
require.NoError(t, db.Where("deployment_id = ?", deploymentID).First(&stored).Error)
|
||||
require.Equal(t, "user-p1", stored.UserID)
|
||||
require.Equal(t, "channel-p1", stored.ChannelID)
|
||||
require.Contains(t, stored.PayloadJSON, `"orchestration_plan"`)
|
||||
require.Contains(t, stored.PermissionManifestJSON, `"grant-git-builder"`)
|
||||
require.Contains(t, stored.PermissionManifestJSON, `"secret_ref"`)
|
||||
require.NotContains(t, strings.ToLower(stored.PayloadJSON), "password")
|
||||
require.NotContains(t, strings.ToLower(stored.PayloadJSON), "private_key")
|
||||
require.NotContains(t, strings.ToLower(stored.PermissionManifestJSON), "access_token")
|
||||
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
getRecorder := httptest.NewRecorder()
|
||||
getCtx, _ := gin.CreateTestContext(getRecorder)
|
||||
getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil)
|
||||
AgnetGetDeployment(getCtx)
|
||||
|
||||
var getBody map[string]any
|
||||
require.NoError(t, common.Unmarshal(getRecorder.Body.Bytes(), &getBody))
|
||||
require.Equal(t, true, getBody["success"])
|
||||
data := getBody["data"].(map[string]any)
|
||||
require.Equal(t, deploymentID, data["deployment_id"])
|
||||
require.Equal(t, "accepted", data["status"])
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
listCtx, _ := gin.CreateTestContext(listRecorder)
|
||||
listCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments?user_id=user-p1", nil)
|
||||
AgnetListDeployments(listCtx)
|
||||
|
||||
var listBody map[string]any
|
||||
require.NoError(t, common.Unmarshal(listRecorder.Body.Bytes(), &listBody))
|
||||
require.Equal(t, true, listBody["success"])
|
||||
listData := listBody["data"].(map[string]any)
|
||||
require.Equal(t, float64(1), listData["total"])
|
||||
}
|
||||
|
||||
func TestAgnetStopDeploymentPersistsState(t *testing.T) {
|
||||
setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
recorder, envelope := postAgnetCreateDeployment(t, baseAgnetResourceGrantPlan())
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.True(t, envelope.Success)
|
||||
var createBody map[string]any
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &createBody))
|
||||
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
|
||||
|
||||
stopRecorder := httptest.NewRecorder()
|
||||
stopCtx, _ := gin.CreateTestContext(stopRecorder)
|
||||
stopCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
stopCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/deployments/"+deploymentID+"/stop", nil)
|
||||
AgnetStopDeployment(stopCtx)
|
||||
require.Equal(t, http.StatusOK, stopRecorder.Code)
|
||||
require.Contains(t, stopRecorder.Body.String(), `"status":"stopped"`)
|
||||
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
getRecorder := httptest.NewRecorder()
|
||||
getCtx, _ := gin.CreateTestContext(getRecorder)
|
||||
getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil)
|
||||
AgnetGetDeployment(getCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, getRecorder.Code)
|
||||
require.Contains(t, getRecorder.Body.String(), `"status":"stopped"`)
|
||||
require.Contains(t, getRecorder.Body.String(), `"runtime_state":"stopped"`)
|
||||
}
|
||||
|
||||
func TestManagerOnlyAgnetSmokeFlow(t *testing.T) {
|
||||
setupAgnetControlPlaneTestDB(t)
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
resourceBody := `{
|
||||
"binding_scope":"project-smoke",
|
||||
"name":"Smoke project repo",
|
||||
"resource_type":"git",
|
||||
"provider":"gitee",
|
||||
"external_id":"https://example.invalid/acme/smoke.git",
|
||||
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/project-smoke",
|
||||
"metadata":{"repo_url":"https://example.invalid/acme/smoke.git","ref":"main"},
|
||||
"permission_scope":{"actions":["repo:read"]},
|
||||
"constraints":{"allowed_paths":"src/**,docs/**"}
|
||||
}`
|
||||
resourceRecorder := performResourceRequest(CreateResource, 7, http.MethodPost, "/", resourceBody)
|
||||
require.Equal(t, http.StatusOK, resourceRecorder.Code)
|
||||
require.Contains(t, resourceRecorder.Body.String(), `"success":true`)
|
||||
require.NotContains(t, strings.ToLower(resourceRecorder.Body.String()), "password")
|
||||
var resourceEnv map[string]any
|
||||
require.NoError(t, common.Unmarshal(resourceRecorder.Body.Bytes(), &resourceEnv))
|
||||
resource := resourceEnv["data"].(map[string]any)
|
||||
resourceID := int(resource["id"].(float64))
|
||||
|
||||
grantBody := fmt.Sprintf(`{
|
||||
"binding_scope":"project-smoke",
|
||||
"resource_id":%d,
|
||||
"role":"builder",
|
||||
"agnet_id":"agent-builder-1",
|
||||
"permission_scope":{"actions":["repo:read","repo:write:feature-branches"]},
|
||||
"constraints":{"allowed_paths":"src/**,docs/**","ref":"main"}
|
||||
}`, resourceID)
|
||||
grantRecorder := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", grantBody)
|
||||
require.Equal(t, http.StatusOK, grantRecorder.Code)
|
||||
require.Contains(t, grantRecorder.Body.String(), `"success":true`)
|
||||
var grantEnv map[string]any
|
||||
require.NoError(t, common.Unmarshal(grantRecorder.Body.Bytes(), &grantEnv))
|
||||
grant := grantEnv["data"].(map[string]any)
|
||||
grantID := int(grant["id"].(float64))
|
||||
|
||||
manifestRecorder := performResourceRequestWithRoute(
|
||||
GenerateResourceGrantManifest,
|
||||
7,
|
||||
http.MethodGet,
|
||||
"/manifest",
|
||||
"/manifest?binding_scope=project-smoke&role=builder&agnet_id=agent-builder-1",
|
||||
"",
|
||||
)
|
||||
require.Equal(t, http.StatusOK, manifestRecorder.Code)
|
||||
require.Contains(t, manifestRecorder.Body.String(), `"resource_grants":[`)
|
||||
require.Contains(t, manifestRecorder.Body.String(), `"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/project-smoke"`)
|
||||
|
||||
plan := baseAgnetResourceGrantPlan()
|
||||
plan.UserContext.UserID = "7"
|
||||
plan.UserContext.ChannelID = "smoke-channel"
|
||||
plan.BillingContext.NewAPIUserRef = "newapi-user-smoke"
|
||||
plan.BillingContext.NewAPIGroup = "vip"
|
||||
plan.BillingContext.QuotaRef = "quota-smoke"
|
||||
plan.Metadata.CorrelationID = "corr-manager-smoke"
|
||||
plan.Agents[0].ResourceGrants = []agnetResourceGrant{
|
||||
{
|
||||
GrantID: fmt.Sprintf("grant-%d", grantID),
|
||||
ResourceID: fmt.Sprintf("%d", resourceID),
|
||||
ResourceType: agnetResourceGit,
|
||||
UserID: "7",
|
||||
BindingScope: "project-smoke",
|
||||
TargetRole: "builder",
|
||||
TargetAgentRef: "agent-builder-1",
|
||||
PermissionScope: []string{"repo:read", "repo:write:feature-branches"},
|
||||
Constraints: map[string]string{"allowed_paths": "src/**,docs/**", "ref": "main"},
|
||||
Metadata: map[string]string{
|
||||
"provider": "gitee",
|
||||
"resource_ref": "https://example.invalid/acme/smoke.git",
|
||||
},
|
||||
Status: agnetGrantStatusActive,
|
||||
SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/project-smoke",
|
||||
Audit: map[string]string{"confirmed_by": "7"},
|
||||
},
|
||||
}
|
||||
|
||||
deployRecorder, envelope := postAgnetCreateDeployment(t, plan)
|
||||
require.Equal(t, http.StatusOK, deployRecorder.Code)
|
||||
require.True(t, envelope.Success)
|
||||
var deployEnv map[string]any
|
||||
require.NoError(t, common.Unmarshal(deployRecorder.Body.Bytes(), &deployEnv))
|
||||
deploymentID := deployEnv["data"].(map[string]any)["deployment_id"].(string)
|
||||
require.NotEmpty(t, deploymentID)
|
||||
|
||||
logRecorder := httptest.NewRecorder()
|
||||
logCtx, _ := gin.CreateTestContext(logRecorder)
|
||||
logCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
logCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID+"/logs", nil)
|
||||
AgnetListDeploymentLogs(logCtx)
|
||||
require.Equal(t, http.StatusOK, logRecorder.Code)
|
||||
require.Contains(t, logRecorder.Body.String(), `"redacted":true`)
|
||||
require.NotContains(t, strings.ToLower(logRecorder.Body.String()), "access_token")
|
||||
|
||||
stopRecorder := httptest.NewRecorder()
|
||||
stopCtx, _ := gin.CreateTestContext(stopRecorder)
|
||||
stopCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
stopCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/deployments/"+deploymentID+"/stop", nil)
|
||||
AgnetStopDeployment(stopCtx)
|
||||
require.Equal(t, http.StatusOK, stopRecorder.Code)
|
||||
require.Contains(t, stopRecorder.Body.String(), `"status":"stopped"`)
|
||||
|
||||
resetAgnetControlPlaneState(t)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
getCtx, _ := gin.CreateTestContext(getRecorder)
|
||||
getCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
|
||||
getCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/deployments/"+deploymentID, nil)
|
||||
AgnetGetDeployment(getCtx)
|
||||
require.Equal(t, http.StatusOK, getRecorder.Code)
|
||||
require.Contains(t, getRecorder.Body.String(), `"status":"stopped"`)
|
||||
|
||||
revokeGrantRecorder := performResourceRequestWithRoute(
|
||||
DeleteResourceGrant,
|
||||
7,
|
||||
http.MethodDelete,
|
||||
"/:id",
|
||||
fmt.Sprintf("/%d", grantID),
|
||||
"",
|
||||
)
|
||||
require.Equal(t, http.StatusOK, revokeGrantRecorder.Code)
|
||||
require.Contains(t, revokeGrantRecorder.Body.String(), `"status":"revoked"`)
|
||||
|
||||
emptyManifestRecorder := performResourceRequestWithRoute(
|
||||
GenerateResourceGrantManifest,
|
||||
7,
|
||||
http.MethodGet,
|
||||
"/manifest",
|
||||
"/manifest?binding_scope=project-smoke&role=builder&agnet_id=agent-builder-1",
|
||||
"",
|
||||
)
|
||||
require.Equal(t, http.StatusOK, emptyManifestRecorder.Code)
|
||||
require.Contains(t, emptyManifestRecorder.Body.String(), `"resource_grants":[]`)
|
||||
}
|
||||
|
||||
func TestAgnetCreateDeploymentAcceptsP1ResourceGrantModel(t *testing.T) {
|
||||
resetAgnetControlPlaneState(t)
|
||||
|
||||
|
||||
@@ -399,9 +399,16 @@ func ListResources(c *gin.Context) {
|
||||
if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" {
|
||||
query = query.Where("binding_scope = ?", bindingScope)
|
||||
}
|
||||
if resourceType := strings.TrimSpace(c.Query("resource_type")); resourceType != "" {
|
||||
resourceType := strings.TrimSpace(c.Query("resource_type"))
|
||||
if resourceType == "" {
|
||||
resourceType = strings.TrimSpace(c.Query("type"))
|
||||
}
|
||||
if resourceType != "" {
|
||||
query = query.Where("resource_type = ?", strings.ToLower(resourceType))
|
||||
}
|
||||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||
query = query.Where("status = ?", strings.ToLower(status))
|
||||
}
|
||||
var resources []model.ResourceBinding
|
||||
if err := query.Order("id desc").Find(&resources).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -500,16 +507,33 @@ func UpdateResource(c *gin.Context) {
|
||||
|
||||
func DeleteResource(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceBinding{})
|
||||
if res.Error != nil {
|
||||
common.ApiError(c, res.Error)
|
||||
var resource model.ResourceBinding
|
||||
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiErrorMsg(c, "resource not found")
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
common.ApiErrorMsg(c, "resource not found")
|
||||
now := common.GetTimestamp()
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
resource.Status = "revoked"
|
||||
if err := tx.Save(&resource).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.ResourceGrant{}).
|
||||
Where("user_id = ? AND resource_id = ? AND status <> ?", userId, resource.Id, "revoked").
|
||||
Updates(map[string]any{
|
||||
"status": "revoked",
|
||||
"revoked_at": now,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"deleted": true})
|
||||
common.ApiSuccess(c, resourceToResponse(resource))
|
||||
}
|
||||
|
||||
func UpsertResourceSecret(c *gin.Context) {
|
||||
@@ -647,6 +671,12 @@ func ListResourceGrants(c *gin.Context) {
|
||||
if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" {
|
||||
query = query.Where("agnet_id = ?", agnetId)
|
||||
}
|
||||
if resourceId := strings.TrimSpace(c.Query("resource_id")); resourceId != "" {
|
||||
query = query.Where("resource_id = ?", resourceId)
|
||||
}
|
||||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||
query = query.Where("status = ?", strings.ToLower(status))
|
||||
}
|
||||
var grants []model.ResourceGrant
|
||||
if err := query.Order("id desc").Find(&grants).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -802,16 +832,29 @@ func UpdateResourceGrant(c *gin.Context) {
|
||||
|
||||
func DeleteResourceGrant(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceGrant{})
|
||||
if res.Error != nil {
|
||||
common.ApiError(c, res.Error)
|
||||
var grant model.ResourceGrant
|
||||
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&grant).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiErrorMsg(c, "resource grant not found")
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
common.ApiErrorMsg(c, "resource grant not found")
|
||||
grant.Status = "revoked"
|
||||
if grant.RevokedAt == 0 {
|
||||
grant.RevokedAt = common.GetTimestamp()
|
||||
}
|
||||
if err := model.DB.Save(&grant).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"deleted": true})
|
||||
var resource model.ResourceBinding
|
||||
resourcePtr := (*model.ResourceBinding)(nil)
|
||||
if err := model.DB.Where("id = ? AND user_id = ?", grant.ResourceId, userId).First(&resource).Error; err == nil {
|
||||
resourcePtr = &resource
|
||||
}
|
||||
common.ApiSuccess(c, resourceGrantToResponse(grant, resourcePtr))
|
||||
}
|
||||
|
||||
func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, string, error) {
|
||||
|
||||
@@ -103,6 +103,62 @@ func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) {
|
||||
require.Contains(t, w.Body.String(), "plaintext secrets are not allowed")
|
||||
}
|
||||
|
||||
func TestDeleteResourceRevokesBindingAndActiveGrants(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
resource := model.ResourceBinding{
|
||||
UserId: 7,
|
||||
BindingScope: "project-alpha",
|
||||
Name: "Project repo",
|
||||
ResourceType: "git",
|
||||
Provider: "gitee",
|
||||
SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/project-alpha",
|
||||
Status: "active",
|
||||
}
|
||||
require.NoError(t, db.Create(&resource).Error)
|
||||
grant := model.ResourceGrant{
|
||||
UserId: 7,
|
||||
BindingScope: "project-alpha",
|
||||
ResourceId: resource.Id,
|
||||
Role: "backend",
|
||||
AgnetId: "agent-backend-1",
|
||||
PermissionScope: `{"actions":["repo:read"]}`,
|
||||
Status: "active",
|
||||
}
|
||||
require.NoError(t, db.Create(&grant).Error)
|
||||
|
||||
w := performResourceRequestWithRoute(
|
||||
DeleteResource,
|
||||
7,
|
||||
http.MethodDelete,
|
||||
"/:id",
|
||||
fmt.Sprintf("/%d", resource.Id),
|
||||
"",
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"status":"revoked"`)
|
||||
|
||||
var storedResource model.ResourceBinding
|
||||
require.NoError(t, db.First(&storedResource, resource.Id).Error)
|
||||
require.Equal(t, "revoked", storedResource.Status)
|
||||
|
||||
var storedGrant model.ResourceGrant
|
||||
require.NoError(t, db.First(&storedGrant, grant.Id).Error)
|
||||
require.Equal(t, "revoked", storedGrant.Status)
|
||||
require.NotZero(t, storedGrant.RevokedAt)
|
||||
|
||||
listActive := performResourceRequestWithRoute(
|
||||
ListResources,
|
||||
7,
|
||||
http.MethodGet,
|
||||
"/",
|
||||
"/?status=active",
|
||||
"",
|
||||
)
|
||||
require.Equal(t, http.StatusOK, listActive.Code)
|
||||
require.Contains(t, listActive.Body.String(), `"items":[]`)
|
||||
}
|
||||
|
||||
func TestCreateResourceGrantAssignsBoundResourceToRoleAgnet(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
resource := model.ResourceBinding{
|
||||
@@ -220,6 +276,59 @@ func TestGenerateResourceGrantManifestIncludesActiveGrantsOnly(t *testing.T) {
|
||||
require.NotContains(t, w.Body.String(), `"allowed_actions":["write"]`)
|
||||
}
|
||||
|
||||
func TestDeleteResourceGrantRevokesInsteadOfDeleting(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
resource := model.ResourceBinding{
|
||||
UserId: 7,
|
||||
BindingScope: "project-alpha",
|
||||
Name: "Project repo",
|
||||
ResourceType: "git",
|
||||
Provider: "gitee",
|
||||
SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/project-alpha",
|
||||
Status: "active",
|
||||
}
|
||||
require.NoError(t, db.Create(&resource).Error)
|
||||
grant := model.ResourceGrant{
|
||||
UserId: 7,
|
||||
BindingScope: "project-alpha",
|
||||
ResourceId: resource.Id,
|
||||
Role: "backend",
|
||||
AgnetId: "agent-backend-1",
|
||||
PermissionScope: `{"actions":["repo:read","repo:write"]}`,
|
||||
Constraints: `{"paths":["src/**"]}`,
|
||||
Status: "active",
|
||||
}
|
||||
require.NoError(t, db.Create(&grant).Error)
|
||||
|
||||
w := performResourceRequestWithRoute(
|
||||
DeleteResourceGrant,
|
||||
7,
|
||||
http.MethodDelete,
|
||||
"/:id",
|
||||
fmt.Sprintf("/%d", grant.Id),
|
||||
"",
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"status":"revoked"`)
|
||||
|
||||
var stored model.ResourceGrant
|
||||
require.NoError(t, db.First(&stored, grant.Id).Error)
|
||||
require.Equal(t, "revoked", stored.Status)
|
||||
require.NotZero(t, stored.RevokedAt)
|
||||
|
||||
manifestRecorder := performResourceRequestWithRoute(
|
||||
GenerateResourceGrantManifest,
|
||||
7,
|
||||
http.MethodGet,
|
||||
"/manifest",
|
||||
"/manifest?binding_scope=project-alpha",
|
||||
"",
|
||||
)
|
||||
require.Equal(t, http.StatusOK, manifestRecorder.Code)
|
||||
require.Contains(t, manifestRecorder.Body.String(), `"resource_grants":[]`)
|
||||
}
|
||||
|
||||
func TestUpsertResourceSecretWritesAzureKeyVaultAndStoresOnlySecretRef(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
resource := model.ResourceBinding{
|
||||
|
||||
@@ -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{},
|
||||
&ResourceBinding{},
|
||||
&ResourceGrant{},
|
||||
&AgnetApprovalRequest{},
|
||||
&AgnetCredentialLease{},
|
||||
&AgnetDeployment{},
|
||||
// V2 device-binding: X25519 keypair the Manager uses for ECDH
|
||||
// body decryption. See model/server_key.go.
|
||||
&ServerKey{},
|
||||
@@ -349,6 +352,10 @@ func migrateDBFast() error {
|
||||
{&GitSource{}, "GitSource"},
|
||||
{&ResourceBinding{}, "ResourceBinding"},
|
||||
{&ResourceGrant{}, "ResourceGrant"},
|
||||
{&AgnetApprovalRequest{}, "AgnetApprovalRequest"},
|
||||
{&AgnetCredentialLease{}, "AgnetCredentialLease"},
|
||||
{&AgnetDeployment{}, "AgnetDeployment"},
|
||||
{&AgnetAuditEvent{}, "AgnetAuditEvent"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
@@ -88,41 +88,64 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func normalizeAliBaseURL(baseURL string) string {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return baseURL
|
||||
}
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
|
||||
for _, suffix := range []string{
|
||||
"/api/v2/apps/protocols/compatible-mode/v1",
|
||||
"/compatible-mode/v1",
|
||||
"/compatible-mode",
|
||||
"/api/v1",
|
||||
} {
|
||||
if strings.HasSuffix(baseURL, suffix) {
|
||||
return strings.TrimSuffix(baseURL, suffix)
|
||||
}
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
var fullRequestURL string
|
||||
baseURL := normalizeAliBaseURL(info.ChannelBaseUrl)
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
if supportsAliAnthropicMessages(info.UpstreamModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", baseURL)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", baseURL)
|
||||
}
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeEmbeddings:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", baseURL)
|
||||
case constant.RelayModeRerank:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", baseURL)
|
||||
case constant.RelayModeResponses:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", baseURL)
|
||||
case constant.RelayModeImagesGenerations:
|
||||
if isSyncImageModel(info.OriginModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", baseURL)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", baseURL)
|
||||
}
|
||||
case constant.RelayModeImagesEdits:
|
||||
if isOldWanModel(info.OriginModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", baseURL)
|
||||
} else if isWanModel(info.OriginModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", baseURL)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", baseURL)
|
||||
}
|
||||
case constant.RelayModeCompletions:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", baseURL)
|
||||
default:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl)
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// Agnet user approval gates and short-lived credential leases.
|
||||
agnetApprovalRoute := apiRouter.Group("/agnet")
|
||||
agnetApprovalRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
agnetApprovalRoute.GET("/approvals", controller.ListAgnetApprovalRequests)
|
||||
agnetApprovalRoute.POST("/approvals", controller.CreateAgnetApprovalRequest)
|
||||
agnetApprovalRoute.GET("/approvals/:approval_id", controller.GetAgnetApprovalRequest)
|
||||
agnetApprovalRoute.POST("/approvals/:approval_id/approve", controller.ApproveAgnetApprovalRequest)
|
||||
agnetApprovalRoute.POST("/approvals/:approval_id/reject", controller.RejectAgnetApprovalRequest)
|
||||
agnetApprovalRoute.GET("/credential-leases", controller.ListAgnetCredentialLeases)
|
||||
agnetApprovalRoute.POST("/credential-leases/:lease_id/revoke", controller.RevokeAgnetCredentialLease)
|
||||
}
|
||||
|
||||
// Agnet orchestration control plane (minimal integration endpoints)
|
||||
agnetRoute := apiRouter.Group("/agnet")
|
||||
agnetRoute.Use(middleware.AdminAuth())
|
||||
|
||||
@@ -129,14 +129,35 @@ export type AgnetCreateDeploymentResult = {
|
||||
role?: string
|
||||
phase?: string
|
||||
}>
|
||||
permission_manifest?: AgnetPermissionManifest
|
||||
}
|
||||
|
||||
export type AgnetPermissionManifest = {
|
||||
user_id?: string
|
||||
binding_scope?: string
|
||||
agent_role?: string
|
||||
target_agent_ref?: string
|
||||
resource_grants?: Array<{
|
||||
grant_id?: string
|
||||
resource_id?: string
|
||||
resource_type?: string
|
||||
resource_ref?: string
|
||||
allowed_actions?: string[]
|
||||
constraints?: Record<string, string>
|
||||
secret_ref?: string
|
||||
status?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type AgnetDeployment = {
|
||||
deployment_id: string
|
||||
status: string
|
||||
phase: string
|
||||
runtime_state?: string
|
||||
failure_reason?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
permission_manifest?: AgnetPermissionManifest
|
||||
orchestration_plan: {
|
||||
intent_id?: string
|
||||
template_hint?: string
|
||||
@@ -149,6 +170,51 @@ export type AgnetDeployment = {
|
||||
}
|
||||
}
|
||||
|
||||
export type AgnetApprovalRequest = {
|
||||
approval_id: string
|
||||
user_id: number
|
||||
deployment_id?: string
|
||||
binding_scope?: string
|
||||
operation: string
|
||||
resource_id: string
|
||||
resource_type: string
|
||||
resource_scope?: string
|
||||
target_role: string
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical'
|
||||
requires_credential: boolean
|
||||
credential_lease_id?: string
|
||||
status: 'pending' | 'approved' | 'rejected' | 'expired'
|
||||
requested_by?: string
|
||||
decided_by?: string
|
||||
request_reason?: string
|
||||
decision_reason?: string
|
||||
ttl_seconds: number
|
||||
expires_at: number
|
||||
decided_at?: number
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
credential_lease?: AgnetCredentialLease
|
||||
}
|
||||
|
||||
export type AgnetCredentialLease = {
|
||||
lease_id: string
|
||||
credential_ref: string
|
||||
approval_id: string
|
||||
user_id: number
|
||||
deployment_id?: string
|
||||
binding_scope?: string
|
||||
resource_id: string
|
||||
resource_type: string
|
||||
resource_scope?: string
|
||||
target_role: string
|
||||
status: 'active' | 'expired' | 'revoked'
|
||||
ttl_seconds: number
|
||||
expires_at: number
|
||||
revoked_at?: number
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
type ApiEnvelope<T> = { success: boolean; data?: T; message?: string }
|
||||
|
||||
export type GitSourceUsage = 'project' | 'sk' | 'combined'
|
||||
@@ -259,6 +325,70 @@ export async function getAgnetAuditLogs() {
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function listAgnetApprovals(params?: {
|
||||
status?: string
|
||||
deployment_id?: string
|
||||
}): Promise<AgnetApprovalRequest[]> {
|
||||
const res = await api.get<ApiEnvelope<{ items?: AgnetApprovalRequest[] }>>(
|
||||
'/api/agnet/approvals',
|
||||
{ params }
|
||||
)
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function approveAgnetApproval(
|
||||
approvalId: string,
|
||||
reason?: string
|
||||
): Promise<AgnetApprovalRequest> {
|
||||
const res = await api.post<ApiEnvelope<AgnetApprovalRequest>>(
|
||||
`/api/agnet/approvals/${approvalId}/approve`,
|
||||
{ reason }
|
||||
)
|
||||
if (!res.data?.success || !res.data.data) {
|
||||
throw new Error(res.data?.message || 'Approve request failed')
|
||||
}
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function rejectAgnetApproval(
|
||||
approvalId: string,
|
||||
reason?: string
|
||||
): Promise<AgnetApprovalRequest> {
|
||||
const res = await api.post<ApiEnvelope<AgnetApprovalRequest>>(
|
||||
`/api/agnet/approvals/${approvalId}/reject`,
|
||||
{ reason }
|
||||
)
|
||||
if (!res.data?.success || !res.data.data) {
|
||||
throw new Error(res.data?.message || 'Reject request failed')
|
||||
}
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function listAgnetCredentialLeases(params?: {
|
||||
status?: string
|
||||
deployment_id?: string
|
||||
}): Promise<AgnetCredentialLease[]> {
|
||||
const res = await api.get<ApiEnvelope<{ items?: AgnetCredentialLease[] }>>(
|
||||
'/api/agnet/credential-leases',
|
||||
{ params }
|
||||
)
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function revokeAgnetCredentialLease(
|
||||
leaseId: string,
|
||||
reason?: string
|
||||
): Promise<AgnetCredentialLease> {
|
||||
const res = await api.post<ApiEnvelope<AgnetCredentialLease>>(
|
||||
`/api/agnet/credential-leases/${leaseId}/revoke`,
|
||||
{ reason }
|
||||
)
|
||||
if (!res.data?.success || !res.data.data) {
|
||||
throw new Error(res.data?.message || 'Revoke lease failed')
|
||||
}
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function getAgnetSnapshots(deploymentId: string) {
|
||||
const res = await api.get<
|
||||
ApiEnvelope<{ items?: Array<Record<string, unknown>> }>
|
||||
|
||||
@@ -59,8 +59,15 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
approveAgnetApproval,
|
||||
getAgnetDeploymentEvents,
|
||||
listAgnetApprovals,
|
||||
listAgnetCredentialLeases,
|
||||
listAgnetDeployments,
|
||||
rejectAgnetApproval,
|
||||
revokeAgnetCredentialLease,
|
||||
type AgnetApprovalRequest,
|
||||
type AgnetCredentialLease,
|
||||
type AgnetDeployment,
|
||||
type AgnetRuntimeExecution,
|
||||
type AgnetSKAccessPolicy,
|
||||
@@ -256,6 +263,13 @@ function describeScope(dep: AgnetDeployment): string {
|
||||
function collectResourceGrants(
|
||||
dep: AgnetDeployment
|
||||
): Record<string, unknown>[] {
|
||||
const manifestGrants = dep.permission_manifest?.resource_grants
|
||||
if (manifestGrants && manifestGrants.length > 0) {
|
||||
return manifestGrants.map((grant) => ({
|
||||
...grant,
|
||||
permission_scope: grant.allowed_actions || [],
|
||||
}))
|
||||
}
|
||||
return (
|
||||
dep.orchestration_plan?.agents
|
||||
?.flatMap((agent) => agent.resource_grants || [])
|
||||
@@ -1169,12 +1183,157 @@ function RedactedField({
|
||||
)
|
||||
}
|
||||
|
||||
function formatUnixMs(value?: number) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
function AgnetApprovalCard({
|
||||
approval,
|
||||
approveBusy,
|
||||
rejectBusy,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
approval: AgnetApprovalRequest
|
||||
approveBusy: boolean
|
||||
rejectBusy: boolean
|
||||
onApprove: () => void
|
||||
onReject: () => void
|
||||
}) {
|
||||
return (
|
||||
<article className='rounded-xl border border-border/70 bg-background/45 p-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='font-mono text-xs font-semibold break-all'>
|
||||
{approval.operation}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs break-all'>
|
||||
{approval.resource_type}:{approval.resource_id} ·{' '}
|
||||
{approval.target_role}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge phase={approval.risk_level} />
|
||||
</div>
|
||||
<dl className='mt-3 grid gap-2 text-xs sm:grid-cols-2'>
|
||||
<RedactedField k='scope' v={approval.binding_scope || '—'} />
|
||||
<RedactedField k='resource_scope' v={approval.resource_scope || '—'} />
|
||||
<RedactedField k='expires_at' v={formatUnixMs(approval.expires_at)} />
|
||||
<RedactedField
|
||||
k='credential'
|
||||
v={approval.requires_credential ? 'required' : 'not required'}
|
||||
/>
|
||||
</dl>
|
||||
<div className='mt-3 flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={rejectBusy || approveBusy}
|
||||
onClick={onReject}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={approveBusy || rejectBusy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function AgnetLeaseCard({
|
||||
lease,
|
||||
busy,
|
||||
onRevoke,
|
||||
}: {
|
||||
lease: AgnetCredentialLease
|
||||
busy: boolean
|
||||
onRevoke: () => void
|
||||
}) {
|
||||
return (
|
||||
<article className='rounded-xl border border-border/70 bg-background/45 p-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='font-mono text-xs font-semibold break-all'>
|
||||
{lease.credential_ref}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs break-all'>
|
||||
{lease.resource_type}:{lease.resource_id} · {lease.target_role}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge phase={lease.status} />
|
||||
</div>
|
||||
<dl className='mt-3 grid gap-2 text-xs'>
|
||||
<RedactedField k='approval' v={lease.approval_id} mono />
|
||||
<RedactedField k='expires_at' v={formatUnixMs(lease.expires_at)} />
|
||||
</dl>
|
||||
<div className='mt-3 flex justify-end'>
|
||||
<Button size='sm' variant='outline' disabled={busy} onClick={onRevoke}>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgnetAuditPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [scope, setScope] = useState('')
|
||||
const [actor, setActor] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
|
||||
const approvalsQuery = useQuery({
|
||||
queryKey: ['agnet', 'approvals', 'pending'],
|
||||
queryFn: () => listAgnetApprovals({ status: 'pending' }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const leasesQuery = useQuery({
|
||||
queryKey: ['agnet', 'credential-leases', 'active'],
|
||||
queryFn: () => listAgnetCredentialLeases({ status: 'active' }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const refreshApprovalState = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['agnet', 'approvals'] })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['agnet', 'credential-leases'],
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ['heicode', 'agnet', 'audit'] })
|
||||
}
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (approvalId: string) =>
|
||||
approveAgnetApproval(approvalId, t('Approved from Manager audit page')),
|
||||
onSuccess: () => {
|
||||
toast.success(t('Approval accepted'))
|
||||
refreshApprovalState()
|
||||
},
|
||||
})
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (approvalId: string) =>
|
||||
rejectAgnetApproval(approvalId, t('Rejected from Manager audit page')),
|
||||
onSuccess: () => {
|
||||
toast.success(t('Approval rejected'))
|
||||
refreshApprovalState()
|
||||
},
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (leaseId: string) =>
|
||||
revokeAgnetCredentialLease(leaseId, t('Revoked from Manager audit page')),
|
||||
onSuccess: () => {
|
||||
toast.success(t('Credential lease revoked'))
|
||||
refreshApprovalState()
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
@@ -1236,6 +1395,72 @@ export function AgnetAuditPage() {
|
||||
'Per docs §6: plaintext credentials never appear here. Long-lived secrets live in the secret vault; only secret_ref and redacted previews are shown.'
|
||||
)}
|
||||
</p>
|
||||
<section className='grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]'>
|
||||
<div className='rounded-xl border border-border/70 bg-card/70 p-4'>
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold'>{t('Pending approvals')}</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Approve or reject high-risk Agnet operations before credentials are leased.')}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge phase='pending' />
|
||||
</div>
|
||||
<QueryState
|
||||
isLoading={approvalsQuery.isLoading}
|
||||
error={approvalsQuery.error}
|
||||
isEmpty={(approvalsQuery.data ?? []).length === 0}
|
||||
retry={() => void approvalsQuery.refetch()}
|
||||
loadingFallback={<LoadingGrid rows={2} height='h-24' />}
|
||||
emptyTitle={t('No pending approvals')}
|
||||
emptyDescription={t('High-risk operations will appear here.')}
|
||||
>
|
||||
<div className='grid gap-3'>
|
||||
{(approvalsQuery.data ?? []).map((approval) => (
|
||||
<AgnetApprovalCard
|
||||
key={approval.approval_id}
|
||||
approval={approval}
|
||||
approveBusy={approveMutation.isPending}
|
||||
rejectBusy={rejectMutation.isPending}
|
||||
onApprove={() => approveMutation.mutate(approval.approval_id)}
|
||||
onReject={() => rejectMutation.mutate(approval.approval_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</QueryState>
|
||||
</div>
|
||||
<div className='rounded-xl border border-border/70 bg-card/70 p-4'>
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold'>{t('Active credential leases')}</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Only short-lived lease references are shown. Revoke after the task ends.')}
|
||||
</p>
|
||||
</div>
|
||||
<ShieldCheck className='text-primary size-4' />
|
||||
</div>
|
||||
<QueryState
|
||||
isLoading={leasesQuery.isLoading}
|
||||
error={leasesQuery.error}
|
||||
isEmpty={(leasesQuery.data ?? []).length === 0}
|
||||
retry={() => void leasesQuery.refetch()}
|
||||
loadingFallback={<LoadingGrid rows={2} height='h-24' />}
|
||||
emptyTitle={t('No active credential leases')}
|
||||
emptyDescription={t('Approved credential leases will appear here.')}
|
||||
>
|
||||
<div className='grid gap-3'>
|
||||
{(leasesQuery.data ?? []).map((lease) => (
|
||||
<AgnetLeaseCard
|
||||
key={lease.lease_id}
|
||||
lease={lease}
|
||||
busy={revokeMutation.isPending}
|
||||
onRevoke={() => revokeMutation.mutate(lease.lease_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</QueryState>
|
||||
</div>
|
||||
</section>
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
error={auditError}
|
||||
|
||||
@@ -155,6 +155,15 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<p className='mt-6 text-center text-xs text-muted-foreground'>
|
||||
{t('Contact')}{' '}
|
||||
<a
|
||||
href='mailto:november@taijiaicloud.com'
|
||||
className='font-medium text-primary underline-offset-4 hover:underline'
|
||||
>
|
||||
november@taijiaicloud.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -414,8 +414,8 @@ export async function updateResource(
|
||||
return env.data
|
||||
}
|
||||
|
||||
export async function revokeResource(id: string): Promise<{ id: string; status: ResourceStatus }> {
|
||||
const env = await mcpFetch<Envelope<{ id: string; status: ResourceStatus }>>(
|
||||
export async function revokeResource(id: string): Promise<ResourceBinding> {
|
||||
const env = await mcpFetch<Envelope<ResourceBinding>>(
|
||||
`/api/resources/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
@@ -430,7 +430,7 @@ export async function revokeResource(id: string): Promise<{ id: string; status:
|
||||
// Field shape per contract §3.1; status enums per §3.0.
|
||||
// =============================================================================
|
||||
|
||||
export type GrantStatus = 'active' | 'suspended' | 'revoked' | 'expired'
|
||||
export type GrantStatus = 'pending' | 'active' | 'disabled' | 'revoked'
|
||||
|
||||
export type ResourceGrant = {
|
||||
id: string
|
||||
@@ -499,7 +499,7 @@ export async function createResourceGrant(body: CreateGrantBody): Promise<Resour
|
||||
return env.data
|
||||
}
|
||||
|
||||
export async function revokeResourceGrant(id: string): Promise<ResourceGrant | { id: string; status: GrantStatus }> {
|
||||
export async function revokeResourceGrant(id: string): Promise<ResourceGrant> {
|
||||
const env = await mcpFetch<Envelope<ResourceGrant>>(
|
||||
`/api/resource-grants/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' }
|
||||
|
||||
Reference in New Issue
Block a user