diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index 3f6f15f..a8ba42d 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -2,7 +2,8 @@ package controller import ( "errors" - "net/http" + "fmt" + "strconv" "strings" "github.com/heicode/manager/common" @@ -12,102 +13,96 @@ import ( "gorm.io/gorm" ) -var allowedResourceTypes = map[string]bool{ - "git": true, - "sk": true, - "project_document": true, - "cloud_account": true, - "cloud_resource": true, +type resourcePayload struct { + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + Name string `json:"name"` + Type string `json:"type"` + Provider string `json:"provider"` + Target string `json:"target"` + Metadata map[string]any `json:"metadata"` + Scopes []string `json:"scopes"` + Constraints map[string]any `json:"constraints"` + SecretRef string `json:"secret_ref"` + Status string `json:"status"` } -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 resourceResponse struct { + Id int `json:"id"` + UserId int `json:"user_id"` + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + Name string `json:"name"` + Type string `json:"type"` + Provider string `json:"provider"` + Target string `json:"target"` + Metadata map[string]any `json:"metadata"` + Scopes []string `json:"scopes"` + Constraints map[string]any `json:"constraints"` + SecretRef string `json:"secret_ref"` + 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"` - 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"` + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + ResourceId int `json:"resource_id"` + AgnetId string `json:"agnet_id"` + Role string `json:"role"` + Scopes []string `json:"scopes"` + 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"` - 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"` + Id int `json:"id"` + UserId int `json:"user_id"` + TenantId string `json:"tenant_id"` + ProjectId string `json:"project_id"` + ResourceId int `json:"resource_id"` + AgnetId string `json:"agnet_id"` + Role string `json:"role"` + Scopes []string `json:"scopes"` + Constraints map[string]any `json:"constraints"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } -func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingPayload, error) { +var allowedResourceTypes = map[string]bool{ + model.ResourceTypeGit: true, + model.ResourceTypeSK: true, + model.ResourceTypeProjectDocument: true, + model.ResourceTypeCloudAccount: true, + model.ResourceTypeCloudResource: true, +} + +func normalizeResourcePayload(p resourcePayload) (resourcePayload, 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.Type = strings.TrimSpace(p.Type) p.Provider = strings.TrimSpace(p.Provider) - p.ResourceRef = strings.TrimSpace(p.ResourceRef) - p.Status = strings.TrimSpace(p.Status) + p.Target = strings.TrimSpace(p.Target) p.SecretRef = strings.TrimSpace(p.SecretRef) - p.Secret = strings.TrimSpace(p.Secret) + p.Status = strings.TrimSpace(p.Status) + 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 !allowedResourceTypes[p.Type] { + return p, errors.New("type must be one of git, sk, project_document, cloud_account, cloud_resource") } - if p.Secret != "" { - return p, errors.New("secret plaintext is not accepted; store credentials in Secret Store and pass secret_ref") + if p.Target == "" { + return p, errors.New("target required") } if p.Provider == "" { p.Provider = "custom" @@ -115,25 +110,22 @@ func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingP if p.Status == "" { p.Status = "active" } - if p.Metadata == nil { - p.Metadata = map[string]any{} + if err := rejectSecretLikeFields("metadata", p.Metadata); err != nil { + return p, err } - if p.PermissionScope == nil { - p.PermissionScope = map[string]any{} - } - if p.Constraints == nil { - p.Constraints = map[string]any{} + if err := rejectSecretLikeFields("constraints", p.Constraints); err != nil { + return p, err } + p.Scopes = normalizeStringList(p.Scopes) return p, nil } func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) { p.TenantId = strings.TrimSpace(p.TenantId) p.ProjectId = strings.TrimSpace(p.ProjectId) + p.AgnetId = strings.TrimSpace(p.AgnetId) 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") } @@ -143,81 +135,137 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload if p.ResourceId <= 0 { return p, errors.New("resource_id required") } + if p.AgnetId == "" { + return p, errors.New("agnet_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{} + if err := rejectSecretLikeFields("constraints", p.Constraints); err != nil { + return p, err } + p.Scopes = normalizeStringList(p.Scopes) 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 +func normalizeStringList(values []string) []string { + items := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + items = append(items, value) } - 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 + return items } -func parseMapField(raw string) map[string]any { - out := map[string]any{} - if raw != "" { - _ = common.UnmarshalJsonStr(raw, &out) +func rejectSecretLikeFields(path string, value any) error { + switch typed := value.(type) { + case nil: + return nil + case map[string]any: + for key, nested := range typed { + if isSecretLikeKey(key) { + return fmt.Errorf("%s.%s must not contain raw secret material", path, key) + } + if err := rejectSecretLikeFields(path+"."+key, nested); err != nil { + return err + } + } + case []any: + for i, nested := range typed { + if err := rejectSecretLikeFields(fmt.Sprintf("%s[%d]", path, i), nested); err != nil { + return err + } + } } - return out + return nil } -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 isSecretLikeKey(key string) bool { + key = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "-", "_")) + secretKeys := []string{"token", "secret", "password", "private_key", "access_key", "refresh_token", "client_secret"} + for _, secretKey := range secretKeys { + if strings.Contains(key, secretKey) { + return true + } } + return false +} + +func marshalResourcePayload(p resourcePayload) (metadata, scopes, constraints string, err error) { + metadataBytes, err := common.Marshal(defaultMap(p.Metadata)) + if err != nil { + return "", "", "", err + } + scopeBytes, err := common.Marshal(p.Scopes) + if err != nil { + return "", "", "", err + } + constraintBytes, err := common.Marshal(defaultMap(p.Constraints)) + if err != nil { + return "", "", "", err + } + return string(metadataBytes), string(scopeBytes), string(constraintBytes), nil +} + +func marshalResourceGrantPayload(p resourceGrantPayload) (scopes, constraints string, err error) { + scopeBytes, err := common.Marshal(p.Scopes) + if err != nil { + return "", "", err + } + constraintBytes, err := common.Marshal(defaultMap(p.Constraints)) + if err != nil { + return "", "", err + } + return string(scopeBytes), string(constraintBytes), nil +} + +func defaultMap(value map[string]any) map[string]any { + if value == nil { + return map[string]any{} + } + return value +} + +func resourceToResponse(src model.Resource) resourceResponse { + metadata := map[string]any{} + constraints := map[string]any{} + var scopes []string + if src.Metadata != "" { + _ = common.UnmarshalJsonStr(src.Metadata, &metadata) + } + if src.Scopes != "" { + _ = common.UnmarshalJsonStr(src.Scopes, &scopes) + } + if src.Constraints != "" { + _ = common.UnmarshalJsonStr(src.Constraints, &constraints) + } + if scopes == nil { + scopes = []string{} + } + return resourceResponse{Id: src.Id, UserId: src.UserId, TenantId: src.TenantId, ProjectId: src.ProjectId, Name: src.Name, Type: src.Type, Provider: src.Provider, Target: src.Target, Metadata: metadata, Scopes: scopes, Constraints: constraints, SecretRef: src.SecretRef, Status: src.Status, CreatedAt: src.CreatedAt, UpdatedAt: src.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, + constraints := map[string]any{} + var scopes []string + if grant.Scopes != "" { + _ = common.UnmarshalJsonStr(grant.Scopes, &scopes) } + if grant.Constraints != "" { + _ = common.UnmarshalJsonStr(grant.Constraints, &constraints) + } + if scopes == nil { + scopes = []string{} + } + return resourceGrantResponse{Id: grant.Id, UserId: grant.UserId, TenantId: grant.TenantId, ProjectId: grant.ProjectId, ResourceId: grant.ResourceId, AgnetId: grant.AgnetId, Role: grant.Role, Scopes: scopes, Constraints: constraints, Status: grant.Status, CreatedAt: grant.CreatedAt, UpdatedAt: grant.UpdatedAt} } func ListResources(c *gin.Context) { @@ -229,59 +277,100 @@ func ListResources(c *gin.Context) { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { query = query.Where("project_id = ?", projectId) } - var resources []model.ResourceBinding + var resources []model.Resource 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)) + items := make([]resourceResponse, 0, len(resources)) + for _, src := range resources { + items = append(items, resourceToResponse(src)) } common.ApiSuccess(c, gin.H{"items": items}) } func CreateResource(c *gin.Context) { userId := c.GetInt("id") - var payload resourceBindingPayload + var payload resourcePayload if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) + common.ApiErrorMsg(c, "invalid params") return } - payload, err := normalizeResourceBindingPayload(payload) + payload, err := normalizeResourcePayload(payload) if err != nil { common.ApiError(c, err) return } - metadata, permissionScope, constraints, err := marshalResourceMaps(payload.Metadata, payload.PermissionScope, payload.Constraints) + metadata, scopes, constraints, err := marshalResourcePayload(payload) 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, - } + resource := model.Resource{UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, Name: payload.Name, Type: payload.Type, Provider: payload.Provider, Target: payload.Target, Metadata: metadata, Scopes: scopes, Constraints: constraints, SecretRef: payload.SecretRef, Status: payload.Status} if err := model.DB.Create(&resource).Error; err != nil { common.ApiError(c, err) return } - common.ApiSuccess(c, resourceBindingToResponse(resource)) + common.ApiSuccess(c, resourceToResponse(resource)) +} + +func UpdateResource(c *gin.Context) { + userId := c.GetInt("id") + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + common.ApiErrorMsg(c, "invalid resource id") + return + } + var resource model.Resource + if err := model.DB.Where("id = ? AND user_id = ?", 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 { + common.ApiErrorMsg(c, "invalid params") + return + } + payload, err = normalizeResourcePayload(payload) + if err != nil { + common.ApiError(c, err) + return + } + metadata, scopes, constraints, err := marshalResourcePayload(payload) + if err != nil { + common.ApiError(c, err) + return + } + resource.TenantId = payload.TenantId + resource.ProjectId = payload.ProjectId + resource.Name = payload.Name + resource.Type = payload.Type + resource.Provider = payload.Provider + resource.Target = payload.Target + resource.Metadata = metadata + resource.Scopes = scopes + resource.Constraints = constraints + resource.SecretRef = payload.SecretRef + 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{}) + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + common.ApiErrorMsg(c, "invalid resource id") + return + } + res := model.DB.Where("id = ? AND user_id = ?", id, userId).Delete(&model.Resource{}) if res.Error != nil { common.ApiError(c, res.Error) return @@ -318,7 +407,7 @@ 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"}) + common.ApiErrorMsg(c, "invalid params") return } payload, err := normalizeResourceGrantPayload(payload) @@ -326,7 +415,7 @@ func CreateResourceGrant(c *gin.Context) { common.ApiError(c, err) return } - var resource model.ResourceBinding + var resource model.Resource 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") @@ -335,23 +424,12 @@ func CreateResourceGrant(c *gin.Context) { common.ApiError(c, err) return } - _, permissionScope, constraints, err := marshalResourceMaps(map[string]any{}, payload.PermissionScope, payload.Constraints) + scopes, constraints, err := marshalResourceGrantPayload(payload) 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, - } + grant := model.ResourceGrant{UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, ResourceId: payload.ResourceId, AgnetId: payload.AgnetId, Role: payload.Role, Scopes: scopes, Constraints: constraints, Status: payload.Status} if err := model.DB.Create(&grant).Error; err != nil { common.ApiError(c, err) return @@ -361,7 +439,12 @@ func CreateResourceGrant(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{}) + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + common.ApiErrorMsg(c, "invalid grant id") + return + } + res := model.DB.Where("id = ? AND user_id = ?", id, userId).Delete(&model.ResourceGrant{}) if res.Error != nil { common.ApiError(c, res.Error) return diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index 4aa98ba..9fdcbc3 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -1,140 +1,65 @@ package controller import ( - "net/http" "testing" - "github.com/heicode/manager/common" "github.com/heicode/manager/model" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func setupResourceControllerTestDB(t *testing.T) { - t.Helper() - db := openTokenControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.ResourceBinding{}, &model.ResourceGrant{})) -} - -func decodeResourceBindingData(t *testing.T, raw []byte) resourceBindingResponse { - t.Helper() - var response tokenAPIResponse - require.NoError(t, common.Unmarshal(raw, &response)) - require.True(t, response.Success, response.Message) - var data resourceBindingResponse - require.NoError(t, common.Unmarshal(response.Data, &data)) - return data -} - -func decodeResourceGrantData(t *testing.T, raw []byte) resourceGrantResponse { - t.Helper() - var response tokenAPIResponse - require.NoError(t, common.Unmarshal(raw, &response)) - require.True(t, response.Success, response.Message) - var data resourceGrantResponse - require.NoError(t, common.Unmarshal(response.Data, &data)) - return data -} - -func TestCreateResourceAcceptsSecretRefOnly(t *testing.T) { - setupResourceControllerTestDB(t) - - body := map[string]any{ - "tenant_id": "tenant-a", - "project_id": "project-a", - "resource_type": "git", - "name": "main repo", - "provider": "github", - "resource_ref": "https://github.com/example/repo.git", - "metadata": map[string]any{ - "ref": "main", +func TestNormalizeResourcePayloadAcceptsP1ResourceModel(t *testing.T) { + payload, err := normalizeResourcePayload(resourcePayload{ + TenantId: " tenant-a ", + ProjectId: " project-a ", + Name: " repo ", + Type: model.ResourceTypeGit, + Target: "https://github.com/example/repo", + Metadata: map[string]any{ + "repo_url": "https://github.com/example/repo", + "ref": "main", }, - "permission_scope": map[string]any{ - "actions": []string{"read", "write"}, + Scopes: []string{" read ", "write", "read", ""}, + Constraints: map[string]any{ + "allowed_paths": []any{".", "docs"}, }, - "constraints": map[string]any{ - "paths": []string{"src"}, - }, - "secret_ref": "vault://tenant-a/git/main-repo", - } - ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resources/", body, 7) + SecretRef: "vault://tenant-a/git/repo", + }) - CreateResource(ctx) - - data := decodeResourceBindingData(t, recorder.Body.Bytes()) - assert.Equal(t, 7, data.UserId) - assert.Equal(t, "tenant-a", data.TenantId) - assert.Equal(t, "project-a", data.ProjectId) - assert.Equal(t, "git", data.ResourceType) - assert.Equal(t, "vault://tenant-a/git/main-repo", data.SecretRef) - assert.Equal(t, "main", data.Metadata["ref"]) - assert.NotContains(t, recorder.Body.String(), "plain-token") + require.NoError(t, err) + require.Equal(t, "tenant-a", payload.TenantId) + require.Equal(t, "project-a", payload.ProjectId) + require.Equal(t, "custom", payload.Provider) + require.Equal(t, "active", payload.Status) + require.Equal(t, []string{"read", "write"}, payload.Scopes) } -func TestCreateResourceRejectsPlaintextSecret(t *testing.T) { - setupResourceControllerTestDB(t) +func TestNormalizeResourcePayloadRejectsRawSecretMaterial(t *testing.T) { + _, err := normalizeResourcePayload(resourcePayload{ + TenantId: "tenant-a", + ProjectId: "project-a", + Name: "cloud", + Type: model.ResourceTypeCloudAccount, + Target: "azure-subscription-1", + Metadata: map[string]any{ + "access_token": "must-not-be-saved", + }, + }) - body := map[string]any{ - "tenant_id": "tenant-a", - "project_id": "project-a", - "resource_type": "cloud_account", - "name": "aws account", - "resource_ref": "arn:aws:organizations::123456789012:account/o-example/123456789012", - "secret": "plain-token", - } - ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resources/", body, 7) - - CreateResource(ctx) - - var response tokenAPIResponse - require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) - assert.False(t, response.Success) - assert.Contains(t, response.Message, "secret plaintext is not accepted") - assert.NotContains(t, recorder.Body.String(), "plain-token") + require.Error(t, err) + require.Contains(t, err.Error(), "must not contain raw secret material") } -func TestCreateResourceGrantBindsProjectRoleAndSubAgnet(t *testing.T) { - setupResourceControllerTestDB(t) +func TestNormalizeResourceGrantPayloadRequiresAgnetRoleBinding(t *testing.T) { + payload, err := normalizeResourceGrantPayload(resourceGrantPayload{ + TenantId: "tenant-a", + ProjectId: "project-a", + ResourceId: 42, + AgnetId: "builder-1", + Role: "developer", + Scopes: []string{"read", "read", "write"}, + }) - resource := model.ResourceBinding{ - UserId: 7, - TenantId: "tenant-a", - ProjectId: "project-a", - ResourceType: "cloud_resource", - Name: "staging vm", - Provider: "azure", - ResourceRef: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm-a", - Metadata: "{}", - PermissionScope: "{}", - Constraints: "{}", - Status: "active", - SecretRef: "vault://tenant-a/azure/vm-a", - } - require.NoError(t, model.DB.Create(&resource).Error) - - body := map[string]any{ - "tenant_id": "tenant-a", - "project_id": "project-a", - "resource_id": resource.Id, - "role": "frontend-developer", - "sub_agnet_id": "agnet-worker-1", - "permission_scope": map[string]any{ - "actions": []string{"read", "deploy"}, - }, - "constraints": map[string]any{ - "environment": "staging", - }, - "audit_note": "grant for deployment smoke test", - } - ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resource-grants/", body, 7) - - CreateResourceGrant(ctx) - - data := decodeResourceGrantData(t, recorder.Body.Bytes()) - assert.Equal(t, "tenant-a", data.TenantId) - assert.Equal(t, "project-a", data.ProjectId) - assert.Equal(t, resource.Id, data.ResourceId) - assert.Equal(t, "frontend-developer", data.Role) - assert.Equal(t, "agnet-worker-1", data.SubAgnetId) - assert.Equal(t, "staging", data.Constraints["environment"]) + require.NoError(t, err) + require.Equal(t, []string{"read", "write"}, payload.Scopes) + require.Equal(t, "active", payload.Status) } diff --git a/heicode/model/main.go b/heicode/model/main.go index e6fd180..222139a 100644 --- a/heicode/model/main.go +++ b/heicode/model/main.go @@ -281,7 +281,7 @@ func migrateDB() error { &CustomOAuthProvider{}, &UserOAuthBinding{}, &GitSource{}, - &ResourceBinding{}, + &Resource{}, &ResourceGrant{}, ) if err != nil { @@ -332,7 +332,7 @@ func migrateDBFast() error { {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, {&GitSource{}, "GitSource"}, - {&ResourceBinding{}, "ResourceBinding"}, + {&Resource{}, "Resource"}, {&ResourceGrant{}, "ResourceGrant"}, } // 动态计算migration数量,确保errChan缓冲区足够大 diff --git a/heicode/model/resource.go b/heicode/model/resource.go index 8c1b07f..761bcae 100644 --- a/heicode/model/resource.go +++ b/heicode/model/resource.go @@ -1,35 +1,42 @@ 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"` +const ( + ResourceTypeGit = "git" + ResourceTypeSK = "sk" + ResourceTypeProjectDocument = "project_document" + ResourceTypeCloudAccount = "cloud_account" + ResourceTypeCloudResource = "cloud_resource" +) + +type Resource 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"` + Name string `json:"name" gorm:"type:varchar(128);not null"` + Type string `json:"type" gorm:"type:varchar(32);index;not null"` + Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"` + Target string `json:"target" gorm:"type:varchar(512);not null"` + Metadata string `json:"metadata" gorm:"type:text"` + Scopes string `json:"scopes" gorm:"type:text"` + Constraints string `json:"constraints" gorm:"type:text"` + SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"` + Status string `json:"status" gorm:"type:varchar(32);default:'active';index"` + 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"` + 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"` + AgnetId string `json:"agnet_id" gorm:"type:varchar(128);index;not null"` + Role string `json:"role" gorm:"type:varchar(128);index;not null"` + Scopes string `json:"scopes" gorm:"type:text"` + Constraints string `json:"constraints" gorm:"type:text"` + Status string `json:"status" gorm:"type:varchar(32);default:'active';index"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` } diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 1fc9817..84870b4 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -189,6 +189,7 @@ func SetApiRouter(router *gin.Engine) { { resourceRoute.GET("/", controller.ListResources) resourceRoute.POST("/", controller.CreateResource) + resourceRoute.PUT("/:id", controller.UpdateResource) resourceRoute.DELETE("/:id", controller.DeleteResource) }