mirror of
https://github.com/netbirdio/netbird.git
synced 2024-11-23 16:43:29 +01:00
672 lines
17 KiB
Go
672 lines
17 KiB
Go
package idp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt"
|
|
"github.com/netbirdio/netbird/management/server/telemetry"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
// azure extension properties template
|
|
wtAccountIDTpl = "extension_%s_wt_account_id"
|
|
wtPendingInviteTpl = "extension_%s_wt_pending_invite"
|
|
|
|
profileFields = "id,displayName,mail,userPrincipalName"
|
|
extensionFields = "id,name,targetObjects"
|
|
)
|
|
|
|
// AzureManager azure manager client instance.
|
|
type AzureManager struct {
|
|
ClientID string
|
|
ObjectID string
|
|
GraphAPIEndpoint string
|
|
httpClient ManagerHTTPClient
|
|
credentials ManagerCredentials
|
|
helper ManagerHelper
|
|
appMetrics telemetry.AppMetrics
|
|
}
|
|
|
|
// AzureClientConfig azure manager client configurations.
|
|
type AzureClientConfig struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
ObjectID string
|
|
|
|
GraphAPIEndpoint string `json:"-"`
|
|
TokenEndpoint string `json:"-"`
|
|
GrantType string `json:"-"`
|
|
}
|
|
|
|
// AzureCredentials azure authentication information.
|
|
type AzureCredentials struct {
|
|
clientConfig AzureClientConfig
|
|
helper ManagerHelper
|
|
httpClient ManagerHTTPClient
|
|
jwtToken JWTToken
|
|
mux sync.Mutex
|
|
appMetrics telemetry.AppMetrics
|
|
}
|
|
|
|
// azureProfile represents an azure user profile.
|
|
type azureProfile map[string]any
|
|
|
|
// passwordProfile represent authentication method for,
|
|
// newly created user profile.
|
|
type passwordProfile struct {
|
|
ForceChangePasswordNextSignIn bool `json:"forceChangePasswordNextSignIn"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// azureExtension represent custom attribute,
|
|
// that can be added to user objects in Azure Active Directory (AD).
|
|
type azureExtension struct {
|
|
Name string `json:"name"`
|
|
DataType string `json:"dataType"`
|
|
TargetObjects []string `json:"targetObjects"`
|
|
}
|
|
|
|
// NewAzureManager creates a new instance of the AzureManager.
|
|
func NewAzureManager(oidcConfig OIDCConfig, config AzureClientConfig,
|
|
appMetrics telemetry.AppMetrics) (*AzureManager, error) {
|
|
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
|
httpTransport.MaxIdleConns = 5
|
|
|
|
httpClient := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: httpTransport,
|
|
}
|
|
|
|
helper := JsonParser{}
|
|
config.TokenEndpoint = oidcConfig.TokenEndpoint
|
|
config.GraphAPIEndpoint = "https://graph.microsoft.com"
|
|
config.GrantType = "client_credentials"
|
|
|
|
if config.ClientID == "" {
|
|
return nil, fmt.Errorf("azure IdP configuration is incomplete, clientID is missing")
|
|
}
|
|
|
|
if config.ClientSecret == "" {
|
|
return nil, fmt.Errorf("azure IdP configuration is incomplete, ClientSecret is missing")
|
|
}
|
|
|
|
if config.ObjectID == "" {
|
|
return nil, fmt.Errorf("azure IdP configuration is incomplete, ObjectID is missing")
|
|
}
|
|
|
|
credentials := &AzureCredentials{
|
|
clientConfig: config,
|
|
httpClient: httpClient,
|
|
helper: helper,
|
|
appMetrics: appMetrics,
|
|
}
|
|
|
|
manager := &AzureManager{
|
|
ObjectID: config.ObjectID,
|
|
ClientID: config.ClientID,
|
|
GraphAPIEndpoint: config.GraphAPIEndpoint,
|
|
httpClient: httpClient,
|
|
credentials: credentials,
|
|
helper: helper,
|
|
appMetrics: appMetrics,
|
|
}
|
|
|
|
err := manager.configureAppMetadata()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return manager, nil
|
|
}
|
|
|
|
// jwtStillValid returns true if the token still valid and have enough time to be used and get a response from azure.
|
|
func (ac *AzureCredentials) jwtStillValid() bool {
|
|
return !ac.jwtToken.expiresInTime.IsZero() && time.Now().Add(5*time.Second).Before(ac.jwtToken.expiresInTime)
|
|
}
|
|
|
|
// requestJWTToken performs request to get jwt token.
|
|
func (ac *AzureCredentials) requestJWTToken() (*http.Response, error) {
|
|
data := url.Values{}
|
|
data.Set("client_id", ac.clientConfig.ClientID)
|
|
data.Set("client_secret", ac.clientConfig.ClientSecret)
|
|
data.Set("grant_type", ac.clientConfig.GrantType)
|
|
data.Set("scope", "https://graph.microsoft.com/.default")
|
|
|
|
payload := strings.NewReader(data.Encode())
|
|
req, err := http.NewRequest(http.MethodPost, ac.clientConfig.TokenEndpoint, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Add("content-type", "application/x-www-form-urlencoded")
|
|
|
|
log.Debug("requesting new jwt token for azure idp manager")
|
|
|
|
resp, err := ac.httpClient.Do(req)
|
|
if err != nil {
|
|
if ac.appMetrics != nil {
|
|
ac.appMetrics.IDPMetrics().CountRequestError()
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unable to get azure token, statusCode %d", resp.StatusCode)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// parseRequestJWTResponse parses jwt raw response body and extracts token and expires in seconds
|
|
func (ac *AzureCredentials) parseRequestJWTResponse(rawBody io.ReadCloser) (JWTToken, error) {
|
|
jwtToken := JWTToken{}
|
|
body, err := io.ReadAll(rawBody)
|
|
if err != nil {
|
|
return jwtToken, err
|
|
}
|
|
|
|
err = ac.helper.Unmarshal(body, &jwtToken)
|
|
if err != nil {
|
|
return jwtToken, err
|
|
}
|
|
|
|
if jwtToken.ExpiresIn == 0 && jwtToken.AccessToken == "" {
|
|
return jwtToken, fmt.Errorf("error while reading response body, expires_in: %d and access_token: %s", jwtToken.ExpiresIn, jwtToken.AccessToken)
|
|
}
|
|
|
|
data, err := jwt.DecodeSegment(strings.Split(jwtToken.AccessToken, ".")[1])
|
|
if err != nil {
|
|
return jwtToken, err
|
|
}
|
|
|
|
// Exp maps into exp from jwt token
|
|
var IssuedAt struct{ Exp int64 }
|
|
err = ac.helper.Unmarshal(data, &IssuedAt)
|
|
if err != nil {
|
|
return jwtToken, err
|
|
}
|
|
jwtToken.expiresInTime = time.Unix(IssuedAt.Exp, 0)
|
|
|
|
return jwtToken, nil
|
|
}
|
|
|
|
// Authenticate retrieves access token to use the azure Management API.
|
|
func (ac *AzureCredentials) Authenticate() (JWTToken, error) {
|
|
ac.mux.Lock()
|
|
defer ac.mux.Unlock()
|
|
|
|
if ac.appMetrics != nil {
|
|
ac.appMetrics.IDPMetrics().CountAuthenticate()
|
|
}
|
|
|
|
// reuse the token without requesting a new one if it is not expired,
|
|
// and if expiry time is sufficient time available to make a request.
|
|
if ac.jwtStillValid() {
|
|
return ac.jwtToken, nil
|
|
}
|
|
|
|
resp, err := ac.requestJWTToken()
|
|
if err != nil {
|
|
return ac.jwtToken, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
jwtToken, err := ac.parseRequestJWTResponse(resp.Body)
|
|
if err != nil {
|
|
return ac.jwtToken, err
|
|
}
|
|
|
|
ac.jwtToken = jwtToken
|
|
|
|
return ac.jwtToken, nil
|
|
}
|
|
|
|
// CreateUser creates a new user in azure AD Idp.
|
|
func (am *AzureManager) CreateUser(email string, name string, accountID string) (*UserData, error) {
|
|
payload, err := buildAzureCreateUserRequestPayload(email, name, accountID, am.ClientID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, err := am.post("users", payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountCreateUser()
|
|
}
|
|
|
|
var profile azureProfile
|
|
err = am.helper.Unmarshal(body, &profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
profile[wtAccountIDField] = accountID
|
|
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
profile[wtPendingInviteField] = true
|
|
|
|
return profile.userData(am.ClientID), nil
|
|
}
|
|
|
|
// GetUserDataByID requests user data from keycloak via ID.
|
|
func (am *AzureManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) {
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
|
|
|
|
q := url.Values{}
|
|
q.Add("$select", selectFields)
|
|
|
|
body, err := am.get("users/"+userID, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountGetUserDataByID()
|
|
}
|
|
|
|
var profile azureProfile
|
|
err = am.helper.Unmarshal(body, &profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return profile.userData(am.ClientID), nil
|
|
}
|
|
|
|
// GetUserByEmail searches users with a given email.
|
|
// If no users have been found, this function returns an empty list.
|
|
func (am *AzureManager) GetUserByEmail(email string) ([]*UserData, error) {
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
|
|
|
|
q := url.Values{}
|
|
q.Add("$select", selectFields)
|
|
|
|
body, err := am.get("users/"+email, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountGetUserByEmail()
|
|
}
|
|
|
|
var profile azureProfile
|
|
err = am.helper.Unmarshal(body, &profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users := make([]*UserData, 0)
|
|
users = append(users, profile.userData(am.ClientID))
|
|
|
|
return users, nil
|
|
}
|
|
|
|
// GetAccount returns all the users for a given profile.
|
|
func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) {
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
|
|
|
|
q := url.Values{}
|
|
q.Add("$select", selectFields)
|
|
q.Add("$filter", fmt.Sprintf("%s eq '%s'", wtAccountIDField, accountID))
|
|
|
|
body, err := am.get("users", q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountGetAccount()
|
|
}
|
|
|
|
var profiles struct{ Value []azureProfile }
|
|
err = am.helper.Unmarshal(body, &profiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users := make([]*UserData, 0)
|
|
for _, profile := range profiles.Value {
|
|
users = append(users, profile.userData(am.ClientID))
|
|
}
|
|
|
|
return users, nil
|
|
}
|
|
|
|
// GetAllAccounts gets all registered accounts with corresponding user data.
|
|
// It returns a list of users indexed by accountID.
|
|
func (am *AzureManager) GetAllAccounts() (map[string][]*UserData, error) {
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
|
|
|
|
q := url.Values{}
|
|
q.Add("$select", selectFields)
|
|
|
|
body, err := am.get("users", q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountGetAllAccounts()
|
|
}
|
|
|
|
var profiles struct{ Value []azureProfile }
|
|
err = am.helper.Unmarshal(body, &profiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
indexedUsers := make(map[string][]*UserData)
|
|
for _, profile := range profiles.Value {
|
|
userData := profile.userData(am.ClientID)
|
|
|
|
accountID := userData.AppMetadata.WTAccountID
|
|
if accountID != "" {
|
|
if _, ok := indexedUsers[accountID]; !ok {
|
|
indexedUsers[accountID] = make([]*UserData, 0)
|
|
}
|
|
indexedUsers[accountID] = append(indexedUsers[accountID], userData)
|
|
}
|
|
|
|
}
|
|
|
|
return indexedUsers, nil
|
|
}
|
|
|
|
// UpdateUserAppMetadata updates user app metadata based on userID.
|
|
func (am *AzureManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
|
|
jwtToken, err := am.credentials.Authenticate()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
|
|
data, err := am.helper.Marshal(map[string]any{
|
|
wtAccountIDField: appMetadata.WTAccountID,
|
|
wtPendingInviteField: appMetadata.WTPendingInvite,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload := strings.NewReader(string(data))
|
|
|
|
reqURL := fmt.Sprintf("%s/users/%s", am.GraphAPIEndpoint, userID)
|
|
req, err := http.NewRequest(http.MethodPatch, reqURL, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
|
|
req.Header.Add("content-type", "application/json")
|
|
|
|
log.Debugf("updating idp metadata for user %s", userID)
|
|
|
|
resp, err := am.httpClient.Do(req)
|
|
if err != nil {
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountRequestError()
|
|
}
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountUpdateUserAppMetadata()
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
return fmt.Errorf("unable to update the appMetadata, statusCode %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (am *AzureManager) getUserExtensions() ([]azureExtension, error) {
|
|
q := url.Values{}
|
|
q.Add("$select", extensionFields)
|
|
|
|
resource := fmt.Sprintf("applications/%s/extensionProperties", am.ObjectID)
|
|
body, err := am.get(resource, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var extensions struct{ Value []azureExtension }
|
|
err = am.helper.Unmarshal(body, &extensions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return extensions.Value, nil
|
|
}
|
|
|
|
func (am *AzureManager) createUserExtension(name string) (*azureExtension, error) {
|
|
extension := azureExtension{
|
|
Name: name,
|
|
DataType: "string",
|
|
TargetObjects: []string{"User"},
|
|
}
|
|
|
|
payload, err := am.helper.Marshal(extension)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resource := fmt.Sprintf("applications/%s/extensionProperties", am.ObjectID)
|
|
body, err := am.post(resource, string(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var userExtension azureExtension
|
|
err = am.helper.Unmarshal(body, &userExtension)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &userExtension, nil
|
|
}
|
|
|
|
// configureAppMetadata sets up app metadata extensions if they do not exists.
|
|
func (am *AzureManager) configureAppMetadata() error {
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
|
|
|
|
extensions, err := am.getUserExtensions()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the wt_account_id extension does not already exist, create it.
|
|
if !hasExtension(extensions, wtAccountIDField) {
|
|
_, err = am.createUserExtension(wtAccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// If the wt_pending_invite extension does not already exist, create it.
|
|
if !hasExtension(extensions, wtPendingInviteField) {
|
|
_, err = am.createUserExtension(wtPendingInvite)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// get perform Get requests.
|
|
func (am *AzureManager) get(resource string, q url.Values) ([]byte, error) {
|
|
jwtToken, err := am.credentials.Authenticate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reqURL := fmt.Sprintf("%s/%s?%s", am.GraphAPIEndpoint, resource, q.Encode())
|
|
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
|
|
req.Header.Add("content-type", "application/json")
|
|
|
|
resp, err := am.httpClient.Do(req)
|
|
if err != nil {
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountRequestError()
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountRequestStatusError()
|
|
}
|
|
|
|
return nil, fmt.Errorf("unable to get %s, statusCode %d", reqURL, resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// post perform Post requests.
|
|
func (am *AzureManager) post(resource string, body string) ([]byte, error) {
|
|
jwtToken, err := am.credentials.Authenticate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reqURL := fmt.Sprintf("%s/%s", am.GraphAPIEndpoint, resource)
|
|
req, err := http.NewRequest(http.MethodPost, reqURL, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
|
|
req.Header.Add("content-type", "application/json")
|
|
|
|
resp, err := am.httpClient.Do(req)
|
|
if err != nil {
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountRequestError()
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
if am.appMetrics != nil {
|
|
am.appMetrics.IDPMetrics().CountRequestStatusError()
|
|
}
|
|
|
|
return nil, fmt.Errorf("unable to post %s, statusCode %d", reqURL, resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// userData construct user data from keycloak profile.
|
|
func (ap azureProfile) userData(clientID string) *UserData {
|
|
id, ok := ap["id"].(string)
|
|
if !ok {
|
|
id = ""
|
|
}
|
|
|
|
email, ok := ap["userPrincipalName"].(string)
|
|
if !ok {
|
|
email = ""
|
|
}
|
|
|
|
name, ok := ap["displayName"].(string)
|
|
if !ok {
|
|
name = ""
|
|
}
|
|
|
|
accountIDField := extensionName(wtAccountIDTpl, clientID)
|
|
accountID, ok := ap[accountIDField].(string)
|
|
if !ok {
|
|
accountID = ""
|
|
}
|
|
|
|
pendingInviteField := extensionName(wtPendingInviteTpl, clientID)
|
|
pendingInvite, ok := ap[pendingInviteField].(bool)
|
|
if !ok {
|
|
pendingInvite = false
|
|
}
|
|
|
|
return &UserData{
|
|
Email: email,
|
|
Name: name,
|
|
ID: id,
|
|
AppMetadata: AppMetadata{
|
|
WTAccountID: accountID,
|
|
WTPendingInvite: &pendingInvite,
|
|
},
|
|
}
|
|
}
|
|
|
|
func buildAzureCreateUserRequestPayload(email, name, accountID, clientID string) (string, error) {
|
|
wtAccountIDField := extensionName(wtAccountIDTpl, clientID)
|
|
wtPendingInviteField := extensionName(wtPendingInviteTpl, clientID)
|
|
|
|
req := &azureProfile{
|
|
"accountEnabled": true,
|
|
"displayName": name,
|
|
"mailNickName": strings.Join(strings.Split(name, " "), ""),
|
|
"userPrincipalName": email,
|
|
"passwordProfile": passwordProfile{
|
|
ForceChangePasswordNextSignIn: true,
|
|
Password: GeneratePassword(8, 1, 1, 1),
|
|
},
|
|
wtAccountIDField: accountID,
|
|
wtPendingInviteField: true,
|
|
}
|
|
|
|
str, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(str), nil
|
|
}
|
|
|
|
func extensionName(extensionTpl, clientID string) string {
|
|
clientID = strings.ReplaceAll(clientID, "-", "")
|
|
return fmt.Sprintf(extensionTpl, clientID)
|
|
}
|
|
|
|
// hasExtension checks whether a given extension by name,
|
|
// exists in an list of extensions.
|
|
func hasExtension(extensions []azureExtension, name string) bool {
|
|
for _, ext := range extensions {
|
|
if ext.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|