diff --git a/docs/developer-guide/extensions/proxy-extensions.md b/docs/developer-guide/extensions/proxy-extensions.md index 4ab80006d2613..e75cc03beae2c 100644 --- a/docs/developer-guide/extensions/proxy-extensions.md +++ b/docs/developer-guide/extensions/proxy-extensions.md @@ -52,6 +52,9 @@ data: maxIdleConnections: 30 services: - url: http://httpbin.org + headers: + - name: some-header + value: '$some.argocd.secret.key' cluster: name: some-cluster server: https://some-cluster @@ -111,6 +114,34 @@ Defines a list with backend url by cluster. Is the address where the extension backend must be available. +#### `extensions.backend.services.headers` (*list*) + +If provided, the headers list will be added on all outgoing requests +for this service config. Existing headers in the incoming request with +the same name will be overriden by the one in this list. Reserved header +names will be ignored (see the [headers](#incoming-request-headers) below). + +#### `extensions.backend.services.headers.name` (*string*) +(mandatory) + +Defines the name of the header. It is a mandatory field if a header is +provided. + +#### `extensions.backend.services.headers.value` (*string*) +(mandatory) + +Defines the value of the header. It is a mandatory field if a header is +provided. The value can be provided as verbatim or as a reference to an +Argo CD secret key. In order to provide it as a reference, it is +necessary to prefix it with a dollar sign. + +Example: + + value: '$some.argocd.secret.key' + +In the example above, the value will be replaced with the one from +the argocd-secret with key 'some.argocd.secret.key'. + #### `extensions.backend.services.cluster` (*object*) (optional) @@ -166,14 +197,14 @@ configuration: └─────────────────┘ ``` -### Headers +### Incoming Request Headers Note that Argo CD API Server requires additional HTTP headers to be sent in order to enforce if the incoming request is authenticated and authorized before being proxied to the backend service. The headers are documented below: -#### `Cookie` (*mandatory*) +#### `Cookie` Argo CD UI keeps the authentication token stored in a cookie (`argocd.token`). This value needs to be sent in the `Cookie` header @@ -212,6 +243,25 @@ same headers are also sent to the backend service. The backend service must also validate if the validated headers are compatible with the rest of the incoming request. +### Outgoing Requets Headers + +Requests sent to backend services will be decorated with additional +headers. The outgoing request headers are documented below: + +#### `Argocd-Target-Cluster-Name` + +Will be populated with the value from `app.Spec.Destination.Name` if +it is not empty string in the application resource. + +#### `Argocd-Target-Cluster-URL` + +Will be populated with the value from `app.Spec.Destination.Server` if +it is not empty string is the Application resource. + +Note that additional pre-configured headers can be added to outgoing +request. See [backend service headers](#extensionsbackendservicesheaders-list) +section for more details. + ### Multi Backend Use-Case In some cases when Argo CD is configured to sync with multiple remote @@ -256,6 +306,28 @@ is then sanitized before being sent to the backend service. The request sanitization will remove sensitive information from the request like the `Cookie` and `Authorization` headers. +A new `Authorization` header can be added to the outgoing request by +defining it as a header in the `extensions.backend.services.headers` +configuration. Consider the following example: + +```yaml +extension.config: | + extensions: + - name: some-extension + backend: + services: + - url: http://extension-name.com:8080 + headers: + - name: Authorization + value: '$some-extension.authorization.header' +``` + +In the example above, all requests sent to +`http://extension-name.com:8080` will have an additional +`Authorization` header. The value of this header will be the one from +the [argocd-secret](../../operator-manual/argocd-secret-yaml.md) with +key `some-extension.authorization.header` + [1]: https://github.com/argoproj/argoproj/blob/master/community/feature-status.md [2]: https://argo-cd.readthedocs.io/en/stable/operator-manual/argocd-cm.yaml [3]: ../../operator-manual/rbac.md#the-extensions-resource diff --git a/server/extension/extension.go b/server/extension/extension.go index 270da5faa88ac..472d9ba3d6e16 100644 --- a/server/extension/extension.go +++ b/server/extension/extension.go @@ -45,6 +45,25 @@ const ( // Example: // Argocd-Project-Name: "default" HeaderArgoCDProjectName = "Argocd-Project-Name" + + // HeaderArgoCDTargetClusterURL defines the target cluster URL + // that the Argo CD application is associated with. This header + // will be populated by the extension proxy and passed to the + // configured backend service. If this header is passed by + // the client, its value will be overriden by the extension + // handler. + // + // Example: + // Argocd-Target-Cluster-URL: "https://kubernetes.default.svc.cluster.local" + HeaderArgoCDTargetClusterURL = "Argocd-Target-Cluster-URL" + + // HeaderArgoCDTargetClusterName defines the target cluster name + // that the Argo CD application is associated with. This header + // will be populated by the extension proxy and passed to the + // configured backend service. If this header is passed by + // the client, its value will be overriden by the extension + // handler. + HeaderArgoCDTargetClusterName = "Argocd-Target-Cluster-Name" ) // RequestResources defines the authorization scope for @@ -138,6 +157,26 @@ type ServiceConfig struct { // destination name to have requests properly forwarded to this // service URL. Cluster *ClusterConfig `json:"cluster,omitempty"` + + // Headers if provided, the headers list will be added on all + // outgoing requests for this service config. + Headers []Header `json:"headers"` +} + +// Header defines the header to be added in the proxy requests. +type Header struct { + // Name defines the name of the header. It is a mandatory field if + // a header is provided. + Name string `json:"name"` + // Value defines the value of the header. The actual value can be + // provided as verbatim or as a reference to an Argo CD secret key. + // In order to provide it as a reference, it is necessary to prefix + // it with a dollar sign. + // Example: + // value: '$some.argocd.secret.key' + // In the example above, the value will be replaced with the one from + // the argocd-secret with key 'some.argocd.secret.key'. + Value string `json:"value"` } type ClusterConfig struct { @@ -304,11 +343,23 @@ func proxyKey(extName, cName, cServer string) ProxyKey { } } -func parseAndValidateConfig(config string) (*ExtensionConfigs, error) { +func parseAndValidateConfig(s *settings.ArgoCDSettings) (*ExtensionConfigs, error) { + extConfigMap := map[string]interface{}{} + err := yaml.Unmarshal([]byte(s.ExtensionConfig), &extConfigMap) + if err != nil { + return nil, fmt.Errorf("invalid extension config: %s", err) + } + + parsedExtConfig := settings.ReplaceMapSecrets(extConfigMap, s.Secrets) + parsedExtConfigBytes, err := yaml.Marshal(parsedExtConfig) + if err != nil { + return nil, fmt.Errorf("error marshaling parsed extension config: %s", err) + } + configs := ExtensionConfigs{} - err := yaml.Unmarshal([]byte(config), &configs) + err = yaml.Unmarshal(parsedExtConfigBytes, &configs) if err != nil { - return nil, fmt.Errorf("invalid yaml: %s", err) + return nil, fmt.Errorf("invalid parsed extension config: %s", err) } err = validateConfigs(&configs) if err != nil { @@ -344,6 +395,16 @@ func validateConfigs(configs *ExtensionConfigs) error { return fmt.Errorf("cluster.name or cluster.server must be defined when cluster is provided in the configuration") } } + if len(svc.Headers) > 0 { + for _, header := range svc.Headers { + if header.Name == "" { + return fmt.Errorf("header.name must be defined when providing service headers in the configuration") + } + if header.Value == "" { + return fmt.Errorf("header.value must be defined when providing service headers in the configuration") + } + } + } } } return nil @@ -351,7 +412,7 @@ func validateConfigs(configs *ExtensionConfigs) error { // NewProxy will instantiate a new reverse proxy based on the provided // targetURL and config. -func NewProxy(targetURL string, config ProxyConfig) (*httputil.ReverseProxy, error) { +func NewProxy(targetURL string, headers []Header, config ProxyConfig) (*httputil.ReverseProxy, error) { url, err := url.Parse(targetURL) if err != nil { return nil, fmt.Errorf("failed to parse proxy URL: %s", err) @@ -363,6 +424,11 @@ func NewProxy(targetURL string, config ProxyConfig) (*httputil.ReverseProxy, err req.URL.Scheme = url.Scheme req.URL.Host = url.Host req.Header.Set("Host", url.Host) + req.Header.Del("Authorization") + req.Header.Del("Cookie") + for _, header := range headers { + req.Header.Set(header.Name, header.Value) + } }, } return proxy, nil @@ -404,16 +470,16 @@ func applyProxyConfigDefaults(c *ProxyConfig) { // router. func (m *Manager) RegisterHandlers(r *mux.Router) error { m.log.Info("Registering extension handlers...") - config, err := m.settings.Get() + settings, err := m.settings.Get() if err != nil { return fmt.Errorf("error getting settings: %s", err) } - if config.ExtensionConfig == "" { + if settings.ExtensionConfig == "" { return fmt.Errorf("No extensions configurations found") } - extConfigs, err := parseAndValidateConfig(config.ExtensionConfig) + extConfigs, err := parseAndValidateConfig(settings) if err != nil { return fmt.Errorf("error parsing extension config: %s", err) } @@ -468,7 +534,7 @@ func (m *Manager) registerExtensions(r *mux.Router, extConfigs *ExtensionConfigs registry := NewProxyRegistry() singleBackend := len(ext.Backend.Services) == 1 for _, service := range ext.Backend.Services { - proxy, err := NewProxy(service.URL, ext.Backend.ProxyConfig) + proxy, err := NewProxy(service.URL, service.Headers, ext.Backend.ProxyConfig) if err != nil { return fmt.Errorf("error creating proxy: %s", err) } @@ -581,17 +647,21 @@ func (m *Manager) CallExtension(extName string, registry ProxyRegistry) func(htt return } - sanitizeRequest(r, extName) + prepareRequest(r, extName, app) m.log.Debugf("proxing request for extension %q", extName) proxy.ServeHTTP(w, r) } } -// sanitizeRequest is reponsible for preparing and cleaning the given +// prepareRequest is reponsible for preparing and cleaning the given // request, removing sensitive information before forwarding it to the // proxy extension. -func sanitizeRequest(r *http.Request, extName string) { +func prepareRequest(r *http.Request, extName string, app *v1alpha1.Application) { r.URL.Path = strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%s/%s", URLPrefix, extName)) - r.Header.Del("Cookie") - r.Header.Del("Authorization") + if app.Spec.Destination.Name != "" { + r.Header.Set(HeaderArgoCDTargetClusterName, app.Spec.Destination.Name) + } + if app.Spec.Destination.Server != "" { + r.Header.Set(HeaderArgoCDTargetClusterURL, app.Spec.Destination.Server) + } } diff --git a/server/extension/extension_test.go b/server/extension/extension_test.go index aafb0d29de4be..51d281960013c 100644 --- a/server/extension/extension_test.go +++ b/server/extension/extension_test.go @@ -210,6 +210,14 @@ func TestRegisterHandlers(t *testing.T) { name: "invalid name", configYaml: getExtensionConfigInvalidName(), }, + { + name: "no header name", + configYaml: getExtensionConfigNoHeaderName(), + }, + { + name: "no header value", + configYaml: getExtensionConfigNoHeaderValue(), + }, } // when @@ -334,9 +342,12 @@ func TestExtensionsHandler(t *testing.T) { f.rbacMock.On("EnforceErr", mock.Anything, rbacpolicy.ResourceExtensions, rbacpolicy.ActionInvoke, mock.Anything).Return(extAccessError) } + secrets := make(map[string]string) + secrets["extension.auth.header"] = "Bearer some-bearer-token" withExtensionConfig := func(configYaml string, f *fixture) { settings := &settings.ArgoCDSettings{ ExtensionConfig: configYaml, + Secrets: secrets, } f.settingsGetterMock.On("Get", mock.Anything).Return(settings, nil) } @@ -393,6 +404,9 @@ func TestExtensionsHandler(t *testing.T) { clusterName := "clusterName" clusterURL := "clusterURL" backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for k, v := range r.Header { + w.Header().Add(k, strings.Join(v, ",")) + } fmt.Fprintln(w, backendResponse) })) defer backendSrv.Close() @@ -417,6 +431,8 @@ func TestExtensionsHandler(t *testing.T) { require.NoError(t, err) actual := strings.TrimSuffix(string(body), "\n") assert.Equal(t, backendResponse, actual) + assert.Equal(t, clusterURL, resp.Header.Get(extension.HeaderArgoCDTargetClusterURL)) + assert.Equal(t, "Bearer some-bearer-token", resp.Header.Get("Authorization")) }) t.Run("will route requests with 2 backends for the same extension successfully", func(t *testing.T) { // given @@ -637,6 +653,9 @@ extensions: backend: services: - url: %s + headers: + - name: Authorization + value: '$extension.auth.header' ` return fmt.Sprintf(cfg, name, url) } @@ -667,6 +686,9 @@ extensions: backend: services: - url: https://httpbin.org + headers: + - name: some-header + value: '$some.secret.ref' - name: some-backend backend: services: @@ -701,3 +723,27 @@ extensions: - cluster: some-cluster ` } + +func getExtensionConfigNoHeaderName() string { + return ` +extensions: +- name: some-extension + backend: + services: + - url: https://httpbin.org + headers: + - value: '$some.secret.key' +` +} + +func getExtensionConfigNoHeaderValue() string { + return ` +extensions: +- name: some-extension + backend: + services: + - url: https://httpbin.org + headers: + - name: some-header-name +` +}