312 lines
9.3 KiB
Go
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 ""
|
|
}
|