Files
heicode-mananger/heicode/controller/azure_resource_discovery.go
T

312 lines
9.3 KiB
Go

package controller
import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"gorm.io/gorm"
)
type azureResourceDiscoveryClient struct {
authorityHost string
armBaseURL string
client *http.Client
}
type azureARMResource struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Location string `json:"location"`
Tags map[string]any `json:"tags"`
}
type azureARMResourceListResponse struct {
Value []azureARMResource `json:"value"`
NextLink string `json:"nextLink"`
}
func newAzureResourceDiscoveryClientFromEnv() azureResourceDiscoveryClient {
authorityHost := strings.TrimRight(strings.TrimSpace(os.Getenv("AZURE_AUTHORITY_HOST")), "/")
if authorityHost == "" {
authorityHost = "https://login.microsoftonline.com"
}
armBaseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("AZURE_ARM_BASE_URL")), "/")
if armBaseURL == "" {
armBaseURL = "https://management.azure.com"
}
return azureResourceDiscoveryClient{
authorityHost: authorityHost,
armBaseURL: armBaseURL,
client: &http.Client{Timeout: 20 * time.Second},
}
}
func DiscoverAzureResources(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" || strings.ToLower(account.Provider) != "azure" {
common.ApiErrorMsg(c, "resource must be an active Azure cloud_account")
return
}
if account.Status != "active" {
common.ApiErrorMsg(c, "resource must be an active Azure cloud_account")
return
}
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
}
metadata := unmarshalResourceJSON(account.Metadata)
credentials := azureDiscoveryCredentials{
SubscriptionID: firstString(metadata, secretData, account.ExternalId, "subscription_id"),
TenantID: firstString(metadata, secretData, account.TenantId, "tenant_id"),
ClientID: firstString(metadata, secretData, "", "client_id"),
ClientSecret: firstString(nil, secretData, "", "client_secret"),
}
if err := credentials.validate(); err != nil {
common.ApiError(c, err)
return
}
azureClient := newAzureResourceDiscoveryClientFromEnv()
token, err := azureClient.clientCredentialsToken(credentials)
if err != nil {
common.ApiError(c, err)
return
}
discovered, err := azureClient.listSubscriptionResources(credentials.SubscriptionID, token)
if err != nil {
common.ApiError(c, err)
return
}
items, err := upsertDiscoveredAzureResources(account, credentials, discovered)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"account": resourceToResponse(account),
"items": items,
"discovered": len(items),
})
}
type azureDiscoveryCredentials struct {
SubscriptionID string
TenantID string
ClientID string
ClientSecret string
}
func (c azureDiscoveryCredentials) validate() error {
if strings.TrimSpace(c.SubscriptionID) == "" {
return errors.New("Azure subscription_id required")
}
if strings.TrimSpace(c.TenantID) == "" {
return errors.New("Azure tenant_id required")
}
if strings.TrimSpace(c.ClientID) == "" {
return errors.New("Azure client_id required")
}
if strings.TrimSpace(c.ClientSecret) == "" {
return errors.New("Azure client_secret required")
}
return nil
}
func firstString(primary map[string]any, secondary map[string]any, fallback string, key string) string {
if value := mapString(primary, key); value != "" {
return value
}
if value := mapString(secondary, key); value != "" {
return value
}
return strings.TrimSpace(fallback)
}
func mapString(values map[string]any, key string) string {
if values == nil {
return ""
}
switch value := values[key].(type) {
case string:
return strings.TrimSpace(value)
case fmt.Stringer:
return strings.TrimSpace(value.String())
default:
return ""
}
}
func (a azureResourceDiscoveryClient) clientCredentialsToken(credentials azureDiscoveryCredentials) (string, error) {
endpoint := fmt.Sprintf("%s/%s/oauth2/v2.0/token", a.authorityHost, url.PathEscape(credentials.TenantID))
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", credentials.ClientID)
form.Set("client_secret", credentials.ClientSecret)
form.Set("scope", "https://management.azure.com/.default")
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("Azure client credential token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readSecretStoreError(resp.Body)
if message == "" {
message = resp.Status
}
return "", fmt.Errorf("Azure client credential token request failed: %s", message)
}
var payload struct {
AccessToken string `json:"access_token"`
}
if err := common.DecodeJson(resp.Body, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.AccessToken) == "" {
return "", errors.New("Azure client credential token response missing access_token")
}
return payload.AccessToken, nil
}
func (a azureResourceDiscoveryClient) listSubscriptionResources(subscriptionID string, token string) ([]azureARMResource, error) {
endpoint := fmt.Sprintf("%s/subscriptions/%s/resources?api-version=2021-04-01", a.armBaseURL, url.PathEscape(subscriptionID))
var out []azureARMResource
for page := 0; page < 20 && endpoint != ""; page++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("Azure ARM resources request failed: %w", err)
}
var payload azureARMResourceListResponse
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
err = common.DecodeJson(resp.Body, &payload)
} else {
message := readSecretStoreError(resp.Body)
if message == "" {
message = resp.Status
}
err = fmt.Errorf("Azure ARM resources request failed: %s", message)
}
resp.Body.Close()
if err != nil {
return nil, err
}
out = append(out, payload.Value...)
endpoint = strings.TrimSpace(payload.NextLink)
}
return out, nil
}
func upsertDiscoveredAzureResources(account model.ResourceBinding, credentials azureDiscoveryCredentials, discovered []azureARMResource) ([]resourceResponse, error) {
items := make([]resourceResponse, 0, len(discovered))
for _, armResource := range discovered {
if strings.TrimSpace(armResource.ID) == "" {
continue
}
metadata := map[string]any{
"subscription_id": credentials.SubscriptionID,
"tenant_id": credentials.TenantID,
"source_account_id": account.Id,
"type": armResource.Type,
"location": armResource.Location,
"resource_group": azureResourceGroupFromID(armResource.ID),
"tags": armResource.Tags,
}
metadataJSON, err := marshalResourceJSON(metadata)
if err != nil {
return nil, err
}
permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{"azure:read"}})
if err != nil {
return nil, err
}
constraints := unmarshalResourceJSON(account.Constraints)
constraintsJSON, err := marshalResourceJSON(constraints)
if err != nil {
return nil, err
}
name := strings.TrimSpace(armResource.Name)
if name == "" {
name = armResource.ID
}
bindingScope := fmt.Sprintf("azure:%s:%s", credentials.SubscriptionID, armResource.ID)
var resource model.ResourceBinding
err = model.DB.Where(
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
account.UserId,
"cloud_resource",
"azure",
armResource.ID,
).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
resource.BindingScope = bindingScope
resource.Name = name
resource.ResourceType = "cloud_resource"
resource.Provider = "azure"
resource.ExternalId = armResource.ID
resource.SecretRef = account.SecretRef
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
}
func azureResourceGroupFromID(id string) string {
parts := strings.Split(strings.Trim(id, "/"), "/")
for i := 0; i+1 < len(parts); i++ {
if strings.EqualFold(parts[i], "resourceGroups") {
return parts[i+1]
}
}
return ""
}