diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index 3f6f15f..b2a8aa9 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -2,7 +2,9 @@ package controller import ( "errors" + "fmt" "net/http" + "sort" "strings" "github.com/heicode/manager/common" @@ -20,19 +22,59 @@ var allowedResourceTypes = map[string]bool{ "cloud_resource": true, } -type resourceBindingPayload struct { +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"` - ResourceType string `json:"resource_type"` Name string `json:"name"` + ResourceType string `json:"resource_type"` Provider string `json:"provider"` - ResourceRef string `json:"resource_ref"` + 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 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"` + ResourceType string `json:"resource_type"` + Provider string `json:"provider"` + ExternalId string `json:"external_id"` SecretRef string `json:"secret_ref"` - Secret string `json:"secret"` + 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 { @@ -40,74 +82,47 @@ type resourceGrantPayload struct { ProjectId string `json:"project_id"` ResourceId int `json:"resource_id"` Role string `json:"role"` - SubAgnetId string `json:"sub_agnet_id"` + AgnetId string `json:"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"` + 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"` + 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 normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingPayload, error) { +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.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType)) p.Provider = strings.TrimSpace(p.Provider) - p.ResourceRef = strings.TrimSpace(p.ResourceRef) - p.Status = strings.TrimSpace(p.Status) + p.ExternalId = strings.TrimSpace(p.ExternalId) p.SecretRef = strings.TrimSpace(p.SecretRef) - p.Secret = strings.TrimSpace(p.Secret) + p.Status = strings.ToLower(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 p.Secret != "" { - return p, errors.New("secret plaintext is not accepted; store credentials in Secret Store and pass secret_ref") + if !allowedResourceTypes[p.ResourceType] { + return p, fmt.Errorf("resource_type must be one of %s", strings.Join(sortedResourceTypes(), ", ")) } if p.Provider == "" { p.Provider = "custom" @@ -115,6 +130,9 @@ func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingP 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{} } @@ -124,6 +142,9 @@ func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingP 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 } @@ -131,9 +152,9 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload 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) + p.AgnetId = strings.TrimSpace(p.AgnetId) + p.Status = strings.ToLower(strings.TrimSpace(p.Status)) + if p.TenantId == "" { return p, errors.New("tenant_id required") } @@ -146,78 +167,130 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload 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 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 sortedResourceTypes() []string { + types := make([]string, 0, len(allowedResourceTypes)) + for resourceType := range allowedResourceTypes { + types = append(types, resourceType) } - 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 + sort.Strings(types) + return types } -func parseMapField(raw string) map[string]any { - out := map[string]any{} - if raw != "" { - _ = common.UnmarshalJsonStr(raw, &out) +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 out + return false } -func resourceBindingToResponse(resource model.ResourceBinding) resourceBindingResponse { - return resourceBindingResponse{ +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, - ResourceType: resource.ResourceType, Name: resource.Name, + ResourceType: resource.ResourceType, Provider: resource.Provider, - ResourceRef: resource.ResourceRef, - Metadata: parseMapField(resource.Metadata), - PermissionScope: parseMapField(resource.PermissionScope), - Constraints: parseMapField(resource.Constraints), - Status: resource.Status, + 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) resourceGrantResponse { - return resourceGrantResponse{ +func resourceGrantToResponse(grant model.ResourceGrant, resource *model.ResourceBinding) resourceGrantResponse { + resp := 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), + AgnetId: grant.AgnetId, + PermissionScope: unmarshalResourceJSON(grant.PermissionScope), + Constraints: unmarshalResourceJSON(grant.Constraints), Status: grant.Status, - AuditNote: grant.AuditNote, + 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) { @@ -229,31 +302,34 @@ func ListResources(c *gin.Context) { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { query = query.Where("project_id = ?", projectId) } + 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([]resourceBindingResponse, 0, len(resources)) + items := make([]resourceResponse, 0, len(resources)) for _, resource := range resources { - items = append(items, resourceBindingToResponse(resource)) + items = append(items, resourceToResponse(resource)) } 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"}) 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, permissionScope, constraints, err := marshalResourcePayloadJSON(payload) if err != nil { common.ApiError(c, err) return @@ -262,21 +338,65 @@ func CreateResource(c *gin.Context) { UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, - ResourceType: payload.ResourceType, Name: payload.Name, + ResourceType: payload.ResourceType, Provider: payload.Provider, - ResourceRef: payload.ResourceRef, + ExternalId: payload.ExternalId, + SecretRef: payload.SecretRef, 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)) + 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.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) { @@ -293,6 +413,22 @@ func DeleteResource(c *gin.Context) { common.ApiSuccess(c, gin.H{"deleted": true}) } +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) @@ -302,6 +438,9 @@ func ListResourceGrants(c *gin.Context) { if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" { query = query.Where("project_id = ?", projectId) } + 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) @@ -309,7 +448,7 @@ func ListResourceGrants(c *gin.Context) { } items := make([]resourceGrantResponse, 0, len(grants)) for _, grant := range grants { - items = append(items, resourceGrantToResponse(grant)) + items = append(items, resourceGrantToResponse(grant, nil)) } common.ApiSuccess(c, gin.H{"items": items}) } @@ -326,16 +465,12 @@ func CreateResourceGrant(c *gin.Context) { 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 - } + resource, err := findGrantResource(userId, payload.ResourceId, payload.TenantId) + if err != nil { common.ApiError(c, err) return } - _, permissionScope, constraints, err := marshalResourceMaps(map[string]any{}, payload.PermissionScope, payload.Constraints) + permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) if err != nil { common.ApiError(c, err) return @@ -346,17 +481,68 @@ func CreateResourceGrant(c *gin.Context) { ProjectId: payload.ProjectId, ResourceId: payload.ResourceId, Role: payload.Role, - SubAgnetId: payload.SubAgnetId, + AgnetId: payload.AgnetId, 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)) + 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.TenantId) + if err != nil { + common.ApiError(c, err) + return + } + permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload) + if err != nil { + common.ApiError(c, err) + return + } + grant.TenantId = payload.TenantId + grant.ProjectId = payload.ProjectId + 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) { @@ -372,3 +558,29 @@ func DeleteResourceGrant(c *gin.Context) { } 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 findGrantResource(userId int, resourceId int, tenantId 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 resource.TenantId != tenantId { + return resource, errors.New("resource tenant_id does not match grant tenant_id") + } + return resource, nil +} diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index 4aa98ba..a7cdeca 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -1,140 +1,166 @@ package controller import ( + "bytes" + "fmt" "net/http" + "net/http/httptest" + "strings" "testing" "github.com/heicode/manager/common" "github.com/heicode/manager/model" - "github.com/stretchr/testify/assert" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) -func setupResourceControllerTestDB(t *testing.T) { +func setupResourceControllerTestDB(t *testing.T) *gorm.DB { t.Helper() - db := openTokenControllerTestDB(t) + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + model.DB = db + model.LOG_DB = db require.NoError(t, db.AutoMigrate(&model.ResourceBinding{}, &model.ResourceGrant{})) + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db } -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 performResourceRequest(handler gin.HandlerFunc, userID int, method string, path string, body string) *httptest.ResponseRecorder { + r := gin.New() + r.Handle(method, path, func(c *gin.Context) { + c.Set("id", userID) + handler(c) + }) + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w } -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 TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) { + db := setupResourceControllerTestDB(t) + body := `{ + "tenant_id":"tenant-a", + "project_id":"project-a", + "name":"Project repository", + "resource_type":"git", + "provider":"github", + "external_id":"https://example.com/org/repo", + "secret_ref":"vault://tenant-a/git/repo", + "metadata":{"repo_url":"https://example.com/org/repo","ref":"main","allowed_paths":["."]}, + "permission_scope":{"actions":["read","write"]}, + "constraints":{"environment":"dev"} + }` + + w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"success":true`) + require.Contains(t, w.Body.String(), `"secret_ref":"vault://tenant-a/git/repo"`) + + var resource model.ResourceBinding + require.NoError(t, db.First(&resource).Error) + require.Equal(t, "git", resource.ResourceType) + require.Equal(t, "vault://tenant-a/git/repo", resource.SecretRef) + require.NotContains(t, resource.Metadata, "token") + require.NotContains(t, resource.PermissionScope, "token") + require.NotContains(t, resource.Constraints, "token") } -func TestCreateResourceAcceptsSecretRefOnly(t *testing.T) { +func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) { setupResourceControllerTestDB(t) + body := `{ + "tenant_id":"tenant-a", + "name":"Cloud account", + "resource_type":"cloud_account", + "metadata":{"account_id":"sub-1","access_key":"do-not-store"}, + "secret_ref":"vault://tenant-a/cloud/sub-1" + }` - 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") + w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"success":false`) + require.Contains(t, w.Body.String(), "plaintext secrets are not allowed") } -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) - +func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) { + db := 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", + UserId: 7, + TenantId: "tenant-a", + ProjectId: "project-a", + Name: "SK repo", + ResourceType: "sk", + Provider: "git", + SecretRef: "vault://tenant-a/sk/repo", + Metadata: `{"repo_url":"https://example.com/sk.git"}`, + Status: "active", } - require.NoError(t, model.DB.Create(&resource).Error) + require.NoError(t, 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) + body := fmt.Sprintf(`{ + "tenant_id":"tenant-a", + "project_id":"project-a", + "resource_id":%d, + "role":"developer", + "agnet_id":"agnet-dev-1", + "permission_scope":{"actions":["read"]}, + "constraints":{"paths":["skills/**"]} + }`, resource.Id) - CreateResourceGrant(ctx) + w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"success":true`) + require.Contains(t, w.Body.String(), `"tenant_id":"tenant-a"`) + require.Contains(t, w.Body.String(), `"project_id":"project-a"`) + require.Contains(t, w.Body.String(), `"role":"developer"`) + require.Contains(t, w.Body.String(), `"agnet_id":"agnet-dev-1"`) - 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"]) + var grant model.ResourceGrant + require.NoError(t, db.First(&grant).Error) + require.Equal(t, resource.Id, grant.ResourceId) + require.Equal(t, "tenant-a", grant.TenantId) + require.Equal(t, "project-a", grant.ProjectId) + require.Equal(t, "developer", grant.Role) + require.Equal(t, "agnet-dev-1", grant.AgnetId) +} + +func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) { + db := setupResourceControllerTestDB(t) + resource := model.ResourceBinding{ + UserId: 7, + TenantId: "tenant-a", + Name: "VM", + ResourceType: "cloud_resource", + Status: "active", + } + require.NoError(t, db.Create(&resource).Error) + + body := fmt.Sprintf(`{ + "tenant_id":"tenant-b", + "project_id":"project-a", + "resource_id":%d, + "role":"operator", + "agnet_id":"agnet-ops-1" + }`, resource.Id) + + w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"success":false`) + require.Contains(t, w.Body.String(), "resource tenant_id does not match grant tenant_id") } diff --git a/heicode/model/resource.go b/heicode/model/resource.go index 8c1b07f..13695d6 100644 --- a/heicode/model/resource.go +++ b/heicode/model/resource.go @@ -1,35 +1,40 @@ package model +// ResourceBinding is the Manager-side resource record described by docs/plan.md P1. +// It stores tenant/project resource metadata and a Secret Store reference only; +// plaintext credentials must never be stored here. 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"` + ProjectId string `json:"project_id" gorm:"type:varchar(64);index"` Name string `json:"name" gorm:"type:varchar(128);not null"` + ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"` Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"` - ResourceRef string `json:"resource_ref" gorm:"type:varchar(512);not null"` + ExternalId string `json:"external_id" gorm:"type:varchar(512)"` + SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"` 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)"` + 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"` } +// ResourceGrant assigns a ResourceBinding to a project, role, and child Agnet. +// It is the auditable Manager expression of "tenant/project grants resource to role". 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"` + ProjectId string `json:"project_id" gorm:"type:varchar(64);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"` + Role string `json:"role" gorm:"type:varchar(128);index;not null"` + AgnetId string `json:"agnet_id" gorm:"type:varchar(128);index;not null"` 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"` + Status string `json:"status" gorm:"type:varchar(32);default:'active';index"` + RevokedAt int64 `json:"revoked_at" gorm:"default:0"` 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..74bc19e 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) } @@ -197,6 +198,7 @@ func SetApiRouter(router *gin.Engine) { { resourceGrantRoute.GET("/", controller.ListResourceGrants) resourceGrantRoute.POST("/", controller.CreateResourceGrant) + resourceGrantRoute.PUT("/:id", controller.UpdateResourceGrant) resourceGrantRoute.DELETE("/:id", controller.DeleteResourceGrant) }