feat: enable authn + authz in proxy extension (#11694)

* feat: enable authn + authz in proxy extension

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

* Better context key

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

* Enable authentication in proxy extensions

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

* Define headers for Authz

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

* add tests to the ValidateHeader function

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

* Fix CSS bug

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

* fix build

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

* Fix unit-test

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

* Run tests in parallel

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

* Implement rbac validation

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

* fix CSS issue

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

* Fix CSS

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

* Add proxy extensions doc file

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

* add title

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

* Add proxy config doc

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

* Document configuration and usage

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

* fix configmap doc

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

* Address review comments

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

* revert terminal changes

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

* Add rbac docs

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

* Fix merge

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

* add more details in the rbac doc

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

* Add upgrading instructions for proxy extensions

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

* Address review comments

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

* Add more detail about headers validation

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

* Fix Host header

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

* fix sanitize

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

* Address review comments

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

* Don't send error details in response

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

* Address comments

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

* typo

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

* Fix codeql warning

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

* fix codeql warning

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

* Implement better proxy correlation logic for multi backend setup

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

* Address security vulnerability

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

* Improve docs

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

* Fix docs

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

---------

Signed-off-by: Leonardo Luz Almeida <leonardo_almeida@intuit.com>
This commit is contained in:
Leonardo Luz Almeida 2023-02-15 17:16:33 -05:00 committed by GitHub
parent b6cfe676f3
commit 974c2de168
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1386 additions and 282 deletions

View file

@ -0,0 +1,261 @@
# Proxy Extensions
*Current Status: [Alpha][1] (Since v2.7.0)*
## Overview
With UI extensions it is possible to enhance Argo CD web interface to
provide valuable data to the user. However the data is restricted to
the resources that belongs to the Application. With proxy extensions
it is also possible to add additional functionality that have access
to data provided by backend services. In this case Argo CD API server
acts as a reverse-proxy authenticating and authorizing incoming
requests before forwarding to the backend service.
## Configuration
As proxy extension is in [Alpha][1] phase, the feature is disabled by
default. To enable it, it is necessary to configure the feature flag
in Argo CD command parameters. The easiest way to to properly enable
this feature flag is by adding the `server.enable.proxy.extension` key
in the existing `argocd-cmd-params-cm`. For example:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmd-params-cm
namespace: argocd
data:
server.enable.proxy.extension: "true"
```
Once the proxy extension is enabled, it can be configured in the main
Argo CD configmap ([argocd-cm][2]).
The example below demonstrate all possible configurations available
for proxy extensions:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
extension.config: |
extensions:
- name: httpbin
backend:
connectionTimeout: 2s
keepAlive: 15s
idleConnectionTimeout: 60s
maxIdleConnections: 30
services:
- url: http://httpbin.org
cluster:
name: some-cluster
server: https://some-cluster
```
If a the configuration is changed, Argo CD Server will need to be
restarted as the proxy handlers are only registered once during the
initialization of the server.
Every configuration entry is explained below:
#### `extensions` (*list*)
Defines configurations for all extensions enabled.
#### `extensions.name` (*string*)
(mandatory)
Defines the endpoint that will be used to register the extension
route. For example, if the value of the property is `extensions.name:
my-extension` then the backend service will be exposed under the
following url:
<argocd-host>/extensions/my-extension
#### `extensions.backend.connectionTimeout` (*duration string*)
(optional. Default: 2s)
Is the maximum amount of time a dial to the extension server will wait
for a connect to complete.
#### `extensions.backend.keepAlive` (*duration string*)
(optional. Default: 15s)
Specifies the interval between keep-alive probes for an active network
connection between the API server and the extension server.
#### `extensions.backend.idleConnectionTimeout` (*duration string*)
(optional. Default: 60s)
Is the maximum amount of time an idle (keep-alive) connection between
the API server and the extension server will remain idle before
closing itself.
#### `extensions.backend.maxIdleConnections` (*int*)
(optional. Default: 30)
Controls the maximum number of idle (keep-alive) connections between
the API server and the extension server.
#### `extensions.backend.services` (*list*)
Defines a list with backend url by cluster.
#### `extensions.backend.services.url` (*string*)
(mandatory)
Is the address where the extension backend must be available.
#### `extensions.backend.services.cluster` (*object*)
(optional)
If provided, and multiple services are configured, will have to match
the application destination name or server to have requests properly
forwarded to this service URL. If there are multiple backends for the
same extension this field is required. In this case at least one of
the two will be required: name or server. It is better to provide both
values to avoid problems with applications unable to send requests to
the proper backend service. If only one backend service is
configured, this field is ignored, and all requests are forwarded to
the configured one.
#### `extensions.backend.services.cluster.name` (*string*)
(optional)
It will be matched with the value from
`Application.Spec.Destination.Name`
#### `extensions.backend.services.cluster.server` (*string*)
(optional)
It will be matched with the value from
`Application.Spec.Destination.Server`.
## Usage
Once a proxy extension is configured it will be made available under
the `/extensions/<extension-name>` endpoint exposed by Argo CD API
server. The example above will proxy requests to
`<apiserver-host>/extensions/httpbin/` to `http://httpbin.org`.
The diagram below illustrates an interaction possible with this
configuration:
```
┌─────────────┐
│ Argo CD UI │
└────┬────────┘
│ ▲
GET <apiserver-host>/extensions/httpbin/anything │ │ 200 OK
+ authn/authz headers │ │
▼ │
┌─────────┴────────┐
│Argo CD API Server│
└──────┬───────────┘
│ ▲
GET http://httpbin.org/anything │ │ 200 OK
│ │
▼ │
┌────────┴────────┐
│ Backend Service │
└─────────────────┘
```
### 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*)
Argo CD UI keeps the authentication token stored in a cookie
(`argocd.token`). This value needs to be sent in the `Cookie` header
so the API server can validate its authenticity.
Example:
Cookie: argocd.token=eyJhbGciOiJIUzI1Ni...
The entire Argo CD cookie list can also be sent. The API server will
only use the `argocd.token` attribute in this case.
#### `Argocd-Application-Name` (mandatory)
This is the name of the project for the application for which the
extension is being invoked. The header value must follow the format:
`"<namespace>:<app-name>"`.
Example:
Argocd-Application-Name: namespace:app-name
#### `Argocd-Project-Name` (mandatory)
The logged in user must have access to this project in order to be
authorized.
Example:
Argocd-Project-Name: default
Argo CD API Server will ensure that the logged in user has the
permission to access the resources provided by the headers above. The
validation is based on pre-configured [Argo CD RBAC rules][3]. The
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.
### Multi Backend Use-Case
In some cases when Argo CD is configured to sync with multiple remote
clusters, there might be a need to call a specific backend service in
each of those clusters. The proxy-extension can be configured to
address this use-case by defining multiple services for the same
extension. Consider the following configuration as an example:
```yaml
extension.config: |
extensions:
- name: some-extension
backend:
services:
- url: http://extension-name.com:8080
cluster
name: kubernetes.local
- url: https://extension-name.ppd.cluster.k8s.local:8080
cluster
server: user@ppd.cluster.k8s.local
```
In the example above, the API server will inspect the Application
destination to verify which URL should be used to proxy the incoming
request to.
## Security
When a request to `/extensions/*` reaches the API Server, it will
first verify if it is authenticated with a valid token. It does so by
inspecting if the `Cookie` header is properly sent from Argo CD UI
extension.
Once the request is authenticated it is then verified if the
user has permission to invoke this extension. The permission is
enforced by Argo CD RBAC configuration. The details about how to
configure the RBAC for proxy-extensions can be found in the [RBAC
documentation][3] page.
Once the request is authenticated and authorized by the API server, it
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.
[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

View file

@ -0,0 +1,97 @@
# UI Extensions
Argo CD web user interface can be extended with additional UI elements. Extensions should be delivered as a javascript file
in the `argocd-server` Pods that are placed in the `/tmp/extensions` directory and starts with `extension` prefix ( matches to `^extension(.*)\.js$` regex ).
```
/tmp/extensions
├── example1
│   └── extension-1.js
└── example2
└── extension-2.js
```
Extensions are loaded during initial page rendering and should register themselves using API exposed in the `extensionsAPI` global variable. (See
corresponding extension type details for additional information).
The extension should provide a React component that is responsible for rendering the UI element. Extension should not bundle the React library.
Instead extension should use the `react` global variable. You can leverage `externals` setting if you are using webpack:
```js
externals: {
react: "React";
}
```
## Resource Tab Extensions
Resource Tab extensions is an extension that provides an additional tab for the resource sliding panel at the Argo CD Application details page.
The resource tab extension should be registered using the `extensionsAPI.registerResourceExtension` method:
```typescript
registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string)
```
- `component: ExtensionComponent` is a React component that receives the following properties:
- application: Application - Argo CD Application resource;
- resource: State - the kubernetes resource object;
- tree: ApplicationTree - includes list of all resources that comprise the application;
See properties interfaces in [models.ts](https://github.com/argoproj/argo-cd/blob/master/ui/src/app/shared/models.ts)
- `group: string` - the glob expression that matches the group of the resource; note: use globstar (`**`) to match all groups including empty string;
- `kind: string` - the glob expression that matches the kind of the resource;
- `tabTitle: string` - the extension tab title.
- `opts: Object` - additional options:
- `icon: string` - the class name the represents the icon from the [https://fontawesome.com/](https://fontawesome.com/) library (e.g. 'fa-calendar-alt');
Below is an example of a resource tab extension:
```javascript
((window) => {
const component = () => {
return React.createElement("div", {}, "Hello World");
};
window.extensionsAPI.registerResourceExtension(
component,
"*",
"*",
"Nice extension"
);
})(window);
```
## System Level Extensions
Argo CD allows you to add new items to the sidebar that will be displayed as a new page with a custom component when clicked. The system level extension should be registered using the `extensionsAPI.registerSystemLevelExtension` method:
```typescript
registerSystemLevelExtension(component: ExtensionComponent, title: string, options: {icon?: string})
```
Below is an example of a simple system level extension:
```typescript
((window) => {
const component = () => {
return React.createElement(
"div",
{ style: { padding: "10px" } },
"Hello World"
);
};
window.extensionsAPI.registerSystemLevelExtension(
component,
"Test Ext",
"/hello",
"fa-flask"
);
})(window);
```
## Application Tab Extensions
Since the Argo CD Application is a Kubernetes resource, application tabs can be the same as any other resource tab.
Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab.

View file

@ -1,97 +1,2 @@
# UI Extensions
Argo CD web user interface can be extended with additional UI elements. Extensions should be delivered as a javascript file
in the `argocd-server` Pods that are placed in the `/tmp/extensions` directory and starts with `extension` prefix ( matches to `^extension(.*)\.js$` regex ).
```
/tmp/extensions
├── example1
│   └── extension-1.js
└── example2
└── extension-2.js
```
Extensions are loaded during initial page rendering and should register themselves using API exposed in the `extensionsAPI` global variable. (See
corresponding extension type details for additional information).
The extension should provide a React component that is responsible for rendering the UI element. Extension should not bundle the React library.
Instead extension should use the `react` global variable. You can leverage `externals` setting if you are using webpack:
```js
externals: {
react: "React";
}
```
## Resource Tab Extensions
Resource Tab extensions is an extension that provides an additional tab for the resource sliding panel at the Argo CD Application details page.
The resource tab extension should be registered using the `extensionsAPI.registerResourceExtension` method:
```typescript
registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string)
```
- `component: ExtensionComponent` is a React component that receives the following properties:
- application: Application - Argo CD Application resource;
- resource: State - the kubernetes resource object;
- tree: ApplicationTree - includes list of all resources that comprise the application;
See properties interfaces in [models.ts](https://github.com/argoproj/argo-cd/blob/master/ui/src/app/shared/models.ts)
- `group: string` - the glob expression that matches the group of the resource; note: use globstar (`**`) to match all groups including empty string;
- `kind: string` - the glob expression that matches the kind of the resource;
- `tabTitle: string` - the extension tab title.
- `opts: Object` - additional options:
- `icon: string` - the class name the represents the icon from the [https://fontawesome.com/](https://fontawesome.com/) library (e.g. 'fa-calendar-alt');
Below is an example of a resource tab extension:
```javascript
((window) => {
const component = () => {
return React.createElement("div", {}, "Hello World");
};
window.extensionsAPI.registerResourceExtension(
component,
"*",
"*",
"Nice extension"
);
})(window);
```
## System Level Extensions
Argo CD allows you to add new items to the sidebar that will be displayed as a new page with a custom component when clicked. The system level extension should be registered using the `extensionsAPI.registerSystemLevelExtension` method:
```typescript
registerSystemLevelExtension(component: ExtensionComponent, title: string, options: {icon?: string})
```
Below is an example of a simple system level extension:
```typescript
((window) => {
const component = () => {
return React.createElement(
"div",
{ style: { padding: "10px" } },
"Hello World"
);
};
window.extensionsAPI.registerSystemLevelExtension(
component,
"Test Ext",
"/hello",
"fa-flask"
);
})(window);
```
## Application Tab Extensions
Since the Argo CD Application is a Kubernetes resource, application tabs can be the same as any other resource tab.
Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab.
The contents of this document have been moved to the
[extensions guide](./extensions/ui-extensions.md)

View file

@ -331,3 +331,46 @@ data:
- url: https://mycompany.splunk.com?search={{.metadata.namespace}}
title: Splunk
if: kind == "Pod" || kind == "Deployment"
extension.config: |
extensions:
# Name defines the endpoint that will be used to register
# the extension route.
# Mandatory field.
- name: some-extension
backend:
# ConnectionTimeout is the maximum amount of time a dial to
# the extension server will wait for a connect to complete.
# Optional field. Default: 2 seconds
connectionTimeout: 2s
# KeepAlive specifies the interval between keep-alive probes
# for an active network connection between the API server and
# the extension server.
# Optional field. Default: 15 seconds
keepAlive: 15s
# IdleConnectionTimeout is the maximum amount of time an idle
# (keep-alive) connection between the API server and the extension
# server will remain idle before closing itself.
# Optional field. Default: 60 seconds
idleConnectionTimeout: 60s
# MaxIdleConnections controls the maximum number of idle (keep-alive)
# connections between the API server and the extension server.
# Optional field. Default: 30
maxIdleConnections: 30
services:
# URL is the address where the extension backend must be available.
# Mandatory field.
- url: http://httpbin.org
# Cluster if provided, will have to match the application
# destination name or the destination server to have requests
# properly forwarded to this service URL.
# Optional field if only one service is specified.
# Mandatory if multiple services are specified.
cluster:
name: some-cluster
server: https://some-cluster

View file

@ -28,7 +28,9 @@ Breaking down the permissions definition differs slightly between applications a
### RBAC Resources and Actions
Resources: `clusters`, `projects`, `applications`, `applicationsets`, `repositories`, `certificates`, `accounts`, `gpgkeys`, `logs`, `exec`
Resources: `clusters`, `projects`, `applications`, `applicationsets`,
`repositories`, `certificates`, `accounts`, `gpgkeys`, `logs`, `exec`,
`extensions`
Actions: `get`, `create`, `update`, `delete`, `sync`, `override`,`action/<group/kind/action-name>`
@ -79,6 +81,40 @@ p, dev-group, applicationsets, *, dev-project/*, allow
With this rule in place, a `dev-group` user will be unable to create an ApplicationSet capable of creating Applications
outside the `dev-project` project.
#### The `extensions` resource
With the `extensions` resource it is possible configure permissions to
invoke [proxy
extensions](../developer-guide/extensions/proxy-extensions.md). The
`extensions` RBAC validation works in conjunction with the
`applications` resource. A user logged in Argo CD (UI or CLI), needs
to have at least read permission on the project, namespace and
application where the request is originated from.
Consider the example below:
```csv
g, ext, role:extension
p, role:extension, applications, get, default/httpbin-app, allow
p, role:extension, extensions, invoke, httpbin, allow
```
Explanation:
- *line1*: defines the group `role:extension` associated with the
subject `ext`.
- *line2*: defines a policy allowing this role to read (`get`) the
`httpbin-app` application in the `default` project.
- *line3*: defines another policy allowing this role to `invoke` the
`httpbin` extension.
**Note 1**: that for extensions requests to be allowed, the policy defined
in the *line2* is also required.
**Note 2**: `invoke` is a new action introduced specifically to be used
with the `extensions` resource. The current actions for `extensions`
are `*` or `invoke`.
## Tying It All Together
Additional roles and groups can be configured in `argocd-rbac-cm` ConfigMap. The example below

View file

@ -0,0 +1,38 @@
# v2.6 to 2.7
## Configure RBAC to account for new `extensions` resource
2.7 introduces the new [Proxy Extensions][1] feature with a new `extensions`
[RBAC resource][2].
When you upgrade to 2.7, RBAC policies with `*` in the *resource*
field and `*` in the action field, it will automatically grant the
`extensions` privilege.
The Proxy Extension feature is disabled by default, however it is
recommended to check your RBAC configurations to enforce the least
necessary privileges.
Example
Old:
```csv
p, role:org-admin, *, *, *, allow
```
New:
```csv
p, role:org-admin, clusters, create, my-proj/*, allow
p, role:org-admin, projects, create, my-proj/*, allow
p, role:org-admin, applications, create, my-proj/*, allow
p, role:org-admin, repositories, create, my-proj/*, allow
p, role:org-admin, certificates, create, my-proj/*, allow
p, role:org-admin, accounts, create, my-proj/*, allow
p, role:org-admin, gpgkeys, create, my-proj/*, allow
# If you don't want to grant the new permission, don't include the following line
p, role:org-admin, extensions, invoke, my-proj/*, allow
```
[1]: ../../developer-guide/extensions/proxy-extensions.md
[2]: https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/#the-extensions-resource

View file

@ -180,7 +180,9 @@ nav:
- developer-guide/releasing.md
- developer-guide/site.md
- developer-guide/static-code-analysis.md
- developer-guide/ui-extensions.md
- Extensions:
- developer-guide/extensions/ui-extensions.md
- developer-guide/extensions/proxy-extensions.md
- developer-guide/faq.md
- faq.md
- security_considerations.md

View file

@ -9,7 +9,6 @@ import (
log "github.com/sirupsen/logrus"
v1 "k8s.io/api/core/v1"
apierr "k8s.io/apimachinery/pkg/api/errors"
apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
@ -65,37 +64,6 @@ func (s *terminalHandler) getApplicationClusterRawConfig(ctx context.Context, a
return clst.RawRestConfig(), nil
}
// isValidPodName checks that a podName is valid
func isValidPodName(name string) bool {
// https://github.com/kubernetes/kubernetes/blob/976a940f4a4e84fe814583848f97b9aafcdb083f/pkg/apis/core/validation/validation.go#L241
validationErrors := apimachineryvalidation.NameIsDNSSubdomain(name, false)
return len(validationErrors) == 0
}
func isValidAppName(name string) bool {
// app names have the same rules as pods.
return isValidPodName(name)
}
func isValidProjectName(name string) bool {
// project names have the same rules as pods.
return isValidPodName(name)
}
// isValidNamespaceName checks that a namespace name is valid
func isValidNamespaceName(name string) bool {
// https://github.com/kubernetes/kubernetes/blob/976a940f4a4e84fe814583848f97b9aafcdb083f/pkg/apis/core/validation/validation.go#L262
validationErrors := apimachineryvalidation.ValidateNamespaceName(name, false)
return len(validationErrors) == 0
}
// isValidContainerName checks that a containerName is valid
func isValidContainerName(name string) bool {
// https://github.com/kubernetes/kubernetes/blob/53a9d106c4aabcd550cc32ae4e8004f32fb0ae7b/pkg/api/validation/validation.go#L280
validationErrors := apimachineryvalidation.NameIsDNSLabel(name, false)
return len(validationErrors) == 0
}
type GetSettingsFunc func() (*settings.ArgoCDSettings, error)
// WithFeatureFlagMiddleware is an HTTP middleware to verify if the terminal
@ -132,27 +100,27 @@ func (s *terminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
appNamespace := q.Get("appNamespace")
if !isValidPodName(podName) {
if !argo.IsValidPodName(podName) {
http.Error(w, "Pod name is not valid", http.StatusBadRequest)
return
}
if !isValidContainerName(container) {
if !argo.IsValidContainerName(container) {
http.Error(w, "Container name is not valid", http.StatusBadRequest)
return
}
if !isValidAppName(app) {
if !argo.IsValidAppName(app) {
http.Error(w, "App name is not valid", http.StatusBadRequest)
return
}
if !isValidProjectName(project) {
if !argo.IsValidProjectName(project) {
http.Error(w, "Project name is not valid", http.StatusBadRequest)
return
}
if !isValidNamespaceName(namespace) {
if !argo.IsValidNamespaceName(namespace) {
http.Error(w, "Namespace name is not valid", http.StatusBadRequest)
return
}
if !isValidNamespaceName(appNamespace) {
if !argo.IsValidNamespaceName(appNamespace) {
http.Error(w, "App namespace name is not valid", http.StatusBadRequest)
return
}

View file

@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
appv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/v2/util/argo"
"github.com/argoproj/argo-cd/v2/util/security"
)
@ -108,7 +109,7 @@ func TestIsValidPodName(t *testing.T) {
},
} {
t.Run(tcase.name, func(t *testing.T) {
result := isValidPodName(tcase.resourceName)
result := argo.IsValidPodName(tcase.resourceName)
if result != tcase.expectedResult {
t.Errorf("Expected result %v, but got %v", tcase.expectedResult, result)
}
@ -139,7 +140,7 @@ func TestIsValidNamespaceName(t *testing.T) {
},
} {
t.Run(tcase.name, func(t *testing.T) {
result := isValidNamespaceName(tcase.resourceName)
result := argo.IsValidNamespaceName(tcase.resourceName)
if result != tcase.expectedResult {
t.Errorf("Expected result %v, but got %v", tcase.expectedResult, result)
}
@ -170,7 +171,7 @@ func TestIsValidContainerNameName(t *testing.T) {
},
} {
t.Run(tcase.name, func(t *testing.T) {
result := isValidContainerName(tcase.resourceName)
result := argo.IsValidContainerName(tcase.resourceName)
if result != tcase.expectedResult {
t.Errorf("Expected result %v, but got %v", tcase.expectedResult, result)
}

View file

@ -2,7 +2,7 @@ package extension
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@ -12,24 +12,97 @@ import (
"strings"
"time"
applicationpkg "github.com/argoproj/argo-cd/v2/pkg/apiclient/application"
v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1"
"github.com/argoproj/argo-cd/v2/server/rbacpolicy"
"github.com/argoproj/argo-cd/v2/util/argo"
"github.com/argoproj/argo-cd/v2/util/db"
"github.com/argoproj/argo-cd/v2/util/security"
"github.com/argoproj/argo-cd/v2/util/settings"
"github.com/ghodss/yaml"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
"k8s.io/utils/pointer"
)
const (
URLPrefix = "/extensions"
HeaderArgoCDApplicationName = "Argocd-Application-Name"
DefaultConnectionTimeout = 2 * time.Second
DefaultKeepAlive = 15 * time.Second
DefaultIdleConnectionTimeout = 60 * time.Second
DefaultMaxIdleConnections = 30
// HeaderArgoCDApplicationName defines the name of the
// expected application header to be passed to the extension
// handler. The header value must follow the format:
// "<namespace>:<app-name>"
// Example:
// Argocd-Application-Name: "namespace:app-name"
HeaderArgoCDApplicationName = "Argocd-Application-Name"
// HeaderArgoCDProjectName defines the name of the expected
// project header to be passed to the extension handler.
// Example:
// Argocd-Project-Name: "default"
HeaderArgoCDProjectName = "Argocd-Project-Name"
)
// RequestResources defines the authorization scope for
// an incoming request to a given extension. This struct
// is populated from pre-defined Argo CD headers.
type RequestResources struct {
ApplicationName string
ApplicationNamespace string
ProjectName string
}
// ValidateHeaders will validate the pre-defined Argo CD
// request headers for extensions and extract the resources
// information populating and returning a RequestResources
// object.
// The pre-defined headers are:
// - Argocd-Application-Name
// - Argocd-Project-Name
//
// The headers expected format is documented in each of the constant
// types defined for them.
func ValidateHeaders(r *http.Request) (*RequestResources, error) {
appHeader := r.Header.Get(HeaderArgoCDApplicationName)
if appHeader == "" {
return nil, fmt.Errorf("header %q must be provided", HeaderArgoCDApplicationName)
}
appNamespace, appName, err := getAppName(appHeader)
if err != nil {
return nil, fmt.Errorf("error getting app details: %s", err)
}
if !argo.IsValidNamespaceName(appNamespace) {
return nil, errors.New("invalid value for namespace")
}
if !argo.IsValidAppName(appName) {
return nil, errors.New("invalid value for application name")
}
projName := r.Header.Get(HeaderArgoCDProjectName)
if projName == "" {
return nil, fmt.Errorf("header %q must be provided", HeaderArgoCDProjectName)
}
if !argo.IsValidProjectName(projName) {
return nil, errors.New("invalid value for project name")
}
return &RequestResources{
ApplicationName: appName,
ApplicationNamespace: appNamespace,
ProjectName: projName,
}, nil
}
func getAppName(appHeader string) (string, string, error) {
parts := strings.Split(appHeader, ":")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid value for %q header: expected format: <namespace>:<app-name>", HeaderArgoCDApplicationName)
}
return parts[0], parts[1], nil
}
// ExtensionConfigs defines the configurations for all extensions
// retrieved from Argo CD configmap (argocd-cm).
type ExtensionConfigs struct {
@ -54,6 +127,26 @@ type BackendConfig struct {
Services []ServiceConfig `json:"services"`
}
// ServiceConfig provides the configuration for a backend service.
type ServiceConfig struct {
// URL is the address where the extension backend must be available.
// Mandatory field.
URL string `json:"url"`
// Cluster if provided, will have to match the application
// destination name to have requests properly forwarded to this
// service URL.
Cluster *ClusterConfig `json:"cluster,omitempty"`
}
type ClusterConfig struct {
// Server specifies the URL of the target cluster and must be set to the Kubernetes control plane API
Server string `json:"server"`
// Name is an alternate way of specifying the target cluster by its symbolic name
Name string `json:"name"`
}
// ProxyConfig allows configuring connection behaviour between Argo CD
// API Server and the backend service.
type ProxyConfig struct {
@ -80,18 +173,6 @@ type ProxyConfig struct {
MaxIdleConnections int `json:"maxIdleConnections"`
}
// ServiceConfig provides the configuration for a backend service.
type ServiceConfig struct {
// URL is the address where the extension backend must be available.
// Mandatory field.
URL string `json:"url"`
// Cluster if provided, will have to match the application
// destination name to have requests properly forwarded to this
// service URL.
Cluster string `json:"cluster"`
}
// SettingsGetter defines the contract to retrieve Argo CD Settings.
type SettingsGetter interface {
Get() (*settings.ArgoCDSettings, error)
@ -114,6 +195,36 @@ func (s *DefaultSettingsGetter) Get() (*settings.ArgoCDSettings, error) {
return s.settingsMgr.GetSettings()
}
// ProjectGetter defines the contract to retrieve Argo CD Project.
type ProjectGetter interface {
Get(name string) (*v1alpha1.AppProject, error)
GetClusters(project string) ([]*v1alpha1.Cluster, error)
}
// DefaultProjectGetter is the real ProjectGetter implementation.
type DefaultProjectGetter struct {
projLister applisters.AppProjectNamespaceLister
db db.ArgoDB
}
// NewDefaultProjectGetter returns a new default project getter
func NewDefaultProjectGetter(lister applisters.AppProjectNamespaceLister, db db.ArgoDB) *DefaultProjectGetter {
return &DefaultProjectGetter{
projLister: lister,
db: db,
}
}
// Get will retrieve the live AppProject state.
func (p *DefaultProjectGetter) Get(name string) (*v1alpha1.AppProject, error) {
return p.projLister.Get(name)
}
// GetClusters will retrieve the clusters configured by a project.
func (p *DefaultProjectGetter) GetClusters(project string) ([]*v1alpha1.Cluster, error) {
return p.db.GetProjectClusters(context.TODO(), project)
}
// ApplicationGetter defines the contract to retrieve the application resource.
type ApplicationGetter interface {
Get(ns, name string) (*v1alpha1.Application, error)
@ -121,23 +232,24 @@ type ApplicationGetter interface {
// DefaultApplicationGetter is the real application getter implementation.
type DefaultApplicationGetter struct {
svc applicationpkg.ApplicationServiceServer
appLister applisters.ApplicationLister
}
// NewDefaultApplicationGetter returns the default application getter.
func NewDefaultApplicationGetter(appSvc applicationpkg.ApplicationServiceServer) *DefaultApplicationGetter {
func NewDefaultApplicationGetter(al applisters.ApplicationLister) *DefaultApplicationGetter {
return &DefaultApplicationGetter{
svc: appSvc,
appLister: al,
}
}
// Get will retrieve the application resorce for the given namespace and name.
func (a *DefaultApplicationGetter) Get(ns, name string) (*v1alpha1.Application, error) {
query := &applicationpkg.ApplicationQuery{
Name: pointer.String(name),
AppNamespace: pointer.String(ns),
}
return a.svc.Get(context.Background(), query)
return a.appLister.Applications(ns).Get(name)
}
// RbacEnforcer defines the contract to enforce rbac rules
type RbacEnforcer interface {
EnforceErr(rvals ...interface{}) error
}
// Manager is the object that will be responsible for registering
@ -146,14 +258,48 @@ type Manager struct {
log *log.Entry
settings SettingsGetter
application ApplicationGetter
project ProjectGetter
rbac RbacEnforcer
}
// NewManager will initialize a new manager.
func NewManager(sg SettingsGetter, ag ApplicationGetter, log *log.Entry) *Manager {
func NewManager(log *log.Entry, sg SettingsGetter, ag ApplicationGetter, pg ProjectGetter, rbac RbacEnforcer) *Manager {
return &Manager{
log: log,
settings: sg,
application: ag,
project: pg,
rbac: rbac,
}
}
// ProxyRegistry is an in memory registry that contains all proxies for a
// given extension. Different extensions will have independent proxy registries.
// This is required to address the use case when one extension is configured with
// multiple backend services in different clusters.
type ProxyRegistry map[ProxyKey]*httputil.ReverseProxy
// NewProxyRegistry will instantiate a new in memory registry for proxies.
func NewProxyRegistry() ProxyRegistry {
r := make(map[ProxyKey]*httputil.ReverseProxy)
return r
}
// ProxyKey defines the struct used as a key in the proxy registry
// map (ProxyRegistry).
type ProxyKey struct {
extensionName string
clusterName string
clusterServer string
}
// proxyKey will build the key to be used in the proxyByCluster
// map.
func proxyKey(extName, cName, cServer string) ProxyKey {
return ProxyKey{
extensionName: extName,
clusterName: cName,
clusterServer: cServer,
}
}
@ -172,6 +318,7 @@ func parseAndValidateConfig(config string) (*ExtensionConfigs, error) {
func validateConfigs(configs *ExtensionConfigs) error {
nameSafeRegex := regexp.MustCompile(`^[A-Za-z0-9-_]+$`)
exts := make(map[string]struct{})
for _, ext := range configs.Extensions {
if ext.Name == "" {
return fmt.Errorf("extensions.name must be configured")
@ -179,14 +326,23 @@ func validateConfigs(configs *ExtensionConfigs) error {
if !nameSafeRegex.MatchString(ext.Name) {
return fmt.Errorf("invalid extensions.name: only alphanumeric characters, hyphens, and underscores are allowed")
}
if _, found := exts[ext.Name]; found {
return fmt.Errorf("duplicated extension found in the configs for %q", ext.Name)
}
exts[ext.Name] = struct{}{}
svcTotal := len(ext.Backend.Services)
for _, svc := range ext.Backend.Services {
if svc.URL == "" {
return fmt.Errorf("extensions.backend.services.url must be configured")
}
if svcTotal > 1 && svc.Cluster == "" {
if svcTotal > 1 && svc.Cluster == nil {
return fmt.Errorf("extensions.backend.services.cluster must be configured when defining more than one service per extension")
}
if svc.Cluster != nil {
if svc.Cluster.Name == "" && svc.Cluster.Server == "" {
return fmt.Errorf("cluster.name or cluster.server must be defined when cluster is provided in the configuration")
}
}
}
}
return nil
@ -199,8 +355,15 @@ func NewProxy(targetURL string, config ProxyConfig) (*httputil.ReverseProxy, err
if err != nil {
return nil, fmt.Errorf("failed to parse proxy URL: %s", err)
}
proxy := httputil.NewSingleHostReverseProxy(url)
proxy.Transport = newTransport(config)
proxy := &httputil.ReverseProxy{
Transport: newTransport(config),
Director: func(req *http.Request) {
req.Host = url.Host
req.URL.Scheme = url.Scheme
req.URL.Host = url.Host
req.Header.Set("Host", url.Host)
},
}
return proxy, nil
}
@ -256,102 +419,178 @@ func (m *Manager) RegisterHandlers(r *mux.Router) error {
return m.registerExtensions(r, extConfigs)
}
// appendProxy will append the given proxy in the given registry. Will use
// the provided extName and service to determine the map key. The key must
// be unique in the map. If the map already has the key and error is returned.
func appendProxy(registry ProxyRegistry,
extName string,
service ServiceConfig,
proxy *httputil.ReverseProxy,
singleBackend bool) error {
if singleBackend {
key := proxyKey(extName, "", "")
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
return nil
}
// This is the case where there are more than one backend configured
// for this extension. In this case we need to add the provided cluster
// configurations for proper correlation to find which proxy to use
// while handling requests.
if service.Cluster.Name != "" {
key := proxyKey(extName, service.Cluster.Name, "")
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
}
if service.Cluster.Server != "" {
key := proxyKey(extName, "", service.Cluster.Server)
if _, exist := registry[key]; exist {
return fmt.Errorf("duplicated proxy configuration found for extension key %q", key)
}
registry[key] = proxy
}
return nil
}
// registerExtensions will iterate over the given extConfigs and register
// http handlers for every extension. It also registers a list extensions
// handler under the "/extensions/" endpoint.
func (m *Manager) registerExtensions(r *mux.Router, extConfigs *ExtensionConfigs) error {
extRouter := r.PathPrefix(fmt.Sprintf("%s/", URLPrefix)).Subrouter()
for _, ext := range extConfigs.Extensions {
proxyByCluster := make(map[string]*httputil.ReverseProxy)
registry := NewProxyRegistry()
singleBackend := len(ext.Backend.Services) == 1
for _, service := range ext.Backend.Services {
proxy, err := NewProxy(service.URL, ext.Backend.ProxyConfig)
if err != nil {
return fmt.Errorf("error creating proxy: %s", err)
}
proxyByCluster[service.Cluster] = proxy
err = appendProxy(registry, ext.Name, service, proxy, singleBackend)
if err != nil {
return fmt.Errorf("error appending proxy: %s", err)
}
}
m.log.Infof("Registering handler for %s/%s...", URLPrefix, ext.Name)
extRouter.PathPrefix(fmt.Sprintf("/%s/", ext.Name)).
HandlerFunc(m.CallExtension(ext.Name, proxyByCluster))
HandlerFunc(m.CallExtension(ext.Name, registry))
}
return nil
}
// authorize will enforce rbac rules are satified for the given RequestResources.
// The following validations are executed:
// - enforce the subject has permission to read application/project provided
// in HeaderArgoCDApplicationName and HeaderArgoCDProjectName.
// - enforce the subject has permission to invoke the extension identified by
// extName.
// - enforce that the project has permission to access the destination cluster.
//
// If all validations are satified it will return the Application resource
func (m *Manager) authorize(ctx context.Context, rr *RequestResources, extName string) (*v1alpha1.Application, error) {
if m.rbac == nil {
return nil, fmt.Errorf("rbac enforcer not set in extension manager")
}
appRBACName := security.AppRBACName(rr.ApplicationNamespace, rr.ProjectName, rr.ApplicationNamespace, rr.ApplicationName)
if err := m.rbac.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, appRBACName); err != nil {
return nil, fmt.Errorf("application authorization error: %s", err)
}
if err := m.rbac.EnforceErr(ctx.Value("claims"), rbacpolicy.ResourceExtensions, rbacpolicy.ActionInvoke, extName); err != nil {
return nil, fmt.Errorf("unauthorized to invoke extension %q: %s", extName, err)
}
// just retrieve the app after checking if subject has access to it
app, err := m.application.Get(rr.ApplicationNamespace, rr.ApplicationName)
if err != nil {
return nil, fmt.Errorf("error getting application: %s", err)
}
if app == nil {
return nil, fmt.Errorf("invalid Application provided in the %q header", HeaderArgoCDApplicationName)
}
if app.Spec.GetProject() != rr.ProjectName {
return nil, fmt.Errorf("project mismatch provided in the %q header", HeaderArgoCDProjectName)
}
proj, err := m.project.Get(app.Spec.GetProject())
if err != nil {
return nil, fmt.Errorf("error getting project: %s", err)
}
if proj == nil {
return nil, fmt.Errorf("invalid project provided in the %q header", HeaderArgoCDProjectName)
}
permitted, err := proj.IsDestinationPermitted(app.Spec.Destination, m.project.GetClusters)
if err != nil {
return nil, fmt.Errorf("error validating project destinations: %s", err)
}
if !permitted {
return nil, fmt.Errorf("the provided project is not allowed to access the cluster configured in the Application destination")
}
return app, nil
}
// findProxy will search the given registry to find the correct proxy to use
// based on the given extName and dest.
func findProxy(registry ProxyRegistry, extName string, dest v1alpha1.ApplicationDestination) (*httputil.ReverseProxy, error) {
// First try to find the proxy in the registry just by the extension name.
// This is the simple case for extensions with only one backend service.
key := proxyKey(extName, "", "")
if proxy, found := registry[key]; found {
return proxy, nil
}
// If extension has multiple backend services configured, the correct proxy
// needs to be searched by the ApplicationDestination.
key = proxyKey(extName, dest.Name, dest.Server)
if proxy, found := registry[key]; found {
return proxy, nil
}
return nil, fmt.Errorf("no proxy found for extension %q", extName)
}
// CallExtension returns a handler func responsible for forwarding requests to the
// extension service. The request will be sanitized by removing sensitive headers.
func (m *Manager) CallExtension(extName string, proxyByCluster map[string]*httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) {
func (m *Manager) CallExtension(extName string, registry ProxyRegistry) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
sanitizeRequest(r, extName)
if len(proxyByCluster) == 1 {
for _, proxy := range proxyByCluster {
proxy.ServeHTTP(w, r)
return
}
}
appHeader := r.Header.Get(HeaderArgoCDApplicationName)
if appHeader == "" {
msg := fmt.Sprintf("Header %q must be provided", HeaderArgoCDApplicationName)
m.writeErrorResponse(http.StatusBadRequest, msg, w)
return
}
appNamespace, appName, err := getAppName(appHeader)
reqResources, err := ValidateHeaders(r)
if err != nil {
msg := fmt.Sprintf("Error getting application name: %s", err)
m.writeErrorResponse(http.StatusBadRequest, msg, w)
http.Error(w, fmt.Sprintf("Invalid headers: %s", err), http.StatusBadRequest)
return
}
app, err := m.application.Get(appNamespace, appName)
app, err := m.authorize(r.Context(), reqResources, extName)
if err != nil {
msg := fmt.Sprintf("Error getting application: %s", err)
m.writeErrorResponse(http.StatusBadRequest, msg, w)
m.log.Infof("unauthorized extension request: %s", err)
http.Error(w, "Unauthorized extension request", http.StatusUnauthorized)
return
}
if app == nil {
msg := fmt.Sprintf("Invalid Application: %s", appHeader)
m.writeErrorResponse(http.StatusBadRequest, msg, w)
return
}
clusterName := app.Spec.Destination.Name
if clusterName == "" {
clusterName = app.Spec.Destination.Server
}
proxy, ok := proxyByCluster[clusterName]
if !ok {
msg := fmt.Sprintf("No extension configured for cluster %q", clusterName)
m.writeErrorResponse(http.StatusBadRequest, msg, w)
proxy, err := findProxy(registry, extName, app.Spec.Destination)
if err != nil {
m.log.Errorf("findProxy error: %s", err)
http.Error(w, "invalid extension", http.StatusBadRequest)
return
}
sanitizeRequest(r, extName)
m.log.Debugf("proxing request for extension %q", extName)
proxy.ServeHTTP(w, r)
}
}
func getAppName(appHeader string) (string, string, error) {
parts := strings.Split(appHeader, "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid header value %q: expected format: <namespace>/<app-name>", appHeader)
}
return parts[0], parts[1], nil
}
// sanitizeRequest 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) {
r.URL.Path = strings.TrimPrefix(r.URL.String(), fmt.Sprintf("%s/%s", URLPrefix, extName))
}
func (m *Manager) writeErrorResponse(status int, message string, w http.ResponseWriter) {
w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
resp := make(map[string]string)
resp["status"] = http.StatusText(status)
resp["message"] = message
jsonResp, err := json.Marshal(resp)
if err != nil {
m.log.Errorf("Error marshaling response for extension: %s", err)
return
}
_, err = w.Write(jsonResp)
if err != nil {
m.log.Errorf("Error writing response for extension: %s", err)
return
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%s/%s", URLPrefix, extName))
r.Header.Del("Cookie")
r.Header.Del("Authorization")
}

View file

@ -2,6 +2,7 @@ package extension_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@ -14,13 +15,130 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/v2/server/extension"
"github.com/argoproj/argo-cd/v2/server/extension/mocks"
"github.com/argoproj/argo-cd/v2/server/rbacpolicy"
"github.com/argoproj/argo-cd/v2/util/settings"
)
func TestValidateHeaders(t *testing.T) {
t.Run("will build RequestResources successfully", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "namespace:app-name")
r.Header.Add(extension.HeaderArgoCDProjectName, "project-name")
// when
rr, err := extension.ValidateHeaders(r)
// then
require.NoError(t, err)
assert.NotNil(t, rr)
assert.Equal(t, "namespace", rr.ApplicationNamespace)
assert.Equal(t, "app-name", rr.ApplicationName)
assert.Equal(t, "project-name", rr.ProjectName)
})
t.Run("will return error if application is malformatted", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "no-namespace")
// when
rr, err := extension.ValidateHeaders(r)
// then
assert.Error(t, err)
assert.Nil(t, rr)
})
t.Run("will return error if application header is missing", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDProjectName, "project-name")
// when
rr, err := extension.ValidateHeaders(r)
// then
assert.Error(t, err)
assert.Nil(t, rr)
})
t.Run("will return error if project header is missing", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "namespace:app-name")
// when
rr, err := extension.ValidateHeaders(r)
// then
assert.Error(t, err)
assert.Nil(t, rr)
})
t.Run("will return error if invalid namespace", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "bad%namespace:app-name")
r.Header.Add(extension.HeaderArgoCDProjectName, "project-name")
// when
rr, err := extension.ValidateHeaders(r)
// then
assert.Error(t, err)
assert.Nil(t, rr)
})
t.Run("will return error if invalid app name", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "namespace:bad@app")
r.Header.Add(extension.HeaderArgoCDProjectName, "project-name")
// when
rr, err := extension.ValidateHeaders(r)
// then
assert.Error(t, err)
assert.Nil(t, rr)
})
t.Run("will return error if invalid project name", func(t *testing.T) {
// given
r, err := http.NewRequest("Get", "http://null", nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "namespace:app")
r.Header.Add(extension.HeaderArgoCDProjectName, "bad^project")
// when
rr, err := extension.ValidateHeaders(r)
// then
assert.Error(t, err)
assert.Nil(t, rr)
})
}
func TestRegisterHandlers(t *testing.T) {
type fixture struct {
settingsGetterMock *mocks.SettingsGetter
@ -32,7 +150,7 @@ func TestRegisterHandlers(t *testing.T) {
logger, _ := test.NewNullLogger()
logEntry := logger.WithContext(context.Background())
m := extension.NewManager(settMock, nil, logEntry)
m := extension.NewManager(logEntry, settMock, nil, nil, nil)
return &fixture{
settingsGetterMock: settMock,
@ -41,6 +159,7 @@ func TestRegisterHandlers(t *testing.T) {
}
t.Run("will register handlers successfully", func(t *testing.T) {
// given
t.Parallel()
f := setup()
router := mux.NewRouter()
settings := &settings.ArgoCDSettings{
@ -69,6 +188,7 @@ func TestRegisterHandlers(t *testing.T) {
})
t.Run("will return error if extension config is invalid", func(t *testing.T) {
// given
t.Parallel()
type testCase struct {
name string
configYaml string
@ -97,6 +217,7 @@ func TestRegisterHandlers(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// given
t.Parallel()
f := setup()
router := mux.NewRouter()
settings := &settings.ArgoCDSettings{
@ -114,21 +235,26 @@ func TestRegisterHandlers(t *testing.T) {
})
}
func TestExtensionsHandlers(t *testing.T) {
func TestExtensionsHandler(t *testing.T) {
type fixture struct {
router *mux.Router
appGetterMock *mocks.ApplicationGetter
settingsGetterMock *mocks.SettingsGetter
rbacMock *mocks.RbacEnforcer
projMock *mocks.ProjectGetter
manager *extension.Manager
}
defaultProjectName := "project-name"
setup := func() *fixture {
appMock := &mocks.ApplicationGetter{}
settMock := &mocks.SettingsGetter{}
rbacMock := &mocks.RbacEnforcer{}
projMock := &mocks.ProjectGetter{}
logger, _ := test.NewNullLogger()
logEntry := logger.WithContext(context.Background())
m := extension.NewManager(settMock, appMock, logEntry)
m := extension.NewManager(logEntry, settMock, appMock, projMock, rbacMock)
router := mux.NewRouter()
@ -136,10 +262,78 @@ func TestExtensionsHandlers(t *testing.T) {
router: router,
appGetterMock: appMock,
settingsGetterMock: settMock,
rbacMock: rbacMock,
projMock: projMock,
manager: m,
}
}
getApp := func(destName, destServer, projName string) *v1alpha1.Application {
return &v1alpha1.Application{
TypeMeta: v1.TypeMeta{},
ObjectMeta: v1.ObjectMeta{},
Spec: v1alpha1.ApplicationSpec{
Destination: v1alpha1.ApplicationDestination{
Name: destName,
Server: destServer,
},
Project: projName,
},
Status: v1alpha1.ApplicationStatus{
Resources: []v1alpha1.ResourceStatus{
{
Group: "apps",
Version: "v1",
Kind: "Pod",
Namespace: "default",
Name: "some-pod",
},
},
},
}
}
getProjectWithDestinations := func(prjName string, destNames []string, destURLs []string) *v1alpha1.AppProject {
destinations := []v1alpha1.ApplicationDestination{}
for _, destName := range destNames {
destination := v1alpha1.ApplicationDestination{
Name: destName,
}
destinations = append(destinations, destination)
}
for _, destURL := range destURLs {
destination := v1alpha1.ApplicationDestination{
Server: destURL,
}
destinations = append(destinations, destination)
}
return &v1alpha1.AppProject{
ObjectMeta: v1.ObjectMeta{
Name: prjName,
},
Spec: v1alpha1.AppProjectSpec{
Destinations: destinations,
},
}
}
withProject := func(prj *v1alpha1.AppProject, f *fixture) {
f.projMock.On("Get", prj.GetName()).Return(prj, nil)
}
withRbac := func(f *fixture, allowApp, allowExt bool) {
var appAccessError error
var extAccessError error
if !allowApp {
appAccessError = errors.New("no app permission")
}
if !allowExt {
extAccessError = errors.New("no extension permission")
}
f.rbacMock.On("EnforceErr", mock.Anything, rbacpolicy.ResourceApplications, rbacpolicy.ActionGet, mock.Anything).Return(appAccessError)
f.rbacMock.On("EnforceErr", mock.Anything, rbacpolicy.ResourceExtensions, rbacpolicy.ActionInvoke, mock.Anything).Return(extAccessError)
}
withExtensionConfig := func(configYaml string, f *fixture) {
settings := &settings.ArgoCDSettings{
ExtensionConfig: configYaml,
@ -148,6 +342,7 @@ func TestExtensionsHandlers(t *testing.T) {
}
startTestServer := func(t *testing.T, f *fixture) *httptest.Server {
t.Helper()
err := f.manager.RegisterHandlers(f.router)
if err != nil {
t.Fatalf("error starting test server: %s", err)
@ -161,8 +356,20 @@ func TestExtensionsHandlers(t *testing.T) {
}))
}
newExtensionRequest := func(t *testing.T, method, url string) *http.Request {
t.Helper()
r, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("error initializing request: %s", err)
}
r.Header.Add(extension.HeaderArgoCDApplicationName, "namespace:app-name")
r.Header.Add(extension.HeaderArgoCDProjectName, defaultProjectName)
return r
}
t.Run("proxy will return 404 if no extension endpoint is registered", func(t *testing.T) {
// given
t.Parallel()
f := setup()
withExtensionConfig(getExtensionConfigString(), f)
ts := startTestServer(t, f)
@ -179,24 +386,33 @@ func TestExtensionsHandlers(t *testing.T) {
})
t.Run("will call extension backend successfully", func(t *testing.T) {
// given
t.Parallel()
f := setup()
backendResponse := "some data"
backendEndpoint := "some-backend"
clusterName := "clusterName"
clusterURL := "clusterURL"
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, backendResponse)
}))
defer backendSrv.Close()
withRbac(f, true, true)
withExtensionConfig(getExtensionConfig(backendEndpoint, backendSrv.URL), f)
ts := startTestServer(t, f)
defer ts.Close()
r := newExtensionRequest(t, "Get", fmt.Sprintf("%s/extensions/%s/", ts.URL, backendEndpoint))
app := getApp(clusterName, clusterURL, defaultProjectName)
proj := getProjectWithDestinations("project-name", nil, []string{clusterURL})
f.appGetterMock.On("Get", mock.Anything, mock.Anything).Return(app, nil)
withProject(proj, f)
// when
resp, err := http.Get(fmt.Sprintf("%s/extensions/%s/", ts.URL, backendEndpoint))
resp, err := http.DefaultClient.Do(r)
// then
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
actual := strings.TrimSuffix(string(body), "\n")
@ -204,52 +420,38 @@ func TestExtensionsHandlers(t *testing.T) {
})
t.Run("will route requests with 2 backends for the same extension successfully", func(t *testing.T) {
// given
t.Parallel()
f := setup()
extName := "some-extension"
response1 := "response backend 1"
cluster1 := "cluster1"
cluster1Name := "cluster1"
beSrv1 := startBackendTestSrv(response1)
defer beSrv1.Close()
response2 := "response backend 2"
cluster2 := "cluster2"
cluster2URL := "cluster2"
beSrv2 := startBackendTestSrv(response2)
defer beSrv2.Close()
withExtensionConfig(getExtensionConfigWith2Backends(extName, beSrv1.URL, cluster1, beSrv2.URL, cluster2), f)
f.appGetterMock.On("Get", "ns1", "app1").Return(getApp(cluster1Name, "", defaultProjectName), nil)
f.appGetterMock.On("Get", "ns2", "app2").Return(getApp("", cluster2URL, defaultProjectName), nil)
withRbac(f, true, true)
withExtensionConfig(getExtensionConfigWith2Backends(extName, beSrv1.URL, cluster1Name, beSrv2.URL, cluster2URL), f)
withProject(getProjectWithDestinations("project-name", []string{cluster1Name}, []string{cluster2URL}), f)
ts := startTestServer(t, f)
defer ts.Close()
app1 := &v1alpha1.Application{
Spec: v1alpha1.ApplicationSpec{
Destination: v1alpha1.ApplicationDestination{
Server: beSrv1.URL,
Name: cluster1,
},
},
}
f.appGetterMock.On("Get", "ns1", "app1").Return(app1, nil)
app2 := &v1alpha1.Application{
Spec: v1alpha1.ApplicationSpec{
Destination: v1alpha1.ApplicationDestination{
Server: beSrv2.URL,
Name: cluster2,
},
},
}
f.appGetterMock.On("Get", "ns2", "app2").Return(app2, nil)
url := fmt.Sprintf("%s/extensions/%s/", ts.URL, extName)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("error creating request: %s", err)
}
req := newExtensionRequest(t, http.MethodGet, url)
req.Header.Del(extension.HeaderArgoCDApplicationName)
req1 := req.Clone(context.Background())
req1.Header.Add(extension.HeaderArgoCDApplicationName, "ns1/app1")
req1.Header.Add(extension.HeaderArgoCDApplicationName, "ns1:app1")
req2 := req.Clone(context.Background())
req2.Header.Add(extension.HeaderArgoCDApplicationName, "ns2/app2")
req2.Header.Add(extension.HeaderArgoCDApplicationName, "ns2:app2")
// when
resp1, err := http.DefaultClient.Do(req1)
@ -259,19 +461,173 @@ func TestExtensionsHandlers(t *testing.T) {
// then
require.NotNil(t, resp1)
require.Equal(t, http.StatusOK, resp1.StatusCode)
assert.Equal(t, http.StatusOK, resp1.StatusCode)
body, err := io.ReadAll(resp1.Body)
require.NoError(t, err)
actual := strings.TrimSuffix(string(body), "\n")
assert.Equal(t, response1, actual)
require.NotNil(t, resp2)
require.Equal(t, http.StatusOK, resp2.StatusCode)
assert.Equal(t, http.StatusOK, resp2.StatusCode)
body, err = io.ReadAll(resp2.Body)
require.NoError(t, err)
actual = strings.TrimSuffix(string(body), "\n")
assert.Equal(t, response2, actual)
})
t.Run("will return 401 if sub has no access to get application", func(t *testing.T) {
// given
t.Parallel()
f := setup()
allowApp := false
allowExtension := true
extName := "some-extension"
withRbac(f, allowApp, allowExtension)
withExtensionConfig(getExtensionConfig(extName, "http://fake"), f)
ts := startTestServer(t, f)
defer ts.Close()
r := newExtensionRequest(t, "Get", fmt.Sprintf("%s/extensions/%s/", ts.URL, extName))
f.appGetterMock.On("Get", mock.Anything, mock.Anything).Return(getApp("", "", defaultProjectName), nil)
// when
resp, err := http.DefaultClient.Do(r)
// then
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("will return 401 if sub has no access to invoke extension", func(t *testing.T) {
// given
t.Parallel()
f := setup()
allowApp := true
allowExtension := false
extName := "some-extension"
withRbac(f, allowApp, allowExtension)
withExtensionConfig(getExtensionConfig(extName, "http://fake"), f)
ts := startTestServer(t, f)
defer ts.Close()
r := newExtensionRequest(t, "Get", fmt.Sprintf("%s/extensions/%s/", ts.URL, extName))
f.appGetterMock.On("Get", mock.Anything, mock.Anything).Return(getApp("", "", defaultProjectName), nil)
// when
resp, err := http.DefaultClient.Do(r)
// then
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("will return 401 if project has no access to target cluster", func(t *testing.T) {
// given
t.Parallel()
f := setup()
allowApp := true
allowExtension := true
extName := "some-extension"
noCluster := []string{}
withRbac(f, allowApp, allowExtension)
withExtensionConfig(getExtensionConfig(extName, "http://fake"), f)
ts := startTestServer(t, f)
defer ts.Close()
r := newExtensionRequest(t, "Get", fmt.Sprintf("%s/extensions/%s/", ts.URL, extName))
f.appGetterMock.On("Get", mock.Anything, mock.Anything).Return(getApp("", "", defaultProjectName), nil)
proj := getProjectWithDestinations("project-name", nil, noCluster)
withProject(proj, f)
// when
resp, err := http.DefaultClient.Do(r)
// then
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("will return 401 if project in application does not exist", func(t *testing.T) {
// given
t.Parallel()
f := setup()
allowApp := true
allowExtension := true
extName := "some-extension"
withRbac(f, allowApp, allowExtension)
withExtensionConfig(getExtensionConfig(extName, "http://fake"), f)
ts := startTestServer(t, f)
defer ts.Close()
r := newExtensionRequest(t, "Get", fmt.Sprintf("%s/extensions/%s/", ts.URL, extName))
f.appGetterMock.On("Get", mock.Anything, mock.Anything).Return(getApp("", "", defaultProjectName), nil)
f.projMock.On("Get", defaultProjectName).Return(nil, nil)
// when
resp, err := http.DefaultClient.Do(r)
// then
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("will return 401 if project in application does not match with header", func(t *testing.T) {
// given
t.Parallel()
f := setup()
allowApp := true
allowExtension := true
extName := "some-extension"
differentProject := "differentProject"
withRbac(f, allowApp, allowExtension)
withExtensionConfig(getExtensionConfig(extName, "http://fake"), f)
ts := startTestServer(t, f)
defer ts.Close()
r := newExtensionRequest(t, "Get", fmt.Sprintf("%s/extensions/%s/", ts.URL, extName))
f.appGetterMock.On("Get", mock.Anything, mock.Anything).Return(getApp("", "", differentProject), nil)
// when
resp, err := http.DefaultClient.Do(r)
// then
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("will return 400 if application defines name and server destination", func(t *testing.T) {
// This test is to validate a security risk with malicious application
// trying to gain access to execute extensions in clusters it doesn't
// have access.
// given
t.Parallel()
f := setup()
extName := "some-extension"
maliciousName := "srv1"
destinationServer := "some-valid-server"
f.appGetterMock.On("Get", "ns1", "app1").Return(getApp(maliciousName, destinationServer, defaultProjectName), nil)
withRbac(f, true, true)
withExtensionConfig(getExtensionConfigWith2Backends(extName, "url1", "clusterName", "url2", "clusterURL"), f)
withProject(getProjectWithDestinations("project-name", nil, []string{"srv1", destinationServer}), f)
ts := startTestServer(t, f)
defer ts.Close()
url := fmt.Sprintf("%s/extensions/%s/", ts.URL, extName)
req := newExtensionRequest(t, http.MethodGet, url)
req.Header.Del(extension.HeaderArgoCDApplicationName)
req1 := req.Clone(context.Background())
req1.Header.Add(extension.HeaderArgoCDApplicationName, "ns1:app1")
// when
resp1, err := http.DefaultClient.Do(req1)
require.NoError(t, err)
// then
require.NotNil(t, resp1)
assert.Equal(t, http.StatusBadRequest, resp1.StatusCode)
body, err := io.ReadAll(resp1.Body)
require.NoError(t, err)
actual := strings.TrimSuffix(string(body), "\n")
assert.Equal(t, "invalid extension", actual)
})
}
func getExtensionConfig(name, url string) string {
@ -285,18 +641,23 @@ extensions:
return fmt.Sprintf(cfg, name, url)
}
func getExtensionConfigWith2Backends(name, url1, clus1, url2, clus2 string) string {
func getExtensionConfigWith2Backends(name, url1, clusName, url2, clusURL string) string {
cfg := `
extensions:
- name: %s
backend:
services:
- url: %s
cluster: %s
cluster:
name: %s
- url: %s
cluster: %s
cluster:
server: %s
`
return fmt.Sprintf(cfg, name, url1, clus1, url2, clus2)
// second extension is configured with the cluster url rather
// than the cluster name so we can validate that both use-cases
// are working
return fmt.Sprintf(cfg, name, url1, clusName, url2, clusURL)
}
func getExtensionConfigString() string {

View file

@ -0,0 +1,74 @@
// Code generated by mockery v2.15.0. DO NOT EDIT.
package mocks
import (
v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
mock "github.com/stretchr/testify/mock"
)
// ProjectGetter is an autogenerated mock type for the ProjectGetter type
type ProjectGetter struct {
mock.Mock
}
// Get provides a mock function with given fields: name
func (_m *ProjectGetter) Get(name string) (*v1alpha1.AppProject, error) {
ret := _m.Called(name)
var r0 *v1alpha1.AppProject
if rf, ok := ret.Get(0).(func(string) *v1alpha1.AppProject); ok {
r0 = rf(name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v1alpha1.AppProject)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetClusters provides a mock function with given fields: project
func (_m *ProjectGetter) GetClusters(project string) ([]*v1alpha1.Cluster, error) {
ret := _m.Called(project)
var r0 []*v1alpha1.Cluster
if rf, ok := ret.Get(0).(func(string) []*v1alpha1.Cluster); ok {
r0 = rf(project)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*v1alpha1.Cluster)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(project)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type mockConstructorTestingTNewProjectGetter interface {
mock.TestingT
Cleanup(func())
}
// NewProjectGetter creates a new instance of ProjectGetter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewProjectGetter(t mockConstructorTestingTNewProjectGetter) *ProjectGetter {
mock := &ProjectGetter{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -0,0 +1,41 @@
// Code generated by mockery v2.15.0. DO NOT EDIT.
package mocks
import mock "github.com/stretchr/testify/mock"
// RbacEnforcer is an autogenerated mock type for the RbacEnforcer type
type RbacEnforcer struct {
mock.Mock
}
// EnforceErr provides a mock function with given fields: rvals
func (_m *RbacEnforcer) EnforceErr(rvals ...interface{}) error {
var _ca []interface{}
_ca = append(_ca, rvals...)
ret := _m.Called(_ca...)
var r0 error
if rf, ok := ret.Get(0).(func(...interface{}) error); ok {
r0 = rf(rvals...)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewRbacEnforcer interface {
mock.TestingT
Cleanup(func())
}
// NewRbacEnforcer creates a new instance of RbacEnforcer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewRbacEnforcer(t mockConstructorTestingTNewRbacEnforcer) *RbacEnforcer {
mock := &RbacEnforcer{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -24,6 +24,7 @@ const (
ResourceGPGKeys = "gpgkeys"
ResourceLogs = "logs"
ResourceExec = "exec"
ResourceExtensions = "extensions"
// please add new items to Actions
ActionGet = "get"
@ -33,6 +34,7 @@ const (
ActionSync = "sync"
ActionOverride = "override"
ActionAction = "action"
ActionInvoke = "invoke"
)
var (

View file

@ -912,15 +912,14 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
th := util_session.WithAuthMiddleware(a.DisableAuth, a.sessionMgr, terminal)
mux.Handle("/terminal", th)
// Dead code for now
// Proxy extension is currently an experimental feature and is disabled
// Proxy extension is currently an alpha feature and is disabled
// by default.
// if a.EnableProxyExtension {
// // API server won't panic if extensions fail to register. In
// // this case an error log will be sent and no extension route
// // will be added in mux.
// registerExtensions(mux, a)
// }
if a.EnableProxyExtension {
// API server won't panic if extensions fail to register. In
// this case an error log will be sent and no extension route
// will be added in mux.
registerExtensions(mux, a)
}
mustRegisterGWHandler(versionpkg.RegisterVersionServiceHandler, ctx, gwmux, conn)
mustRegisterGWHandler(clusterpkg.RegisterClusterServiceHandler, ctx, gwmux, conn)
mustRegisterGWHandler(applicationpkg.RegisterApplicationServiceHandler, ctx, gwmux, conn)
@ -969,12 +968,15 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
// registerExtensions will try to register all configured extensions
// in the given mux. If any error is returned while registering
// extensions handlers, no route will be added in the given mux.
// nolint:deadcode,unused,staticcheck
func registerExtensions(mux *http.ServeMux, a *ArgoCDServer) {
sg := extension.NewDefaultSettingsGetter(a.settingsMgr)
ag := extension.NewDefaultApplicationGetter(a.serviceSet.ApplicationService)
em := extension.NewManager(sg, ag, a.log)
ag := extension.NewDefaultApplicationGetter(a.appLister)
pg := extension.NewDefaultProjectGetter(a.projLister, a.db)
em := extension.NewManager(a.log, sg, ag, pg, a.enf)
r := gmux.NewRouter()
// register an Auth middleware to ensure all requests to
// extensions are authenticated first.
r.Use(a.sessionMgr.AuthMiddlewareFunc(a.DisableAuth))
err := em.RegisterHandlers(r)
if err != nil {

View file

@ -16,6 +16,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apierr "k8s.io/apimachinery/pkg/api/errors"
apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
@ -969,3 +970,36 @@ func AppInstanceNameFromQualified(name string, defaultNs string) string {
func ErrProjectNotPermitted(appName, appNamespace, projName string) error {
return fmt.Errorf("application '%s' in namespace '%s' is not permitted to use project '%s'", appName, appNamespace, projName)
}
// IsValidPodName checks that a podName is valid
func IsValidPodName(name string) bool {
// https://github.com/kubernetes/kubernetes/blob/976a940f4a4e84fe814583848f97b9aafcdb083f/pkg/apis/core/validation/validation.go#L241
validationErrors := apimachineryvalidation.NameIsDNSSubdomain(name, false)
return len(validationErrors) == 0
}
// IsValidAppName checks if the name can be used as application name
func IsValidAppName(name string) bool {
// app names have the same rules as pods.
return IsValidPodName(name)
}
// IsValidProjectName checks if the name can be used as project name
func IsValidProjectName(name string) bool {
// project names have the same rules as pods.
return IsValidPodName(name)
}
// IsValidNamespaceName checks that a namespace name is valid
func IsValidNamespaceName(name string) bool {
// https://github.com/kubernetes/kubernetes/blob/976a940f4a4e84fe814583848f97b9aafcdb083f/pkg/apis/core/validation/validation.go#L262
validationErrors := apimachineryvalidation.ValidateNamespaceName(name, false)
return len(validationErrors) == 0
}
// IsValidContainerName checks that a containerName is valid
func IsValidContainerName(name string) bool {
// https://github.com/kubernetes/kubernetes/blob/53a9d106c4aabcd550cc32ae4e8004f32fb0ae7b/pkg/api/validation/validation.go#L280
validationErrors := apimachineryvalidation.NameIsDNSLabel(name, false)
return len(validationErrors) == 0
}