package controller import ( "errors" "fmt" "net/http" "sort" "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, } var allowedResourceStatuses = map[string]bool{ "active": true, "disabled": true, "revoked": true, } var secretLikeKeys = map[string]bool{ "access_key": true, "access_key_id": true, "api_key": true, "client_secret": true, "database_password": true, "db_password": true, "newapi_key": true, "password": true, "private_key": true, "refresh_token": true, "secret": true, "secret_key": true, "ssh_key": true, "token": true, } type resourcePayload struct { TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` BindingScope string `json:"binding_scope"` Name string `json:"name"` ResourceType string `json:"resource_type"` Provider string `json:"provider"` ExternalId string `json:"external_id"` SecretRef string `json:"secret_ref"` Metadata map[string]any `json:"metadata"` PermissionScope map[string]any `json:"permission_scope"` Constraints map[string]any `json:"constraints"` Status string `json:"status"` } type resourceSecretPayload struct { Data map[string]any `json:"data"` } type resourceResponse struct { Id int `json:"id"` UserId int `json:"user_id"` TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` BindingScope string `json:"binding_scope"` Name string `json:"name"` ResourceType string `json:"resource_type"` Provider string `json:"provider"` ExternalId string `json:"external_id"` SecretRef string `json:"secret_ref"` Metadata map[string]any `json:"metadata"` PermissionScope map[string]any `json:"permission_scope"` Constraints map[string]any `json:"constraints"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } type resourceGrantPayload struct { TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` BindingScope string `json:"binding_scope"` ResourceId int `json:"resource_id"` Role string `json:"role"` AgnetId string `json:"agnet_id"` PermissionScope map[string]any `json:"permission_scope"` Constraints map[string]any `json:"constraints"` Status string `json:"status"` } type resourceGrantResponse struct { Id int `json:"id"` UserId int `json:"user_id"` TenantId string `json:"tenant_id"` ProjectId string `json:"project_id"` BindingScope string `json:"binding_scope"` ResourceId int `json:"resource_id"` Role string `json:"role"` AgnetId string `json:"agnet_id"` PermissionScope map[string]any `json:"permission_scope"` Constraints map[string]any `json:"constraints"` Status string `json:"status"` RevokedAt int64 `json:"revoked_at"` Resource *resourceResponse `json:"resource,omitempty"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { p.TenantId = strings.TrimSpace(p.TenantId) p.ProjectId = strings.TrimSpace(p.ProjectId) p.BindingScope = strings.TrimSpace(p.BindingScope) p.Name = strings.TrimSpace(p.Name) p.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType)) p.Provider = strings.TrimSpace(p.Provider) p.ExternalId = strings.TrimSpace(p.ExternalId) p.SecretRef = strings.TrimSpace(p.SecretRef) p.Status = strings.ToLower(strings.TrimSpace(p.Status)) if p.BindingScope == "" { p.BindingScope = inferResourceBindingScope(p) } if p.Name == "" { return p, errors.New("name required") } if !allowedResourceTypes[p.ResourceType] { return p, fmt.Errorf("resource_type must be one of %s", strings.Join(sortedResourceTypes(), ", ")) } if p.Provider == "" { p.Provider = "custom" } if p.Status == "" { p.Status = "active" } if !allowedResourceStatuses[p.Status] { return p, errors.New("status must be active, disabled, or revoked") } 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{} } if containsPlaintextSecret(p.Metadata) || containsPlaintextSecret(p.PermissionScope) || containsPlaintextSecret(p.Constraints) { return p, errors.New("plaintext secrets are not allowed; store credentials in Secret Store and provide secret_ref only") } return p, nil } func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) { p.TenantId = strings.TrimSpace(p.TenantId) p.ProjectId = strings.TrimSpace(p.ProjectId) p.BindingScope = strings.TrimSpace(p.BindingScope) p.Role = strings.TrimSpace(p.Role) p.AgnetId = strings.TrimSpace(p.AgnetId) p.Status = strings.ToLower(strings.TrimSpace(p.Status)) if p.ResourceId <= 0 { return p, errors.New("resource_id required") } if p.Role == "" { return p, errors.New("role required") } if p.AgnetId == "" { return p, errors.New("agnet_id required") } if p.Status == "" { p.Status = "active" } if !allowedResourceStatuses[p.Status] { return p, errors.New("status must be active, disabled, or revoked") } if p.PermissionScope == nil { p.PermissionScope = map[string]any{} } if p.Constraints == nil { p.Constraints = map[string]any{} } if containsPlaintextSecret(p.PermissionScope) || containsPlaintextSecret(p.Constraints) { return p, errors.New("plaintext secrets are not allowed; store credentials in Secret Store and provide secret_ref only") } return p, nil } func inferResourceBindingScope(p resourcePayload) string { switch { case p.ExternalId != "": return p.ExternalId case p.ProjectId != "": return p.ProjectId case p.TenantId != "": return p.TenantId case p.Name != "": return p.Name default: return "resource" } } func sortedResourceTypes() []string { types := make([]string, 0, len(allowedResourceTypes)) for resourceType := range allowedResourceTypes { types = append(types, resourceType) } sort.Strings(types) return types } func containsPlaintextSecret(value any) bool { switch v := value.(type) { case map[string]any: for key, child := range v { if isSecretLikeKey(key) { return true } if containsPlaintextSecret(child) { return true } } case []any: for _, child := range v { if containsPlaintextSecret(child) { return true } } } return false } func isSecretLikeKey(key string) bool { normalized := strings.ToLower(strings.TrimSpace(key)) normalized = strings.ReplaceAll(normalized, "-", "_") normalized = strings.ReplaceAll(normalized, " ", "_") if secretLikeKeys[normalized] { return true } return strings.HasSuffix(normalized, "_token") || strings.HasSuffix(normalized, "_secret") || strings.HasSuffix(normalized, "_password") || strings.HasSuffix(normalized, "_private_key") } func marshalResourceJSON(value map[string]any) (string, error) { if value == nil { value = map[string]any{} } data, err := common.Marshal(value) if err != nil { return "", err } return string(data), nil } func unmarshalResourceJSON(raw string) map[string]any { if strings.TrimSpace(raw) == "" { return map[string]any{} } var value map[string]any if err := common.UnmarshalJsonStr(raw, &value); err != nil || value == nil { return map[string]any{} } return value } func resourceToResponse(resource model.ResourceBinding) resourceResponse { return resourceResponse{ Id: resource.Id, UserId: resource.UserId, TenantId: resource.TenantId, ProjectId: resource.ProjectId, BindingScope: resource.BindingScope, Name: resource.Name, ResourceType: resource.ResourceType, Provider: resource.Provider, ExternalId: resource.ExternalId, SecretRef: resource.SecretRef, Metadata: unmarshalResourceJSON(resource.Metadata), PermissionScope: unmarshalResourceJSON(resource.PermissionScope), Constraints: unmarshalResourceJSON(resource.Constraints), Status: resource.Status, CreatedAt: resource.CreatedAt, UpdatedAt: resource.UpdatedAt, } } func resourceGrantToResponse(grant model.ResourceGrant, resource *model.ResourceBinding) resourceGrantResponse { resp := resourceGrantResponse{ Id: grant.Id, UserId: grant.UserId, TenantId: grant.TenantId, ProjectId: grant.ProjectId, BindingScope: grant.BindingScope, ResourceId: grant.ResourceId, Role: grant.Role, AgnetId: grant.AgnetId, PermissionScope: unmarshalResourceJSON(grant.PermissionScope), Constraints: unmarshalResourceJSON(grant.Constraints), Status: grant.Status, RevokedAt: grant.RevokedAt, CreatedAt: grant.CreatedAt, UpdatedAt: grant.UpdatedAt, } if resource != nil { resourceResp := resourceToResponse(*resource) resp.Resource = &resourceResp } return resp } 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) } if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" { query = query.Where("binding_scope = ?", bindingScope) } if resourceType := strings.TrimSpace(c.Query("resource_type")); resourceType != "" { query = query.Where("resource_type = ?", strings.ToLower(resourceType)) } var resources []model.ResourceBinding if err := query.Order("id desc").Find(&resources).Error; err != nil { common.ApiError(c, err) return } items := make([]resourceResponse, 0, len(resources)) for _, resource := range resources { items = append(items, resourceToResponse(resource)) } common.ApiSuccess(c, gin.H{"items": items}) } func CreateResource(c *gin.Context) { userId := c.GetInt("id") var payload resourcePayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) return } payload, err := normalizeResourcePayload(payload) if err != nil { common.ApiError(c, err) return } metadata, permissionScope, constraints, err := marshalResourcePayloadJSON(payload) if err != nil { common.ApiError(c, err) return } resource := model.ResourceBinding{ UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, BindingScope: payload.BindingScope, Name: payload.Name, ResourceType: payload.ResourceType, Provider: payload.Provider, ExternalId: payload.ExternalId, SecretRef: payload.SecretRef, Metadata: metadata, PermissionScope: permissionScope, Constraints: constraints, Status: payload.Status, } if err := model.DB.Create(&resource).Error; err != nil { common.ApiError(c, err) return } common.ApiSuccess(c, resourceToResponse(resource)) } func UpdateResource(c *gin.Context) { userId := c.GetInt("id") 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 } var payload resourcePayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) return } payload, err := normalizeResourcePayload(payload) if err != nil { common.ApiError(c, err) return } metadata, permissionScope, constraints, err := marshalResourcePayloadJSON(payload) if err != nil { common.ApiError(c, err) return } resource.TenantId = payload.TenantId resource.ProjectId = payload.ProjectId resource.BindingScope = payload.BindingScope resource.Name = payload.Name resource.ResourceType = payload.ResourceType resource.Provider = payload.Provider resource.ExternalId = payload.ExternalId resource.SecretRef = payload.SecretRef resource.Metadata = metadata resource.PermissionScope = permissionScope resource.Constraints = constraints resource.Status = payload.Status if err := model.DB.Save(&resource).Error; err != nil { common.ApiError(c, err) return } common.ApiSuccess(c, resourceToResponse(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 UpsertResourceSecret(c *gin.Context) { userId := c.GetInt("id") 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 } var payload resourceSecretPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) return } if len(payload.Data) == 0 { common.ApiErrorMsg(c, "secret data required") return } client, err := newSecretStoreClientFromEnv() if err != nil { common.ApiError(c, err) return } secretPath := resourceSecretPath(resource) if err := client.putKV2(secretPath, payload.Data); err != nil { common.ApiError(c, err) return } resource.SecretRef = fmt.Sprintf("vault://%s/%s", client.mount, secretPath) if err := model.DB.Save(&resource).Error; err != nil { common.ApiError(c, err) return } common.ApiSuccess(c, gin.H{ "resource": resourceToResponse(resource), "secret_ref": resource.SecretRef, }) } func resourceSecretPath(resource model.ResourceBinding) string { scope := resource.BindingScope if strings.TrimSpace(scope) == "" { scope = resource.ExternalId } if strings.TrimSpace(scope) == "" { scope = resource.Name } return strings.Join([]string{ "users", fmt.Sprintf("%d", resource.UserId), "bindings", sanitizeSecretPathSegment(scope), "resources", fmt.Sprintf("%d", resource.Id), }, "/") } func sanitizeSecretPathSegment(value string) string { value = strings.TrimSpace(value) if value == "" { return "_" } var b strings.Builder for _, r := range value { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= 'A' && r <= 'Z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '_' || r == '-' || r == '.': b.WriteRune(r) default: b.WriteRune('-') } } return b.String() } func marshalResourcePayloadJSON(payload resourcePayload) (string, string, string, error) { metadata, err := marshalResourceJSON(payload.Metadata) if err != nil { return "", "", "", err } permissionScope, err := marshalResourceJSON(payload.PermissionScope) if err != nil { return "", "", "", err } constraints, err := marshalResourceJSON(payload.Constraints) if err != nil { return "", "", "", err } return metadata, permissionScope, constraints, nil } 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) } if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" { query = query.Where("binding_scope = ?", bindingScope) } if agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" { query = query.Where("agnet_id = ?", agnetId) } 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, nil)) } 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 } resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope) if err != nil { common.ApiError(c, err) return } payload = inheritResourceGrantScope(payload, resource) permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) if err != nil { common.ApiError(c, err) return } grant := model.ResourceGrant{ UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, BindingScope: payload.BindingScope, ResourceId: payload.ResourceId, Role: payload.Role, AgnetId: payload.AgnetId, PermissionScope: permissionScope, Constraints: constraints, Status: payload.Status, } if err := model.DB.Create(&grant).Error; err != nil { common.ApiError(c, err) return } common.ApiSuccess(c, resourceGrantToResponse(grant, &resource)) } func UpdateResourceGrant(c *gin.Context) { userId := c.GetInt("id") 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 } 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 } resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope) if err != nil { common.ApiError(c, err) return } payload = inheritResourceGrantScope(payload, resource) permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) if err != nil { common.ApiError(c, err) return } grant.TenantId = payload.TenantId grant.ProjectId = payload.ProjectId grant.BindingScope = payload.BindingScope grant.ResourceId = payload.ResourceId grant.Role = payload.Role grant.AgnetId = payload.AgnetId grant.PermissionScope = permissionScope grant.Constraints = constraints grant.Status = payload.Status if payload.Status == "revoked" && grant.RevokedAt == 0 { grant.RevokedAt = common.GetTimestamp() } if payload.Status != "revoked" { grant.RevokedAt = 0 } if err := model.DB.Save(&grant).Error; err != nil { common.ApiError(c, err) return } common.ApiSuccess(c, resourceGrantToResponse(grant, &resource)) } 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}) } func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, string, error) { permissionScope, err := marshalResourceJSON(payload.PermissionScope) if err != nil { return "", "", err } constraints, err := marshalResourceJSON(payload.Constraints) if err != nil { return "", "", err } return permissionScope, constraints, nil } func inheritResourceGrantScope(payload resourceGrantPayload, resource model.ResourceBinding) resourceGrantPayload { if payload.BindingScope == "" { payload.BindingScope = resource.BindingScope } if payload.TenantId == "" { payload.TenantId = resource.TenantId } if payload.ProjectId == "" { payload.ProjectId = resource.ProjectId } return payload } func findGrantResource(userId int, resourceId int, bindingScope string) (model.ResourceBinding, error) { var resource model.ResourceBinding if err := model.DB.Where("id = ? AND user_id = ?", resourceId, userId).First(&resource).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return resource, errors.New("resource not found") } return resource, err } if bindingScope != "" && resource.BindingScope != "" && resource.BindingScope != bindingScope { return resource, errors.New("resource binding_scope does not match grant binding_scope") } return resource, nil }