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:
2026-06-10 14:29:57 +08:00
co-authored by Claude Opus 4.8
parent 1e28ab1100
commit df8fa80eba
4 changed files with 109 additions and 55 deletions
+6 -2
View File
@@ -20,8 +20,12 @@ import (
// 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
// Tagging API GetResources (AWS JSON 1.1) to enumerate the account's tagged
// resources across services, then normalizes ARNs to discoveredCloudResource.
// Tagging API GetResources (AWS JSON 1.1), 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,
// region, optional session_token.
+33 -23
View File
@@ -85,6 +85,34 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri
if strings.TrimSpace(d.ExternalId) == "" {
continue
}
var resource model.ResourceBinding
err := model.DB.Where(
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
account.UserId, "cloud_resource", provider, d.ExternalId,
).First(&resource).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err := applyDiscoveredResourceFields(&resource, account, provider, d); err != nil {
return nil, err
}
if resource.Id == 0 {
if err := model.DB.Create(&resource).Error; err != nil {
return nil, err
}
} else if err := model.DB.Save(&resource).Error; err != nil {
return nil, err
}
items = append(items, resourceToResponse(resource))
}
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
@@ -99,15 +127,15 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri
}
metadataJSON, err := marshalResourceJSON(metadata)
if err != nil {
return nil, err
return err
}
permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}})
if err != nil {
return nil, err
return err
}
constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints))
if err != nil {
return nil, err
return err
}
name := strings.TrimSpace(d.Name)
if name == "" {
@@ -117,15 +145,6 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri
if bindingScope == "" {
bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId)
}
var resource model.ResourceBinding
err = model.DB.Where(
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
account.UserId, "cloud_resource", provider, d.ExternalId,
).First(&resource).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
resource.UserId = account.UserId
resource.TenantId = account.TenantId
resource.ProjectId = account.ProjectId
@@ -134,21 +153,12 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri
resource.ResourceType = "cloud_resource"
resource.Provider = provider
resource.ExternalId = d.ExternalId
resource.SecretRef = account.SecretRef
resource.SecretRef = "" // 见上:绝不继承账号 secret_ref
resource.Metadata = metadataJSON
resource.PermissionScope = permissionScopeJSON
resource.Constraints = constraintsJSON
resource.Status = "active"
if resource.Id == 0 {
if err := model.DB.Create(&resource).Error; err != nil {
return nil, err
}
} else if err := model.DB.Save(&resource).Error; err != nil {
return nil, err
}
items = append(items, resourceToResponse(resource))
}
return items, nil
return nil
}
// DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
@@ -78,6 +79,39 @@ func TestAWSSigV4_VanillaVector(t *testing.T) {
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 正确。
func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
+12 -6
View File
@@ -162,6 +162,17 @@ type gcpSearchResponse struct {
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) {
cred := gcpDiscoveryCredentials{
ClientEmail: mapString(creds, "client_email"),
@@ -177,15 +188,10 @@ func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) (
return nil, err
}
scope := "projects/" + cred.ProjectID
base := "https://cloudasset.googleapis.com/v1/" + url.PathEscape(scope) + ":searchAllResources"
out := []discoveredCloudResource{}
pageToken := ""
for page := 0; page < 50; page++ {
endpoint := base + "?pageSize=500"
if pageToken != "" {
endpoint += "&pageToken=" + url.QueryEscape(pageToken)
}
endpoint := gcpSearchAllResourcesURL(cred.ProjectID, pageToken)
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err