Skip to content

Commit

Permalink
chore: Add header support for proxy extension requests (#14800)
Browse files Browse the repository at this point in the history
* chore: add server URL in the header of proxy extensions

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* feat: add header support for proxy extension requests

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* Address review comments

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* address review comments

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* Address review comments

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

* Address review comments

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>

---------

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>
  • Loading branch information
leoluz authored Aug 2, 2023
1 parent 2e92d12 commit eef03ca
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 15 deletions.
76 changes: 74 additions & 2 deletions docs/developer-guide/extensions/proxy-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
96 changes: 83 additions & 13 deletions server/extension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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
Expand Down Expand Up @@ -137,6 +156,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 {
Expand Down Expand Up @@ -303,11 +342,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 {
Expand Down Expand Up @@ -343,14 +394,24 @@ 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
}

// 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)
Expand All @@ -362,6 +423,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
Expand Down Expand Up @@ -403,16 +469,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)
}
Expand Down Expand Up @@ -467,7 +533,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)
}
Expand Down Expand Up @@ -580,17 +646,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)
}
}
46 changes: 46 additions & 0 deletions server/extension/extension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -637,6 +653,9 @@ extensions:
backend:
services:
- url: %s
headers:
- name: Authorization
value: '$extension.auth.header'
`
return fmt.Sprintf(cfg, name, url)
}
Expand Down Expand Up @@ -667,6 +686,9 @@ extensions:
backend:
services:
- url: https://httpbin.org
headers:
- name: some-header
value: '$some.secret.ref'
- name: some-backend
backend:
services:
Expand Down Expand Up @@ -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
`
}

0 comments on commit eef03ca

Please sign in to comment.