mirror of
https://github.com/argoproj/argo-cd
synced 2026-04-21 17:07:16 +00:00
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:
parent
b6cfe676f3
commit
974c2de168
16 changed files with 1386 additions and 282 deletions
261
docs/developer-guide/extensions/proxy-extensions.md
Normal file
261
docs/developer-guide/extensions/proxy-extensions.md
Normal 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
|
||||
97
docs/developer-guide/extensions/ui-extensions.md
Normal file
97
docs/developer-guide/extensions/ui-extensions.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
docs/operator-manual/upgrading/2.6-2.7.md
Normal file
38
docs/operator-manual/upgrading/2.6-2.7.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
74
server/extension/mocks/ProjectGetter.go
Normal file
74
server/extension/mocks/ProjectGetter.go
Normal 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
|
||||
}
|
||||
41
server/extension/mocks/RbacEnforcer.go
Normal file
41
server/extension/mocks/RbacEnforcer.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue