From 05182f02773b91e1078f4bc69e23574c4b073ee6 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Sat, 2 May 2026 23:29:56 +0800 Subject: [PATCH] task: implement manager resource grants --- heicode/controller/resource.go | 417 +++++++++++----------------- heicode/controller/resource_test.go | 257 +++++++++-------- heicode/model/main.go | 4 +- heicode/model/resource.go | 65 ++--- heicode/router/api-router.go | 1 - 5 files changed, 325 insertions(+), 419 deletions(-) diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index a8ba42d..3f6f15f 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -2,8 +2,7 @@ package controller import ( "errors" - "fmt" - "strconv" + "net/http" "strings" "github.com/heicode/manager/common" @@ -13,96 +12,102 @@ import ( "gorm.io/gorm" ) -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"` +var allowedResourceTypes = map[string]bool{ + "git": true, + "sk": true, + "project_document": true, + "cloud_account": true, + "cloud_resource": true, } -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 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"` - AgnetId string `json:"agnet_id"` - Role string `json:"role"` - Scopes []string `json:"scopes"` - Constraints map[string]any `json:"constraints"` - Status string `json:"status"` + 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"` - 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"` + 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"` } -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) { +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.Type = strings.TrimSpace(p.Type) p.Provider = strings.TrimSpace(p.Provider) - p.Target = strings.TrimSpace(p.Target) - p.SecretRef = strings.TrimSpace(p.SecretRef) + 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 !allowedResourceTypes[p.Type] { - return p, errors.New("type must be one of git, sk, project_document, cloud_account, cloud_resource") + if p.ResourceRef == "" { + return p, errors.New("resource_ref required") } - if p.Target == "" { - return p, errors.New("target 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" @@ -110,22 +115,25 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) { if p.Status == "" { p.Status = "active" } - if err := rejectSecretLikeFields("metadata", p.Metadata); err != nil { - return p, err + if p.Metadata == nil { + p.Metadata = map[string]any{} } - if err := rejectSecretLikeFields("constraints", p.Constraints); err != nil { - return p, err + if p.PermissionScope == nil { + p.PermissionScope = map[string]any{} + } + if p.Constraints == nil { + p.Constraints = map[string]any{} } - 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") } @@ -135,137 +143,81 @@ 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 err := rejectSecretLikeFields("constraints", p.Constraints); err != nil { - return p, err + if p.PermissionScope == nil { + p.PermissionScope = map[string]any{} + } + if p.Constraints == nil { + p.Constraints = map[string]any{} } - p.Scopes = normalizeStringList(p.Scopes) return p, nil } -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) - } - return items -} - -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 nil -} - -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)) +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 } - scopeBytes, err := common.Marshal(p.Scopes) + permissionBytes, err := common.Marshal(permissionScope) if err != nil { return "", "", "", err } - constraintBytes, err := common.Marshal(defaultMap(p.Constraints)) + constraintBytes, err := common.Marshal(constraints) if err != nil { return "", "", "", err } - return string(metadataBytes), string(scopeBytes), string(constraintBytes), nil + return string(metadataBytes), string(permissionBytes), string(constraintBytes), nil } -func marshalResourceGrantPayload(p resourceGrantPayload) (scopes, constraints string, err error) { - scopeBytes, err := common.Marshal(p.Scopes) - if err != nil { - return "", "", err +func parseMapField(raw string) map[string]any { + out := map[string]any{} + if raw != "" { + _ = common.UnmarshalJsonStr(raw, &out) } - constraintBytes, err := common.Marshal(defaultMap(p.Constraints)) - if err != nil { - return "", "", err - } - return string(scopeBytes), string(constraintBytes), nil + return out } -func defaultMap(value map[string]any) map[string]any { - if value == nil { - return map[string]any{} +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, } - 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 { - constraints := map[string]any{} - var scopes []string - if grant.Scopes != "" { - _ = common.UnmarshalJsonStr(grant.Scopes, &scopes) + 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, } - 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) { @@ -277,100 +229,59 @@ func ListResources(c *gin.Context) { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { query = query.Where("project_id = ?", projectId) } - var resources []model.Resource + 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 _, src := range resources { - items = append(items, resourceToResponse(src)) + 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 resourcePayload + var payload resourceBindingPayload if err := c.ShouldBindJSON(&payload); err != nil { - common.ApiErrorMsg(c, "invalid params") + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) return } - payload, err := normalizeResourcePayload(payload) + payload, err := normalizeResourceBindingPayload(payload) if err != nil { common.ApiError(c, err) return } - metadata, scopes, constraints, err := marshalResourcePayload(payload) + metadata, permissionScope, constraints, err := marshalResourceMaps(payload.Metadata, payload.PermissionScope, payload.Constraints) if err != nil { common.ApiError(c, err) return } - 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} + 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, 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)) + common.ApiSuccess(c, resourceBindingToResponse(resource)) } func DeleteResource(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 - } - res := model.DB.Where("id = ? AND user_id = ?", id, userId).Delete(&model.Resource{}) + 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 @@ -407,7 +318,7 @@ func CreateResourceGrant(c *gin.Context) { userId := c.GetInt("id") var payload resourceGrantPayload if err := c.ShouldBindJSON(&payload); err != nil { - common.ApiErrorMsg(c, "invalid params") + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"}) return } payload, err := normalizeResourceGrantPayload(payload) @@ -415,7 +326,7 @@ func CreateResourceGrant(c *gin.Context) { common.ApiError(c, err) return } - var resource model.Resource + 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") @@ -424,12 +335,23 @@ func CreateResourceGrant(c *gin.Context) { common.ApiError(c, err) return } - scopes, constraints, err := marshalResourceGrantPayload(payload) + _, 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, AgnetId: payload.AgnetId, Role: payload.Role, Scopes: scopes, Constraints: constraints, Status: payload.Status} + 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 @@ -439,12 +361,7 @@ func CreateResourceGrant(c *gin.Context) { func DeleteResourceGrant(c *gin.Context) { userId := c.GetInt("id") - 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{}) + 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 diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index 83b1374..4aa98ba 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -1,143 +1,140 @@ package controller import ( - "bytes" "net/http" - "net/http/httptest" - "strconv" "testing" - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" "github.com/heicode/manager/common" "github.com/heicode/manager/model" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/gorm" ) -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", - }, - Scopes: []string{" read ", "write", "read", ""}, - Constraints: map[string]any{ - "allowed_paths": []any{".", "docs"}, - }, - SecretRef: "vault://tenant-a/git/repo", - }) - - 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 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", - }, - }) - - require.Error(t, err) - require.Contains(t, err.Error(), "must not contain raw secret material") -} - -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"}, - }) - - require.NoError(t, err) - require.Equal(t, []string{"read", "write"}, payload.Scopes) - require.Equal(t, "active", payload.Status) -} - -func TestResourceHandlersCreateResourceAndGrant(t *testing.T) { - originalDB := model.DB - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.Resource{}, &model.ResourceGrant{})) - model.DB = db - t.Cleanup(func() { model.DB = originalDB }) - - resourceBody := []byte(`{ - "tenant_id":"tenant-a", - "project_id":"project-a", - "name":"repo", - "type":"git", - "target":"https://github.com/example/repo", - "metadata":{"repo_url":"https://github.com/example/repo","ref":"main"}, - "scopes":["read","write"], - "constraints":{"allowed_paths":["."]}, - "secret_ref":"vault://tenant-a/git/repo" - }`) - resourceRecorder := callResourceHandler(t, http.MethodPost, "/api/resources/", resourceBody, CreateResource) - var resourceEnvelope struct { - Success bool `json:"success"` - Data struct { - Id int `json:"id"` - SecretRef string `json:"secret_ref"` - Scopes []string `json:"scopes"` - } `json:"data"` - } - require.NoError(t, common.Unmarshal(resourceRecorder.Body.Bytes(), &resourceEnvelope)) - require.True(t, resourceEnvelope.Success) - require.NotZero(t, resourceEnvelope.Data.Id) - require.Equal(t, "vault://tenant-a/git/repo", resourceEnvelope.Data.SecretRef) - require.Equal(t, []string{"read", "write"}, resourceEnvelope.Data.Scopes) - - grantBody := []byte(`{ - "tenant_id":"tenant-a", - "project_id":"project-a", - "resource_id":` + strconv.Itoa(resourceEnvelope.Data.Id) + `, - "agnet_id":"builder-1", - "role":"developer", - "scopes":["read"] - }`) - grantRecorder := callResourceHandler(t, http.MethodPost, "/api/resource-grants/", grantBody, CreateResourceGrant) - var grantEnvelope struct { - Success bool `json:"success"` - Data struct { - ResourceId int `json:"resource_id"` - AgnetId string `json:"agnet_id"` - Role string `json:"role"` - } `json:"data"` - } - require.NoError(t, common.Unmarshal(grantRecorder.Body.Bytes(), &grantEnvelope)) - require.True(t, grantEnvelope.Success) - require.Equal(t, resourceEnvelope.Data.Id, grantEnvelope.Data.ResourceId) - require.Equal(t, "builder-1", grantEnvelope.Data.AgnetId) - require.Equal(t, "developer", grantEnvelope.Data.Role) -} - -func callResourceHandler(t *testing.T, method string, target string, body []byte, handler gin.HandlerFunc) *httptest.ResponseRecorder { +func setupResourceControllerTestDB(t *testing.T) { t.Helper() - gin.SetMode(gin.TestMode) - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(method, target, bytes.NewReader(body)) - ctx.Request.Header.Set("Content-Type", "application/json") - ctx.Set("id", 7) - handler(ctx) - return recorder + 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", + }, + "permission_scope": map[string]any{ + "actions": []string{"read", "write"}, + }, + "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) + + 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") +} + +func TestCreateResourceRejectsPlaintextSecret(t *testing.T) { + setupResourceControllerTestDB(t) + + 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") +} + +func TestCreateResourceGrantBindsProjectRoleAndSubAgnet(t *testing.T) { + setupResourceControllerTestDB(t) + + 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"]) } diff --git a/heicode/model/main.go b/heicode/model/main.go index 222139a..e6fd180 100644 --- a/heicode/model/main.go +++ b/heicode/model/main.go @@ -281,7 +281,7 @@ func migrateDB() error { &CustomOAuthProvider{}, &UserOAuthBinding{}, &GitSource{}, - &Resource{}, + &ResourceBinding{}, &ResourceGrant{}, ) if err != nil { @@ -332,7 +332,7 @@ func migrateDBFast() error { {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, {&GitSource{}, "GitSource"}, - {&Resource{}, "Resource"}, + {&ResourceBinding{}, "ResourceBinding"}, {&ResourceGrant{}, "ResourceGrant"}, } // 动态计算migration数量,确保errChan缓冲区足够大 diff --git a/heicode/model/resource.go b/heicode/model/resource.go index 761bcae..8c1b07f 100644 --- a/heicode/model/resource.go +++ b/heicode/model/resource.go @@ -1,42 +1,35 @@ package model -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 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"` - 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"` + 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"` } diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 84870b4..1fc9817 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -189,7 +189,6 @@ func SetApiRouter(router *gin.Engine) { { resourceRoute.GET("/", controller.ListResources) resourceRoute.POST("/", controller.CreateResource) - resourceRoute.PUT("/:id", controller.UpdateResource) resourceRoute.DELETE("/:id", controller.DeleteResource) }