diff --git a/docs/developer-guide/extensions/proxy-extensions.md b/docs/developer-guide/extensions/proxy-extensions.md new file mode 100644 index 0000000000000..4ab80006d2613 --- /dev/null +++ b/docs/developer-guide/extensions/proxy-extensions.md @@ -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: + + /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/` endpoint exposed by Argo CD API +server. The example above will proxy requests to +`/extensions/httpbin/` to `http://httpbin.org`. + +The diagram below illustrates an interaction possible with this +configuration: + +``` + ┌─────────────┐ + │ Argo CD UI │ + └────┬────────┘ + │ ▲ + GET /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: +`":"`. + +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 diff --git a/docs/developer-guide/extensions/ui-extensions.md b/docs/developer-guide/extensions/ui-extensions.md new file mode 100644 index 0000000000000..2c25748beb148 --- /dev/null +++ b/docs/developer-guide/extensions/ui-extensions.md @@ -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. diff --git a/docs/developer-guide/ui-extensions.md b/docs/developer-guide/ui-extensions.md index 2c25748beb148..dfabfb5574ead 100644 --- a/docs/developer-guide/ui-extensions.md +++ b/docs/developer-guide/ui-extensions.md @@ -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) diff --git a/docs/operator-manual/argocd-cm.yaml b/docs/operator-manual/argocd-cm.yaml index 6618b567beac6..549c01ff58774 100644 --- a/docs/operator-manual/argocd-cm.yaml +++ b/docs/operator-manual/argocd-cm.yaml @@ -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 diff --git a/docs/operator-manual/rbac.md b/docs/operator-manual/rbac.md index fac03e4b2f744..b26abf36230ba 100644 --- a/docs/operator-manual/rbac.md +++ b/docs/operator-manual/rbac.md @@ -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/` @@ -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 diff --git a/docs/operator-manual/upgrading/2.6-2.7.md b/docs/operator-manual/upgrading/2.6-2.7.md new file mode 100644 index 0000000000000..72b5fae977a09 --- /dev/null +++ b/docs/operator-manual/upgrading/2.6-2.7.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 46dba67da3776..05a04fa35ed41 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/server/application/terminal.go b/server/application/terminal.go index 38368f486b665..5052e38d92c1c 100644 --- a/server/application/terminal.go +++ b/server/application/terminal.go @@ -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 } diff --git a/server/application/terminal_test.go b/server/application/terminal_test.go index 4e7a2652521bc..c9bd789fe1c9e 100644 --- a/server/application/terminal_test.go +++ b/server/application/terminal_test.go @@ -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) } diff --git a/server/extension/extension.go b/server/extension/extension.go index 0865026e5444c..58563364c979c 100644 --- a/server/extension/extension.go +++ b/server/extension/extension.go @@ -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: + // ":" + // 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: :", 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) - return - } - if app == nil { - msg := fmt.Sprintf("Invalid Application: %s", appHeader) - m.writeErrorResponse(http.StatusBadRequest, msg, w) + m.log.Infof("unauthorized extension request: %s", err) + http.Error(w, "Unauthorized extension request", http.StatusUnauthorized) 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 } - 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: /", appHeader) + sanitizeRequest(r, extName) + m.log.Debugf("proxing request for extension %q", extName) + proxy.ServeHTTP(w, r) } - 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") } diff --git a/server/extension/extension_test.go b/server/extension/extension_test.go index a6fa7b5d6cf8f..aafb0d29de4be 100644 --- a/server/extension/extension_test.go +++ b/server/extension/extension_test.go @@ -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) - ts := startTestServer(t, f) - defer ts.Close() + f.appGetterMock.On("Get", "ns1", "app1").Return(getApp(cluster1Name, "", defaultProjectName), nil) + f.appGetterMock.On("Get", "ns2", "app2").Return(getApp("", cluster2URL, defaultProjectName), nil) - app1 := &v1alpha1.Application{ - Spec: v1alpha1.ApplicationSpec{ - Destination: v1alpha1.ApplicationDestination{ - Server: beSrv1.URL, - Name: cluster1, - }, - }, - } - f.appGetterMock.On("Get", "ns1", "app1").Return(app1, nil) + withRbac(f, true, true) + withExtensionConfig(getExtensionConfigWith2Backends(extName, beSrv1.URL, cluster1Name, beSrv2.URL, cluster2URL), f) + withProject(getProjectWithDestinations("project-name", []string{cluster1Name}, []string{cluster2URL}), f) - app2 := &v1alpha1.Application{ - Spec: v1alpha1.ApplicationSpec{ - Destination: v1alpha1.ApplicationDestination{ - Server: beSrv2.URL, - Name: cluster2, - }, - }, - } - f.appGetterMock.On("Get", "ns2", "app2").Return(app2, nil) + ts := startTestServer(t, f) + defer ts.Close() 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 { diff --git a/server/extension/mocks/ProjectGetter.go b/server/extension/mocks/ProjectGetter.go new file mode 100644 index 0000000000000..d70b0c70ccfc6 --- /dev/null +++ b/server/extension/mocks/ProjectGetter.go @@ -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 +} diff --git a/server/extension/mocks/RbacEnforcer.go b/server/extension/mocks/RbacEnforcer.go new file mode 100644 index 0000000000000..01fb0c7421c69 --- /dev/null +++ b/server/extension/mocks/RbacEnforcer.go @@ -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 +} diff --git a/server/rbacpolicy/rbacpolicy.go b/server/rbacpolicy/rbacpolicy.go index 1ec420b642fdb..6d039dcdd6246 100644 --- a/server/rbacpolicy/rbacpolicy.go +++ b/server/rbacpolicy/rbacpolicy.go @@ -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 ( diff --git a/server/server.go b/server/server.go index cf3324d8ca6ed..46eddcb8ed97d 100644 --- a/server/server.go +++ b/server/server.go @@ -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 { diff --git a/util/argo/argo.go b/util/argo/argo.go index 1d9c064818695..80f4c83a9d8f0 100644 --- a/util/argo/argo.go +++ b/util/argo/argo.go @@ -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 +}