diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go new file mode 100644 index 0000000..3f6f15f --- /dev/null +++ b/heicode/controller/resource.go @@ -0,0 +1,374 @@ +package controller + +import ( + "errors" + "net/http" + "strings" + + "github.com/heicode/manager/common" + "github.com/heicode/manager/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var allowedResourceTypes = map[string]bool{ + "git": true, + "sk": true, + "project_document": true, + "cloud_account": true, + "cloud_resource": true, +} + +type resourceBindingPayload struct { + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + ResourceType string `json:"resource_type"` + Name string `json:"name"` + Provider string `json:"provider"` + ResourceRef string `json:"resource_ref"` + Metadata map[string]any `json:"metadata"` + PermissionScope map[string]any `json:"permission_scope"` + Constraints map[string]any `json:"constraints"` + Status string `json:"status"` + SecretRef string `json:"secret_ref"` + Secret string `json:"secret"` +} + +type resourceGrantPayload struct { + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + ResourceId int `json:"resource_id"` + Role string `json:"role"` + SubAgnetId string `json:"sub_agnet_id"` + PermissionScope map[string]any `json:"permission_scope"` + Constraints map[string]any `json:"constraints"` + Status string `json:"status"` + AuditNote string `json:"audit_note"` +} + +type resourceBindingResponse struct { + Id int `json:"id"` + UserId int `json:"user_id"` + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + ResourceType string `json:"resource_type"` + Name string `json:"name"` + Provider string `json:"provider"` + ResourceRef string `json:"resource_ref"` + Metadata map[string]any `json:"metadata"` + PermissionScope map[string]any `json:"permission_scope"` + Constraints map[string]any `json:"constraints"` + Status string `json:"status"` + SecretRef string `json:"secret_ref,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type resourceGrantResponse struct { + Id int `json:"id"` + UserId int `json:"user_id"` + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + ResourceId int `json:"resource_id"` + Role string `json:"role"` + SubAgnetId string `json:"sub_agnet_id"` + PermissionScope map[string]any `json:"permission_scope"` + Constraints map[string]any `json:"constraints"` + Status string `json:"status"` + AuditNote string `json:"audit_note"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingPayload, error) { + p.TenantId = strings.TrimSpace(p.TenantId) + p.ProjectId = strings.TrimSpace(p.ProjectId) + p.ResourceType = strings.TrimSpace(p.ResourceType) + p.Name = strings.TrimSpace(p.Name) + p.Provider = strings.TrimSpace(p.Provider) + p.ResourceRef = strings.TrimSpace(p.ResourceRef) + p.Status = strings.TrimSpace(p.Status) + p.SecretRef = strings.TrimSpace(p.SecretRef) + p.Secret = strings.TrimSpace(p.Secret) + if p.TenantId == "" { + return p, errors.New("tenant_id required") + } + if p.ProjectId == "" { + return p, errors.New("project_id required") + } + if !allowedResourceTypes[p.ResourceType] { + return p, errors.New("resource_type must be one of git, sk, project_document, cloud_account, cloud_resource") + } + if p.Name == "" { + return p, errors.New("name required") + } + if p.ResourceRef == "" { + return p, errors.New("resource_ref required") + } + if p.Secret != "" { + return p, errors.New("secret plaintext is not accepted; store credentials in Secret Store and pass secret_ref") + } + if p.Provider == "" { + p.Provider = "custom" + } + if p.Status == "" { + p.Status = "active" + } + if p.Metadata == nil { + p.Metadata = map[string]any{} + } + if p.PermissionScope == nil { + p.PermissionScope = map[string]any{} + } + if p.Constraints == nil { + p.Constraints = map[string]any{} + } + return p, nil +} + +func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) { + p.TenantId = strings.TrimSpace(p.TenantId) + p.ProjectId = strings.TrimSpace(p.ProjectId) + p.Role = strings.TrimSpace(p.Role) + p.SubAgnetId = strings.TrimSpace(p.SubAgnetId) + p.Status = strings.TrimSpace(p.Status) + p.AuditNote = strings.TrimSpace(p.AuditNote) + if p.TenantId == "" { + return p, errors.New("tenant_id required") + } + if p.ProjectId == "" { + return p, errors.New("project_id required") + } + if p.ResourceId <= 0 { + return p, errors.New("resource_id required") + } + if p.Role == "" { + return p, errors.New("role required") + } + if p.Status == "" { + p.Status = "active" + } + if p.PermissionScope == nil { + p.PermissionScope = map[string]any{} + } + if p.Constraints == nil { + p.Constraints = map[string]any{} + } + return p, nil +} + +func marshalResourceMaps(metadata map[string]any, permissionScope map[string]any, constraints map[string]any) (string, string, string, error) { + metadataBytes, err := common.Marshal(metadata) + if err != nil { + return "", "", "", err + } + permissionBytes, err := common.Marshal(permissionScope) + if err != nil { + return "", "", "", err + } + constraintBytes, err := common.Marshal(constraints) + if err != nil { + return "", "", "", err + } + return string(metadataBytes), string(permissionBytes), string(constraintBytes), nil +} + +func parseMapField(raw string) map[string]any { + out := map[string]any{} + if raw != "" { + _ = common.UnmarshalJsonStr(raw, &out) + } + return out +} + +func resourceBindingToResponse(resource model.ResourceBinding) resourceBindingResponse { + return resourceBindingResponse{ + Id: resource.Id, + UserId: resource.UserId, + TenantId: resource.TenantId, + ProjectId: resource.ProjectId, + ResourceType: resource.ResourceType, + Name: resource.Name, + Provider: resource.Provider, + ResourceRef: resource.ResourceRef, + Metadata: parseMapField(resource.Metadata), + PermissionScope: parseMapField(resource.PermissionScope), + Constraints: parseMapField(resource.Constraints), + Status: resource.Status, + SecretRef: resource.SecretRef, + CreatedAt: resource.CreatedAt, + UpdatedAt: resource.UpdatedAt, + } +} + +func resourceGrantToResponse(grant model.ResourceGrant) resourceGrantResponse { + return resourceGrantResponse{ + Id: grant.Id, + UserId: grant.UserId, + TenantId: grant.TenantId, + ProjectId: grant.ProjectId, + ResourceId: grant.ResourceId, + Role: grant.Role, + SubAgnetId: grant.SubAgnetId, + PermissionScope: parseMapField(grant.PermissionScope), + Constraints: parseMapField(grant.Constraints), + Status: grant.Status, + AuditNote: grant.AuditNote, + CreatedAt: grant.CreatedAt, + UpdatedAt: grant.UpdatedAt, + } +} + +func ListResources(c *gin.Context) { + userId := c.GetInt("id") + query := model.DB.Where("user_id = ?", userId) + if tenantId := strings.TrimSpace(c.Query("tenant_id")); tenantId != "" { + query = query.Where("tenant_id = ?", tenantId) + } + if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { + query = query.Where("project_id = ?", projectId) + } + var resources []model.ResourceBinding + if err := query.Order("id desc").Find(&resources).Error; err != nil { + common.ApiError(c, err) + return + } + items := make([]resourceBindingResponse, 0, len(resources)) + for _, resource := range resources { + items = append(items, resourceBindingToResponse(resource)) + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +func CreateResource(c *gin.Context) { + userId := c.GetInt("id") + var payload resourceBindingPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) + return + } + payload, err := normalizeResourceBindingPayload(payload) + if err != nil { + common.ApiError(c, err) + return + } + metadata, permissionScope, constraints, err := marshalResourceMaps(payload.Metadata, payload.PermissionScope, payload.Constraints) + if err != nil { + common.ApiError(c, err) + return + } + resource := model.ResourceBinding{ + UserId: userId, + TenantId: payload.TenantId, + ProjectId: payload.ProjectId, + ResourceType: payload.ResourceType, + Name: payload.Name, + Provider: payload.Provider, + ResourceRef: payload.ResourceRef, + Metadata: metadata, + PermissionScope: permissionScope, + Constraints: constraints, + Status: payload.Status, + SecretRef: payload.SecretRef, + } + if err := model.DB.Create(&resource).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, resourceBindingToResponse(resource)) +} + +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) + return + } + if res.RowsAffected == 0 { + common.ApiErrorMsg(c, "resource not found") + return + } + common.ApiSuccess(c, gin.H{"deleted": true}) +} + +func ListResourceGrants(c *gin.Context) { + userId := c.GetInt("id") + query := model.DB.Where("user_id = ?", userId) + if tenantId := strings.TrimSpace(c.Query("tenant_id")); tenantId != "" { + query = query.Where("tenant_id = ?", tenantId) + } + if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { + query = query.Where("project_id = ?", projectId) + } + var grants []model.ResourceGrant + if err := query.Order("id desc").Find(&grants).Error; err != nil { + common.ApiError(c, err) + return + } + items := make([]resourceGrantResponse, 0, len(grants)) + for _, grant := range grants { + items = append(items, resourceGrantToResponse(grant)) + } + common.ApiSuccess(c, gin.H{"items": items}) +} + +func CreateResourceGrant(c *gin.Context) { + userId := c.GetInt("id") + var payload resourceGrantPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) + return + } + payload, err := normalizeResourceGrantPayload(payload) + if err != nil { + common.ApiError(c, err) + return + } + var resource model.ResourceBinding + if err := model.DB.Where("id = ? AND user_id = ? AND tenant_id = ? AND project_id = ?", payload.ResourceId, userId, payload.TenantId, payload.ProjectId).First(&resource).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "resource not found") + return + } + common.ApiError(c, err) + return + } + _, permissionScope, constraints, err := marshalResourceMaps(map[string]any{}, payload.PermissionScope, payload.Constraints) + if err != nil { + common.ApiError(c, err) + return + } + grant := model.ResourceGrant{ + UserId: userId, + TenantId: payload.TenantId, + ProjectId: payload.ProjectId, + ResourceId: payload.ResourceId, + Role: payload.Role, + SubAgnetId: payload.SubAgnetId, + PermissionScope: permissionScope, + Constraints: constraints, + Status: payload.Status, + AuditNote: payload.AuditNote, + } + if err := model.DB.Create(&grant).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, resourceGrantToResponse(grant)) +} + +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) + return + } + if res.RowsAffected == 0 { + common.ApiErrorMsg(c, "resource grant not found") + return + } + common.ApiSuccess(c, gin.H{"deleted": true}) +} diff --git a/heicode/model/resource.go b/heicode/model/resource.go new file mode 100644 index 0000000..8c1b07f --- /dev/null +++ b/heicode/model/resource.go @@ -0,0 +1,35 @@ +package model + +type ResourceBinding struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index;not null"` + TenantId string `json:"tenant_id" gorm:"type:varchar(64);index;not null"` + ProjectId string `json:"project_id" gorm:"type:varchar(128);index;not null"` + ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"` + Name string `json:"name" gorm:"type:varchar(128);not null"` + Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"` + ResourceRef string `json:"resource_ref" gorm:"type:varchar(512);not null"` + Metadata string `json:"metadata" gorm:"type:text"` + PermissionScope string `json:"permission_scope" gorm:"type:text"` + Constraints string `json:"constraints" gorm:"type:text"` + Status string `json:"status" gorm:"type:varchar(32);default:'active'"` + SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` +} + +type ResourceGrant struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index;not null"` + TenantId string `json:"tenant_id" gorm:"type:varchar(64);index;not null"` + ProjectId string `json:"project_id" gorm:"type:varchar(128);index;not null"` + ResourceId int `json:"resource_id" gorm:"index;not null"` + Role string `json:"role" gorm:"type:varchar(128);not null"` + SubAgnetId string `json:"sub_agnet_id" gorm:"type:varchar(128);index"` + PermissionScope string `json:"permission_scope" gorm:"type:text"` + Constraints string `json:"constraints" gorm:"type:text"` + Status string `json:"status" gorm:"type:varchar(32);default:'active'"` + AuditNote string `json:"audit_note" gorm:"type:text"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` +}