github.com/vmware/go-vcloud-director/v2@v2.24.0/govcd/org_saml.go (about)

     1  /*
     2   * Copyright 2023 VMware, Inc.  All rights reserved.  Licensed under the Apache v2 License.
     3   */
     4  
     5  package govcd
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/json"
    10  	"encoding/xml"
    11  	"fmt"
    12  	"github.com/vmware/go-vcloud-director/v2/types/v56"
    13  	"github.com/vmware/go-vcloud-director/v2/util"
    14  	"io"
    15  	"net/http"
    16  	"net/url"
    17  	"regexp"
    18  	"strings"
    19  )
    20  
    21  // GetFederationSettings retrieves the current federation (SAML) settings for a given organization
    22  func (adminOrg *AdminOrg) GetFederationSettings() (*types.OrgFederationSettings, error) {
    23  	var settings types.OrgFederationSettings
    24  
    25  	if adminOrg.AdminOrg.OrgSettings == nil || adminOrg.AdminOrg.OrgSettings.Link == nil {
    26  		return nil, fmt.Errorf("no Org settings links found in Org %s", adminOrg.AdminOrg.Name)
    27  	}
    28  	fsUrl := getUrlFromLink(adminOrg.AdminOrg.OrgSettings.Link, "down", types.MimeFederationSettingsXml)
    29  	if fsUrl == "" {
    30  		return nil, fmt.Errorf("no link found for federation settings (SAML: %s) in Org %s", types.MimeFederationSettingsXml, adminOrg.AdminOrg.Name)
    31  	}
    32  
    33  	resp, err := adminOrg.client.ExecuteRequest(fsUrl, http.MethodGet, types.MimeFederationSettingsXml,
    34  		"error fetching federation settings: %s", nil, &settings)
    35  
    36  	if err != nil {
    37  		return nil, err
    38  	}
    39  
    40  	_, err = checkResp(resp, err)
    41  	if err != nil {
    42  		return nil, err
    43  	}
    44  
    45  	return &settings, nil
    46  }
    47  
    48  // SetFederationSettings creates or replaces federation (SAML) settings for a given organization
    49  func (adminOrg *AdminOrg) SetFederationSettings(settings *types.OrgFederationSettings) (*types.OrgFederationSettings, error) {
    50  
    51  	if adminOrg.AdminOrg.OrgSettings == nil || adminOrg.AdminOrg.OrgSettings.Link == nil {
    52  		return nil, fmt.Errorf("no Org settings links found in Org %s", adminOrg.AdminOrg.Name)
    53  	}
    54  	fsUrl := getUrlFromLink(adminOrg.AdminOrg.OrgSettings.Link, "down", types.MimeFederationSettingsJson)
    55  	if fsUrl == "" {
    56  		return nil, fmt.Errorf("no URL found for federation settings (SAML) in Org %s", adminOrg.AdminOrg.Name)
    57  	}
    58  
    59  	setUrl, err := url.Parse(fsUrl)
    60  	if err != nil {
    61  		return nil, err
    62  	}
    63  
    64  	text := bytes.Buffer{}
    65  	encoder := json.NewEncoder(&text)
    66  	encoder.SetEscapeHTML(false)
    67  	err = encoder.Encode(settings)
    68  	if err != nil {
    69  		return nil, err
    70  	}
    71  	body := strings.NewReader(text.String())
    72  	apiVersion := adminOrg.client.APIVersion
    73  	headAccept := http.Header{}
    74  	// NOTE: given that the UI uses JSON based API to run SAML settings, it seemed the safest way to
    75  	// imitate it and use JSON payload and results for this operation
    76  	headAccept.Set("Accept", types.JSONMime)
    77  	headAccept.Set("Content-Type", types.MimeFederationSettingsJson)
    78  	request := adminOrg.client.newRequest(nil, nil, http.MethodPut, *setUrl, body, apiVersion, headAccept)
    79  	request.Header.Set("Accept", fmt.Sprintf("application/*+json;version=%s", apiVersion))
    80  	request.Header.Set("Content-Type", types.MimeFederationSettingsJson)
    81  
    82  	resp, err := adminOrg.client.Http.Do(request)
    83  	if err != nil {
    84  		return nil, err
    85  	}
    86  
    87  	if !isSuccessStatus(resp.StatusCode) {
    88  		body, _ := io.ReadAll(resp.Body)
    89  		var jsonError types.OpenApiError
    90  		err = json.Unmarshal(body, &jsonError)
    91  		// By default, we return the whole response body as error message. This may also contain the stack trace
    92  		message := string(body)
    93  		// if the body contains a valid JSON representation of the error, we return a more agile message, using the
    94  		// exposed fields, and hiding the stack trace from view
    95  		if err == nil {
    96  			message = fmt.Sprintf("%s - %s", jsonError.MinorErrorCode, jsonError.Message)
    97  		}
    98  		return nil, fmt.Errorf("error setting SAML for org %s: %s (%d) - %s", adminOrg.AdminOrg.Name, resp.Status, resp.StatusCode, message)
    99  	}
   100  
   101  	_, err = checkResp(resp, err)
   102  	if err != nil {
   103  		return nil, err
   104  	}
   105  
   106  	return adminOrg.GetFederationSettings()
   107  }
   108  
   109  // UnsetFederationSettings removes federation (SAML) settings for a given organization
   110  func (adminOrg *AdminOrg) UnsetFederationSettings() error {
   111  	settings, err := adminOrg.GetFederationSettings()
   112  	if err != nil {
   113  		return fmt.Errorf("[UnsetFederationSettings] error getting SAML settings for Org %s: %s", adminOrg.AdminOrg.Name, err)
   114  	}
   115  
   116  	settings.SAMLMetadata = ""
   117  	settings.Enabled = false
   118  	_, err = adminOrg.SetFederationSettings(settings)
   119  	return err
   120  }
   121  
   122  // GetServiceProviderSamlMetadata retrieves the service provider SAML metadata of the given Org
   123  func (adminOrg *AdminOrg) GetServiceProviderSamlMetadata() (*types.VcdSamlMetadata, error) {
   124  
   125  	metadataText, err := adminOrg.RetrieveServiceProviderSamlMetadata()
   126  	if err != nil {
   127  		return nil, err
   128  	}
   129  	var metadata types.VcdSamlMetadata
   130  
   131  	err = xml.Unmarshal([]byte(metadataText), &metadata)
   132  	if err != nil {
   133  		return nil, fmt.Errorf("[GetSamlMetadata] error decoding metadata retrieved from %s: %s", adminOrg.AdminOrg.Name, err)
   134  	}
   135  
   136  	return &metadata, nil
   137  }
   138  
   139  // RetrieveServiceProviderSamlMetadata retrieves the SAML metadata of the given Org
   140  func (adminOrg *AdminOrg) RetrieveServiceProviderSamlMetadata() (string, error) {
   141  
   142  	settings, err := adminOrg.GetFederationSettings()
   143  	if err != nil {
   144  		return "", err
   145  	}
   146  	metadataUrl := getUrlFromLink(settings.Link, "down", types.MimeSamlMetadata)
   147  	if metadataUrl == "" {
   148  		return "", fmt.Errorf("[RetrieveRemoteDocument] no URL found for metadata retrieval (%s) in org %s", types.MimeSamlMetadata, adminOrg.AdminOrg.Name)
   149  	}
   150  
   151  	metadataText, err := adminOrg.client.RetrieveRemoteDocument(metadataUrl)
   152  	if err != nil {
   153  		return "", fmt.Errorf("[RetrieveRemoteDocument] error retrieving SAML metadata from %s: %s", metadataUrl, err)
   154  	}
   155  	return string(metadataText), nil
   156  }
   157  
   158  func getUrlFromLink(linkList types.LinkList, wantRel, wantType string) string {
   159  	for _, link := range linkList {
   160  		if link.Rel == wantRel && link.Type == wantType {
   161  			return link.HREF
   162  		}
   163  	}
   164  	return ""
   165  }
   166  
   167  var (
   168  	// samlMetadataItems contains name space identifiers and corresponding tags
   169  	// that should be found in VCD SAML service provider metadata
   170  	samlMetadataItems = map[string][]string{
   171  		"ds": {
   172  			"KeyInfo",
   173  			"X509Certificate",
   174  			"X509Data",
   175  		},
   176  		"md": {
   177  			"AssertionConsumerService",
   178  			"EntityDescriptor",
   179  			"KeyDescriptor",
   180  			"NameIDFormat",
   181  			"SPSSODescriptor",
   182  			"SingleLogoutService",
   183  		},
   184  		"hoksso": {
   185  			"ProtocolBinding",
   186  		},
   187  	}
   188  )
   189  
   190  // RetrieveRemoteDocument gets the contents of a given URL
   191  func (client *Client) RetrieveRemoteDocument(metadataUrl string) ([]byte, error) {
   192  
   193  	retrieveUrl, err := url.Parse(metadataUrl)
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  	request := client.newRequest(nil, nil, http.MethodGet, *retrieveUrl, nil, client.APIVersion, nil)
   198  
   199  	resp, err := client.Http.Do(request)
   200  
   201  	if err != nil {
   202  		return nil, fmt.Errorf("[RetrieveRemoteDocument] error retrieving metadata from %s: %s", metadataUrl, err)
   203  	}
   204  
   205  	body, err := io.ReadAll(resp.Body)
   206  	if err != nil {
   207  		return nil, fmt.Errorf("[RetrieveRemoteDocument] error reading response body from metadata retrieved from %s: %s", metadataUrl, err)
   208  	}
   209  
   210  	util.ProcessResponseOutput("[RetrieveRemoteDocument]", resp, string(body))
   211  	return body, nil
   212  }
   213  
   214  // normalizeServiceProviderSamlMetadata takes a string containing the XML code with Metadata definition
   215  // and makes sure it has all the expected elements
   216  func normalizeServiceProviderSamlMetadata(in string) (string, error) {
   217  	var metadata types.VcdSamlMetadata
   218  
   219  	// Phase 1: Decode the XML, to find possible encoding errors
   220  	err := xml.Unmarshal([]byte(in), &metadata)
   221  	if err != nil {
   222  		return "", fmt.Errorf("[normalizeSamlMetadata] error decoding SAML metadata definition from XML: %s", err)
   223  	}
   224  
   225  	// Phase 2: Add the namespace definition elements, required to recognize the structure as a valid SAML definition
   226  	metadata.Md = types.SamlNamespaceMd
   227  	metadata.SPSSODescriptor.Ds = types.SamlNamespaceDs
   228  	for i := 0; i < len(metadata.SPSSODescriptor.AssertionConsumerService); i++ {
   229  		metadata.SPSSODescriptor.AssertionConsumerService[i].Hoksso = types.SamlNamespaceHoksso
   230  	}
   231  
   232  	// Phase 3: Convert the data structure to text again. The text now includes the needed namespace definition elements
   233  	out, err := xml.Marshal(metadata)
   234  	if err != nil {
   235  		return "", fmt.Errorf("[normalizeSamlMetadata] error encoding SAML metadata text: %s", err)
   236  	}
   237  
   238  	// Phase 4: Add the namespace elements to the XML text
   239  	metadataText := string(out)
   240  	for ns, fields := range samlMetadataItems {
   241  		if !strings.Contains(metadataText, ns) {
   242  			return metadataText, fmt.Errorf("[normalizeSamlMetadata] namespace '%s' not found in SAML metadata", ns)
   243  		}
   244  		for _, fieldName := range fields {
   245  			fullName := fmt.Sprintf("%s:%s", ns, fieldName)
   246  			// If we find just "FieldName", but not "namespace:FieldName", then we replace the bare FieldName with the full identifier
   247  			if strings.Contains(metadataText, fieldName) && !strings.Contains(metadataText, fullName) {
   248  				metadataText = strings.Replace(metadataText, fieldName, fullName, -1)
   249  			}
   250  		}
   251  	}
   252  
   253  	return metadataText, nil
   254  }
   255  
   256  // validateNamespaceDefinition checks that a metadata XML text contains the expected namespace definition
   257  func validateNamespaceDefinition(metadataText string, namespace string) bool {
   258  	reEmptyDefinition := regexp.MustCompile(`xmlns:` + namespace + `\s*=\s*""`)
   259  	reFilledDefinition := regexp.MustCompile(`xmlns:` + namespace + `\s*=\s*"\S+"`)
   260  	// Check that the namespace is mentioned at all in the metadata text
   261  	if !strings.Contains(metadataText, namespace) {
   262  		return false
   263  	}
   264  	// Check that an empty namespace definition is NOT found in the metadata text
   265  	// (for example: xmlns:md="")
   266  	if reEmptyDefinition.FindString(metadataText) != "" {
   267  		return false
   268  	}
   269  	// Check that a filled namespace definition is found in the metadata text
   270  	// (for example: xmlns:md="something")
   271  	found := reFilledDefinition.FindString(metadataText)
   272  	return found != ""
   273  }
   274  
   275  // ValidateSamlServiceProviderMetadata tells whether a given string contains valid XML that defines SAML service provider metadata
   276  // Returns nil on valid data, and an array of errors for invalid data
   277  func ValidateSamlServiceProviderMetadata(metadataText string) []error {
   278  	var metadata types.VcdSamlMetadata
   279  	var errors []error
   280  
   281  	// Check n. 1: encode the string into XML, thus establishing that it is valid syntax
   282  	err := xml.Unmarshal([]byte(metadataText), &metadata)
   283  	if err != nil {
   284  		errors = append(errors, fmt.Errorf("[ValidateSamlMetadata] error decoding XML into SAML metadata structure: %s", err))
   285  	}
   286  
   287  	reNameSpace, err := regexp.Compile(`<(\w+):(\w+)`)
   288  
   289  	if err != nil {
   290  		errors = append(errors, fmt.Errorf("error compiling regular expression: %s", err))
   291  		return errors
   292  	}
   293  
   294  	nsInfoList := reNameSpace.FindAllStringSubmatch(metadataText, -1)
   295  	processed := map[string]bool{}
   296  
   297  	// Check n. 2: make sure that each namespace used in the metadata text has a corresponding definition
   298  	for _, nsInfo := range nsInfoList {
   299  		seen, ok := processed[nsInfo[0]]
   300  		if ok && seen {
   301  			continue
   302  		}
   303  		ns := nsInfo[1]
   304  		if !validateNamespaceDefinition(metadataText, ns) {
   305  			errors = append(errors, fmt.Errorf("[ValidateSamlMetadata] namespace '%s' undefined in SAML metadata", ns))
   306  		}
   307  		processed[nsInfo[0]] = true
   308  	}
   309  
   310  	if len(errors) == 0 {
   311  		return nil
   312  	}
   313  	return errors
   314  }
   315  
   316  // GetErrorMessageFromErrorSlice returns a single error message from a list of error
   317  func GetErrorMessageFromErrorSlice(errors []error) string {
   318  	result := ""
   319  	for i, err := range errors {
   320  		result = fmt.Sprintf("%s\n%2d %s", result, i, err)
   321  	}
   322  	return result
   323  }