Files
heicode-mananger/heicode/controller/gcp_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

237 lines
7.5 KiB
Go

package controller
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// GCP resource discovery (#5). Pure-Go service-account flow (no GCP SDK):
// build an RS256-signed JWT assertion from the SA private key, exchange it for an
// OAuth2 access token, then call Cloud Asset Inventory searchAllResources (REST)
// to enumerate the project's resources. Normalizes assets to discoveredCloudResource.
//
// Creds (from cloud_account metadata/secret): client_email, private_key (PEM),
// project_id. token_uri optional (defaults to Google's).
type gcpCloudDiscoveryProvider struct{}
func (gcpCloudDiscoveryProvider) name() string { return "gcp" }
func (gcpCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
if mapString(creds, "project_id") == "" && strings.TrimSpace(account.ExternalId) != "" {
creds["project_id"] = account.ExternalId
}
return discoverGCP(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
}
type gcpDiscoveryCredentials struct {
ClientEmail string
PrivateKey string // PEM
ProjectID string
TokenURI string
}
func (c gcpDiscoveryCredentials) validate() error {
if strings.TrimSpace(c.ClientEmail) == "" {
return errors.New("GCP client_email required")
}
if strings.TrimSpace(c.PrivateKey) == "" {
return errors.New("GCP private_key required")
}
if strings.TrimSpace(c.ProjectID) == "" {
return errors.New("GCP project_id required")
}
return nil
}
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// parseRSAPrivateKeyPEM parses a PEM private key (PKCS#8 or PKCS#1) into an RSA key.
func parseRSAPrivateKeyPEM(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(strings.TrimSpace(pemStr)))
if block == nil {
return nil, errors.New("GCP private_key is not valid PEM")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("GCP private_key parse failed: %w", err)
}
rsaKey, ok := keyAny.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("GCP private_key is not an RSA key")
}
return rsaKey, nil
}
// buildGCPAssertion builds the RS256-signed JWT assertion for the OAuth2 token
// exchange. Pure & deterministic given t — unit-testable with a generated key.
func buildGCPAssertion(cred gcpDiscoveryCredentials, scope string, aud string, t time.Time) (string, error) {
key, err := parseRSAPrivateKeyPEM(cred.PrivateKey)
if err != nil {
return "", err
}
header, err := common.Marshal(map[string]any{"alg": "RS256", "typ": "JWT"})
if err != nil {
return "", err
}
iat := t.UTC().Unix()
claims, err := common.Marshal(map[string]any{
"iss": cred.ClientEmail,
"scope": scope,
"aud": aud,
"iat": iat,
"exp": iat + 3600,
})
if err != nil {
return "", err
}
signingInput := b64url(header) + "." + b64url(claims)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return signingInput + "." + b64url(sig), nil
}
func gcpAccessToken(cred gcpDiscoveryCredentials, t time.Time, httpClient *http.Client) (string, error) {
tokenURI := strings.TrimSpace(cred.TokenURI)
if tokenURI == "" {
tokenURI = "https://oauth2.googleapis.com/token"
}
assertion, err := buildGCPAssertion(cred, "https://www.googleapis.com/auth/cloud-platform", tokenURI, t)
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
req, err := http.NewRequest(http.MethodPost, tokenURI, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("GCP token exchange failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("GCP token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var payload struct {
AccessToken string `json:"access_token"`
}
if err := common.Unmarshal(raw, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.AccessToken) == "" {
return "", errors.New("GCP token response missing access_token")
}
return payload.AccessToken, nil
}
type gcpAsset struct {
Name string `json:"name"`
AssetType string `json:"assetType"`
DisplayName string `json:"displayName"`
Location string `json:"location"`
}
type gcpSearchResponse struct {
Results []gcpAsset `json:"results"`
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"),
PrivateKey: mapString(creds, "private_key"),
ProjectID: mapString(creds, "project_id"),
TokenURI: mapString(creds, "token_uri"),
}
if err := cred.validate(); err != nil {
return nil, err
}
token, err := gcpAccessToken(cred, now, httpClient)
if err != nil {
return nil, err
}
out := []discoveredCloudResource{}
pageToken := ""
for page := 0; page < 50; page++ {
endpoint := gcpSearchAllResourcesURL(cred.ProjectID, pageToken)
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("GCP searchAllResources failed: %w", err)
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GCP searchAllResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var payload gcpSearchResponse
if err := common.Unmarshal(raw, &payload); err != nil {
return nil, err
}
for _, a := range payload.Results {
if strings.TrimSpace(a.Name) == "" {
continue
}
name := strings.TrimSpace(a.DisplayName)
if name == "" {
name = lastPathSegment(a.Name)
}
out = append(out, discoveredCloudResource{
ExternalId: a.Name,
Name: name,
NativeType: a.AssetType, // e.g. compute.googleapis.com/Instance
Location: a.Location,
BindingScope: "gcp:" + a.Name,
Metadata: map[string]any{"project_id": cred.ProjectID, "asset_type": a.AssetType},
})
}
pageToken = strings.TrimSpace(payload.NextPageToken)
if pageToken == "" {
break
}
}
return out, nil
}