回应 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>
270 lines
10 KiB
Go
270 lines
10 KiB
Go
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
|
||
}
|