fix(discovery): address #5 review — no secret inheritance, GCP URL, tagged-only scope
回应 Fasthei 复审(PR #52 CHANGES_REQUESTED): 1. 发现出的子资源不再继承账号 secret_ref:抽出 DB 无关的 applyDiscoveredResourceFields, 恒置 SecretRef=""(避免账号级凭据引用经 resourceToResponse / grant manifest 扩散到每个 VM/S3/DB)。加 TestApplyDiscoveredResourceFields_NoSecretInheritance。 3. GCP searchAllResources URL 修正:gcpSearchAllResourcesURL 不再 PathEscape 整个 scope (slash 是路径模板一部分),生成 /v1/projects/<id>:searchAllResources。加 TestGCPSearchAllResourcesURL。 2. AWS 覆盖范围据实声明:Resource Groups Tagging GetResources 仅覆盖 tagged/曾 tagged 资源, 不覆盖未打标签 EC2/RDS/S3;注释明确「tagged resources only」,#5 全量发现诉求不据此关闭。 controller 全套测试通过,go build/vet 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,12 @@ import (
|
|||||||
|
|
||||||
// AWS resource discovery (#5). Pure-Go AWS Signature V4 (no AWS SDK), mirroring
|
// AWS resource discovery (#5). Pure-Go AWS Signature V4 (no AWS SDK), mirroring
|
||||||
// the REST approach of azure_resource_discovery.go. Uses the Resource Groups
|
// the REST approach of azure_resource_discovery.go. Uses the Resource Groups
|
||||||
// Tagging API GetResources (AWS JSON 1.1) to enumerate the account's tagged
|
// Tagging API GetResources (AWS JSON 1.1), then normalizes ARNs to discoveredCloudResource.
|
||||||
// resources across services, then normalizes ARNs to discoveredCloudResource.
|
//
|
||||||
|
// ⚠️ 覆盖范围(#5 复审 #2,据实声明):GetResources 仅返回**已打标签或曾打标签**的资源,
|
||||||
|
// **不覆盖完全未打标签的 EC2/RDS/S3 等**。因此本适配器是「**tagged resources only**」的发现,
|
||||||
|
// 不等于账号内全量资源;补全需后续加服务级 List/Describe(DescribeInstances / DescribeDBInstances /
|
||||||
|
// ListBuckets 等)。在此之前 #5 的「全量发现」诉求不应据本适配器关闭。
|
||||||
//
|
//
|
||||||
// Creds (from cloud_account metadata/secret): access_key_id, secret_access_key,
|
// Creds (from cloud_account metadata/secret): access_key_id, secret_access_key,
|
||||||
// region, optional session_token.
|
// region, optional session_token.
|
||||||
|
|||||||
@@ -85,60 +85,17 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri
|
|||||||
if strings.TrimSpace(d.ExternalId) == "" {
|
if strings.TrimSpace(d.ExternalId) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
metadata := map[string]any{}
|
|
||||||
for k, v := range d.Metadata {
|
|
||||||
metadata[k] = v
|
|
||||||
}
|
|
||||||
metadata["source_account_id"] = account.Id
|
|
||||||
metadata["classified_type"] = classifyCloudResourceType(d.NativeType)
|
|
||||||
if strings.TrimSpace(d.NativeType) != "" {
|
|
||||||
metadata["native_type"] = d.NativeType
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(d.Location) != "" {
|
|
||||||
metadata["location"] = d.Location
|
|
||||||
}
|
|
||||||
metadataJSON, err := marshalResourceJSON(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(d.Name)
|
|
||||||
if name == "" {
|
|
||||||
name = d.ExternalId
|
|
||||||
}
|
|
||||||
bindingScope := strings.TrimSpace(d.BindingScope)
|
|
||||||
if bindingScope == "" {
|
|
||||||
bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resource model.ResourceBinding
|
var resource model.ResourceBinding
|
||||||
err = model.DB.Where(
|
err := model.DB.Where(
|
||||||
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
|
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
|
||||||
account.UserId, "cloud_resource", provider, d.ExternalId,
|
account.UserId, "cloud_resource", provider, d.ExternalId,
|
||||||
).First(&resource).Error
|
).First(&resource).Error
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resource.UserId = account.UserId
|
if err := applyDiscoveredResourceFields(&resource, account, provider, d); err != nil {
|
||||||
resource.TenantId = account.TenantId
|
return nil, err
|
||||||
resource.ProjectId = account.ProjectId
|
}
|
||||||
resource.BindingScope = bindingScope
|
|
||||||
resource.Name = name
|
|
||||||
resource.ResourceType = "cloud_resource"
|
|
||||||
resource.Provider = provider
|
|
||||||
resource.ExternalId = d.ExternalId
|
|
||||||
resource.SecretRef = account.SecretRef
|
|
||||||
resource.Metadata = metadataJSON
|
|
||||||
resource.PermissionScope = permissionScopeJSON
|
|
||||||
resource.Constraints = constraintsJSON
|
|
||||||
resource.Status = "active"
|
|
||||||
if resource.Id == 0 {
|
if resource.Id == 0 {
|
||||||
if err := model.DB.Create(&resource).Error; err != nil {
|
if err := model.DB.Create(&resource).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -151,6 +108,59 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyDiscoveredResourceFields 把一条归一化发现结果写入 ResourceBinding 字段(DB 无关,可单测)。
|
||||||
|
// 关键安全约束(#5 复审 #1):**SecretRef 恒为空**——发现出的子资源绝不继承云账号 secret_ref,
|
||||||
|
// 否则会把账号级凭据引用扩散到每个 VM/S3/DB 并经 resourceToResponse / grant manifest 下发给 agent。
|
||||||
|
// 发现是只读清单;如需 agent 读取某资源,应另行 grant/审批/最小权限 secret。
|
||||||
|
func applyDiscoveredResourceFields(resource *model.ResourceBinding, account model.ResourceBinding, provider string, d discoveredCloudResource) error {
|
||||||
|
metadata := map[string]any{}
|
||||||
|
for k, v := range d.Metadata {
|
||||||
|
metadata[k] = v
|
||||||
|
}
|
||||||
|
metadata["source_account_id"] = account.Id
|
||||||
|
metadata["classified_type"] = classifyCloudResourceType(d.NativeType)
|
||||||
|
if strings.TrimSpace(d.NativeType) != "" {
|
||||||
|
metadata["native_type"] = d.NativeType
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Location) != "" {
|
||||||
|
metadata["location"] = d.Location
|
||||||
|
}
|
||||||
|
metadataJSON, err := marshalResourceJSON(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(d.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = d.ExternalId
|
||||||
|
}
|
||||||
|
bindingScope := strings.TrimSpace(d.BindingScope)
|
||||||
|
if bindingScope == "" {
|
||||||
|
bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId)
|
||||||
|
}
|
||||||
|
resource.UserId = account.UserId
|
||||||
|
resource.TenantId = account.TenantId
|
||||||
|
resource.ProjectId = account.ProjectId
|
||||||
|
resource.BindingScope = bindingScope
|
||||||
|
resource.Name = name
|
||||||
|
resource.ResourceType = "cloud_resource"
|
||||||
|
resource.Provider = provider
|
||||||
|
resource.ExternalId = d.ExternalId
|
||||||
|
resource.SecretRef = "" // 见上:绝不继承账号 secret_ref
|
||||||
|
resource.Metadata = metadataJSON
|
||||||
|
resource.PermissionScope = permissionScopeJSON
|
||||||
|
resource.Constraints = constraintsJSON
|
||||||
|
resource.Status = "active"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic
|
// DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic
|
||||||
// discovery dispatcher (#5). Routes by the account's provider to the matching
|
// discovery dispatcher (#5). Routes by the account's provider to the matching
|
||||||
// adapter (azure/aws/gcp), then upserts via the unified path.
|
// adapter (azure/aws/gcp), then upserts via the unified path.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/heicode/manager/model"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +79,39 @@ func TestAWSSigV4_VanillaVector(t *testing.T) {
|
|||||||
require.Contains(t, auth, "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31")
|
require.Contains(t, auth, "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #5 复审 #3:GCP searchAllResources URL —— scope 的 slash 不得被 %2F 转义。
|
||||||
|
func TestGCPSearchAllResourcesURL(t *testing.T) {
|
||||||
|
u := gcpSearchAllResourcesURL("my-proj", "")
|
||||||
|
require.Equal(t, "https://cloudasset.googleapis.com/v1/projects/my-proj:searchAllResources?pageSize=500", u)
|
||||||
|
require.NotContains(t, u, "%2F", "scope slash 不应被转义")
|
||||||
|
|
||||||
|
u2 := gcpSearchAllResourcesURL("my-proj", "tok en/+")
|
||||||
|
require.Contains(t, u2, "&pageToken=tok+en%2F%2B")
|
||||||
|
}
|
||||||
|
|
||||||
|
// #5 复审 #1:发现出的资源**绝不继承**账号 secret_ref;元数据带 classified_type,不含账号凭据引用。
|
||||||
|
func TestApplyDiscoveredResourceFields_NoSecretInheritance(t *testing.T) {
|
||||||
|
account := model.ResourceBinding{
|
||||||
|
Id: 42, UserId: 7, ResourceType: "cloud_account", Provider: "aws",
|
||||||
|
SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/aws-keys",
|
||||||
|
}
|
||||||
|
d := discoveredCloudResource{
|
||||||
|
ExternalId: "arn:aws:ec2:us-east-1:123:instance/i-1",
|
||||||
|
Name: "i-1", NativeType: "AWS::EC2::Instance", Location: "us-east-1",
|
||||||
|
Metadata: map[string]any{"region": "us-east-1"},
|
||||||
|
}
|
||||||
|
var r model.ResourceBinding
|
||||||
|
require.NoError(t, applyDiscoveredResourceFields(&r, account, "aws", d))
|
||||||
|
|
||||||
|
require.Equal(t, "", r.SecretRef, "发现出的子资源绝不继承账号 secret_ref")
|
||||||
|
require.Equal(t, "cloud_resource", r.ResourceType)
|
||||||
|
require.Equal(t, "aws", r.Provider)
|
||||||
|
require.Equal(t, 7, r.UserId)
|
||||||
|
require.NotContains(t, r.Metadata, "azkv://", "metadata 不得含账号凭据引用")
|
||||||
|
require.Contains(t, r.Metadata, "\"classified_type\":\"vm\"")
|
||||||
|
require.Contains(t, r.PermissionScope, "aws:read")
|
||||||
|
}
|
||||||
|
|
||||||
// #5: GCP SA JWT —— RS256 断言可被对应公钥验签,且 claims 正确。
|
// #5: GCP SA JWT —— RS256 断言可被对应公钥验签,且 claims 正确。
|
||||||
func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) {
|
func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) {
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
|||||||
@@ -162,6 +162,17 @@ type gcpSearchResponse struct {
|
|||||||
NextPageToken string `json:"nextPageToken"`
|
NextPageToken string `json:"nextPageToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gcpSearchAllResourcesURL 构造 Cloud Asset Inventory searchAllResources 端点。
|
||||||
|
// #5 复审 #3:scope 里的 slash 是路径模板的一部分,**不能** PathEscape 成 %2F —— 正确形如
|
||||||
|
// /v1/projects/<id>:searchAllResources。projectID 仍按单段转义,pageToken 走 query 转义。
|
||||||
|
func gcpSearchAllResourcesURL(projectID, pageToken string) string {
|
||||||
|
endpoint := "https://cloudasset.googleapis.com/v1/projects/" + url.PathEscape(projectID) + ":searchAllResources?pageSize=500"
|
||||||
|
if strings.TrimSpace(pageToken) != "" {
|
||||||
|
endpoint += "&pageToken=" + url.QueryEscape(pageToken)
|
||||||
|
}
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||||
cred := gcpDiscoveryCredentials{
|
cred := gcpDiscoveryCredentials{
|
||||||
ClientEmail: mapString(creds, "client_email"),
|
ClientEmail: mapString(creds, "client_email"),
|
||||||
@@ -177,15 +188,10 @@ func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) (
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
scope := "projects/" + cred.ProjectID
|
|
||||||
base := "https://cloudasset.googleapis.com/v1/" + url.PathEscape(scope) + ":searchAllResources"
|
|
||||||
out := []discoveredCloudResource{}
|
out := []discoveredCloudResource{}
|
||||||
pageToken := ""
|
pageToken := ""
|
||||||
for page := 0; page < 50; page++ {
|
for page := 0; page < 50; page++ {
|
||||||
endpoint := base + "?pageSize=500"
|
endpoint := gcpSearchAllResourcesURL(cred.ProjectID, pageToken)
|
||||||
if pageToken != "" {
|
|
||||||
endpoint += "&pageToken=" + url.QueryEscape(pageToken)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user