github.com/argoproj/argo-cd/v3@v3.2.1/server/extension/extension.go (about) 1 package extension 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "net" 8 "net/http" 9 "net/http/httputil" 10 "net/url" 11 "regexp" 12 "strings" 13 "time" 14 15 "github.com/felixge/httpsnoop" 16 log "github.com/sirupsen/logrus" 17 "gopkg.in/yaml.v3" 18 19 "github.com/argoproj/argo-cd/v3/util/rbac" 20 21 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 22 applisters "github.com/argoproj/argo-cd/v3/pkg/client/listers/application/v1alpha1" 23 "github.com/argoproj/argo-cd/v3/server/rbacpolicy" 24 "github.com/argoproj/argo-cd/v3/util/argo" 25 "github.com/argoproj/argo-cd/v3/util/db" 26 "github.com/argoproj/argo-cd/v3/util/security" 27 "github.com/argoproj/argo-cd/v3/util/session" 28 "github.com/argoproj/argo-cd/v3/util/settings" 29 ) 30 31 const ( 32 URLPrefix = "/extensions" 33 DefaultConnectionTimeout = 2 * time.Second 34 DefaultKeepAlive = 15 * time.Second 35 DefaultIdleConnectionTimeout = 60 * time.Second 36 DefaultMaxIdleConnections = 30 37 38 // HeaderArgoCDNamespace defines the namespace of the 39 // argo control plane to be passed to the extension handler. 40 // Example: 41 // Argocd-Namespace: "namespace" 42 HeaderArgoCDNamespace = "Argocd-Namespace" 43 44 // HeaderArgoCDApplicationName defines the name of the 45 // expected application header to be passed to the extension 46 // handler. The header value must follow the format: 47 // "<namespace>:<app-name>" 48 // Example: 49 // Argocd-Application-Name: "namespace:app-name" 50 HeaderArgoCDApplicationName = "Argocd-Application-Name" 51 52 // HeaderArgoCDProjectName defines the name of the expected 53 // project header to be passed to the extension handler. 54 // Example: 55 // Argocd-Project-Name: "default" 56 HeaderArgoCDProjectName = "Argocd-Project-Name" 57 58 // HeaderArgoCDTargetClusterURL defines the target cluster URL 59 // that the Argo CD application is associated with. This header 60 // will be populated by the extension proxy and passed to the 61 // configured backend service. If this header is passed by 62 // the client, its value will be overridden by the extension 63 // handler. 64 // 65 // Example: 66 // Argocd-Target-Cluster-URL: "https://kubernetes.default.svc.cluster.local" 67 HeaderArgoCDTargetClusterURL = "Argocd-Target-Cluster-URL" 68 69 // HeaderArgoCDTargetClusterName defines the target cluster name 70 // that the Argo CD application is associated with. This header 71 // will be populated by the extension proxy and passed to the 72 // configured backend service. If this header is passed by 73 // the client, its value will be overridden by the extension 74 // handler. 75 HeaderArgoCDTargetClusterName = "Argocd-Target-Cluster-Name" 76 77 // HeaderArgoCDUsername is the header name that defines the username of the logged 78 // in user authenticated by Argo CD. 79 HeaderArgoCDUsername = "Argocd-Username" 80 81 // HeaderArgoCDUserId is the header name that defines the internal user id of the logged 82 // in user authenticated by Argo CD. 83 HeaderArgoCDUserId = "Argocd-User-Id" 84 85 // HeaderArgoCDGroups is the header name that provides the 'groups' 86 // claim from the users authenticated in Argo CD. 87 HeaderArgoCDGroups = "Argocd-User-Groups" 88 ) 89 90 // RequestResources defines the authorization scope for 91 // an incoming request to a given extension. This struct 92 // is populated from pre-defined Argo CD headers. 93 type RequestResources struct { 94 ApplicationName string 95 ApplicationNamespace string 96 ProjectName string 97 } 98 99 // ValidateHeaders will validate the pre-defined Argo CD 100 // request headers for extensions and extract the resources 101 // information populating and returning a RequestResources 102 // object. 103 // The pre-defined headers are: 104 // - Argocd-Application-Name 105 // - Argocd-Project-Name 106 // 107 // The headers expected format is documented in each of the constant 108 // types defined for them. 109 func ValidateHeaders(r *http.Request) (*RequestResources, error) { 110 appHeader := r.Header.Get(HeaderArgoCDApplicationName) 111 if appHeader == "" { 112 return nil, fmt.Errorf("header %q must be provided", HeaderArgoCDApplicationName) 113 } 114 appNamespace, appName, err := getAppName(appHeader) 115 if err != nil { 116 return nil, fmt.Errorf("error getting app details: %w", err) 117 } 118 if !argo.IsValidNamespaceName(appNamespace) { 119 return nil, errors.New("invalid value for namespace") 120 } 121 if !argo.IsValidAppName(appName) { 122 return nil, errors.New("invalid value for application name") 123 } 124 125 projName := r.Header.Get(HeaderArgoCDProjectName) 126 if projName == "" { 127 return nil, fmt.Errorf("header %q must be provided", HeaderArgoCDProjectName) 128 } 129 if !argo.IsValidProjectName(projName) { 130 return nil, errors.New("invalid value for project name") 131 } 132 return &RequestResources{ 133 ApplicationName: appName, 134 ApplicationNamespace: appNamespace, 135 ProjectName: projName, 136 }, nil 137 } 138 139 func getAppName(appHeader string) (string, string, error) { 140 parts := strings.Split(appHeader, ":") 141 if len(parts) != 2 { 142 return "", "", fmt.Errorf("invalid value for %q header: expected format: <namespace>:<app-name>", HeaderArgoCDApplicationName) 143 } 144 return parts[0], parts[1], nil 145 } 146 147 // ExtensionConfigs defines the configurations for all extensions 148 // retrieved from Argo CD configmap (argocd-cm). 149 type ExtensionConfigs struct { 150 Extensions []ExtensionConfig `yaml:"extensions"` 151 } 152 153 // ExtensionConfig defines the configuration for one extension. 154 type ExtensionConfig struct { 155 // Name defines the endpoint that will be used to register 156 // the extension route. Mandatory field. 157 Name string `yaml:"name"` 158 Backend BackendConfig `yaml:"backend"` 159 } 160 161 // BackendConfig defines the backend service configurations that will 162 // be used by an specific extension. An extension can have multiple services 163 // associated. This is necessary when Argo CD is managing applications in 164 // external clusters. In this case, each cluster may have its own backend 165 // service. 166 type BackendConfig struct { 167 ProxyConfig 168 Services []ServiceConfig `yaml:"services"` 169 } 170 171 // ServiceConfig provides the configuration for a backend service. 172 type ServiceConfig struct { 173 // URL is the address where the extension backend must be available. 174 // Mandatory field. 175 URL string `yaml:"url"` 176 177 // Cluster if provided, will have to match the application 178 // destination name to have requests properly forwarded to this 179 // service URL. 180 Cluster *ClusterConfig `yaml:"cluster,omitempty"` 181 182 // Headers if provided, the headers list will be added on all 183 // outgoing requests for this service config. 184 Headers []Header `yaml:"headers"` 185 } 186 187 // Header defines the header to be added in the proxy requests. 188 type Header struct { 189 // Name defines the name of the header. It is a mandatory field if 190 // a header is provided. 191 Name string `yaml:"name"` 192 // Value defines the value of the header. The actual value can be 193 // provided as verbatim or as a reference to an Argo CD secret key. 194 // In order to provide it as a reference, it is necessary to prefix 195 // it with a dollar sign. 196 // Example: 197 // value: '$some.argocd.secret.key' 198 // In the example above, the value will be replaced with the one from 199 // the argocd-secret with key 'some.argocd.secret.key'. 200 Value string `yaml:"value"` 201 } 202 203 type ClusterConfig struct { 204 // Server specifies the URL of the target cluster's Kubernetes control plane API. This must be set if Name is not set. 205 Server string `yaml:"server"` 206 207 // Name is an alternate way of specifying the target cluster by its symbolic name. This must be set if Server is not set. 208 Name string `yaml:"name"` 209 } 210 211 // ProxyConfig allows configuring connection behaviour between Argo CD 212 // API Server and the backend service. 213 type ProxyConfig struct { 214 // ConnectionTimeout is the maximum amount of time a dial to 215 // the extension server will wait for a connect to complete. 216 // Default: 2 seconds 217 ConnectionTimeout time.Duration `yaml:"connectionTimeout"` 218 219 // KeepAlive specifies the interval between keep-alive probes 220 // for an active network connection between the API server and 221 // the extension server. 222 // Default: 15 seconds 223 KeepAlive time.Duration `yaml:"keepAlive"` 224 225 // IdleConnectionTimeout is the maximum amount of time an idle 226 // (keep-alive) connection between the API server and the extension 227 // server will remain idle before closing itself. 228 // Default: 60 seconds 229 IdleConnectionTimeout time.Duration `yaml:"idleConnectionTimeout"` 230 231 // MaxIdleConnections controls the maximum number of idle (keep-alive) 232 // connections between the API server and the extension server. 233 // Default: 30 234 MaxIdleConnections int `yaml:"maxIdleConnections"` 235 } 236 237 // SettingsGetter defines the contract to retrieve Argo CD Settings. 238 type SettingsGetter interface { 239 Get() (*settings.ArgoCDSettings, error) 240 } 241 242 // DefaultSettingsGetter is the real settings getter implementation. 243 type DefaultSettingsGetter struct { 244 settingsMgr *settings.SettingsManager 245 } 246 247 // NewDefaultSettingsGetter returns a new default settings getter. 248 func NewDefaultSettingsGetter(mgr *settings.SettingsManager) *DefaultSettingsGetter { 249 return &DefaultSettingsGetter{ 250 settingsMgr: mgr, 251 } 252 } 253 254 // Get will retrieve the Argo CD settings. 255 func (s *DefaultSettingsGetter) Get() (*settings.ArgoCDSettings, error) { 256 return s.settingsMgr.GetSettings() 257 } 258 259 // ProjectGetter defines the contract to retrieve Argo CD Project. 260 type ProjectGetter interface { 261 Get(name string) (*v1alpha1.AppProject, error) 262 GetClusters(project string) ([]*v1alpha1.Cluster, error) 263 } 264 265 // DefaultProjectGetter is the real ProjectGetter implementation. 266 type DefaultProjectGetter struct { 267 projLister applisters.AppProjectNamespaceLister 268 db db.ArgoDB 269 } 270 271 // NewDefaultProjectGetter returns a new default project getter 272 func NewDefaultProjectGetter(lister applisters.AppProjectNamespaceLister, db db.ArgoDB) *DefaultProjectGetter { 273 return &DefaultProjectGetter{ 274 projLister: lister, 275 db: db, 276 } 277 } 278 279 // Get will retrieve the live AppProject state. 280 func (p *DefaultProjectGetter) Get(name string) (*v1alpha1.AppProject, error) { 281 return p.projLister.Get(name) 282 } 283 284 // GetClusters will retrieve the clusters configured by a project. 285 func (p *DefaultProjectGetter) GetClusters(project string) ([]*v1alpha1.Cluster, error) { 286 return p.db.GetProjectClusters(context.TODO(), project) 287 } 288 289 // UserGetter defines the contract to retrieve info from the logged in user. 290 type UserGetter interface { 291 GetUserId(ctx context.Context) string 292 GetUsername(ctx context.Context) string 293 GetGroups(ctx context.Context) []string 294 } 295 296 // DefaultUserGetter is the main UserGetter implementation. 297 type DefaultUserGetter struct { 298 policyEnf *rbacpolicy.RBACPolicyEnforcer 299 } 300 301 // NewDefaultUserGetter return a new default UserGetter 302 func NewDefaultUserGetter(policyEnf *rbacpolicy.RBACPolicyEnforcer) *DefaultUserGetter { 303 return &DefaultUserGetter{ 304 policyEnf: policyEnf, 305 } 306 } 307 308 // GetUsername will return the username of the current logged in user 309 func (u *DefaultUserGetter) GetUsername(ctx context.Context) string { 310 return session.Username(ctx) 311 } 312 313 // GetUserId will return the user id of the current logged in user 314 func (u *DefaultUserGetter) GetUserId(ctx context.Context) string { 315 return session.GetUserIdentifier(ctx) 316 } 317 318 // GetGroups will return the groups associated with the logged in user. 319 func (u *DefaultUserGetter) GetGroups(ctx context.Context) []string { 320 return session.Groups(ctx, u.policyEnf.GetScopes()) 321 } 322 323 // ApplicationGetter defines the contract to retrieve the application resource. 324 type ApplicationGetter interface { 325 Get(ns, name string) (*v1alpha1.Application, error) 326 } 327 328 // DefaultApplicationGetter is the real application getter implementation. 329 type DefaultApplicationGetter struct { 330 appLister applisters.ApplicationLister 331 } 332 333 // NewDefaultApplicationGetter returns the default application getter. 334 func NewDefaultApplicationGetter(al applisters.ApplicationLister) *DefaultApplicationGetter { 335 return &DefaultApplicationGetter{ 336 appLister: al, 337 } 338 } 339 340 // Get will retrieve the application resource for the given namespace and name. 341 func (a *DefaultApplicationGetter) Get(ns, name string) (*v1alpha1.Application, error) { 342 return a.appLister.Applications(ns).Get(name) 343 } 344 345 // RbacEnforcer defines the contract to enforce rbac rules 346 type RbacEnforcer interface { 347 EnforceErr(rvals ...any) error 348 } 349 350 // Manager is the object that will be responsible for registering 351 // and handling proxy extensions. 352 type Manager struct { 353 log *log.Entry 354 namespace string 355 settings SettingsGetter 356 application ApplicationGetter 357 project ProjectGetter 358 cluster argo.ClusterGetter 359 rbac RbacEnforcer 360 registry ExtensionRegistry 361 metricsReg ExtensionMetricsRegistry 362 userGetter UserGetter 363 } 364 365 // ExtensionMetricsRegistry exposes operations to update http metrics in the Argo CD 366 // API server. 367 type ExtensionMetricsRegistry interface { 368 // IncExtensionRequestCounter will increase the request counter for the given 369 // extension with the given status. 370 IncExtensionRequestCounter(extension string, status int) 371 // ObserveExtensionRequestDuration will register the request roundtrip duration 372 // between Argo CD API Server and the extension backend service for the given 373 // extension. 374 ObserveExtensionRequestDuration(extension string, duration time.Duration) 375 } 376 377 // NewManager will initialize a new manager. 378 func NewManager(log *log.Entry, namespace string, sg SettingsGetter, ag ApplicationGetter, pg ProjectGetter, cg argo.ClusterGetter, rbac RbacEnforcer, ug UserGetter) *Manager { 379 return &Manager{ 380 log: log, 381 namespace: namespace, 382 settings: sg, 383 application: ag, 384 project: pg, 385 cluster: cg, 386 rbac: rbac, 387 userGetter: ug, 388 } 389 } 390 391 // ExtensionRegistry is an in memory registry that contains contains all 392 // proxies for all extensions. The key is the extension name defined in 393 // the Argo CD configmap. 394 type ExtensionRegistry map[string]ProxyRegistry 395 396 // ProxyRegistry is an in memory registry that contains all proxies for a 397 // given extension. Different extensions will have independent proxy registries. 398 // This is required to address the use case when one extension is configured with 399 // multiple backend services in different clusters. 400 type ProxyRegistry map[ProxyKey]*httputil.ReverseProxy 401 402 // NewProxyRegistry will instantiate a new in memory registry for proxies. 403 func NewProxyRegistry() ProxyRegistry { 404 r := make(map[ProxyKey]*httputil.ReverseProxy) 405 return r 406 } 407 408 // ProxyKey defines the struct used as a key in the proxy registry 409 // map (ProxyRegistry). 410 type ProxyKey struct { 411 //nolint:unused // used as part of a map kay 412 extensionName string 413 //nolint:unused // used as part of a map kay 414 clusterName string 415 //nolint:unused // used as part of a map kay 416 clusterServer string 417 } 418 419 // proxyKey will build the key to be used in the proxyByCluster 420 // map. 421 func proxyKey(extName, cName, cServer string) ProxyKey { 422 return ProxyKey{ 423 extensionName: extName, 424 clusterName: cName, 425 clusterServer: cServer, 426 } 427 } 428 429 func parseAndValidateConfig(s *settings.ArgoCDSettings) (*ExtensionConfigs, error) { 430 if len(s.ExtensionConfig) == 0 { 431 return nil, errors.New("no extensions configurations found") 432 } 433 434 configs := ExtensionConfigs{} 435 for extName, extConfig := range s.ExtensionConfig { 436 extConfigMap := map[string]any{} 437 err := yaml.Unmarshal([]byte(extConfig), &extConfigMap) 438 if err != nil { 439 return nil, fmt.Errorf("invalid extension config: %w", err) 440 } 441 442 parsedExtConfig := settings.ReplaceMapSecrets(extConfigMap, s.Secrets) 443 parsedExtConfigBytes, err := yaml.Marshal(parsedExtConfig) 444 if err != nil { 445 return nil, fmt.Errorf("error marshaling parsed extension config: %w", err) 446 } 447 // empty extName means that this is the main configuration defined by 448 // the 'extension.config' configmap key 449 if extName == "" { 450 mainConfig := ExtensionConfigs{} 451 err = yaml.Unmarshal(parsedExtConfigBytes, &mainConfig) 452 if err != nil { 453 return nil, fmt.Errorf("invalid parsed extension config: %w", err) 454 } 455 configs.Extensions = append(configs.Extensions, mainConfig.Extensions...) 456 } else { 457 backendConfig := BackendConfig{} 458 err = yaml.Unmarshal(parsedExtConfigBytes, &backendConfig) 459 if err != nil { 460 return nil, fmt.Errorf("invalid parsed backend extension config for extension %s: %w", extName, err) 461 } 462 ext := ExtensionConfig{ 463 Name: extName, 464 Backend: backendConfig, 465 } 466 configs.Extensions = append(configs.Extensions, ext) 467 } 468 } 469 err := validateConfigs(&configs) 470 if err != nil { 471 return nil, fmt.Errorf("validation error: %w", err) 472 } 473 return &configs, nil 474 } 475 476 func validateConfigs(configs *ExtensionConfigs) error { 477 nameSafeRegex := regexp.MustCompile(`^[A-Za-z0-9-_]+$`) 478 exts := make(map[string]struct{}) 479 for _, ext := range configs.Extensions { 480 if ext.Name == "" { 481 return errors.New("extensions.name must be configured") 482 } 483 if !nameSafeRegex.MatchString(ext.Name) { 484 return errors.New("invalid extensions.name: only alphanumeric characters, hyphens, and underscores are allowed") 485 } 486 if _, found := exts[ext.Name]; found { 487 return fmt.Errorf("duplicated extension found in the configs for %q", ext.Name) 488 } 489 exts[ext.Name] = struct{}{} 490 svcTotal := len(ext.Backend.Services) 491 if svcTotal == 0 { 492 return fmt.Errorf("no backend service configured for extension %s", ext.Name) 493 } 494 for _, svc := range ext.Backend.Services { 495 if svc.URL == "" { 496 return errors.New("extensions.backend.services.url must be configured") 497 } 498 if svcTotal > 1 && svc.Cluster == nil { 499 return errors.New("extensions.backend.services.cluster must be configured when defining more than one service per extension") 500 } 501 if svc.Cluster != nil { 502 if svc.Cluster.Name == "" && svc.Cluster.Server == "" { 503 return errors.New("cluster.name or cluster.server must be defined when cluster is provided in the configuration") 504 } 505 } 506 if len(svc.Headers) > 0 { 507 for _, header := range svc.Headers { 508 if header.Name == "" { 509 return errors.New("header.name must be defined when providing service headers in the configuration") 510 } 511 if header.Value == "" { 512 return errors.New("header.value must be defined when providing service headers in the configuration") 513 } 514 } 515 } 516 } 517 } 518 return nil 519 } 520 521 // NewProxy will instantiate a new reverse proxy based on the provided 522 // targetURL and config. It will remove sensitive information from the 523 // incoming request such as the Authorization and Cookie headers. 524 func NewProxy(targetURL string, headers []Header, config ProxyConfig) (*httputil.ReverseProxy, error) { 525 url, err := url.Parse(targetURL) 526 if err != nil { 527 return nil, fmt.Errorf("failed to parse proxy URL: %w", err) 528 } 529 proxy := &httputil.ReverseProxy{ 530 Transport: newTransport(config), 531 Director: func(req *http.Request) { 532 req.Host = url.Host 533 req.URL.Scheme = url.Scheme 534 req.URL.Host = url.Host 535 req.Header.Set("Host", url.Host) 536 req.Header.Del("Authorization") 537 req.Header.Del("Cookie") 538 for _, header := range headers { 539 req.Header.Set(header.Name, header.Value) 540 } 541 }, 542 } 543 return proxy, nil 544 } 545 546 // newTransport will build a new transport to be used in the proxy 547 // applying default values if not defined in the given config. 548 func newTransport(config ProxyConfig) *http.Transport { 549 applyProxyConfigDefaults(&config) 550 return &http.Transport{ 551 DialContext: (&net.Dialer{ 552 Timeout: config.ConnectionTimeout, 553 KeepAlive: config.KeepAlive, 554 }).DialContext, 555 MaxIdleConns: config.MaxIdleConnections, 556 IdleConnTimeout: config.IdleConnectionTimeout, 557 TLSHandshakeTimeout: 10 * time.Second, 558 ExpectContinueTimeout: 1 * time.Second, 559 } 560 } 561 562 func applyProxyConfigDefaults(c *ProxyConfig) { 563 if c.ConnectionTimeout == 0 { 564 c.ConnectionTimeout = DefaultConnectionTimeout 565 } 566 if c.KeepAlive == 0 { 567 c.KeepAlive = DefaultKeepAlive 568 } 569 if c.IdleConnectionTimeout == 0 { 570 c.IdleConnectionTimeout = DefaultIdleConnectionTimeout 571 } 572 if c.MaxIdleConnections == 0 { 573 c.MaxIdleConnections = DefaultMaxIdleConnections 574 } 575 } 576 577 // RegisterExtensions will retrieve all extensions configurations 578 // and update the extension registry. 579 func (m *Manager) RegisterExtensions() error { 580 settings, err := m.settings.Get() 581 if err != nil { 582 return fmt.Errorf("error getting settings: %w", err) 583 } 584 if len(settings.ExtensionConfig) == 0 { 585 m.log.Infof("No extensions configured.") 586 return nil 587 } 588 err = m.UpdateExtensionRegistry(settings) 589 if err != nil { 590 return fmt.Errorf("error updating extension registry: %w", err) 591 } 592 return nil 593 } 594 595 // UpdateExtensionRegistry will first parse and validate the extensions 596 // configurations from the given settings. If no errors are found, it will 597 // iterate over the given configurations building a new extension registry. 598 // At the end, it will update the manager with the newly created registry. 599 func (m *Manager) UpdateExtensionRegistry(s *settings.ArgoCDSettings) error { 600 extConfigs, err := parseAndValidateConfig(s) 601 if err != nil { 602 return fmt.Errorf("error parsing extension config: %w", err) 603 } 604 extReg := make(map[string]ProxyRegistry) 605 for _, ext := range extConfigs.Extensions { 606 proxyReg := NewProxyRegistry() 607 singleBackend := len(ext.Backend.Services) == 1 608 for _, service := range ext.Backend.Services { 609 proxy, err := NewProxy(service.URL, service.Headers, ext.Backend.ProxyConfig) 610 if err != nil { 611 return fmt.Errorf("error creating proxy: %w", err) 612 } 613 err = appendProxy(proxyReg, ext.Name, service, proxy, singleBackend) 614 if err != nil { 615 return fmt.Errorf("error appending proxy: %w", err) 616 } 617 } 618 extReg[ext.Name] = proxyReg 619 } 620 m.registry = extReg 621 return nil 622 } 623 624 // appendProxy will append the given proxy in the given registry. Will use 625 // the provided extName and service to determine the map key. The key must 626 // be unique in the map. If the map already has the key and error is returned. 627 func appendProxy(registry ProxyRegistry, 628 extName string, 629 service ServiceConfig, 630 proxy *httputil.ReverseProxy, 631 singleBackend bool, 632 ) error { 633 if singleBackend { 634 key := proxyKey(extName, "", "") 635 if _, exist := registry[key]; exist { 636 return fmt.Errorf("duplicated proxy configuration found for extension key %q", key) 637 } 638 registry[key] = proxy 639 return nil 640 } 641 642 // This is the case where there are more than one backend configured 643 // for this extension. In this case we need to add the provided cluster 644 // configurations for proper correlation to find which proxy to use 645 // while handling requests. 646 if service.Cluster.Name != "" { 647 key := proxyKey(extName, service.Cluster.Name, "") 648 if _, exist := registry[key]; exist { 649 return fmt.Errorf("duplicated proxy configuration found for extension key %q", key) 650 } 651 registry[key] = proxy 652 } 653 if service.Cluster.Server != "" { 654 key := proxyKey(extName, "", service.Cluster.Server) 655 if _, exist := registry[key]; exist { 656 return fmt.Errorf("duplicated proxy configuration found for extension key %q", key) 657 } 658 registry[key] = proxy 659 } 660 if service.Cluster.Name != "" && service.Cluster.Server != "" { 661 key := proxyKey(extName, service.Cluster.Name, service.Cluster.Server) 662 if _, exist := registry[key]; exist { 663 return fmt.Errorf("duplicated proxy configuration found for extension key %q", key) 664 } 665 registry[key] = proxy 666 } 667 668 return nil 669 } 670 671 // authorize will enforce rbac rules are satisfied for the given RequestResources. 672 // The following validations are executed: 673 // - enforce the subject has permission to read application/project provided 674 // in HeaderArgoCDApplicationName and HeaderArgoCDProjectName. 675 // - enforce the subject has permission to invoke the extension identified by 676 // extName. 677 // - enforce that the project has permission to access the destination cluster. 678 // 679 // If all validations are satisfied it will return the Application resource 680 func (m *Manager) authorize(ctx context.Context, rr *RequestResources, extName string) (*v1alpha1.Application, error) { 681 if m.rbac == nil { 682 return nil, errors.New("rbac enforcer not set in extension manager") 683 } 684 appRBACName := security.RBACName(rr.ApplicationNamespace, rr.ProjectName, rr.ApplicationNamespace, rr.ApplicationName) 685 if err := m.rbac.EnforceErr(ctx.Value("claims"), rbac.ResourceApplications, rbac.ActionGet, appRBACName); err != nil { 686 return nil, fmt.Errorf("application authorization error: %w", err) 687 } 688 689 if err := m.rbac.EnforceErr(ctx.Value("claims"), rbac.ResourceExtensions, rbac.ActionInvoke, extName); err != nil { 690 return nil, fmt.Errorf("unauthorized to invoke extension %q: %w", extName, err) 691 } 692 693 // just retrieve the app after checking if subject has access to it 694 app, err := m.application.Get(rr.ApplicationNamespace, rr.ApplicationName) 695 if err != nil { 696 return nil, fmt.Errorf("error getting application: %w", err) 697 } 698 if app == nil { 699 return nil, fmt.Errorf("invalid Application provided in the %q header", HeaderArgoCDApplicationName) 700 } 701 702 if app.Spec.GetProject() != rr.ProjectName { 703 return nil, fmt.Errorf("project mismatch provided in the %q header", HeaderArgoCDProjectName) 704 } 705 706 proj, err := m.project.Get(app.Spec.GetProject()) 707 if err != nil { 708 return nil, fmt.Errorf("error getting project: %w", err) 709 } 710 if proj == nil { 711 return nil, fmt.Errorf("invalid project provided in the %q header", HeaderArgoCDProjectName) 712 } 713 destCluster, err := argo.GetDestinationCluster(ctx, app.Spec.Destination, m.cluster) 714 if err != nil { 715 return nil, fmt.Errorf("error getting destination cluster: %w", err) 716 } 717 permitted, err := proj.IsDestinationPermitted(destCluster, app.Spec.Destination.Namespace, m.project.GetClusters) 718 if err != nil { 719 return nil, fmt.Errorf("error validating project destinations: %w", err) 720 } 721 if !permitted { 722 return nil, errors.New("the provided project is not allowed to access the cluster configured in the Application destination") 723 } 724 725 return app, nil 726 } 727 728 // findProxy will search the given registry to find the correct proxy to use 729 // based on the given extName and dest. 730 func findProxy(registry ProxyRegistry, extName string, dest v1alpha1.ApplicationDestination) (*httputil.ReverseProxy, error) { 731 // First try to find the proxy in the registry just by the extension name. 732 // This is the simple case for extensions with only one backend service. 733 key := proxyKey(extName, "", "") 734 if proxy, found := registry[key]; found { 735 return proxy, nil 736 } 737 738 // If extension has multiple backend services configured, the correct proxy 739 // needs to be searched by the ApplicationDestination. 740 key = proxyKey(extName, dest.Name, dest.Server) 741 if proxy, found := registry[key]; found { 742 return proxy, nil 743 } 744 745 return nil, fmt.Errorf("no proxy found for extension %q", extName) 746 } 747 748 // ProxyRegistry returns the proxy registry associated for the given 749 // extension name. 750 func (m *Manager) ProxyRegistry(name string) (ProxyRegistry, bool) { 751 pReg, found := m.registry[name] 752 return pReg, found 753 } 754 755 // CallExtension returns a handler func responsible for forwarding requests to the 756 // extension service. The request will be sanitized by removing sensitive headers. 757 func (m *Manager) CallExtension() func(http.ResponseWriter, *http.Request) { 758 return func(w http.ResponseWriter, r *http.Request) { 759 segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") 760 if segments[0] != "extensions" { 761 http.Error(w, "Invalid URL: first segment must be "+URLPrefix, http.StatusBadRequest) 762 return 763 } 764 extName := segments[1] 765 if extName == "" { 766 http.Error(w, "Invalid URL: extension name must be provided", http.StatusBadRequest) 767 return 768 } 769 extName = strings.ReplaceAll(extName, "\n", "") 770 extName = strings.ReplaceAll(extName, "\r", "") 771 reqResources, err := ValidateHeaders(r) 772 if err != nil { 773 http.Error(w, fmt.Sprintf("Invalid headers: %s", err), http.StatusBadRequest) 774 return 775 } 776 app, err := m.authorize(r.Context(), reqResources, extName) 777 if err != nil { 778 m.log.Infof("unauthorized extension request: %s", err) 779 http.Error(w, "Unauthorized extension request", http.StatusUnauthorized) 780 return 781 } 782 783 proxyRegistry, ok := m.ProxyRegistry(extName) 784 if !ok { 785 m.log.Warnf("proxy extension warning: attempt to call unregistered extension: %s", extName) 786 http.Error(w, "Extension not found", http.StatusNotFound) 787 return 788 } 789 proxy, err := findProxy(proxyRegistry, extName, app.Spec.Destination) 790 if err != nil { 791 m.log.Errorf("findProxy error: %s", err) 792 http.Error(w, "invalid extension", http.StatusBadRequest) 793 return 794 } 795 796 userId := m.userGetter.GetUserId(r.Context()) 797 username := m.userGetter.GetUsername(r.Context()) 798 groups := m.userGetter.GetGroups(r.Context()) 799 prepareRequest(r, m.namespace, extName, app, userId, username, groups) 800 m.log.WithFields(log.Fields{ 801 HeaderArgoCDUserId: userId, 802 HeaderArgoCDUsername: username, 803 HeaderArgoCDGroups: strings.Join(groups, ","), 804 HeaderArgoCDNamespace: m.namespace, 805 HeaderArgoCDApplicationName: fmt.Sprintf("%s:%s", app.GetNamespace(), app.GetName()), 806 "extension": extName, 807 "path": r.URL.Path, 808 }).Info("sending proxy extension request") 809 // httpsnoop package is used to properly wrap the responseWriter 810 // and avoid optional intefaces issue: 811 // https://github.com/felixge/httpsnoop#why-this-package-exists 812 // CaptureMetrics will call the proxy and return the metrics from it. 813 metrics := httpsnoop.CaptureMetrics(proxy, w, r) 814 815 go registerMetrics(extName, metrics, m.metricsReg) 816 } 817 } 818 819 func registerMetrics(extName string, metrics httpsnoop.Metrics, extensionMetricsRegistry ExtensionMetricsRegistry) { 820 if extensionMetricsRegistry != nil { 821 extensionMetricsRegistry.IncExtensionRequestCounter(extName, metrics.Code) 822 extensionMetricsRegistry.ObserveExtensionRequestDuration(extName, metrics.Duration) 823 } 824 } 825 826 // prepareRequest is responsible for cleaning the incoming request URL removing 827 // the Argo CD extension API section from it. It provides additional information to 828 // the backend service appending them in the outgoing request headers. The appended 829 // headers are: 830 // - Control plane namespace 831 // - Cluster destination name 832 // - Cluster destination server 833 // - Argo CD authenticated username 834 func prepareRequest(r *http.Request, namespace string, extName string, app *v1alpha1.Application, userId string, username string, groups []string) { 835 r.URL.Path = strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%s/%s", URLPrefix, extName)) 836 r.Header.Set(HeaderArgoCDNamespace, namespace) 837 if app.Spec.Destination.Name != "" { 838 r.Header.Set(HeaderArgoCDTargetClusterName, app.Spec.Destination.Name) 839 } 840 if app.Spec.Destination.Server != "" { 841 r.Header.Set(HeaderArgoCDTargetClusterURL, app.Spec.Destination.Server) 842 } 843 if userId != "" { 844 r.Header.Set(HeaderArgoCDUserId, userId) 845 } 846 if username != "" { 847 r.Header.Set(HeaderArgoCDUsername, username) 848 } 849 if len(groups) > 0 { 850 r.Header.Set(HeaderArgoCDGroups, strings.Join(groups, ",")) 851 } 852 } 853 854 // AddMetricsRegistry will associate the given metricsReg in the Manager. 855 func (m *Manager) AddMetricsRegistry(metricsReg ExtensionMetricsRegistry) { 856 m.metricsReg = metricsReg 857 }