Files
heicode-mananger/heicode/controller/cloud_resource_discovery.go
T
chenchenandClaude Opus 4.8 df8fa80eba 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>
2026-06-10 14:29:57 +08:00

270 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"errors"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"gorm.io/gorm"
)
// Cloud resource discovery — provider-agnostic layer (#5).
//
// 历史上只有 Azure 发现(controller/azure_resource_discovery.go,硬要求 provider==azure)。
// 本文件抽象出统一的 provider 接口,让 AWS / GCP 走同一条「发现 → 归一化 → 落库」路径,
// 并对 vm / database / blob 做跨云一致的分类(DoD #3)。Azure 适配器复用既有 ARM 客户端,
// 故三家行为一致;具体云的签名/鉴权在 aws_resource_discovery.go / gcp_resource_discovery.go。
// discoveredCloudResource 是各 provider 适配器输出的**归一化**资源视图。upsert 时由统一
// 路径补 source_account_id 与 classified_type,并写入 ResourceBinding(resource_type=cloud_resource)。
type discoveredCloudResource struct {
ExternalId string // ARN / Azure resource id / GCP asset name —— 跨 provider 的稳定外部 id
Name string // 展示名
NativeType string // provider 原生类型字符串(如 Microsoft.Compute/virtualMachines、AWS::EC2::Instance、compute.googleapis.com/Instance)
Location string // region / location
BindingScope string // 可选;为空时默认 "<provider>:<external_id>"
Metadata map[string]any // provider 特定附加字段
}
// cloudDiscoveryProvider 是「连接云账号 → 列资源」的统一抽象(DoD #3)。
type cloudDiscoveryProvider interface {
name() string
// discover 用 creds(已合并 metadata + secret JSON)列出 account 下的资源。
discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error)
}
func cloudDiscoveryProviderByName(provider string) (cloudDiscoveryProvider, bool) {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "azure":
return azureCloudDiscoveryProvider{}, true
case "aws", "amazon":
return awsCloudDiscoveryProvider{}, true
case "gcp", "google", "gce":
return gcpCloudDiscoveryProvider{}, true
}
return nil, false
}
// classifyCloudResourceType 把 provider 原生类型映射到跨云一致的逻辑类别(DoD #3):
// vm / database / blob / cloud_resource(兜底)。基于不区分大小写的子串匹配,覆盖三家常见服务。
func classifyCloudResourceType(nativeType string) string {
t := strings.ToLower(nativeType)
containsAny := func(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
switch {
// 数据库(放在 vm 之前:避免 "sqlvirtual..." 之类被误判为 vm;数据库特征更强)
case containsAny(t, "sql", "postgres", "mysql", "mariadb", "rds", "database", "cosmos",
"dynamodb", "spanner", "cloudsql", "bigtable", "redis", "documentdb", "memcache"):
return "database"
case containsAny(t, "virtualmachine", "ec2::instance", "ec2/instance", "compute/instance",
"compute.googleapis.com/instance", "virtualmachinescaleset", "instance"):
return "vm"
case containsAny(t, "storageaccount", "::s3::", "s3:::", "/buckets/", "storage/bucket",
"storage.googleapis.com/bucket", "blob", "bucket"):
return "blob"
default:
return "cloud_resource"
}
}
// upsertDiscoveredCloudResources 把归一化资源幂等写入 ResourceBinding(按 user+type+provider+external_id
// 去重)。统一所有 provider 的落库格式:resource_type 固定为 cloud_resource(与既有 Azure 行为一致,
// 不让发现的资源直接变成可绑定的 vm/db/blob),跨云类别放在 metadata.classified_type。
func upsertDiscoveredCloudResources(account model.ResourceBinding, provider string, discovered []discoveredCloudResource) ([]resourceResponse, error) {
items := make([]resourceResponse, 0, len(discovered))
for _, d := range discovered {
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
}
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
// discovery dispatcher (#5). Routes by the account's provider to the matching
// adapter (azure/aws/gcp), then upserts via the unified path.
func DiscoverCloudResources(c *gin.Context) {
userId := c.GetInt("id")
var account model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "resource not found")
return
}
common.ApiError(c, err)
return
}
if account.ResourceType != "cloud_account" || account.Status != "active" {
common.ApiErrorMsg(c, "resource must be an active cloud_account")
return
}
impl, ok := cloudDiscoveryProviderByName(account.Provider)
if !ok {
common.ApiErrorMsg(c, "unsupported cloud provider: "+account.Provider+" (supported: azure, aws, gcp)")
return
}
// creds = metadata(非密)合并 secret JSON(密)。secret 优先级在适配器内部按需处理。
creds := unmarshalResourceJSON(account.Metadata)
if creds == nil {
creds = map[string]any{}
}
if strings.TrimSpace(account.SecretRef) != "" {
secretClient, err := newSecretStoreClientFromEnv()
if err != nil {
common.ApiError(c, err)
return
}
secretData, err := secretClient.getJSONSecret(account.SecretRef)
if err != nil {
common.ApiError(c, err)
return
}
for k, v := range secretData {
creds[k] = v // secret 覆盖同名非密字段
}
}
discovered, err := impl.discover(account, creds)
if err != nil {
common.ApiError(c, err)
return
}
items, err := upsertDiscoveredCloudResources(account, impl.name(), discovered)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"account": resourceToResponse(account),
"provider": impl.name(),
"items": items,
"discovered": len(items),
})
}
// azureCloudDiscoveryProvider 让既有 Azure ARM 发现实现统一接口(复用 azure_resource_discovery.go
// 的 client/token/list),保证三家走同一抽象。
type azureCloudDiscoveryProvider struct{}
func (azureCloudDiscoveryProvider) name() string { return "azure" }
func (azureCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
cred := azureDiscoveryCredentials{
SubscriptionID: firstString(creds, nil, account.ExternalId, "subscription_id"),
TenantID: firstString(creds, nil, account.TenantId, "tenant_id"),
ClientID: mapString(creds, "client_id"),
ClientSecret: mapString(creds, "client_secret"),
}
if err := cred.validate(); err != nil {
return nil, err
}
client := newAzureResourceDiscoveryClientFromEnv()
token, err := client.clientCredentialsToken(cred)
if err != nil {
return nil, err
}
arm, err := client.listSubscriptionResources(cred.SubscriptionID, token)
if err != nil {
return nil, err
}
out := make([]discoveredCloudResource, 0, len(arm))
for _, r := range arm {
out = append(out, discoveredCloudResource{
ExternalId: r.ID,
Name: r.Name,
NativeType: r.Type,
Location: r.Location,
BindingScope: fmt.Sprintf("azure:%s:%s", cred.SubscriptionID, r.ID),
Metadata: map[string]any{
"subscription_id": cred.SubscriptionID,
"tenant_id": cred.TenantID,
"resource_group": azureResourceGroupFromID(r.ID),
"tags": r.Tags,
},
})
}
return out, nil
}