回应 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>
237 lines
7.5 KiB
Go
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
|
|
}
|